Files
abdulive/scripts/make-layout-template.py
ZeasyAlexandClaude Opus 5 974dfd075b 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
2026-09-09 10:55:54 +02:00

96 lines
3.5 KiB
Python

#!/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]})")