S3: 9:16-Render mit Warteschlange, nachrangig zum Stream
Aus der Rohdatei entsteht das fertige Hochformat. Der Filtergraph liegt jetzt in packages/media und wird von calibrate und vom Render-Schritt gemeinsam benutzt - die Zahlen darin sind eingemessen, sie gehoeren an genau eine Stelle. Eine Aufgabe nach der anderen, und waehrend gesendet wird standardmaessig gar nicht (renderWhileLive: false). ffmpeg laeuft unter nice, mit Zeitlimit, und die letzten Zeilen von stderr landen in der Fehlermeldung statt im Nichts. Aus dem 90-Sekunden-Puffer werden die letzten 45 Sekunden genommen (-sseof vor -i, sonst wird die ganze Datei dekodiert und danach verworfen). Dabei einen eigenen Fehler aus S2 korrigiert: die Rohdatei wurde in den Windows-Bind-Mount kopiert und dort verarbeitet - genau das, was ADR-0004 Punkt 2 verbietet. Jetzt liegt sie im Arbeits-Volume, auf die Windows-Seite kommt nur das fertige, kleine Ergebnis. Verifiziert an einer kuenstlichen Aufnahme mit drei getrennten Tonspuren: 1920x1080/70 s hinein, 1080x1920/45 s heraus, Naht an der richtigen Stelle, 27,9 s Rechenzeit unter nice 15. 39 Tests gruen. Neuer Befehl `render <datei>` zum Nachrendern nach Layout-Aenderungen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013VTGjd92LsDDWF3frd9FTB
This commit is contained in:
1 parent
684f10eb02
commit
0e020f3073
11 files changed
+597
-27
No files matched your search
+65
-13
@@ -7,7 +7,10 @@ import fs from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { loadLayout, paths, type LayoutProfile, type Rect } from '#core/config.ts';
|
||||
import { loadLayout, paths, type Rect } from '#core/config.ts';
|
||||
import { probe, run as runMedia } from '#media/ffmpeg.ts';
|
||||
import { thumbnailArgs, verticalArgs, verticalFilter } from '#media/vertical.ts';
|
||||
import { loadConfig } from '#core/config.ts';
|
||||
import { openDb } from '#db/index.ts';
|
||||
import { secretStatus } from '#twitch/secrets.ts';
|
||||
|
||||
@@ -245,15 +248,6 @@ async function fetchModel(name = 'ggml-small.bin'): Promise<void> {
|
||||
|
||||
// ---------------------------------------------------------------- calibrate
|
||||
|
||||
function buildFilter(prof: LayoutProfile): string {
|
||||
const c = (r: Rect) => `crop=${r.w}:${r.h}:${r.x}:${r.y}`;
|
||||
return [
|
||||
`[0:v]${c(prof.cam.crop)},scale=${prof.cam.band.w}:${prof.cam.band.h},setsar=1[cam]`,
|
||||
`[0:v]${c(prof.game.crop)},scale=${prof.game.band.w}:${prof.game.band.h},setsar=1[game]`,
|
||||
'[cam][game]vstack=inputs=2[v]',
|
||||
].join(';');
|
||||
}
|
||||
|
||||
async function calibrate(input?: string, profileName = 'default'): Promise<void> {
|
||||
const layout = loadLayout();
|
||||
const prof = layout.profiles?.[profileName];
|
||||
@@ -277,7 +271,7 @@ async function calibrate(input?: string, profileName = 'default'): Promise<void>
|
||||
await runAsync('ffmpeg', [...quiet, ...seek, '-i', input, '-frames:v', '1', frame]);
|
||||
|
||||
const preview = path.join(outDir, `preview-${profileName}.png`);
|
||||
await runAsync('ffmpeg', [...quiet, '-i', frame, '-filter_complex', buildFilter(prof),
|
||||
await runAsync('ffmpeg', [...quiet, '-i', frame, '-filter_complex', verticalFilter(prof),
|
||||
'-map', '[v]', '-frames:v', '1', preview]);
|
||||
|
||||
const marked = path.join(outDir, 'ausschnitte.png');
|
||||
@@ -293,6 +287,62 @@ async function calibrate(input?: string, profileName = 'default'): Promise<void>
|
||||
console.log('und den Befehl erneut ausfuehren.');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- render
|
||||
|
||||
/**
|
||||
* Rendert eine Rohdatei nach 9:16. Dieselben Bausteine wie die Warteschlange
|
||||
* im Daemon, aber ohne Datenbank - gedacht zum Nachrendern, wenn sich das
|
||||
* Layout geaendert hat.
|
||||
*/
|
||||
async function renderOne(input?: string, profileName = 'default'): Promise<void> {
|
||||
if (!input) throw new Error('Datei angeben: render <datei> [profil]');
|
||||
const layout = loadLayout();
|
||||
const prof = layout.profiles?.[profileName];
|
||||
if (!prof) throw new Error(`Profil "${profileName}" steht nicht in config/layout.yaml`);
|
||||
const cfg = loadConfig();
|
||||
|
||||
const info = await probe(input);
|
||||
console.log(`Quelle: ${info.width}x${info.height}, ${info.durationSeconds.toFixed(1)} s, ` +
|
||||
`${info.audio.length} Tonspur(en)`);
|
||||
for (const [i, a] of info.audio.entries()) {
|
||||
console.log(` Spur ${i}: ${a.codec}, ${a.channels} Kanal/Kanaele` +
|
||||
(a.title ? ` - "${a.title}"` : ''));
|
||||
}
|
||||
if (info.width !== layout.source.width || info.height !== layout.source.height) {
|
||||
console.log(` Achtung: Layout ist fuer ${layout.source.width}x${layout.source.height} ` +
|
||||
'eingemessen, der Ausschnitt sitzt vermutlich falsch.');
|
||||
}
|
||||
|
||||
const outDir = path.join(paths.clips, '_manuell', new Date().toISOString().replace(/[:.]/g, '-'));
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
const vertical = path.join(outDir, 'vertical.mp4');
|
||||
|
||||
const track = cfg.clipper.audio.outputTrack < info.audio.length
|
||||
? cfg.clipper.audio.outputTrack : 0;
|
||||
|
||||
const started = Date.now();
|
||||
let lastShown = 0;
|
||||
await runMedia('ffmpeg', verticalArgs({
|
||||
input, output: vertical, profile: prof,
|
||||
tailSeconds: Math.min(cfg.clipper.tailSeconds, Math.max(1, Math.floor(info.durationSeconds))),
|
||||
audioTrack: track,
|
||||
encoder: cfg.clipper.ffmpeg.encoder,
|
||||
preset: cfg.clipper.ffmpeg.preset,
|
||||
crf: cfg.clipper.ffmpeg.crf,
|
||||
}), {
|
||||
niceness: cfg.clipper.ffmpeg.niceness,
|
||||
onProgress: (sec) => process.stdout.write(`
|
||||
${sec.toFixed(0)} s ... `),
|
||||
});
|
||||
await runMedia('ffmpeg', thumbnailArgs(vertical, path.join(outDir, 'thumb.jpg')));
|
||||
|
||||
const out = await probe(vertical);
|
||||
console.log(`
|
||||
Fertig: ${vertical}`);
|
||||
console.log(` ${out.width}x${out.height}, ${out.durationSeconds.toFixed(1)} s, ` +
|
||||
`Rechenzeit ${((Date.now() - started) / 1000).toFixed(1)} s`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- help
|
||||
|
||||
function help(): void {
|
||||
@@ -304,12 +354,13 @@ abdulive-toolkit - Container-Einstiegspunkt
|
||||
db:migrate Migrationen anwenden
|
||||
fetch-model [name] Whisper-Modell laden (Vorgabe: ggml-small.bin)
|
||||
calibrate [datei] [profil] Facecam-Ausschnitt pruefen, Vorschau rendern
|
||||
render <datei> [profil] Rohdatei nach 9:16 rendern (zum Nachrendern)
|
||||
help Diese Uebersicht
|
||||
|
||||
Einrichtung nach dem Start: http://localhost:4792/setup
|
||||
|
||||
Stand: Scheiben S0 und S1 aus docs/07-mvp-plan.md. OBS-Anbindung, Clipping
|
||||
und Untertitel folgen in S2 bis S4.
|
||||
Stand: Scheiben S0 bis S3 aus docs/07-mvp-plan.md. Untertitel (S4) und die
|
||||
Sichtungsseite mit Onboarding (S5) folgen.
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -327,6 +378,7 @@ try {
|
||||
case 'db:migrate': openDb(); console.log('Migrationen sind aktuell.'); break;
|
||||
case 'fetch-model': await fetchModel(args[0]); break;
|
||||
case 'calibrate': await calibrate(args[0], args[1]); break;
|
||||
case 'render': await renderOne(args[0], args[1]); break;
|
||||
case 'help': case undefined: help(); break;
|
||||
default:
|
||||
console.error(`Unbekannter Befehl "${cmd}".`);
|
||||
|
||||
Reference in new issue
Block a user