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>
142 lines
6.5 KiB
Python
142 lines
6.5 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Laengen-Check: Video gegen Audio je Sprache. Schreibt einen Bericht als Textdatei.
|
|
|
|
A) VOR dem Export (nur Schnittliste + Audio, es wird nichts gerendert):
|
|
python -I laengen_check.py --name f01 --schnittliste schnittlisten\\f01.csv --audio-ordner D:\\ton\\f01
|
|
|
|
B) NACH dem Export (fertige mp4 pruefen: Aufloesung, Bild-/Tonlaenge, Lautheit):
|
|
python -I laengen_check.py --name f01 --export-ordner schnitt\\ausgabe\\folgen
|
|
(prueft f01_de.mp4, f01_es.mp4, f01_fr.mp4; optional mit --audio-ordner: Quellton gegen Export)
|
|
python -I laengen_check.py --datei X.mp4 [Y.mp4 ...]
|
|
|
|
Bericht: schnitt\\berichte\\laengen_<name>_<zeit>.txt
|
|
"""
|
|
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
|
|
|
|
TOL = 0.15 # s Toleranz Bild/Ton
|
|
|
|
|
|
def vorab(args, sprachen, zeilen, warn):
|
|
audio = P.parse_audio_args(args.audio, args.audio_ordner, lambda l: f"{args.name}_{l}", sprachen)
|
|
pro_sprache = "{lang}" in args.schnittliste
|
|
zeilen.append(f"VORAB-CHECK {args.name}: Schnittliste {args.schnittliste}")
|
|
gruppen = {l: [l] for l in sprachen} if pro_sprache else {None: sprachen}
|
|
for schluessel, langs in gruppen.items():
|
|
liste = args.schnittliste.replace("{lang}", schluessel) if schluessel else args.schnittliste
|
|
from folge_exportieren import lese_schnittliste
|
|
segs = lese_schnittliste(liste, args.clips_ordner, warn)
|
|
_, V, _ = P.bild_graph(segs, "quer")
|
|
zeilen.append(f"Bildspur{' ' + schluessel if schluessel else ''}: {len(segs)} Clips, {V:.2f} s ({P.zeit(V)})")
|
|
for l in langs:
|
|
if l not in audio:
|
|
warn.append(f"{l}: keine Audiodatei gefunden.")
|
|
continue
|
|
a = P.dauer(audio[l])
|
|
diff = a - V
|
|
urteil = "passt" if abs(diff) <= TOL else (
|
|
f"Ton {diff:.1f} s LAENGER als Bild -> Standbild/Verlangsamen noetig" if diff > 0
|
|
else f"Ton {-diff:.1f} s KUERZER als Bild -> stiller Schluss")
|
|
zeilen.append(f" {l}: Ton {a:.2f} s ({P.zeit(a)}), Differenz {diff:+.2f} s -> {urteil}")
|
|
if diff > 3:
|
|
warn.append(f"{l}: Bild muesste um {diff:.1f} s verlaengert werden (mehr als 3 s).")
|
|
if -diff > 8:
|
|
warn.append(f"{l}: Ton {-diff:.1f} s kuerzer als Bild.")
|
|
if len(audio) > 1:
|
|
al = {l: P.dauer(p) for l, p in audio.items()}
|
|
k, g = min(al, key=al.get), max(al, key=al.get)
|
|
zeilen.append(f"Laengste Sprache: {g} {al[g]:.1f} s, kuerzeste: {k} {al[k]:.1f} s "
|
|
f"(+{(al[g] / al[k] - 1) * 100:.0f} %)")
|
|
if al[g] > al[k] * 1.2:
|
|
warn.append("Sprachen weichen um mehr als 20 % ab.")
|
|
|
|
|
|
def nachher(dateien, quell_audio, zeilen, warn):
|
|
zeilen.append("EXPORT-CHECK")
|
|
laengen = {}
|
|
for d in dateien:
|
|
d = Path(d)
|
|
if not d.exists():
|
|
warn.append(f"{d} fehlt.")
|
|
continue
|
|
info = P.probe(d)
|
|
v, a = info["video"], info["audio"]
|
|
if not v:
|
|
warn.append(f"{d.name}: kein Videostream.")
|
|
continue
|
|
vd = float(v.get("duration") or info["dauer"])
|
|
ad = float(a.get("duration") or 0) if a else 0.0
|
|
lau = P.messe_lautheit(d) if a else {"lufs": None, "lra": None, "tp": None}
|
|
zeilen.append(f"{d.name}: {v['width']}x{v['height']}, {v['r_frame_rate']} fps, Bild {vd:.2f} s, "
|
|
f"Ton {ad:.2f} s, {lau['lufs']} LUFS, LRA {lau['lra']}, TP {lau['tp']} dBFS")
|
|
laengen[d.stem] = vd
|
|
if not a:
|
|
warn.append(f"{d.name}: kein Audiostream.")
|
|
elif abs(vd - ad) > TOL:
|
|
warn.append(f"{d.name}: Bild {vd:.2f} s vs. Ton {ad:.2f} s (Differenz {vd - ad:+.2f} s).")
|
|
if lau["lufs"] is not None and abs(lau["lufs"] - P.LUFS_ZIEL) > 1.0:
|
|
warn.append(f"{d.name}: Lautheit {lau['lufs']} LUFS (Ziel {P.LUFS_ZIEL:g} +-1).")
|
|
if lau["tp"] is not None and lau["tp"] > -1.0:
|
|
warn.append(f"{d.name}: True Peak {lau['tp']} dBFS ueber -1.")
|
|
if quell_audio:
|
|
l = d.stem.rsplit("_", 1)[-1]
|
|
q = quell_audio.get(l)
|
|
if q:
|
|
qd = P.dauer(q)
|
|
zeilen.append(f" Quellton {l}: {qd:.2f} s, Export-Ton {ad:.2f} s")
|
|
if ad + TOL < qd:
|
|
warn.append(f"{d.name}: Export ist {qd - ad:.2f} s KUERZER als der Quellton (Sprache abgeschnitten!).")
|
|
werte = list(laengen.values())
|
|
if len(werte) > 1 and max(werte) - min(werte) > TOL:
|
|
warn.append("Die Sprachfassungen sind unterschiedlich lang: " +
|
|
", ".join(f"{k} {v:.1f} s" for k, v in laengen.items()))
|
|
|
|
|
|
def main():
|
|
P.init_konsole()
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("--name", default="check")
|
|
ap.add_argument("--schnittliste")
|
|
ap.add_argument("--export-ordner", dest="export_ordner")
|
|
ap.add_argument("--datei", nargs="*")
|
|
ap.add_argument("--sprachen", default="de,es,fr")
|
|
ap.add_argument("--audio", nargs="*")
|
|
ap.add_argument("--audio-ordner", dest="audio_ordner")
|
|
ap.add_argument("--clips-ordner", default=str(P.PROJEKT / "clips"), dest="clips_ordner")
|
|
args = ap.parse_args()
|
|
sprachen = [x.strip().lower() for x in args.sprachen.split(",") if x.strip()]
|
|
zeilen, warn = [f"Laengen-Check {datetime.datetime.now():%d.%m.%Y %H:%M}"], []
|
|
|
|
if args.schnittliste:
|
|
vorab(args, sprachen, zeilen, warn)
|
|
dateien = list(args.datei or [])
|
|
if args.export_ordner:
|
|
dateien += [Path(args.export_ordner) / f"{args.name}_{l}.mp4" for l in sprachen]
|
|
if dateien:
|
|
qa = P.parse_audio_args(args.audio, args.audio_ordner, lambda l: f"{args.name}_{l}", sprachen) \
|
|
if (args.audio or args.audio_ordner) else {}
|
|
nachher(dateien, qa, zeilen, warn)
|
|
if not args.schnittliste and not dateien:
|
|
raise P.Fehler("--schnittliste, --export-ordner oder --datei angeben.")
|
|
|
|
zeilen += ["", "Warnungen:"] + (warn or ["keine"])
|
|
P.BERICHTE.mkdir(parents=True, exist_ok=True)
|
|
r = P.BERICHTE / f"laengen_{args.name}_{datetime.datetime.now():%Y%m%d_%H%M%S}.txt"
|
|
r.write_text("\n".join(zeilen) + "\n", encoding="utf-8")
|
|
print("\n".join(zeilen))
|
|
print(f"\nBericht: {r}")
|
|
sys.exit(1 if warn else 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except P.Fehler as e:
|
|
print(f"FEHLER: {e}")
|
|
sys.exit(1)
|