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>
200 lines
9.6 KiB
Python
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)
|