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
@@ -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}`);
|
||||
|
||||
@@ -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<void> {
|
||||
}
|
||||
);
|
||||
|
||||
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<void> {
|
||||
...(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<void> {
|
||||
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,
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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 -
|
||||
|
||||
+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}".`);
|
||||
|
||||
+17
-2
@@ -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 <datei>` 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,
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
Reference in new issue
Block a user