From 974dfd075b8c76e9647f45c7d97a0ae468375df5 Mon Sep 17 00:00:00 2001 From: ZeasyAlex Date: Wed, 9 Sep 2026 10:55:54 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_013VTGjd92LsDDWF3frd9FTB --- config/layout.yaml | 59 ++++--- docker/entrypoint.ts | 55 ++++++- docs/07-mvp-plan.md | 28 +++- docs/assets/obs-layout-vorlage.png | Bin 0 -> 81897 bytes docs/obs-layout.md | 119 ++++++++++++++ packages/core/src/config.ts | 2 + packages/media/src/subtitles.test.ts | 154 ++++++++++++++++++ packages/media/src/subtitles.ts | 223 +++++++++++++++++++++++++++ packages/media/src/vertical.ts | 16 +- packages/media/src/whisper.ts | 106 +++++++++++++ scripts/make-layout-template.py | 95 ++++++++++++ 11 files changed, 824 insertions(+), 33 deletions(-) create mode 100644 docs/assets/obs-layout-vorlage.png create mode 100644 docs/obs-layout.md create mode 100644 packages/media/src/subtitles.test.ts create mode 100644 packages/media/src/subtitles.ts create mode 100644 packages/media/src/whisper.ts create mode 100644 scripts/make-layout-template.py 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 0000000000000000000000000000000000000000..f5be278b813938843567d74a2bdad93293288482 GIT binary patch literal 81897 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

Z@b2#-q`0JK`4psWUZLdlf7RpIh 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 C8@rtV literal 0 HcmV?d00001 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]})")