S4: Untertitel aus der Mikrofonspur, plus vorgegebenes OBS-Layout
whisper.cpp laeuft ueber die Tonspur und liefert Tokens; daraus werden Woerter mit Zeitstempeln (Wortstuecke wie "Sav"+"ina" wieder zusammengesetzt) und daraus kurze Zeilen im ASS-Format mit wortweiser Hervorhebung. SRT und Transkript fallen nebenbei ab. An einem echten deutschen Clip geprueft. Drei Fehler kamen erst dabei heraus: Zeilen mit einem einzigen Wort bei zoegerndem Sprechen, ein Nachlauf, der in die naechste Zeile ragte (ASS stapelt gleichzeitige Zeilen uebereinander), und ein Uebertragsfehler im Zeitformat bei 59,999 s. Layout wird jetzt VORGEGEBEN statt nachgemessen: ein Vergleich zweier Aufnahmen zeigte, dass die Facecam von Stream zu Stream anders liegt. Das Profil "default" beschreibt die Vorgabe (Facecam 480x270 oben links, Chat ab x=1405, Gameplay-Band 890 breit mittig auf x=960), "bestand-2026-09" haelt den alten Zustand fuer altes Material fest. Dazu eine Ausrichtungsvorlage als Bild, die in OBS ueber die Szene gelegt wird, und docs/obs-layout.md. Befund am echten Clip: im Streambild sind bereits zwei Textebenen eingebrannt (Spieluntertitel und ein Live-Caption-Overlay). Beides ist im Layout-Dokument behandelt. 53 Tests gruen. 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
0e020f3073
commit
974dfd075b
11 files changed
+824
-33
No files matched your search
+50
-5
@@ -9,7 +9,9 @@ import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
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 { micTrackArgs, thumbnailArgs, verticalArgs, verticalFilter } from '#media/vertical.ts';
|
||||
import { transcribe } from '#media/whisper.ts';
|
||||
import { buildAss, buildSrt, toLines } from '#media/subtitles.ts';
|
||||
import { loadConfig } from '#core/config.ts';
|
||||
import { openDb } from '#db/index.ts';
|
||||
import { secretStatus } from '#twitch/secrets.ts';
|
||||
@@ -320,11 +322,12 @@ async function renderOne(input?: string, profileName = 'default'): Promise<void>
|
||||
const track = cfg.clipper.audio.outputTrack < info.audio.length
|
||||
? cfg.clipper.audio.outputTrack : 0;
|
||||
|
||||
const tail = Math.min(cfg.clipper.tailSeconds, Math.max(1, Math.floor(info.durationSeconds)));
|
||||
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))),
|
||||
tailSeconds: tail,
|
||||
audioTrack: track,
|
||||
encoder: cfg.clipper.ffmpeg.encoder,
|
||||
preset: cfg.clipper.ffmpeg.preset,
|
||||
@@ -336,9 +339,51 @@ async function renderOne(input?: string, profileName = 'default'): Promise<void>
|
||||
});
|
||||
await runMedia('ffmpeg', thumbnailArgs(vertical, path.join(outDir, 'thumb.jpg')));
|
||||
|
||||
const out = await probe(vertical);
|
||||
console.log(`
|
||||
Fertig: ${vertical}`);
|
||||
// ---- Untertitel (S4)
|
||||
const model = path.join(paths.models, cfg.clipper.whisper.model);
|
||||
let finalFile = vertical;
|
||||
if (await fs.stat(model).then(() => true, () => false)) {
|
||||
const micTrack = cfg.clipper.audio.transcribeTrack < info.audio.length
|
||||
? cfg.clipper.audio.transcribeTrack : 0;
|
||||
if (micTrack !== cfg.clipper.audio.transcribeTrack) {
|
||||
console.log(` Hinweis: Tonspur ${cfg.clipper.audio.transcribeTrack} (Mikrofon) gibt es ` +
|
||||
`nicht - transkribiere Spur ${micTrack}. Bei Twitch-Clips ist das normal, ` +
|
||||
'die enthalten nur die Mischung.');
|
||||
}
|
||||
const wav = path.join(outDir, 'mic.wav');
|
||||
await runMedia('ffmpeg', micTrackArgs(input, wav, micTrack, tail));
|
||||
|
||||
console.log(' Transkribiere ...');
|
||||
const words = await transcribe(wav, {
|
||||
binary: process.env['WHISPER_BIN'] ?? 'whisper-cli',
|
||||
model,
|
||||
language: cfg.clipper.whisper.language,
|
||||
threads: cfg.clipper.whisper.threads,
|
||||
});
|
||||
const lines = toLines(words);
|
||||
const ass = path.join(outDir, 'subs.ass');
|
||||
await fs.writeFile(ass, buildAss(lines, { width: 1080, height: 1920 }));
|
||||
await fs.writeFile(path.join(outDir, 'subs.srt'), buildSrt(lines));
|
||||
await fs.writeFile(path.join(outDir, 'transcript.txt'),
|
||||
lines.map((l) => l.words.map((w) => w.text).join(' ')).join('\n'));
|
||||
|
||||
finalFile = path.join(outDir, 'final.mp4');
|
||||
await runMedia('ffmpeg', [
|
||||
'-y', '-hide_banner', '-loglevel', 'error', '-i', vertical,
|
||||
'-vf', `ass=${ass}`, '-c:a', 'copy',
|
||||
'-c:v', cfg.clipper.ffmpeg.encoder, '-preset', cfg.clipper.ffmpeg.preset,
|
||||
'-crf', String(cfg.clipper.ffmpeg.crf), '-pix_fmt', 'yuv420p',
|
||||
'-movflags', '+faststart', finalFile,
|
||||
], { niceness: cfg.clipper.ffmpeg.niceness });
|
||||
console.log(` ${words.length} Woerter, ${lines.length} Zeilen`);
|
||||
} else {
|
||||
console.log(` Kein Sprachmodell unter ${model} - ohne Untertitel. ` +
|
||||
'Holen mit: fetch-model');
|
||||
}
|
||||
|
||||
const out = await probe(finalFile);
|
||||
if (process.stdout.isTTY) process.stdout.write('\r');
|
||||
console.log(`\nFertig: ${finalFile}`);
|
||||
console.log(` ${out.width}x${out.height}, ${out.durationSeconds.toFixed(1)} s, ` +
|
||||
`Rechenzeit ${((Date.now() - started) / 1000).toFixed(1)} s`);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user