youtube/schnitt/pipeline/short_rendern.py
Lara c27a7ab9f5 Pino-Material aus Laras Chat und Plan-Dokument ins Repo übernehmen
Alle 44 Tabs des Plan-Dokuments (Drehbücher, Sprechtexte DE/ES/FR,
Clip-Prompts, Adventskalender, Kanaltexte, Produktionsliste) als Markdown
unter drehbuecher/, konzept/, ideen/ und veroeffentlichung/. Dazu Projektstand
und Entscheidungen (wissen/), Schnitt-Pipeline, Shorts-Katalog,
Figuren-Referenzbilder und das Kinderkanal-Team als Subagenten
(.claude/agents/). Videos, Startbilder und Ton bleiben außerhalb des Git.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:50:01 +01:00

200 lines
9.6 KiB
Python

# -*- coding: utf-8 -*-
"""Rendert Shorts (1080x1920) je Sprache aus einer Zeile des Moment-Katalogs.
Aufruf (immer mit -I):
python -I short_rendern.py --id S001 --audio-ordner D:\\ton\\shorts
python -I short_rendern.py --id S001,S002 --sprachen de,es
python -I short_rendern.py --alle --audio-ordner ... (alle Zeilen mit Status offen)
python -I short_rendern.py --clips "f01_c08-13" --modus blur --name probe --audio de=a.wav es=b.wav fr=c.wav
Audio je Sprache: <audio-ordner>\\<id>_<sprache>.wav|mp3|m4a|flac (S001_de.wav ...)
Ausgabe: <ausgabe>\\shorts\\<id>_<sprache>.mp4 (Standard: schnitt\\ausgabe)
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # noetig bei python -I
import pinolib as P # noqa: E402
def segmente_aus_zeile(z, clips_root):
tokens = P.expandiere(z["clips"])
if not tokens:
raise P.Fehler("Spalte 'clips' ist leer.")
pfade = P.suche_clips(tokens, clips_root)
n = len(tokens)
tempo = P.zahl(z.get("tempo"), 1.0)
if tempo < 0.8 or tempo > 1.0:
print(f" WARNUNG: tempo {tempo} ausserhalb 0,8-1,0 (ruhiges Tempo).")
modi = P.auf_laenge(P.liste_texte(z.get("modus"), "blur"), n, "modus")
cxs = P.auf_laenge(P.liste_zahlen(z.get("crop_x"), 0.5), n, "crop_x")
for m in modi:
if m not in ("crop", "blur"):
raise P.Fehler(f"modus muss crop oder blur sein, nicht '{m}'.")
uebergang = P.zahl(z.get("uebergang_s"), 0.5)
return [P.Seg(t, p, tempo=tempo, uebergang_s=uebergang, modus=m, crop_x=c)
for t, p, m, c in zip(tokens, pfade, modi, cxs)]
def render_short(kid, segs, start, ende, audio, musik, args, bericht):
from pathlib import Path
ausdir = P.pruefe_ziel(Path(args.ausgabe) / "shorts", args.ausserhalb)
sprachen = [l for l in args.sprachen if l in audio]
fehlend = [l for l in args.sprachen if l not in audio]
if fehlend:
bericht.append(f"{kid}: kein Audio fuer {', '.join(fehlend)} -> diese Sprache uebersprungen.")
print(" " + bericht[-1])
if not sprachen:
raise P.Fehler(f"{kid}: fuer keine Sprache Audio vorhanden.")
# Bildlaenge bestimmen (Probe-Graph ohne Extra)
_, gesamt_probe_laenge, _ = P.bild_graph(segs, "hoch")
ende_e = min(ende or gesamt_probe_laenge, gesamt_probe_laenge)
start_e = start or 0.0
if ende and ende > gesamt_probe_laenge + 0.05:
bericht.append(f"{kid}: ende_s {ende} > Clipfolge {gesamt_probe_laenge:.2f} s -> gekuerzt.")
L = ende_e - start_e
alen = {l: P.dauer(audio[l]) for l in sprachen}
# Jede Sprache bekommt ihre eigene Laenge (eigener Kanal, eigener Upload):
# Bild = Clipfolge, bei laengerem Ton wird das letzte Bild gehalten.
Tl = {l: max(L, alen[l] + args.nachlauf) for l in sprachen}
msgs = [f"{kid}: Bild {L:.2f} s, Ton " + ", ".join(f"{l} {a:.2f} s" for l, a in alen.items())
+ " -> Ausgabe " + ", ".join(f"{l} {Tl[l]:.2f} s" for l in sprachen)]
for l in sprachen:
extra = Tl[l] - L
if extra > 0.05:
msgs.append(f"{kid} {l}: Bild wird {extra:.2f} s als Standbild gehalten"
+ (" (WARNUNG: mehr als 3 s - Clips verlangsamen oder anderes Stueck waehlen)"
if extra > 3 else ""))
if not 20 <= Tl[l] <= 45:
msgs.append(f"{kid} {l}: WARNUNG Laenge {Tl[l]:.1f} s ausserhalb 20-45 s.")
if L - alen[l] > 4:
msgs.append(f"{kid} {l}: WARNUNG Bild endet {L - alen[l]:.1f} s nach dem Ton (stiller Schluss).")
for m in msgs:
print(" " + m)
bericht += msgs
# Zielpfade pruefen, bevor irgendetwas gerendert wird
ziele = {}
for l in sprachen:
z = ausdir / f"{kid}_{l}.mp4"
P.pruefe_ziel(z, args.ausserhalb)
P.schreibschutz(z, args.ueberschreiben)
ziele[l] = z
if not args.trocken:
ausdir.mkdir(parents=True, exist_ok=True)
temps, ergebnisse, bilder = [], {}, {}
for l in sprachen:
extra = max(0.0, Tl[l] - L)
key = round(extra, 2)
if key not in bilder:
graph, _, _ = P.bild_graph(segs, "hoch", start=start_e, ende=ende_e,
extra_s=extra, ausblenden_s=args.ausblenden)
tmp_bild = P.tmp_datei(f"{kid}_bild_{len(bilder)}.mp4")
print(f" Bildspur rendern ({len(segs)} Clips, {segs[0].modus}, {L + extra:.1f} s) ...")
temps.append(tmp_bild)
temps.append(P.kodiere_bild([s.pfad for s in segs], graph, tmp_bild,
f"{kid}_v{len(bilder)}", dry=args.trocken))
bilder[key] = tmp_bild
print(f" Ton {l}: mischen, auf {P.LUFS_ZIEL:g} LUFS normalisieren ...")
t, mess = P.mische_und_schreibe(bilder[key], audio[l], musik, L + extra, args.ausblenden,
args.musik_db, ziele[l], f"{kid}_{l}",
dry=args.trocken, duck=not args.ohne_ducking)
temps += t
if not args.trocken:
info = P.probe(ziele[l])
v = info["video"]
lau = P.messe_lautheit(ziele[l])
zeile = (f"{ziele[l].name}: {v['width']}x{v['height']}, {info['dauer']:.2f} s, "
f"{lau['lufs']} LUFS, TP {lau['tp']} dBFS")
print(" -> " + zeile)
bericht.append(zeile)
ergebnisse[l] = (ziele[l], info, lau)
P.aufraeumen(temps)
return ergebnisse
def main():
P.init_konsole()
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--katalog", default=str(P.SCHNITT / "katalog_shorts.csv"))
ap.add_argument("--id", help="Katalog-ID(s), Komma getrennt")
ap.add_argument("--alle", action="store_true", help="alle Katalogzeilen (nur mit Status offen)")
ap.add_argument("--clips", help="statt Katalog: Clipliste, z. B. f01_c08-13")
ap.add_argument("--name", default="probe", help="Name bei --clips")
ap.add_argument("--modus", default="blur", help="crop|blur (bei --clips), mehrere mit |")
ap.add_argument("--crop-x", default="0.5", dest="crop_x")
ap.add_argument("--tempo", default="1.0")
ap.add_argument("--start", type=float, default=None)
ap.add_argument("--ende", type=float, default=None)
ap.add_argument("--sprachen", default="de,es,fr")
ap.add_argument("--audio", nargs="*", help="sprache=datei (z. B. de=a.wav es=b.wav)")
ap.add_argument("--audio-ordner", default=str(P.PROJEKT / "ton" / "shorts"))
ap.add_argument("--musik", help="Musikdatei (leise darunter, wird geloopt)")
ap.add_argument("--musik-db", type=float, default=-20.0, dest="musik_db")
ap.add_argument("--ohne-ducking", action="store_true", dest="ohne_ducking")
ap.add_argument("--clips-ordner", default=str(P.PROJEKT / "clips"), dest="clips_ordner")
ap.add_argument("--ausgabe", default=str(P.AUSGABE))
ap.add_argument("--ausblenden", type=float, default=1.0, help="Ausblendzeit am Ende in s (0 = aus)")
ap.add_argument("--nachlauf", type=float, default=0.8, help="Bild laeuft so viele s nach dem Ton weiter")
ap.add_argument("--ueberschreiben", action="store_true")
ap.add_argument("--ausserhalb", action="store_true", help="Ausgabe ausserhalb von schnitt\\ erlauben")
ap.add_argument("--trocken", action="store_true", help="nur anzeigen, nichts rendern")
args = ap.parse_args()
args.sprachen = [x.strip().lower() for x in args.sprachen.split(",") if x.strip()]
zeilen = []
if args.clips:
zeilen.append({"id": args.name, "clips": args.clips, "modus": args.modus,
"crop_x": args.crop_x, "tempo": args.tempo,
"start_s": "" if args.start is None else str(args.start),
"ende_s": "" if args.ende is None else str(args.ende), "musik": ""})
else:
kat = P.lese_csv(args.katalog)
if args.id:
wanted = [x.strip() for x in args.id.split(",")]
by = {z["id"]: z for z in kat}
miss = [w for w in wanted if w not in by]
if miss:
raise P.Fehler(f"ID nicht im Katalog: {', '.join(miss)}")
zeilen = [by[w] for w in wanted]
elif args.alle:
zeilen = [z for z in kat if all(z.get(f"status_{l}", "offen") in ("", "offen")
for l in args.sprachen)]
else:
raise P.Fehler("--id, --alle oder --clips angeben.")
bericht, fehler = [], 0
for z in zeilen:
kid = z["id"]
print(f"== {kid}: {z.get('thema', '')}")
try:
segs = segmente_aus_zeile(z, args.clips_ordner)
audio = P.parse_audio_args(args.audio, args.audio_ordner,
lambda l, k=kid: f"{k}_{l}", args.sprachen)
musik = args.musik or (z.get("musik") or None)
if musik and not os.path.isabs(musik):
musik = os.path.join(args.audio_ordner, musik)
render_short(kid, segs, P.zahl(z.get("start_s")), P.zahl(z.get("ende_s")),
audio, musik, args, bericht)
except P.Fehler as e:
fehler += 1
print(f" FEHLER: {e}")
bericht.append(f"{kid}: FEHLER {e}")
if not args.trocken and bericht:
import datetime
P.BERICHTE.mkdir(parents=True, exist_ok=True)
r = P.BERICHTE / f"shorts_{datetime.datetime.now():%Y%m%d_%H%M%S}.txt"
r.write_text("\n".join(bericht) + "\n", encoding="utf-8")
print(f"Bericht: {r}")
sys.exit(1 if fehler else 0)
if __name__ == "__main__":
try:
main()
except P.Fehler as e:
print(f"FEHLER: {e}")
sys.exit(1)