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

+133
View File
@@ -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,
};
}
+101
View File
@@ -0,0 +1,101 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { LayoutProfile } from '#core/config.ts';
import { parseProbe, parseProgress } from './ffmpeg.ts';
import { micTrackArgs, verticalArgs, verticalFilter } from './vertical.ts';
// Das eingemessene Profil aus config/layout.yaml
const PROFILE: LayoutProfile = {
cam: { crop: { x: 0, y: 100, w: 440, h: 208 }, band: { w: 1080, h: 512 } },
game: { crop: { x: 440, y: 0, w: 828, h: 1080 }, band: { w: 1080, h: 1408 } },
};
test('Filtergraph nutzt die eingemessenen Werte', () => {
const f = verticalFilter(PROFILE);
assert.match(f, /crop=440:208:0:100/);
assert.match(f, /crop=828:1080:440:0/);
assert.match(f, /scale=1080:512/);
assert.match(f, /scale=1080:1408/);
assert.match(f, /vstack=inputs=2/);
// setsar verhindert, dass ein krummes Pixelseitenverhaeltnis durchschlaegt
assert.equal((f.match(/setsar=1/g) ?? []).length, 2);
});
test('Baender ergeben zusammen die Zielhoehe', () => {
assert.equal(PROFILE.cam.band.h + PROFILE.game.band.h, 1920);
});
test('-sseof steht vor -i, sonst wird die ganze Datei dekodiert', () => {
const args = verticalArgs({
input: '/work/raw.mkv', output: '/out/v.mp4', profile: PROFILE, tailSeconds: 45,
});
const sseof = args.indexOf('-sseof');
const input = args.indexOf('-i');
assert.ok(sseof !== -1, '-sseof fehlt');
assert.ok(sseof < input, '-sseof muss vor -i stehen');
assert.equal(args[sseof + 1], '-45');
});
test('ohne tailSeconds kein -sseof', () => {
const args = verticalArgs({ input: 'a.mkv', output: 'b.mp4', profile: PROFILE });
assert.equal(args.includes('-sseof'), false);
});
test('Tonspur wird gewaehlt und darf fehlen', () => {
const args = verticalArgs({
input: 'a.mkv', output: 'b.mp4', profile: PROFILE, audioTrack: 2,
});
// Das Fragezeichen macht die Zuordnung optional - ohne das bricht ffmpeg ab,
// wenn die Spur nicht existiert
assert.ok(args.includes('0:a:2?'), `-map fehlt: ${args.join(' ')}`);
});
test('Ausgabe ist fuer Handys geeignet kodiert', () => {
const args = verticalArgs({ input: 'a.mkv', output: 'b.mp4', profile: PROFILE });
assert.ok(args.includes('yuv420p'), 'ohne yuv420p spielen viele Player nichts ab');
assert.ok(args.includes('+faststart'), 'ohne faststart laedt das Video traege');
});
test('Mikrofonspur wird als 16-kHz-Mono-WAV gezogen', () => {
const args = micTrackArgs('/work/raw.mkv', '/work/mic.wav', 1);
assert.ok(args.includes('0:a:1'));
assert.ok(args.includes('16000'));
assert.ok(args.includes('pcm_s16le'));
assert.deepEqual(args.slice(-3), ['-c:a', 'pcm_s16le', '/work/mic.wav']);
});
test('liest den Fortschritt aus der ffmpeg-Ausgabe', () => {
assert.equal(parseProgress('frame=120\nout_time_ms=4500000\nspeed=1.2x'), 4.5);
assert.equal(parseProgress('frame=120\nspeed=1.2x'), null);
});
test('erkennt Aufloesung, Laenge und mehrere Tonspuren', () => {
// So sieht eine OBS-Aufnahme mit getrennten Spuren aus
const json = JSON.stringify({
format: { duration: '92.416000' },
streams: [
{ index: 0, codec_type: 'video', codec_name: 'h264', width: 1920, height: 1080 },
{ index: 1, codec_type: 'audio', codec_name: 'aac', channels: 2, tags: { title: 'Mischung' } },
{ index: 2, codec_type: 'audio', codec_name: 'aac', channels: 1, tags: { title: 'Mikrofon' } },
{ index: 3, codec_type: 'audio', codec_name: 'aac', channels: 2, tags: { title: 'Spiel' } },
],
});
const r = parseProbe(json);
assert.equal(r.width, 1920);
assert.equal(r.height, 1080);
assert.ok(Math.abs(r.durationSeconds - 92.416) < 0.001);
assert.equal(r.audio.length, 3);
// Wichtig: die Nummerierung fuer -map 0:a:N zaehlt nur die Audiospuren,
// nicht die Streams insgesamt
assert.deepEqual(r.audio.map((a) => a.index), [0, 1, 2]);
assert.equal(r.audio[1]?.title, 'Mikrofon');
});
test('kommt mit einer Datei ohne Ton klar', () => {
const r = parseProbe(JSON.stringify({
format: { duration: '10' },
streams: [{ index: 0, codec_type: 'video', width: 1280, height: 720 }],
}));
assert.equal(r.audio.length, 0);
assert.equal(r.width, 1280);
});
+78
View File
@@ -0,0 +1,78 @@
// Bau des 9:16-Filtergraphen aus einem Layout-Profil.
//
// Warum eine eigene Datei: derselbe Graph wird von `calibrate` (Standbild) und
// vom Render-Schritt (Video) gebraucht, und die Zahlen darin sind eingemessen -
// siehe config/layout.yaml und docs/projects/clip-shorts.md.
import type { LayoutProfile, Rect } from '#core/config.ts';
const crop = (r: Rect) => `crop=${r.w}:${r.h}:${r.x}:${r.y}`;
/**
* Facecam oben, Gameplay unten, beide ohne Verzerrung: aus jeder Quelle wird
* ein Ausschnitt mit der Proportion des Zielbandes genommen, nicht das ganze
* Bild gestaucht.
*/
export function verticalFilter(prof: LayoutProfile): string {
return [
`[0:v]${crop(prof.cam.crop)},scale=${prof.cam.band.w}:${prof.cam.band.h},setsar=1[cam]`,
`[0:v]${crop(prof.game.crop)},scale=${prof.game.band.w}:${prof.game.band.h},setsar=1[game]`,
'[cam][game]vstack=inputs=2[v]',
].join(';');
}
export type VerticalOptions = {
input: string;
output: string;
profile: LayoutProfile;
/** Nur die letzten n Sekunden nehmen. Der Wiedergabepuffer ist laenger als ein Short. */
tailSeconds?: number;
/** Position der Tonspur unter den Audiospuren (0 = erste). */
audioTrack?: number;
encoder?: string;
preset?: string;
crf?: number;
};
export function verticalArgs(o: VerticalOptions): string[] {
const args = ['-y', '-hide_banner', '-loglevel', 'error', '-progress', 'pipe:1', '-nostats'];
// -sseof schneidet vom Ende her. Muss vor -i stehen, sonst wird die ganze
// Datei dekodiert und erst danach verworfen.
if (o.tailSeconds && o.tailSeconds > 0) args.push('-sseof', `-${o.tailSeconds}`);
args.push('-i', o.input);
args.push('-filter_complex', verticalFilter(o.profile), '-map', '[v]');
args.push('-map', `0:a:${o.audioTrack ?? 0}?`); // ? = fehlt die Spur, kein Abbruch
args.push(
'-c:v', o.encoder ?? 'libx264',
'-preset', o.preset ?? 'veryfast',
'-crf', String(o.crf ?? 20),
'-pix_fmt', 'yuv420p',
'-c:a', 'aac', '-b:a', '160k', '-ac', '2',
'-movflags', '+faststart',
o.output
);
return args;
}
/** Einzelbild fuer die Vorschau in der Sichtungsseite. */
export function thumbnailArgs(input: string, output: string, atSeconds = 1): string[] {
return [
'-y', '-hide_banner', '-loglevel', 'error',
'-ss', String(atSeconds), '-i', input,
'-frames:v', '1', '-q:v', '3', output,
];
}
/** Mikrofonspur als 16 kHz Mono WAV - genau das erwartet whisper.cpp (S4). */
export function micTrackArgs(input: string, output: string, audioTrack: number): string[] {
return [
'-y', '-hide_banner', '-loglevel', 'error',
'-i', input,
'-map', `0:a:${audioTrack}`,
'-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le',
output,
];
}