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>
167 lines
8.4 KiB
Python
167 lines
8.4 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Exportiert eine Folge oder ein Tuerchen (16:9, 1280x720) je Sprache.
|
|
|
|
Gleiche Bildspur fuer alle Sprachen; die Bildlaenge richtet sich nach der laengsten
|
|
Sprachspur (Standbild am Ende) und es wird gewarnt, wenn die Sprachen zu weit
|
|
auseinanderliegen. Alle Sprachen werden gleich lang.
|
|
|
|
Aufruf:
|
|
python -I folge_exportieren.py --name f01 --schnittliste schnittlisten\\f01.csv ^
|
|
--audio-ordner D:\\ton\\f01 (erwartet f01_de.wav, f01_es.wav, f01_fr.wav)
|
|
Eigene Schnittliste je Sprache: --schnittliste schnittlisten\\f01_{lang}.csv
|
|
|
|
Schnittliste (CSV, Semikolon): clip;in_s;out_s;tempo;halten_s;uebergang_s;notiz
|
|
clip f01_c09, t09_c03, k_05 oder Bereich f01_c08-13
|
|
in_s/out_s Ausschnitt im Clip (leer = ganzer Clip)
|
|
tempo 0,8 bis 1,0 (leer = 1) halten_s Standbild nach dem Clip (z. B. 3 = Mitmachpause)
|
|
uebergang_s Ueberblendung IN den naechsten Clip (leer = 0,5; max. 1)
|
|
"""
|
|
import argparse
|
|
import datetime
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # noetig bei python -I
|
|
import pinolib as P # noqa: E402
|
|
|
|
|
|
def lese_schnittliste(pfad, clips_root, warn):
|
|
zeilen = P.lese_csv(pfad)
|
|
segs = []
|
|
for nr, z in enumerate(zeilen, 1):
|
|
tokens = P.expandiere(z.get("clip", ""))
|
|
pfade = P.suche_clips(tokens, clips_root)
|
|
tempo = P.zahl(z.get("tempo"), 1.0)
|
|
if tempo < 0.8:
|
|
warn.append(f"Zeile {nr} ({z.get('clip')}): tempo {tempo} < 0,8 (Grenze fuer ruhiges Tempo).")
|
|
if tempo > 1.0:
|
|
warn.append(f"Zeile {nr} ({z.get('clip')}): tempo {tempo} > 1 (nicht ruhig).")
|
|
ue = P.zahl(z.get("uebergang_s"), 0.5)
|
|
if ue > 1.0:
|
|
warn.append(f"Zeile {nr}: Ueberblendung {ue} s -> auf 1 s begrenzt.")
|
|
for t, p in zip(tokens, pfade):
|
|
segs.append(P.Seg(t, p, in_s=P.zahl(z.get("in_s"), 0.0) or 0.0,
|
|
out_s=P.zahl(z.get("out_s"), None), tempo=tempo,
|
|
halten_s=P.zahl(z.get("halten_s"), 0.0) or 0.0,
|
|
uebergang_s=min(ue, 1.0), modus="quer"))
|
|
if not segs:
|
|
raise P.Fehler(f"Schnittliste leer: {pfad}")
|
|
return segs
|
|
|
|
|
|
def main():
|
|
P.init_konsole()
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("--name", required=True, help="Dateiname ohne Sprache, z. B. f01 oder t09")
|
|
ap.add_argument("--schnittliste", required=True, help="CSV; {lang} fuer eigene Liste je Sprache")
|
|
ap.add_argument("--sprachen", default="de,es,fr")
|
|
ap.add_argument("--audio", nargs="*", help="sprache=datei")
|
|
ap.add_argument("--audio-ordner", default=None, dest="audio_ordner",
|
|
help="enthaelt <name>_<sprache>.wav|mp3|m4a|flac")
|
|
ap.add_argument("--musik", help="Musik unter der Sprache (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)
|
|
ap.add_argument("--nachlauf", type=float, default=1.0, help="Bild laeuft so viele s nach dem Ton weiter")
|
|
ap.add_argument("--max-abweichung", type=float, default=0.20, dest="max_abw",
|
|
help="Warnung, wenn laengste Sprache so viel (Anteil) laenger ist als die kuerzeste")
|
|
ap.add_argument("--ueberschreiben", action="store_true")
|
|
ap.add_argument("--ausserhalb", action="store_true")
|
|
ap.add_argument("--trocken", action="store_true")
|
|
args = ap.parse_args()
|
|
sprachen = [x.strip().lower() for x in args.sprachen.split(",") if x.strip()]
|
|
|
|
audio = P.parse_audio_args(args.audio, args.audio_ordner, lambda l: f"{args.name}_{l}", sprachen)
|
|
fehlt = [l for l in sprachen if l not in audio]
|
|
if fehlt:
|
|
print(f"WARNUNG: kein Audio fuer {', '.join(fehlt)} -> diese Sprache wird nicht exportiert.")
|
|
sprachen = [l for l in sprachen if l in audio]
|
|
if not sprachen:
|
|
raise P.Fehler("Fuer keine Sprache Audio gefunden (--audio oder --audio-ordner).")
|
|
|
|
warn, bericht = [], []
|
|
pro_sprache = "{lang}" in args.schnittliste
|
|
gruppen = {l: [l] for l in sprachen} if pro_sprache else {None: sprachen}
|
|
|
|
alen = {l: P.dauer(audio[l]) for l in sprachen}
|
|
bericht.append(f"Folge {args.name}, {datetime.datetime.now():%d.%m.%Y %H:%M}")
|
|
bericht.append("Tonlaengen: " + ", ".join(f"{l} {a:.2f} s ({P.zeit(a)})" for l, a in alen.items()))
|
|
kurz, lang = min(alen, key=alen.get), max(alen, key=alen.get)
|
|
if len(alen) > 1 and alen[lang] > alen[kurz] * (1 + args.max_abw):
|
|
w = (f"Sprachlaengen weichen um {(alen[lang] / alen[kurz] - 1) * 100:.0f} % ab "
|
|
f"({lang} {alen[lang]:.1f} s vs. {kurz} {alen[kurz]:.1f} s).")
|
|
if not pro_sprache:
|
|
w += (" Eine gemeinsame Bildspur passt dann nicht zu allen Sprachen: besser eigene "
|
|
"Schnittliste je Sprache (--schnittliste ...{lang}.csv) oder Clips an die laengste Sprache anpassen.")
|
|
warn.append(w)
|
|
|
|
ausdir = P.pruefe_ziel(Path(args.ausgabe) / "folgen", args.ausserhalb)
|
|
ziele = {}
|
|
for l in sprachen:
|
|
z = ausdir / f"{args.name}_{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 = [], []
|
|
for schluessel, langs in gruppen.items():
|
|
liste = args.schnittliste.replace("{lang}", schluessel) if schluessel else args.schnittliste
|
|
segs = lese_schnittliste(liste, args.clips_ordner, warn)
|
|
graph0, V, _ = P.bild_graph(segs, "quer")
|
|
T = max(V, max(alen[l] for l in langs) + args.nachlauf)
|
|
extra = T - V
|
|
tag = f" [{schluessel}]" if schluessel else ""
|
|
bericht.append(f"Bildspur{tag}: {len(segs)} Clips, {V:.2f} s ({P.zeit(V)}); Ausgabe {T:.2f} s ({P.zeit(T)})")
|
|
if extra > 0.05:
|
|
msg = f"Bild{tag} wird um {extra:.2f} s als Standbild am Ende verlaengert."
|
|
if extra > 3:
|
|
msg = "WARNUNG " + msg[:-1] + " (mehr als 3 s: Clips verlangsamen / Standbilder an Sprechstellen einplanen)."
|
|
warn.append(msg)
|
|
for l in langs:
|
|
rest = T - alen[l]
|
|
bericht.append(f" {l}: Ton {alen[l]:.2f} s, Ton endet {rest:.2f} s vor Bildende")
|
|
if rest > 8:
|
|
warn.append(f"{l}: Ton endet {rest:.1f} s vor Bildende (andere Sprache ist deutlich laenger).")
|
|
graph, _, ges = P.bild_graph(segs, "quer", extra_s=extra, ausblenden_s=args.ausblenden)
|
|
tmp_bild = P.tmp_datei(f"{args.name}_{schluessel or 'alle'}_bild.mp4")
|
|
temps.append(tmp_bild)
|
|
print(f"Bildspur{tag} rendern: {len(segs)} Clips, {ges:.1f} s ...")
|
|
temps.append(P.kodiere_bild([s.pfad for s in segs], graph, tmp_bild,
|
|
f"{args.name}_{schluessel or 'alle'}", dry=args.trocken))
|
|
for l in langs:
|
|
print(f"Ton {l}: mischen, auf {P.LUFS_ZIEL:g} LUFS normalisieren, exportieren ...")
|
|
t, _ = P.mische_und_schreibe(tmp_bild, audio[l], args.musik, ges, args.ausblenden,
|
|
args.musik_db, ziele[l], f"{args.name}_{l}",
|
|
dry=args.trocken, duck=not args.ohne_ducking)
|
|
temps += t
|
|
if not args.trocken:
|
|
info = P.probe(ziele[l])
|
|
lau = P.messe_lautheit(ziele[l])
|
|
v = info["video"]
|
|
z = (f"{ziele[l].name}: {v['width']}x{v['height']}, {info['dauer']:.2f} s ({P.zeit(info['dauer'])}), "
|
|
f"{lau['lufs']} LUFS, LRA {lau['lra']}, TP {lau['tp']} dBFS")
|
|
print(" -> " + z)
|
|
ergebnisse.append(z)
|
|
P.aufraeumen(temps)
|
|
|
|
bericht += ["", "Ergebnis:"] + ergebnisse
|
|
bericht += ["", "Warnungen:"] + (warn or ["keine"])
|
|
print("\n".join(["", "Warnungen:"] + (warn or ["keine"])))
|
|
if not args.trocken:
|
|
P.BERICHTE.mkdir(parents=True, exist_ok=True)
|
|
r = P.BERICHTE / f"export_{args.name}_{datetime.datetime.now():%Y%m%d_%H%M%S}.txt"
|
|
r.write_text("\n".join(bericht) + "\n", encoding="utf-8")
|
|
print(f"Bericht: {r}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except P.Fehler as e:
|
|
print(f"FEHLER: {e}")
|
|
sys.exit(1)
|