diff --git a/config/layout.yaml b/config/layout.yaml index 3d218b3..d1bda89 100644 --- a/config/layout.yaml +++ b/config/layout.yaml @@ -1,44 +1,61 @@ -# Eingemessen am 2026-09-09 aus einem VOD-Screenshot (GTA 5, Szene "Gameplay"). +# Bildaufteilung fuer den 9:16-Zuschnitt. # Alle Werte in Koordinaten des fertigen Streambildes 1920x1080. # # Nachmessen: docker compose run --rm toolkit calibrate -# Hintergrund: docs/projects/clip-shorts.md +# Anleitung fuer OBS: docs/obs-layout.md source: width: 1920 height: 1080 -# Ziel: 9:16 fuer Shorts/Reels/TikTok target: width: 1080 height: 1920 profiles: - # Standardprofil: Facecam oben links, Chat-Overlay rechts eingebrannt + # ------------------------------------------------------------------ + # Das VORGEGEBENE Layout. Abdu richtet OBS danach aus, nicht umgekehrt. + # Vorlage zum Ausrichten: docs/assets/obs-layout-vorlage.png + # + # Die Zahlen sind so gewaehlt, dass sie fuer beide Formate gleichzeitig + # aufgehen: die Facecam ist 16:9 (kein Streifenschnitt noetig), das + # Gameplay-Band liegt mittig auf x=960 (dort passiert die Handlung), und + # zwischen Kamera und Band bleiben 35 px Luft. + # ------------------------------------------------------------------ default: description: > - Facecam bündig oben links (440x330, 4:3). Chat-Overlay ist ins Streambild - eingebrannt und beginnt bei x=1270 - der Gameplay-Ausschnitt endet - deshalb bei x=1268. Minimap (x<360) und Handy-Overlay (x>1540) liegen - ausserhalb und fallen automatisch weg. + Vorgegebenes Layout. Facecam 480x270 buendig oben links, Chat und + Alerts erst ab x=1405, dazwischen das Gameplay-Band. + + cam: + # Ganze Kamera, unveraendert - sie ist bereits 16:9 + crop: { x: 0, y: 0, w: 480, h: 270 } + band: { w: 1080, h: 608 } + + game: + # 890 breit, mittig auf x=960. Beginnt bei 515, also 35 px rechts + # neben der Kamera; endet bei 1405, wo die Chatspalte anfaengt. + crop: { x: 515, y: 0, w: 890, h: 1080 } + band: { w: 1080, h: 1312 } + + # 608 + 1312 = 1920 + # Vergroesserung: Kamera 2,25x, Gameplay 1,21x + + # ------------------------------------------------------------------ + # Was am 2026-09-05 tatsaechlich im Bild war. Nur als Rueckfalloption, + # solange OBS noch nicht umgestellt ist - zum Nachrendern alter Clips: + # docker compose run --rm toolkit render bestand-2026-09 + # ------------------------------------------------------------------ + bestand-2026-09: + description: > + Aus einem VOD-Screenshot eingemessen, bevor das Layout vorgegeben + wurde. Facecam 4:3 und deshalb nur als Streifen nutzbar; Chat-Overlay + begann bereits bei x=1270. cam: - # Facecam im Streambild: x 0..440, y 0..330 - # Davon wird ein Streifen genommen, der zum Zielband passt - NICHT - # verzerrt. 4:3 auf 16:9 zu strecken wuerde das Gesicht breit ziehen. crop: { x: 0, y: 100, w: 440, h: 208 } band: { w: 1080, h: 512 } game: - # Sauberes Band zwischen Facecam (endet 440) und Chat (beginnt 1270) crop: { x: 440, y: 0, w: 828, h: 1080 } band: { w: 1080, h: 1408 } - - # 512 + 1408 = 1920 - -# Messwerte aus dem Referenzbild, zur Nachvollziehbarkeit: -# Videobereich im Screenshot : x 0..1916, y 60..1137 (1917x1078) -# Facecam rechte Kante : x 439 (konstant bei y=120/200/280/360) -# Facecam untere Kante : y 391 (konstant bei x=40/140/240/340/420) -# Chat-Overlay beginnt : x 1268 -# Gesicht (Cam-lokal) : y 126..300, Mitte ~213 diff --git a/docker/entrypoint.ts b/docker/entrypoint.ts index 645e825..a21cac7 100644 --- a/docker/entrypoint.ts +++ b/docker/entrypoint.ts @@ -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 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 }); 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`); } diff --git a/docs/07-mvp-plan.md b/docs/07-mvp-plan.md index eed8332..7dc60e3 100644 --- a/docs/07-mvp-plan.md +++ b/docs/07-mvp-plan.md @@ -38,8 +38,11 @@ gibt. Vorher ist sie nur Ballast. | 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**, Abnahme braucht laufendes OBS | -| S3 9:16-Render | **fertig** — Durchlauf verifiziert, 39 Tests grün | -| S4–S6 | offen | +| S3 9:16-Render | **fertig** — Durchlauf verifiziert | +| S4 Untertitel | **fertig** — an echtem Clip verifiziert, 53 Tests grün | +| S5 Onboarding + Sichtung | als Nächstes | +| S6 Erstchatter-Begrüßung | offen | +| OBS-Layout vorgegeben | **fertig** — [`obs-layout.md`](obs-layout.md) | **S1 abgenommen am 2026-09-09.** Der Daemon läuft, hängt am Chat, die Einrichtung ist unter `http://localhost:4792/setup` erreichbar. @@ -197,6 +200,27 @@ mindestens 320 px. Ins Video kommt weiterhin die Mischung von Spur 1. Untertitel, und der Text wird auf dem Handy von der TikTok-Oberfläche nicht verdeckt. +**S4 abgenommen am 2026-09-09**, geprüft an einem echten 30-Sekunden-Clip mit +deutscher Sprache. 41 Wörter in 37 s Rechenzeit auf der CPU, Transkript +inhaltlich korrekt, Untertitel sitzen zeitlich und sind lesbar. + +Drei Fehler kamen erst am echten Material heraus: + +- **Ein-Wort-Zeilen.** Bei zögerndem Sprechen zerfiel der Text in Zeilen mit + einem einzigen Wort („Dank" / „es," / „Savina,"). Die Obergrenze von 2,2 s je + Zeile war zu eng; jetzt 3,2 s plus ein Nachverdichten, das allein stehende + Wörter an den Nachbarn hängt. 17 Zeilen wurden zu 10. +- **Überlappende Zeilen.** Der Nachlauf von 0,25 s ragte in die nächste Zeile. + ASS stapelt gleichzeitig sichtbare Zeilen übereinander — das sieht kaputt + aus. Das Ende wird jetzt am Beginn der Folgezeile abgeschnitten. +- **Zeitformat.** Bei 59,999 s rundeten die Hundertstel auf 100 und der Übertrag + lief nicht in die Minuten: `0:00:60.00` statt `0:01:00.00`. Gefunden durch + einen Test, nicht durch Zusehen. + +**Befund fürs Streamsetup:** Im Quellmaterial lagen bereits zwei Textebenen +(Spieluntertitel und ein Live-Caption-Overlay mit Wort-Markierung). Beide sind +fest eingebrannt. Behandelt in [`obs-layout.md`](obs-layout.md). + ### S5 — Oberfläche: Onboarding und Sichtung · ~2 Tage **Erststart-Assistent.** Die heutige Einrichtungsseite ist ein Formularblock — diff --git a/docs/assets/obs-layout-vorlage.png b/docs/assets/obs-layout-vorlage.png new file mode 100644 index 0000000..f5be278 Binary files /dev/null and b/docs/assets/obs-layout-vorlage.png differ diff --git a/docs/obs-layout.md b/docs/obs-layout.md new file mode 100644 index 0000000..a110f24 --- /dev/null +++ b/docs/obs-layout.md @@ -0,0 +1,119 @@ +# OBS-Layout — die Vorgabe + +**Kurzfassung für Abdu:** Facecam **480×270** bündig oben links. Chat und +Alerts erst ab **x = 1405**. Der Streifen dazwischen bleibt frei. + +![Ausrichtungsvorlage](assets/obs-layout-vorlage.png) + +## Warum überhaupt eine Vorgabe + +Der Zuschnitt auf 9:16 nimmt feste Bildbereiche aus dem fertigen Streambild. +Verrutscht die Kamera um 50 Pixel, sitzt in jedem Short der Kopf schief — und +das merkt man erst, wenn der Clip fertig ist. + +Ein Vergleich zweier Aufnahmen hat gezeigt, dass die Kamera derzeit von Stream +zu Stream unterschiedlich liegt. Statt das nachzumessen, steht die Position +jetzt fest. Einmal einrichten, danach passt jeder Clip. + +## Die Zahlen + +| Element | Position | Größe | +|---|---|---| +| **Facecam** | x 0, y 0 (bündig oben links) | **480 × 270** | +| **Gameplay-Band** | x 515 bis 1405 | 890 × 1080 | +| **Chat, Alerts, Overlays** | ab x 1405 | 515 px Spalte | +| **Minimap, HUD, Kacheln** | x 0 bis 515, unterhalb der Kamera | frei | + +Zwischen Kamera (endet bei 480) und Gameplay-Band (beginnt bei 515) liegen +35 Pixel Luft. Der Abstand ist Absicht: Ein leicht verrutschter Kamerarahmen +schiebt dann nicht sofort einen Streifen Gesicht in das Gameplay-Bild. + +## So richtest du es ein + +1. **Vorlage laden.** In OBS eine **Bild-Quelle** anlegen, Datei + `docs/assets/obs-layout-vorlage.png`, und sie in der Quellenliste **ganz + nach oben** ziehen. Größe auf 1920×1080, Position 0,0. +2. **Deckkraft runter.** Rechtsklick auf die Quelle → Filter → *Farbkorrektur* + → Deckkraft ca. 60 %. Dann sieht man die eigenen Quellen darunter. +3. **Facecam ziehen**, bis sie den grünen Rahmen ausfüllt. Beim Skalieren + `Shift` **nicht** gedrückt halten — das Seitenverhältnis muss 16:9 bleiben, + die Kamera darf nicht verzerrt werden. + > Steht die Kamera in OBS als 4:3 (z. B. 640×480), vorher in den + > Kameraeinstellungen auf eine 16:9-Auflösung umstellen (1280×720 oder + > 1920×1080). Ein 4:3-Bild in einen 16:9-Rahmen zu ziehen staucht das + > Gesicht. +4. **Chat-Overlay und Alerts** nach rechts schieben, sodass sie komplett + rechts der orangen Linie liegen. +5. **Alles andere** (Minimap gehört zum Spiel, Kacheln, Sub-Ziel) nach links + unten oder in die rechte Spalte — nur nicht in den cyanen Bereich. +6. **Vorlage ausblenden** (Auge anklicken). Nicht löschen: beim nächsten + Layout-Umbau ist sie wieder da. + +## Prüfen + +```powershell +docker compose run --rm toolkit calibrate +``` + +Nimmt das neueste Video aus dem Wiedergabepuffer-Ordner und legt drei Bilder in +`data/clips/_calibration/` ab: das Einzelbild, dasselbe mit eingezeichneten +Ausschnitten, und die fertige 9:16-Vorschau. Sitzt in der Vorschau der Kopf +mittig und ist im Gameplay-Teil kein Chat zu sehen, stimmt das Layout. + +## Text im Bild — hier wird es eng + +Bei der Prüfung eines echten Clips lagen **drei Textebenen übereinander**: + +1. die Untertitel des Spiels („Genau, ich mach da … sag zu dem Typen") +2. ein **Live-Caption-Overlay** mit lila Wort-Markierung („Okay FIFA **Es**") +3. dazu käme der Untertitel, den das Toolkit für das Short erzeugt + +Das ist eine Ebene zu viel — und die ersten beiden sind fest ins Streambild +eingebrannt, lassen sich also nachträglich nicht entfernen. + +**Spieluntertitel: aus.** Sie laufen über die volle Bildbreite, werden vom +Ausschnitt rechts angeschnitten und bringen im Short nichts. + +**Live-Captions: verschieben, nicht abschalten.** Für Zuschauer im Stream sind +sie wertvoll, gerade für Leute, die ohne Ton mitlesen. Sie müssen nur aus dem +cyanen Band heraus. Zwei brauchbare Plätze: + +| Ort | Vorteil | Nachteil | +|---|---|---| +| **unten links**, x 0 bis 515, zweizeilig | bleibt live sichtbar, stört das Short nicht | schmaler, also mehr Zeilenumbrüche | +| **rechte Spalte** unter dem Chat, ab x 1405 | viel Platz | konkurriert mit dem Chat um Aufmerksamkeit | + +Ganz abschalten geht auch — dann haben nur die Shorts Untertitel. Das ist eine +Entscheidung für den Stream, nicht fürs Toolkit; beides funktioniert. + +Ein **HUD am rechten Rand** (Killfeed, Inventar) ist im Short nicht zu sehen. +Wo das stört, bekommt das Spiel ein eigenes Profil in +[`config/layout.yaml`](../config/layout.yaml). + +## Was das Ergebnis begrenzt — und wie man es hebt + +Das Short entsteht aus dem **fertig zusammengesetzten Streambild**. Die Kamera +ist darin 480 Pixel breit und wird für das Hochformat auf 1080 vergrößert, also +um Faktor 2,25. Das sieht weicher aus als das Original. Genauso das Gameplay, +Faktor 1,21 — das fällt kaum auf. + +Wer schärfer will, braucht eine zweite Bildquelle statt eines Zuschnitts: + +| Weg | Aufwand | Ergebnis | +|---|---|---| +| Zuschnitt aus dem Streambild (heute) | keiner | brauchbar, leicht weich | +| OBS-Plugin **Source Record** — Kamera zusätzlich einzeln aufnehmen | Plugin installieren, eine Quelle konfigurieren | Kamera in voller Auflösung, Zusammensetzen aus zwei Dateien | +| OBS-Plugin **Aitum Vertical** — echte zweite Leinwand 1080×1920 | Plugin plus eigene Szene | bestes Ergebnis, kein Zuschnitt nötig | + +Das ist eine Aufgabe für nach dem MVP. Der Zuschnitt reicht, um anzufangen — +und ohne Clips, die überhaupt entstehen, bringt die beste Bildqualität nichts. + +## Wenn das Layout sich doch ändert + +`config/layout.yaml` kennt mehrere Profile. Das Profil `bestand-2026-09` hält +fest, wie es vor der Vorgabe aussah — damit lassen sich alte Aufnahmen noch +korrekt rendern: + +```powershell +docker compose run --rm toolkit render bestand-2026-09 +``` diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 282af91..4115c46 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -37,6 +37,7 @@ export type Config = { tailSeconds: number; audio: { transcribeTrack: number; outputTrack: number }; ffmpeg: { encoder: string; preset: string; crf: number; niceness: number }; + whisper: { model: string; language: string; threads: number }; }; ingestServer: { port: number }; dashboard: { port: number }; @@ -71,6 +72,7 @@ const DEFAULTS = { // Hier zaehlen die Positionen unter den Audiospuren, also 0-basiert. audio: { transcribeTrack: 1, outputTrack: 0 }, ffmpeg: { encoder: 'libx264', preset: 'veryfast', crf: 20, niceness: 15 }, + whisper: { model: 'ggml-small.bin', language: 'de', threads: 4 }, }, ingestServer: { port: 4790 }, dashboard: { port: 4792 }, diff --git a/packages/media/src/subtitles.test.ts b/packages/media/src/subtitles.test.ts new file mode 100644 index 0000000..7bc6105 --- /dev/null +++ b/packages/media/src/subtitles.test.ts @@ -0,0 +1,154 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import type { Word } from './whisper.ts'; +import { wordsFromJson } from './whisper.ts'; +import { assColor, assTime, buildAss, buildSrt, isNoise, toLines } from './subtitles.ts'; + +function w(text: string, start: number, end: number): Word { + return { text, start, end }; +} + +// ------------------------------------------------------------ Wortzerlegung + +test('setzt Wortstuecke wieder zusammen', () => { + // So sieht whisper.cpp wirklich aus: Tokens mit fuehrendem Leerzeichen + // beginnen ein Wort, alles andere haengt sich an. + const json = JSON.stringify({ + transcription: [{ + offsets: { from: 0, to: 4200 }, + tokens: [ + { text: '[_BEG_]', offsets: { from: 0, to: 0 } }, + { text: ' Sav', offsets: { from: 2280, to: 3120 } }, + { text: 'ina', offsets: { from: 3280, to: 3880 } }, + { text: ',', offsets: { from: 3990, to: 4140 } }, + ], + }], + }); + const words = wordsFromJson(json); + assert.deepEqual(words.map((x) => x.text), ['Savina,']); + assert.equal(words[0]?.start, 2.28); +}); + +test('wirft Sondertokens weg', () => { + const json = JSON.stringify({ + transcription: [{ tokens: [ + { text: '[_BEG_]', offsets: { from: 0, to: 0 } }, + { text: ' hallo', offsets: { from: 100, to: 400 } }, + { text: '[_TT_120]', offsets: { from: 400, to: 400 } }, + ] }], + }); + assert.deepEqual(wordsFromJson(json).map((x) => x.text), ['hallo']); +}); + +test('zieht rueckwaerts laufende Zeitstempel gerade', () => { + const json = JSON.stringify({ + transcription: [{ tokens: [ + { text: ' eins', offsets: { from: 1000, to: 2000 } }, + { text: ' zwei', offsets: { from: 1500, to: 1800 } }, + ] }], + }); + const words = wordsFromJson(json); + assert.ok(words[1]!.start >= words[0]!.end, 'zweites Wort darf nicht vor dem ersten enden'); + assert.ok(words[1]!.end > words[1]!.start); +}); + +// ------------------------------------------------------------ Zeilenbildung + +test('bricht lange Rede in kurze Zeilen', () => { + const words = ['eins', 'zwei', 'drei', 'vier', 'fuenf', 'sechs', 'sieben', 'acht'] + .map((t, i) => w(t, i * 0.4, i * 0.4 + 0.35)); + const lines = toLines(words); + assert.ok(lines.length >= 2, 'acht Woerter gehoeren nicht auf eine Zeile'); + for (const l of lines) assert.ok(l.words.length <= 5, 'Zeile zu lang'); +}); + +test('haengt einzeln stehende Woerter an den Nachbarn', () => { + // Genau der Fall aus dem echten Clip: langsames Sprechen erzeugte + // Zeilen mit einem Wort ("Dank", "es,", "Savina,") + const words = [w('Dank', 0, 1.14), w('es,', 1.14, 2.28), w('Savina,', 2.28, 4.14)]; + const lines = toLines(words); + assert.ok(lines.length < 3, `immer noch zersplittert: ${JSON.stringify(lines.map(l => l.words.map(x => x.text)))}`); + for (const l of lines) { + assert.ok(l.words.length > 1 || lines.length === 1, 'Zeile mit nur einem Wort uebrig'); + } +}); + +test('trennt bei laengeren Sprechpausen', () => { + const words = [w('vor', 0, 0.4), w('der', 0.4, 0.8), w('Pause', 4.0, 4.4)]; + const lines = toLines(words); + assert.equal(lines.length, 2); + assert.equal(lines[1]?.words[0]?.text, 'Pause'); +}); + +test('filtert Geraeuschbeschreibungen', () => { + assert.equal(isNoise('*Lachen*'), true); + assert.equal(isNoise('[Musik]'), true); + assert.equal(isNoise('(Applaus)'), true); + assert.equal(isNoise('Lachen'), false); + const lines = toLines([w('hallo', 0, 0.5), w('*Lachen*', 0.6, 1.2)]); + assert.deepEqual(lines.flatMap((l) => l.words.map((x) => x.text)), ['hallo']); +}); + +// ------------------------------------------------------------ ASS-Ausgabe + +test('Zeilen ueberlappen sich nie', () => { + // Zwei gleichzeitig sichtbare Dialoge stapelt ASS uebereinander + const words = Array.from({ length: 12 }, (_, i) => w(`w${i}`, i * 0.5, i * 0.5 + 0.45)); + const ass = buildAss(toLines(words), { width: 1080, height: 1920 }); + const times = [...ass.matchAll(/^Dialogue: 0,([\d:.]+),([\d:.]+),/gm)] + .map((m) => [toSeconds(m[1]!), toSeconds(m[2]!)] as const); + assert.ok(times.length >= 2); + for (let i = 1; i < times.length; i++) { + assert.ok(times[i]![0] >= times[i - 1]![1] - 0.011, + `Zeile ${i} beginnt bei ${times[i]![0]}, vorherige endet erst bei ${times[i - 1]![1]}`); + } +}); + +test('jede Zeile bleibt mindestens kurz stehen', () => { + const ass = buildAss(toLines([w('kurz', 0, 0.05)]), { width: 1080, height: 1920 }); + const m = /^Dialogue: 0,([\d:.]+),([\d:.]+),/m.exec(ass); + assert.ok(m); + assert.ok(toSeconds(m[2]!) - toSeconds(m[1]!) >= 0.29); +}); + +test('Kopf enthaelt Aufloesung, Stil und Sicherheitsabstand', () => { + const ass = buildAss(toLines([w('test', 0, 1)]), { width: 1080, height: 1920 }); + assert.match(ass, /PlayResX: 1080/); + assert.match(ass, /PlayResY: 1920/); + // Alignment 2 = unten mittig, MarginV 340 haelt die Bedienoberflaeche frei + assert.match(ass, /,2,70,70,340,1$/m); +}); + +test('Farben werden in die ASS-Reihenfolge gedreht', () => { + // RRGGBB -> &HAABBGGRR + assert.equal(assColor('FFD400'), '&H0000D4FF'); + assert.equal(assColor('FFFFFF'), '&H00FFFFFF'); + assert.equal(assColor('000000', 0x80), '&H80000000'); +}); + +test('Zeitformat entspricht ASS', () => { + assert.equal(assTime(0), '0:00:00.00'); + assert.equal(assTime(61.5), '0:01:01.50'); + assert.equal(assTime(3661.239), '1:01:01.24'); + // Aufrundung auf 100 Hundertstel darf nicht ".100" ergeben + assert.equal(assTime(59.999), '0:01:00.00'); +}); + +test('jedes Wort bekommt eine Karaoke-Marke', () => { + const ass = buildAss(toLines([w('eins', 0, 0.5), w('zwei', 0.5, 1.0)]), + { width: 1080, height: 1920 }); + const line = /^Dialogue:.*,,(.*)$/m.exec(ass)?.[1] ?? ''; + assert.equal((line.match(/\{\\k\d+\}/g) ?? []).length, 2); +}); + +test('SRT enthaelt denselben Text', () => { + const lines = toLines([w('hallo', 0, 0.5), w('welt', 0.5, 1.0)]); + const srt = buildSrt(lines); + assert.match(srt, /hallo welt/); + assert.match(srt, /00:00:00,000 --> /); +}); + +function toSeconds(t: string): number { + const [h, m, s] = t.split(':'); + return Number(h) * 3600 + Number(m) * 60 + Number(s); +} diff --git a/packages/media/src/subtitles.ts b/packages/media/src/subtitles.ts new file mode 100644 index 0000000..098946f --- /dev/null +++ b/packages/media/src/subtitles.ts @@ -0,0 +1,223 @@ +// Untertitel im ASS-Format mit wortweisem Hervorheben. +// +// Warum ASS und nicht SRT: SRT kann nur ganze Blöcke ein- und ausblenden. Der +// Stil, der auf Shorts funktioniert, hebt das gerade gesprochene Wort hervor - +// dafuer gibt es in ASS die Karaoke-Marke \k. Sie faerbt eine Silbe zum +// richtigen Zeitpunkt von SecondaryColour nach PrimaryColour um. +// +// Der Sicherheitsabstand nach unten ist keine Kosmetik: TikTok, Reels und +// Shorts legen ihre eigene Bedienoberflaeche ueber das untere Bilddrittel. + +import type { Word } from './whisper.ts'; + +export type SubtitleStyle = { + fontName: string; + fontSize: number; + /** Farbe, sobald das Wort gesprochen ist (RGB). */ + highlight: string; + /** Farbe, solange das Wort noch nicht dran ist (RGB). */ + base: string; + outline: number; + shadow: number; + /** Abstand zum unteren Bildrand in Pixeln. */ + marginBottom: number; + marginSide: number; +}; + +export const DEFAULT_STYLE: SubtitleStyle = { + fontName: 'DejaVu Sans', + fontSize: 76, + highlight: 'FFD400', + base: 'FFFFFF', + outline: 6, + shadow: 3, + // 1920 hoch, unten bleiben 340 px frei - dort sitzt die Bedienoberflaeche + // der Plattformen (Beschreibung, Knoepfe, Musiktitel). + marginBottom: 340, + marginSide: 70, +}; + +export type LineOptions = { + maxWords: number; + maxSeconds: number; + maxChars: number; +}; + +// maxSeconds war anfangs 2,2 s. An echtem Material zeigte sich, dass bei +// langsamem oder zoegerndem Sprechen dann Zeilen mit einem einzigen Wort +// entstehen ("Dank" / "es," / "Savina,"). 3,2 s plus das Nachverdichten +// weiter unten ergibt lesbare Zeilen. +export const DEFAULT_LINES: LineOptions = { maxWords: 5, maxSeconds: 3.2, maxChars: 30 }; + +export type Line = { words: Word[]; start: number; end: number }; + +/** Geraeuschbeschreibungen von Whisper: *Lachen*, [Musik], (Applaus). */ +const NOISE = /^[*[(].*[*\])]$/; + +export function isNoise(text: string): boolean { + return NOISE.test(text.trim()); +} + +/** `RRGGBB` -> `&HAABBGGRR` (ASS speichert Farben umgedreht und mit Alpha). */ +export function assColor(rgb: string, alpha = 0): string { + const v = rgb.replace('#', '').padStart(6, '0'); + const r = v.slice(0, 2), g = v.slice(2, 4), b = v.slice(4, 6); + return `&H${alpha.toString(16).padStart(2, '0').toUpperCase()}${b}${g}${r}`.toUpperCase(); +} + +/** + * Sekunden -> `H:MM:SS.cc` + * + * Erst auf Hundertstel runden, DANN zerlegen. Andersherum kann die Rundung + * 100 Hundertstel ergeben, und der Uebertrag laeuft nicht in die Minuten - + * aus 59,999 s wurde `0:00:60.00`. + */ +export function assTime(seconds: number): string { + const totalCs = Math.max(0, Math.round(seconds * 100)); + const cs = totalCs % 100; + const totalSec = (totalCs - cs) / 100; + const s = totalSec % 60; + const m = Math.floor(totalSec / 60) % 60; + const h = Math.floor(totalSec / 3600); + return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.` + + String(cs).padStart(2, '0'); +} + +/** + * Bricht den Wortstrom in kurze Zeilen. Kurz ist hier wichtiger als + * grammatisch sauber - auf einem Handy liest niemand fuenf Woerter in + * anderthalb Sekunden. + */ +export function toLines(words: Word[], opts: LineOptions = DEFAULT_LINES): Line[] { + const lines: Line[] = []; + let current: Word[] = []; + + const flush = () => { + if (!current.length) return; + lines.push({ words: current, start: current[0]!.start, end: current[current.length - 1]!.end }); + current = []; + }; + + for (const w of words.filter((x) => !isNoise(x.text))) { + const wouldBeChars = current.reduce((n, x) => n + x.text.length + 1, 0) + w.text.length; + const wouldBeSeconds = current.length ? w.end - current[0]!.start : 0; + + if (current.length && ( + current.length >= opts.maxWords || + wouldBeChars > opts.maxChars || + wouldBeSeconds > opts.maxSeconds || + // Eine Pause von mehr als einer halben Sekunde ist ein Satzende, + // egal was die Zeichensetzung sagt + w.start - current[current.length - 1]!.end > 0.5 + )) flush(); + + current.push(w); + if (/[.!?]$/.test(w.text)) flush(); + } + flush(); + return mergeStragglers(lines, opts); +} + +/** + * Einzelne Woerter, die allein auf einer Zeile stehen, an den Nachbarn haengen. + * Eine Zeile mit einem Wort steht anderthalb Sekunden im Bild und wirkt wie ein + * Fehler - selbst wenn sie dadurch etwas laenger wird als die Obergrenze. + */ +function mergeStragglers(lines: Line[], opts: LineOptions): Line[] { + const out: Line[] = []; + for (const line of lines) { + const prev = out[out.length - 1]; + const chars = (l: Line) => l.words.reduce((n, w) => n + w.text.length + 1, 0); + const soloAndClose = prev && line.words.length === 1 && + line.start - prev.end < 0.6 && + prev.words.length + 1 <= opts.maxWords + 1 && + chars(prev) + chars(line) <= opts.maxChars + 8; + + if (soloAndClose) { + prev.words.push(...line.words); + prev.end = line.end; + } else { + out.push(line); + } + } + return out; +} + +export function buildAss( + lines: Line[], + opts: { width: number; height: number; style?: Partial } +): string { + const s: SubtitleStyle = { ...DEFAULT_STYLE, ...opts.style }; + + const header = [ + '[Script Info]', + 'ScriptType: v4.00+', + `PlayResX: ${opts.width}`, + `PlayResY: ${opts.height}`, + 'WrapStyle: 2', + 'ScaledBorderAndShadow: yes', + '', + '[V4+ Styles]', + 'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, ' + + 'BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, ' + + 'BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding', + [ + 'Style: Short', s.fontName, s.fontSize, + assColor(s.highlight), // PrimaryColour: schon gesprochen + assColor(s.base), // SecondaryColour: noch nicht dran + assColor('000000'), // Outline + assColor('000000', 0x80), // BackColour + -1, 0, 0, 0, // Bold, Italic, Underline, StrikeOut + 100, 100, 0, 0, // ScaleX, ScaleY, Spacing, Angle + 1, s.outline, s.shadow, // BorderStyle, Outline, Shadow + 2, // Alignment: unten mittig + s.marginSide, s.marginSide, s.marginBottom, + 1, + ].join(','), + '', + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ]; + + const events = lines.map((line, li) => { + // Nachlauf, damit die Zeile nicht im Moment des letzten Lautes verschwindet - + // aber nie in die naechste Zeile hinein. Zwei gleichzeitig sichtbare + // Dialoge stapelt ASS uebereinander, und genau das sieht kaputt aus. + const next = lines[li + 1]; + const end = next ? Math.min(line.end + 0.25, next.start) : line.end + 0.4; + + const text = line.words.map((w, i) => { + const nextWord = line.words[i + 1]; + // Die Marke laeuft bis zum Beginn des naechsten Wortes, damit Pausen + // innerhalb der Zeile nicht als Sprung wirken. Das letzte Wort haelt + // bis zum Zeilenende. + const until = nextWord ? nextWord.start : Math.max(w.end, end); + const cs = Math.max(1, Math.round((until - w.start) * 100)); + return `{\\k${cs}}${escapeAss(w.text)}`; + }).join(' '); + return `Dialogue: 0,${assTime(line.start)},${assTime(Math.max(end, line.start + 0.3))},` + + `Short,,0,0,0,,${text}`; + }); + + return [...header, ...events, ''].join('\n'); +} + +function escapeAss(text: string): string { + return text.replace(/\\/g, '\\\\').replace(/\{/g, '\\{').replace(/\}/g, '\\}'); +} + +/** Zusaetzlich eine SRT schreiben - praktisch fuer die Upload-Beschreibung. */ +export function buildSrt(lines: Line[]): string { + return lines.map((line, i) => { + const t = (sec: number) => { + const ms = Math.round(sec * 1000); + const h = Math.floor(ms / 3600000); + const m = Math.floor((ms % 3600000) / 60000); + const s = Math.floor((ms % 60000) / 1000); + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:` + + `${String(s).padStart(2, '0')},${String(ms % 1000).padStart(3, '0')}`; + }; + return `${i + 1}\n${t(line.start)} --> ${t(line.end)}\n` + + `${line.words.map((w) => w.text).join(' ')}\n`; + }).join('\n'); +} diff --git a/packages/media/src/vertical.ts b/packages/media/src/vertical.ts index c182751..8abc653 100644 --- a/packages/media/src/vertical.ts +++ b/packages/media/src/vertical.ts @@ -67,12 +67,18 @@ export function thumbnailArgs(input: string, output: string, atSeconds = 1): str } /** 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', +export function micTrackArgs( + input: string, output: string, audioTrack: number, tailSeconds?: number +): string[] { + const args = ['-y', '-hide_banner', '-loglevel', 'error']; + // Denselben Ausschnitt transkribieren, der auch im Video landet - + // muss wie beim Bild vor -i stehen. + if (tailSeconds && tailSeconds > 0) args.push('-sseof', `-${tailSeconds}`); + args.push( '-i', input, '-map', `0:a:${audioTrack}`, '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', - output, - ]; + output + ); + return args; } diff --git a/packages/media/src/whisper.ts b/packages/media/src/whisper.ts new file mode 100644 index 0000000..7db22b3 --- /dev/null +++ b/packages/media/src/whisper.ts @@ -0,0 +1,106 @@ +// whisper.cpp aufrufen und die Ausgabe in Woerter mit Zeitstempeln umwandeln. +// +// Wir nehmen `-ojf` (JSON mit Tokens), nicht die fertige SRT: fuer das +// wortweise Hervorheben brauchen wir Zeitstempel je Wort, und die stehen nur +// in den Tokens. Tokens sind Wortstuecke - ein neues Wort beginnt dort, wo der +// Tokentext mit einem Leerzeichen anfaengt. + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { logger } from '#core/logger.ts'; +import { run } from './ffmpeg.ts'; + +const log = logger('whisper'); + +export type Word = { text: string; start: number; end: number }; + +export type WhisperOptions = { + binary?: string; + model: string; + language?: string; + threads?: number; + timeoutMs?: number; +}; + +/** Sondertokens von whisper.cpp, die nicht in den Text gehoeren. */ +const SPECIAL = /^\[_[A-Z]+_?[^\]]*\]$/; + +type WhisperJson = { + transcription?: Array<{ + offsets?: { from: number; to: number }; + text?: string; + tokens?: Array<{ text: string; offsets?: { from: number; to: number } }>; + }>; +}; + +export function wordsFromJson(raw: string): Word[] { + const data = JSON.parse(raw) as WhisperJson; + const words: Word[] = []; + + for (const seg of data.transcription ?? []) { + const tokens = seg.tokens ?? []; + if (!tokens.length) { + // Kein Token-Detail: dann eben das ganze Segment als ein "Wort" + const text = (seg.text ?? '').trim(); + if (text && seg.offsets) { + words.push({ text, start: seg.offsets.from / 1000, end: seg.offsets.to / 1000 }); + } + continue; + } + + let current: Word | null = null; + for (const t of tokens) { + const raw = t.text ?? ''; + if (SPECIAL.test(raw.trim())) continue; + const startsWord = raw.startsWith(' '); + const piece = raw.trim(); + if (!piece) continue; + + const from = (t.offsets?.from ?? 0) / 1000; + const to = (t.offsets?.to ?? 0) / 1000; + + if (!current || startsWord) { + if (current) words.push(current); + current = { text: piece, start: from, end: to }; + } else { + current.text += piece; + current.end = to; + } + } + if (current) words.push(current); + } + + // Zeitstempel monoton ziehen - vereinzelt liefert whisper.cpp Rueckspruenge + for (let i = 1; i < words.length; i++) { + const prev = words[i - 1]!; + const cur = words[i]!; + if (cur.start < prev.end) cur.start = prev.end; + if (cur.end <= cur.start) cur.end = cur.start + 0.08; + } + return words; +} + +export async function transcribe(wav: string, opts: WhisperOptions): Promise { + const prefix = wav.replace(/\.wav$/i, ''); + const args = [ + '-m', opts.model, + '-f', wav, + '-l', opts.language ?? 'de', + '-t', String(opts.threads ?? 4), + '-ojf', // JSON mit Tokens + '-of', prefix, + '-np', // keine Fortschrittsausgabe + ]; + + const started = Date.now(); + await run(opts.binary ?? 'whisper-cli', args, { + timeoutMs: opts.timeoutMs ?? 15 * 60_000, + niceness: 15, + }); + + const jsonFile = `${prefix}.json`; + const words = wordsFromJson(await fs.readFile(jsonFile, 'utf8')); + log.info(`${words.length} Woerter erkannt in ${((Date.now() - started) / 1000).toFixed(1)} s ` + + `(${path.basename(wav)})`); + return words; +} diff --git a/scripts/make-layout-template.py b/scripts/make-layout-template.py new file mode 100644 index 0000000..bcccdaf --- /dev/null +++ b/scripts/make-layout-template.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Erzeugt die Ausrichtungsvorlage fuer OBS. + +Ein 1920x1080-Bild, das Abdu in OBS als oberste Bildquelle einfuegt. Danach +zieht er Facecam, Chat und Overlays so, dass sie in die markierten Zonen +passen, und loescht die Quelle wieder. + +Aufruf: python scripts/make-layout-template.py +Braucht: Pillow (nur zum Erzeugen der Vorlage, nicht zur Laufzeit) + +Die Zahlen hier und in config/layout.yaml muessen uebereinstimmen - +siehe docs/obs-layout.md. +""" +from PIL import Image, ImageDraw, ImageFont +from pathlib import Path + +W, H = 1920, 1080 +CAM = (0, 0, 480, 270) # x, y, w, h +GAME = (515, 0, 890, 1080) # der Bereich, der ins Hochformat wandert +CHAT_X = 1405 # ab hier darf Chat und Overlay stehen + +OUT = Path(__file__).resolve().parent.parent / "docs" / "assets" / "obs-layout-vorlage.png" + + +def font(size, bold=True): + for name in ("arialbd.ttf" if bold else "arial.ttf", "segoeuib.ttf", "DejaVuSans-Bold.ttf"): + try: + return ImageFont.truetype(name, size) + except OSError: + continue + return ImageFont.load_default() + + +def box(d, rect, color, width=6): + x, y, w, h = rect + d.rectangle([x, y, x + w - 1, y + h - 1], outline=color, width=width) + + +def label(d, xy, text, color, size=30, bg=(0, 0, 0)): + f = font(size) + x, y = xy + l, t, r, b = d.textbbox((x, y), text, font=f) + d.rectangle([l - 10, t - 6, r + 10, b + 6], fill=bg) + d.text((x, y), text, fill=color, font=f) + + +img = Image.new("RGB", (W, H), (18, 18, 22)) +d = ImageDraw.Draw(img) + +# Flaechen, die nicht ins Hochformat wandern, abdunkeln +d.rectangle([0, 0, GAME[0] - 1, H], fill=(30, 26, 26)) +d.rectangle([GAME[0] + GAME[2], 0, W, H], fill=(30, 26, 26)) + +# Raster alle 100 px +for x in range(0, W, 100): + d.line([(x, 0), (x, H)], fill=(52, 52, 60), width=1) +for y in range(0, H, 100): + d.line([(0, y), (W, y)], fill=(52, 52, 60), width=1) + +# Gameplay-Band: was hier steht, landet im Short +box(d, GAME, (0, 200, 255), 8) +d.rectangle([GAME[0], 0, GAME[0] + GAME[2] - 1, H - 1], outline=None, fill=None) +label(d, (GAME[0] + 24, 108), "GAMEPLAY", (0, 200, 255), 40) +label(d, (GAME[0] + 24, 162), "dieser Streifen wird zum Short", (0, 200, 255), 30) +label(d, (GAME[0] + 24, 208), f"x {GAME[0]} bis {GAME[0] + GAME[2]} ({GAME[2]} px breit)", + (150, 220, 255), 26) +label(d, (GAME[0] + 24, H - 80), "Hier NICHTS drueberlegen: kein Chat, keine Alerts, keine Kamera", + (255, 220, 120), 26) + +# Facecam +box(d, CAM, (60, 230, 130), 8) +label(d, (16, 300), "FACECAM", (60, 230, 130), 40) +label(d, (16, 352), f"genau {CAM[2]} x {CAM[3]} px", (170, 240, 200), 28) +label(d, (16, 396), "oben links, buendig an der Ecke", (170, 240, 200), 24) +label(d, (16, 434), "Seitenverhaeltnis 16:9 —", (170, 240, 200), 24) +label(d, (16, 468), "die Kamera NICHT verzerren", (170, 240, 200), 24) + +# Chatspalte +d.line([(CHAT_X, 0), (CHAT_X, H)], fill=(255, 150, 60), width=6) +label(d, (CHAT_X + 20, 108), "CHAT + ALERTS", (255, 150, 60), 34) +label(d, (CHAT_X + 20, 160), f"nur rechts von x = {CHAT_X}", (255, 200, 150), 26) +label(d, (CHAT_X + 20, 204), f"{W - CHAT_X} px Platz", (255, 200, 150), 26) + +# Linke Restspalte +label(d, (16, 620), "Frei fuer Minimap,", (200, 170, 170), 26) +label(d, (16, 656), "HUD, Kacheln —", (200, 170, 170), 26) +label(d, (16, 692), "kommt nicht ins Short", (200, 170, 170), 26) + +# Kopfzeile +label(d, (16, 16), "OBS-Ausrichtungsvorlage", (235, 235, 235), 30) +label(d, (16, 60), "docs/obs-layout.md", (170, 170, 170), 22) + +OUT.parent.mkdir(parents=True, exist_ok=True) +img.save(OUT) +print(f"geschrieben: {OUT} ({img.size[0]}x{img.size[1]})")