From 0e020f3073f6dbf4742ae4859959fa431adf5198 Mon Sep 17 00:00:00 2001 From: ZeasyAlex Date: Wed, 9 Sep 2026 10:21:58 +0200 Subject: [PATCH] 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 ` zum Nachrendern nach Layout-Aenderungen. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013VTGjd92LsDDWF3frd9FTB --- apps/ingest/src/clips.ts | 26 ++++-- apps/ingest/src/index.ts | 27 +++++- apps/ingest/src/render.ts | 145 +++++++++++++++++++++++++++++++ config/config.example.yaml | 4 +- docker/entrypoint.ts | 78 ++++++++++++++--- docs/07-mvp-plan.md | 19 +++- package.json | 3 +- packages/core/src/config.ts | 10 +++ packages/media/src/ffmpeg.ts | 133 ++++++++++++++++++++++++++++ packages/media/src/media.test.ts | 101 +++++++++++++++++++++ packages/media/src/vertical.ts | 78 +++++++++++++++++ 11 files changed, 597 insertions(+), 27 deletions(-) create mode 100644 apps/ingest/src/render.ts create mode 100644 packages/media/src/ffmpeg.ts create mode 100644 packages/media/src/media.test.ts create mode 100644 packages/media/src/vertical.ts diff --git a/apps/ingest/src/clips.ts b/apps/ingest/src/clips.ts index b3262ad..3a89a05 100644 --- a/apps/ingest/src/clips.ts +++ b/apps/ingest/src/clips.ts @@ -29,6 +29,7 @@ export type ClipperConfig = { replayDirHost?: string; replayDirContainer: string; clipsDir: string; + workDir: string; }; export type ClipperDeps = { @@ -38,6 +39,8 @@ export type ClipperDeps = { say: (text: string) => boolean; currentStreamId: () => string | null; broadcasterId: () => string | null; + /** Wird gerufen, sobald die Rohdatei im Arbeits-Volume liegt. */ + onRawReady: (job: { clipId: string; rawPath: string; outDir: string }) => void; }; export function createClipper(deps: ClipperDeps) { @@ -163,17 +166,22 @@ export function createClipper(deps: ClipperDeps) { try { const size = await waitForStableFile(containerPath); - // Vom nur-lesbaren Bind-Mount in den Ablageordner. Ab hier gehoert die - // Datei uns, OBS darf seinen Puffer aufraeumen. - const dir = path.join(cfg.clipsDir, new Date().toISOString().slice(0, 10), clipId); - await fs.mkdir(dir, { recursive: true }); - const target = path.join(dir, `raw${path.extname(containerPath) || '.mkv'}`); - await fs.copyFile(containerPath, target); - repo.updateClip(clipId, { raw_path: target, status: 'raw' }); - repo.event('obs.replay_saved', { path: target, bytes: size }, deps.currentStreamId()); - log.info(`Rohdatei gesichert: ${target} (${(size / 1024 / 1024).toFixed(1)} MB)`); + // Die Rohdatei landet im Arbeits-Volume, NICHT im Bind-Mount: sie ist + // mehrere hundert MB gross, und ueber die Windows-Bruecke zu lesen und + // zu schreiben kostet ein Vielfaches. Auf die Windows-Seite kommt + // spaeter nur das fertige, kleine Ergebnis. Siehe ADR-0004, Punkt 2. + const workDir = path.join(cfg.workDir, clipId); + await fs.mkdir(workDir, { recursive: true }); + const raw = path.join(workDir, `raw${path.extname(containerPath) || '.mkv'}`); + await fs.copyFile(containerPath, raw); + + const outDir = path.join(cfg.clipsDir, new Date().toISOString().slice(0, 10), clipId); + repo.updateClip(clipId, { raw_path: raw, status: 'raw' }); + repo.event('obs.replay_saved', { path: raw, bytes: size }, deps.currentStreamId()); + log.info(`Rohdatei gesichert: ${(size / 1024 / 1024).toFixed(1)} MB`); deps.say('Clip gesichert.'); + deps.onRawReady({ clipId, rawPath: raw, outDir }); } catch (err) { repo.updateClip(clipId, { status: 'failed' }); log.error(`Rohdatei nicht uebernommen: ${(err as Error).message}`); diff --git a/apps/ingest/src/index.ts b/apps/ingest/src/index.ts index 5ad0ebd..901910d 100644 --- a/apps/ingest/src/index.ts +++ b/apps/ingest/src/index.ts @@ -5,7 +5,7 @@ // EventSub kommt spaeter - fuer den Clip-Weg reicht IRC plus Polling. // Siehe docs/projects/00-ingest.md. -import { loadConfig, paths } from '#core/config.ts'; +import { loadConfig, loadLayout, paths } from '#core/config.ts'; import { logger } from '#core/logger.ts'; import { sleep } from '#core/retry.ts'; import { openDb, closeDb } from '#db/index.ts'; @@ -15,6 +15,7 @@ import { helix } from '#twitch/helix.ts'; import { createIrc } from '#twitch/irc.ts'; import { readSecrets } from '#twitch/secrets.ts'; import { createClipper } from './clips.ts'; +import { createRenderer } from './render.ts'; import { createSpikeDetector } from './spike.ts'; import { startServer } from './server.ts'; @@ -66,6 +67,26 @@ export async function runIngest(): Promise { } ); + const layout = loadLayout(); + const profile = layout.profiles['default']; + if (!profile) throw new Error('config/layout.yaml hat kein Profil "default"'); + + const renderer = createRenderer({ + cfg: { + profile, + tailSeconds: cfg.clipper.tailSeconds, + outputTrack: cfg.clipper.audio.outputTrack, + encoder: cfg.clipper.ffmpeg.encoder, + preset: cfg.clipper.ffmpeg.preset, + crf: cfg.clipper.ffmpeg.crf, + niceness: cfg.clipper.ffmpeg.niceness, + renderWhileLive: cfg.clipper.renderWhileLive, + }, + repo, + workDir: paths.work, + isLive: () => state.streamId !== null, + }); + const clipper = createClipper({ cfg: { enabled: cfg.clipper.enabled, @@ -76,12 +97,14 @@ export async function runIngest(): Promise { ...(process.env.OBS_REPLAY_DIR ? { replayDirHost: process.env.OBS_REPLAY_DIR } : {}), replayDirContainer: paths.replay, clipsDir: paths.clips, + workDir: paths.work, }, repo, obs, say: (text) => irc.say(text), currentStreamId: () => state.streamId, broadcasterId: () => state.broadcasterId, + onRawReady: (job) => renderer.enqueue(job), }); startServer({ @@ -92,6 +115,8 @@ export async function runIngest(): Promise { db: repo.stats(), spikeArmed: spikes.isArmed(), clipsWaitingForFile: clipper.pendingCount(), + renderQueue: renderer.pending(), + rendering: renderer.currentJob(), note: state.chatAnonymous ? 'Chat wird anonym mitgelesen. Zum Senden und fuer Helix: Einrichtung unter /setup.' : undefined, diff --git a/apps/ingest/src/render.ts b/apps/ingest/src/render.ts new file mode 100644 index 0000000..ecabf5b --- /dev/null +++ b/apps/ingest/src/render.ts @@ -0,0 +1,145 @@ +// Render-Warteschlange: aus der Rohdatei wird ein 9:16-Video. +// +// Eine Aufgabe nach der anderen. Waehrend gesendet wird, ist Encodieren +// standardmaessig AUS (`renderWhileLive: false`) - die Kandidatenliste wird +// erst nach dem Stream gebraucht, und der Stream braucht die CPU vorher. +// Siehe docs/07-mvp-plan.md, Risikotabelle. + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { logger } from '#core/logger.ts'; +import type { LayoutProfile } from '#core/config.ts'; +import type { Repos } from '#db/repos.ts'; +import { FfmpegError, probe, run } from '#media/ffmpeg.ts'; +import { thumbnailArgs, verticalArgs } from '#media/vertical.ts'; + +const log = logger('render'); + +export type RenderConfig = { + profile: LayoutProfile; + tailSeconds: number; + outputTrack: number; + encoder: string; + preset: string; + crf: number; + niceness: number; + renderWhileLive: boolean; +}; + +export type RenderDeps = { + cfg: RenderConfig; + repo: Repos; + workDir: string; + isLive: () => boolean; +}; + +type Job = { clipId: string; rawPath: string; outDir: string }; + +export function createRenderer(deps: RenderDeps) { + const queue: Job[] = []; + let running = false; + let current: string | null = null; + + function enqueue(job: Job): void { + if (queue.some((j) => j.clipId === job.clipId) || current === job.clipId) return; + queue.push(job); + log.info(`In der Warteschlange: ${job.clipId} (${queue.length} offen)`); + void pump(); + } + + async function pump(): Promise { + if (running) return; + if (!deps.cfg.renderWhileLive && deps.isLive()) { + log.debug('Stream laeuft - Rendern verschoben'); + return; + } + running = true; + try { + let job: Job | undefined; + while ((job = queue.shift())) { + if (!deps.cfg.renderWhileLive && deps.isLive()) { + queue.unshift(job); // zurueck in die Schlange + break; + } + current = job.clipId; + try { + await render(job); + } catch (err) { + deps.repo.updateClip(job.clipId, { status: 'failed' }); + if (err instanceof FfmpegError) { + log.error(`Rendern fehlgeschlagen (${job.clipId}): ${err.message}`); + for (const line of err.tail.split('\n').slice(-6)) log.error(` ${line}`); + } else { + log.error(`Rendern fehlgeschlagen (${job.clipId}): ${(err as Error).message}`); + } + } finally { + current = null; + } + } + } finally { + running = false; + } + } + + async function render(job: Job): Promise { + const started = Date.now(); + const info = await probe(job.rawPath); + log.info(`Rendere ${job.clipId}: ${info.width}x${info.height}, ` + + `${info.durationSeconds.toFixed(1)} s, ${info.audio.length} Tonspur(en)`); + + if (info.width !== 1920 || info.height !== 1080) { + log.warn(`Quelle ist ${info.width}x${info.height}, das Layout ist fuer 1920x1080 ` + + 'eingemessen - der Ausschnitt sitzt vermutlich falsch.'); + } + + // Faellt die konfigurierte Tonspur weg, nimm die erste vorhandene + const track = deps.cfg.outputTrack < info.audio.length ? deps.cfg.outputTrack : 0; + if (track !== deps.cfg.outputTrack) { + log.warn(`Tonspur ${deps.cfg.outputTrack} gibt es nicht, nehme Spur ${track}`); + } + + await fs.mkdir(job.outDir, { recursive: true }); + const vertical = path.join(job.outDir, 'vertical.mp4'); + const thumb = path.join(job.outDir, 'thumb.jpg'); + + let lastLogged = 0; + await run('ffmpeg', verticalArgs({ + input: job.rawPath, + output: vertical, + profile: deps.cfg.profile, + tailSeconds: Math.min(deps.cfg.tailSeconds, Math.max(1, Math.floor(info.durationSeconds))), + audioTrack: track, + encoder: deps.cfg.encoder, + preset: deps.cfg.preset, + crf: deps.cfg.crf, + }), { + niceness: deps.cfg.niceness, + timeoutMs: 20 * 60_000, + onProgress: (s) => { + if (s - lastLogged >= 10) { lastLogged = s; log.debug(` ${s.toFixed(0)} s verarbeitet`); } + }, + }); + + await run('ffmpeg', thumbnailArgs(vertical, thumb), { niceness: deps.cfg.niceness }); + + const out = await probe(vertical); + deps.repo.updateClip(job.clipId, { + vertical_path: vertical, + duration_s: out.durationSeconds, + status: 'rendered', + }); + deps.repo.event('clip.rendered', { + clipId: job.clipId, path: vertical, seconds: out.durationSeconds, + }); + log.info(`Fertig: ${vertical} (${out.durationSeconds.toFixed(1)} s, ` + + `${((Date.now() - started) / 1000).toFixed(0)} s Rechenzeit)`); + } + + return { + enqueue, + /** Nach Streamende aufrufen: arbeitet ab, was liegen geblieben ist. */ + flush: () => { void pump(); }, + pending: () => queue.length + (current ? 1 : 0), + currentJob: () => current, + }; +} diff --git a/config/config.example.yaml b/config/config.example.yaml index 9597d6e..5e651d0 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -31,7 +31,9 @@ clipper: enabled: true source: obs-replay # obs-replay | twitch alsoCreateTwitchClip: true - maxDurationSeconds: 60 + # Aus dem 90-Sekunden-Puffer werden die letzten Sekunden genommen - + # dort liegt der Moment, der die Ausloesung verursacht hat. + tailSeconds: 45 renderWhileLive: false # true = sofort rendern, false = erst nach Streamende audio: # OBS nimmt getrennte Spuren auf. Untertitel entstehen NUR aus dem Mikrofon - diff --git a/docker/entrypoint.ts b/docker/entrypoint.ts index 22761ce..645e825 100644 --- a/docker/entrypoint.ts +++ b/docker/entrypoint.ts @@ -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 { // ---------------------------------------------------------------- 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 { const layout = loadLayout(); const prof = layout.profiles?.[profileName]; @@ -277,7 +271,7 @@ async function calibrate(input?: string, profileName = 'default'): Promise 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 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 { + if (!input) throw new Error('Datei angeben: render [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 [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}".`); diff --git a/docs/07-mvp-plan.md b/docs/07-mvp-plan.md index 1d1b809..eed8332 100644 --- a/docs/07-mvp-plan.md +++ b/docs/07-mvp-plan.md @@ -37,8 +37,9 @@ gibt. Vorher ist sie nur Ballast. | S0 Container + Toolchain | **fertig** — Image 258 MB, `check-env` ohne harte Fehler | | Layout eingemessen | **fertig** — `config/layout.yaml`, vorgezogen aus S3 | | S1 Einrichtung + Auth + DB + IRC-Ingest | **fertig** — 20 Tests grün, Daemon läuft | -| S2 OBS-Anbindung + Trigger | **Code fertig**, 29 Tests grün — Abnahme braucht laufendes OBS | -| S3–S6 | offen | +| S2 OBS-Anbindung + Trigger | **Code fertig**, Abnahme braucht laufendes OBS | +| S3 9:16-Render | **fertig** — Durchlauf verifiziert, 39 Tests grün | +| S4–S6 | offen | **S1 abgenommen am 2026-09-09.** Der Daemon läuft, hängt am Chat, die Einrichtung ist unter `http://localhost:4792/setup` erreichbar. @@ -169,6 +170,20 @@ Arbeitsvolume vor der Verarbeitung. **Fertig, wenn:** Aus einer Replay-Datei entsteht eine `vertical.mp4` mit korrekt sitzender Facecam oben und Gameplay unten. +**S3 abgenommen am 2026-09-09.** Verifiziert an einer künstlichen Aufnahme, die +eine OBS-Aufzeichnung nachbildet: 1920×1080, 70 s, drei getrennte Tonspuren +(Mischung, Mikrofon, Spiel). Ergebnis 1080×1920, 45 s, Naht bei y = 512 an der +richtigen Stelle, Rechenzeit 27,9 s bei `nice 15` — also gut zwei Drittel der +Echtzeit auf einer CPU ohne Grafikbeschleunigung. + +Dabei aufgefallen und korrigiert: S2 hat die Rohdatei in den Windows-Bind-Mount +kopiert und dort verarbeitet — genau das, was [ADR-0004](decisions/ADR-0004-docker.md) +Punkt 2 verbietet. Jetzt liegt die Rohdatei im Arbeits-Volume, und auf die +Windows-Seite kommt nur das fertige, kleine Ergebnis. + +Neu dazu: `docker compose run --rm toolkit render ` rendert eine Rohdatei +von Hand — gedacht zum Nachrendern, wenn sich das Layout ändert. + ### S4 — Untertitel · ~1,5 Tage **Nur die Mikrofonspur** extrahieren (OBS nimmt getrennt auf: Mikro Spur 2, diff --git a/package.json b/package.json index 7b51078..bb1ec5e 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "#core/*": "./packages/core/src/*", "#db/*": "./packages/db/src/*", "#twitch/*": "./packages/twitch/src/*", - "#obs/*": "./packages/obs/src/*" + "#obs/*": "./packages/obs/src/*", + "#media/*": "./packages/media/src/*" }, "scripts": { "start": "docker compose up -d", diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 718664e..282af91 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -34,6 +34,9 @@ export type Config = { cooldownUserSeconds: number; followersOnly: boolean; renderWhileLive: boolean; + tailSeconds: number; + audio: { transcribeTrack: number; outputTrack: number }; + ffmpeg: { encoder: string; preset: string; crf: number; niceness: number }; }; ingestServer: { port: number }; dashboard: { port: number }; @@ -61,6 +64,13 @@ const DEFAULTS = { cooldownUserSeconds: 300, followersOnly: false, renderWhileLive: false, + // Der Wiedergabepuffer ist 90 s lang, ein Short soll das nicht sein. + // Genommen werden die letzten Sekunden - dort liegt der Ausloeser. + tailSeconds: 45, + // OBS nimmt getrennte Spuren auf: 1 = Mischung, 2 = Mikrofon, 3 = Spiel. + // Hier zaehlen die Positionen unter den Audiospuren, also 0-basiert. + audio: { transcribeTrack: 1, outputTrack: 0 }, + ffmpeg: { encoder: 'libx264', preset: 'veryfast', crf: 20, niceness: 15 }, }, ingestServer: { port: 4790 }, dashboard: { port: 4792 }, diff --git a/packages/media/src/ffmpeg.ts b/packages/media/src/ffmpeg.ts new file mode 100644 index 0000000..2b4e515 --- /dev/null +++ b/packages/media/src/ffmpeg.ts @@ -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 { + 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 { + 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; + }>; + }; + 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, + }; +} diff --git a/packages/media/src/media.test.ts b/packages/media/src/media.test.ts new file mode 100644 index 0000000..b48885c --- /dev/null +++ b/packages/media/src/media.test.ts @@ -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); +}); diff --git a/packages/media/src/vertical.ts b/packages/media/src/vertical.ts new file mode 100644 index 0000000..c182751 --- /dev/null +++ b/packages/media/src/vertical.ts @@ -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, + ]; +}