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:
ZeasyAlexandClaude Opus 5 committed 2026-09-09 10:55:54 +02:00
1 parent 0e020f3073
commit 974dfd075b
11 files changed
+824 -33

No files matched your search

+38 -21
View File
@@ -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 <datei> 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
+50 -5
View File
@@ -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`);
}
+26 -2
View File
@@ -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 —
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+119
View File
@@ -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 <datei> bestand-2026-09
```
+2
View File
@@ -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 },
+154
View File
@@ -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);
}
+223
View File
@@ -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<SubtitleStyle> }
): 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');
}
+11 -5
View File
@@ -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;
}
+106
View File
@@ -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<Word[]> {
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;
}
+95
View File
@@ -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]})")