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:
ZeasyAlexandClaude Opus 5 committed 2026-09-09 10:21:58 +02:00
1 parent 684f10eb02
commit 0e020f3073
11 files changed
+597 -27

No files matched your search

+65 -13
View File
@@ -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}".`);