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
@@ -0,0 +1,133 @@
|
||||
// Aufrufe von ffmpeg und ffprobe als Kindprozesse.
|
||||
//
|
||||
// Zwei Dinge sind hier wichtiger als alles andere (ADR-0006):
|
||||
// 1. Der Prozess laeuft nachrangig (`nice`), damit das Encodieren dem
|
||||
// laufenden Stream keine Frames kostet.
|
||||
// 2. Er darf scheitern, ohne den Daemon mitzureissen - deshalb Zeitlimit,
|
||||
// sauberes Toeten und die letzten Zeilen von stderr in der Fehlermeldung.
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { logger } from '#core/logger.ts';
|
||||
|
||||
const log = logger('ffmpeg');
|
||||
|
||||
export type RunOptions = {
|
||||
niceness?: number;
|
||||
timeoutMs?: number;
|
||||
onProgress?: (seconds: number) => void;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export class FfmpegError extends Error {
|
||||
code: number | null;
|
||||
tail: string;
|
||||
constructor(message: string, code: number | null, tail: string) {
|
||||
super(message);
|
||||
this.name = 'FfmpegError';
|
||||
this.code = code;
|
||||
this.tail = tail;
|
||||
}
|
||||
}
|
||||
|
||||
/** Liest `out_time_ms=` aus der Fortschrittsausgabe von ffmpeg. */
|
||||
export function parseProgress(chunk: string): number | null {
|
||||
const m = /out_time_ms=(\d+)/.exec(chunk);
|
||||
return m?.[1] ? Number(m[1]) / 1_000_000 : null;
|
||||
}
|
||||
|
||||
export function run(bin: string, args: string[], opts: RunOptions = {}): Promise<string> {
|
||||
const niceness = opts.niceness ?? 0;
|
||||
const cmd = niceness > 0 ? 'nice' : bin;
|
||||
const argv = niceness > 0 ? ['-n', String(niceness), bin, ...args] : args;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
log.debug(`${cmd} ${argv.slice(0, 6).join(' ')} ...`);
|
||||
const child = spawn(cmd, argv, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
|
||||
let stdout = '';
|
||||
const errLines: string[] = [];
|
||||
let done = false;
|
||||
|
||||
const finish = (fn: () => void) => { if (!done) { done = true; clearTimeout(timer); fn(); } };
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
finish(() => {
|
||||
child.kill('SIGKILL');
|
||||
reject(new FfmpegError(`${bin} hat das Zeitlimit ueberschritten`, null, errLines.join('\n')));
|
||||
});
|
||||
}, opts.timeoutMs ?? 30 * 60_000);
|
||||
|
||||
opts.signal?.addEventListener('abort', () => {
|
||||
finish(() => { child.kill('SIGKILL'); reject(new Error('abgebrochen')); });
|
||||
}, { once: true });
|
||||
|
||||
child.stdout.on('data', (d: Buffer) => {
|
||||
const text = d.toString();
|
||||
stdout += text;
|
||||
if (opts.onProgress) {
|
||||
const s = parseProgress(text);
|
||||
if (s !== null) opts.onProgress(s);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on('data', (d: Buffer) => {
|
||||
// Nur die letzten Zeilen behalten - ffmpeg ist gespraechig, und im
|
||||
// Fehlerfall steht das Wesentliche am Ende.
|
||||
for (const line of d.toString().split('\n')) {
|
||||
if (line.trim()) errLines.push(line.trim());
|
||||
}
|
||||
if (errLines.length > 40) errLines.splice(0, errLines.length - 40);
|
||||
});
|
||||
|
||||
child.on('error', (err) => finish(() => reject(
|
||||
new FfmpegError(`${bin} nicht startbar: ${err.message}`, null, errLines.join('\n')))));
|
||||
|
||||
child.on('close', (code) => finish(() => {
|
||||
if (code === 0) resolve(stdout);
|
||||
else reject(new FfmpegError(`${bin} endete mit Code ${code}`, code, errLines.join('\n')));
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
export type AudioStream = { index: number; codec: string; channels: number; language?: string; title?: string };
|
||||
export type ProbeResult = { durationSeconds: number; width: number; height: number; audio: AudioStream[] };
|
||||
|
||||
export async function probe(file: string): Promise<ProbeResult> {
|
||||
const out = await run('ffprobe', [
|
||||
'-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', file,
|
||||
], { timeoutMs: 60_000 });
|
||||
return parseProbe(out);
|
||||
}
|
||||
|
||||
export function parseProbe(json: string): ProbeResult {
|
||||
const data = JSON.parse(json) as {
|
||||
format?: { duration?: string };
|
||||
streams?: Array<{
|
||||
index: number; codec_type: string; codec_name?: string;
|
||||
width?: number; height?: number; channels?: number;
|
||||
tags?: Record<string, string>;
|
||||
}>;
|
||||
};
|
||||
const streams = data.streams ?? [];
|
||||
const video = streams.find((s) => s.codec_type === 'video');
|
||||
const audio = streams
|
||||
.filter((s) => s.codec_type === 'audio')
|
||||
.map((s, i) => {
|
||||
const tags = s.tags ?? {};
|
||||
const out: AudioStream = {
|
||||
index: i, // Position unter den Audiospuren, fuer -map 0:a:N
|
||||
codec: s.codec_name ?? '?',
|
||||
channels: s.channels ?? 0,
|
||||
};
|
||||
if (tags['language']) out.language = tags['language'];
|
||||
if (tags['title']) out.title = tags['title'];
|
||||
return out;
|
||||
});
|
||||
|
||||
return {
|
||||
durationSeconds: Number(data.format?.duration ?? 0),
|
||||
width: video?.width ?? 0,
|
||||
height: video?.height ?? 0,
|
||||
audio,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user