# -*- coding: utf-8 -*- """Gemeinsame Funktionen der Schnitt-Pipeline (Pinos kleine Welt). Wird von den anderen Skripten importiert. Nicht direkt aufrufen. Regeln: - Es wird nur unterhalb von schnitt\\ geschrieben (ausser mit --ausserhalb). - Vorhandene Dateien werden nie ueberschrieben (ausser mit --ueberschreiben). - Originalclips werden nur gelesen. """ import csv import json import os import re import shutil import subprocess import sys from pathlib import Path SCHNITT = Path(__file__).resolve().parents[1] # ...\Pino-Kanal\schnitt PROJEKT = SCHNITT.parent # ...\Pino-Kanal TMP = SCHNITT / "tmp" BERICHTE = SCHNITT / "berichte" AUSGABE = SCHNITT / "ausgabe" FFMPEG_STANDARD = Path( r"C:\Users\lk-lo\AppData\Local\Microsoft\WinGet\Packages" r"\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe" r"\ffmpeg-9.0-full_build\bin\ffmpeg.exe") SPRACHEN = ("de", "es", "fr") FPS = 24 LUFS_ZIEL = -14.0 TP_ZIEL = -1.5 LRA_ZIEL = 11.0 AUDIO_ENDUNGEN = (".wav", ".mp3", ".m4a", ".flac", ".ogg", ".aac") MAX_SEGMENTE = 70 class Fehler(Exception): """Verstaendlicher Fehler fuer die Nutzerin (kein Traceback).""" def init_konsole(): for s in (sys.stdout, sys.stderr): try: s.reconfigure(encoding="utf-8", errors="replace") except Exception: pass # ----------------------------------------------------------------- Programme def finde_programm(name): kandidaten = [] env = os.environ.get("PINO_FFMPEG_ORDNER") if env: kandidaten.append(Path(env) / (name + ".exe")) kandidaten.append(FFMPEG_STANDARD.with_name(name + ".exe")) for k in kandidaten: if k.exists(): return str(k) p = shutil.which(name) if p: return p raise Fehler(f"{name} nicht gefunden. Pfad in pinolib.py (FFMPEG_STANDARD) " f"oder Umgebungsvariable PINO_FFMPEG_ORDNER setzen.") FFMPEG = None FFPROBE = None def programme(): global FFMPEG, FFPROBE if FFMPEG is None: FFMPEG = finde_programm("ffmpeg") FFPROBE = finde_programm("ffprobe") return FFMPEG, FFPROBE def lauf(args, dry=False): """Fuehrt ein Programm aus. Gibt (returncode, stdout, stderr) zurueck.""" if dry: print(" [trocken] " + subprocess.list2cmdline([str(a) for a in args])) return 0, "", "" r = subprocess.run([str(a) for a in args], capture_output=True) return (r.returncode, r.stdout.decode("utf-8", "replace"), r.stderr.decode("utf-8", "replace")) def ffmpeg_lauf(args, dry=False, info=False, was="ffmpeg"): ff, _ = programme() basis = [ff, "-y", "-hide_banner", "-nostdin", "-nostats", "-loglevel", "info" if info else "error"] rc, out, err = lauf(basis + list(args), dry=dry) if rc != 0: raise Fehler(f"{was} fehlgeschlagen (Code {rc}):\n{err.strip()[-1500:]}") return out, err _probe_cache = {} def probe(pfad): pfad = str(pfad) if pfad in _probe_cache: return _probe_cache[pfad] _, fp = programme() rc, out, err = lauf([fp, "-v", "error", "-show_entries", "format=duration:stream=index,codec_type,codec_name,width,height," "r_frame_rate,sample_rate,channels,duration", "-of", "json", pfad]) if rc != 0: raise Fehler(f"ffprobe kann {pfad} nicht lesen:\n{err.strip()[-500:]}") d = json.loads(out) erg = {"dauer": float(d.get("format", {}).get("duration", 0) or 0), "video": None, "audio": None} for s in d.get("streams", []): if s.get("codec_type") == "video" and erg["video"] is None: erg["video"] = s if s.get("codec_type") == "audio" and erg["audio"] is None: erg["audio"] = s _probe_cache[pfad] = erg return erg def dauer(pfad): return probe(pfad)["dauer"] def messe_lautheit(pfad): """Integrierte Lautheit (LUFS), LRA und True Peak einer Datei.""" ff, _ = programme() rc, out, err = lauf([ff, "-hide_banner", "-nostdin", "-nostats", "-i", str(pfad), "-vn", "-af", "ebur128=peak=true", "-f", "null", "-"]) teil = err.split("Summary:")[-1] if "Summary:" in err else err def zahl(muster): m = re.findall(muster, teil) return float(m[-1]) if m else None return {"lufs": zahl(r"I:\s+(-?[\d.]+)\s+LUFS"), "lra": zahl(r"LRA:\s+(-?[\d.]+)\s+LU"), "tp": zahl(r"Peak:\s+(-?[\d.]+)\s+dBFS")} # --------------------------------------------------------------- Schreibschutz def pruefe_ziel(pfad, ausserhalb_ok=False): p = Path(pfad).resolve() if not ausserhalb_ok and SCHNITT not in p.parents and p != SCHNITT: raise Fehler(f"Ziel {p} liegt ausserhalb von {SCHNITT}. " f"Mit --ausserhalb ausdruecklich erlauben.") return p def schreibschutz(pfad, ueberschreiben=False): p = Path(pfad) if p.exists() and not ueberschreiben: raise Fehler(f"{p} existiert schon. Nichts wird ueberschrieben " f"(--ueberschreiben, um es doch zu tun).") def tmp_datei(name): TMP.mkdir(parents=True, exist_ok=True) return TMP / name def aufraeumen(dateien): """Loescht nur eigene Temp-Dateien in schnitt\\tmp.""" for d in dateien: try: p = Path(d).resolve() if TMP in p.parents and p.exists(): p.unlink() except Exception: pass # -------------------------------------------------------------- Zahlen / Listen def zahl(text, standard=None): if text is None: return standard t = str(text).strip().replace(",", ".") if t == "": return standard return float(t) def liste_zahlen(text, standard): t = (text or "").strip().replace(",", ".") if not t: return [standard] return [float(x) for x in t.split("|") if x.strip() != ""] def liste_texte(text, standard): t = (text or "").strip().lower() if not t: return [standard] return [x.strip() for x in re.split(r"[|,;]", t) if x.strip()] def auf_laenge(werte, n, name): if len(werte) == 1: return werte * n if len(werte) != n: raise Fehler(f"{name}: {len(werte)} Werte, aber {n} Clips.") return werte # ---------------------------------------------------------------------- Clips _TOKEN = re.compile(r"^([ft])(\d+)_c(\d+)$", re.I) _RANGE = re.compile(r"^([ft]\d+_c)(\d+)-(?:\1)?(\d+)$", re.I) def expandiere(text): """'f01_c08-13, f01_c20' -> ['f01_c08', ..., 'f01_c13', 'f01_c20']""" erg = [] for tok in re.split(r"[,\s]+", (text or "").strip()): if not tok: continue m = _RANGE.match(tok) if m: pre, a, b = m.group(1), int(m.group(2)), int(m.group(3)) if b < a: raise Fehler(f"Bereich rueckwaerts: {tok}") breite = max(2, len(m.group(2))) erg += [f"{pre}{n:0{breite}d}" for n in range(a, b + 1)] else: erg.append(tok) return erg def clip_pfad(token, clips_root): """Findet die Clipdatei zu 'f01_c09', 't09_c03' oder 'k_05' / 'k_01_kerze3'.""" root = Path(clips_root) m = _TOKEN.match(token) if m: art, nn, cc = m.group(1).lower(), int(m.group(2)), int(m.group(3)) ordner = root / (f"folge{nn:02d}" if art == "f" else "tuerchen") namen = [f"{art}{nn:02d}_c{cc:02d}.mp4", f"{art}{nn}_c{cc:02d}.mp4", f"{art}{nn:02d}_c{cc}.mp4"] for n in namen: if (ordner / n).exists(): return ordner / n for n in namen: # Notfall: irgendwo unter clips\ treffer = list(root.rglob(n)) if treffer: return treffer[0] return None if token.lower().startswith("k_"): ordner = root / "bausteine" genau = ordner / (token + ".mp4") if genau.exists(): return genau treffer = sorted(ordner.glob(token + "*.mp4")) if ordner.exists() else [] if len(treffer) == 1: return treffer[0] if len(treffer) > 1: raise Fehler(f"Baustein '{token}' ist mehrdeutig: " + ", ".join(t.name for t in treffer)) return None # direkter Dateiname / Pfad p = Path(token) if p.exists(): return p if (root / token).exists(): return root / token return None def suche_clips(tokens, clips_root): pfade, fehlt = [], [] for t in tokens: p = clip_pfad(t, clips_root) if p is None: fehlt.append(t) pfade.append(p) if fehlt: raise Fehler("Clips fehlen unter " + str(clips_root) + ": " + ", ".join(fehlt)) return pfade # -------------------------------------------------------------------- Segmente class Seg: def __init__(self, token, pfad, in_s=0.0, out_s=None, tempo=1.0, halten_s=0.0, uebergang_s=0.5, modus="blur", crop_x=0.5): self.token = token self.pfad = Path(pfad) d = dauer(pfad) self.in_s = max(0.0, in_s) self.out_s = min(out_s, d) if out_s and out_s > 0 else d if self.out_s <= self.in_s: raise Fehler(f"{token}: out_s ({self.out_s}) <= in_s ({self.in_s})") self.tempo = tempo self.halten_s = max(0.0, halten_s) self.uebergang_s = uebergang_s self.modus = modus self.crop_x = min(1.0, max(0.0, crop_x)) def dauer_s(self): frames = max(1, round(((self.out_s - self.in_s) / self.tempo) * FPS)) return frames / FPS + self.halten_s def seg_filter(i, s, ziel): """Filterkette fuer ein Segment. ziel = 'quer' (1280x720) oder 'hoch' (1080x1920).""" pre = (f"[{i}:v]trim=start={s.in_s:.4f}:end={s.out_s:.4f}," f"setpts=(PTS-STARTPTS)/{s.tempo:.4f},fps={FPS}") hold = f",tpad=stop_mode=clone:stop_duration={s.halten_s:.4f}" if s.halten_s > 0 else "" if ziel == "quer": return (f"{pre},scale=1280:720:force_original_aspect_ratio=decrease:flags=lanczos," f"pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1," f"format=yuv420p{hold}[v{i}]") if s.modus == "crop": return (f"{pre},crop=w='trunc(ih*9/16/2)*2':h=ih:x='(iw-ow)*{s.crop_x:.3f}':y=0," f"scale=1080:1920:flags=lanczos,setsar=1,format=yuv420p{hold}[v{i}]") # blur = Blur-Pad: scharfes 16:9-Bild in der Mitte, unscharfer Rahmen return (f"{pre},split[a{i}][b{i}];" f"[a{i}]scale=270:480:force_original_aspect_ratio=increase,crop=270:480," f"gblur=sigma=5,scale=1080:1920:flags=bicubic,eq=brightness=-0.04:saturation=0.85[bg{i}];" f"[b{i}]scale=1080:-2:flags=lanczos[fg{i}];" f"[bg{i}][fg{i}]overlay=(W-w)/2:(H-h)/2,setsar=1,format=yuv420p{hold}[v{i}]") def bild_graph(segs, ziel, start=None, ende=None, extra_s=0.0, ausblenden_s=1.0): """Baut den Filtergraphen der Bildspur. Gibt (graph_text, laenge_ohne_extra, gesamtlaenge) zurueck, Ausgang [vout].""" if not segs: raise Fehler("Keine Clips in der Bildspur.") if len(segs) > MAX_SEGMENTE: raise Fehler(f"{len(segs)} Segmente sind zu viel fuer einen Durchgang " f"(max. {MAX_SEGMENTE}). Gesamtfolgen aus fertigen Tuerchen-" f"Exporten zusammensetzen, nicht aus Einzelclips.") teile = [seg_filter(i, s, ziel) for i, s in enumerate(segs)] d = [s.dauer_s() for s in segs] letzt = "v0" akk = d[0] for i in range(1, len(segs)): t = segs[i - 1].uebergang_s t = max(1.0 / FPS, min(t, 1.0, d[i] - 1.0 / FPS, akk - 1.0 / FPS)) off = akk - t teile.append(f"[{letzt}][v{i}]xfade=transition=fade:duration={t:.4f}:offset={off:.4f}[x{i}]") akk = akk + d[i] - t letzt = f"x{i}" gesamt = akk nach = [] laenge = gesamt if start is not None or ende is not None: a = start or 0.0 b = min(ende if ende else gesamt, gesamt) nach.append(f"trim=start={a:.4f}:end={b:.4f}") nach.append("setpts=PTS-STARTPTS") laenge = b - a gesamtlaenge = laenge + max(0.0, extra_s) if extra_s > 0: nach.append(f"tpad=stop_mode=clone:stop_duration={extra_s:.4f}") if ausblenden_s > 0: nach.append(f"fade=t=out:st={max(0.0, gesamtlaenge - ausblenden_s):.4f}:d={ausblenden_s:.4f}") nach.append("format=yuv420p") teile.append(f"[{letzt}]" + ",".join(nach) + "[vout]") return ";\n".join(teile), laenge, gesamtlaenge def schreibe_graph(text, name): p = tmp_datei(name) p.write_text(text, encoding="utf-8") return p def kodiere_bild(clip_pfade, graph_text, ziel_datei, tmpname, dry=False): gp = schreibe_graph(graph_text, tmpname + "_video.txt") args = [] for p in clip_pfade: args += ["-i", str(p)] args += ["-/filter_complex", str(gp), "-map", "[vout]", "-an", "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p", "-profile:v", "high", "-r", str(FPS), "-movflags", "+faststart", str(ziel_datei)] ffmpeg_lauf(args, dry=dry, was="Bildspur") return gp # ----------------------------------------------------------------------- Audio def audio_graph(idx_voice, idx_musik, T, ausblenden_s, musik_db=-20.0, duck=True): a = (f"[{idx_voice}:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo," f"apad=whole_dur={T:.3f},atrim=duration={T:.3f},asetpts=PTS-STARTPTS[voice]") g = [a] if idx_musik is not None: g.append(f"[{idx_musik}:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo," f"volume={musik_db}dB,atrim=duration={T:.3f},asetpts=PTS-STARTPTS," f"afade=t=in:d=1.5[mus]") if duck: g.append("[voice]asplit=2[vo][vsc]") g.append("[mus][vsc]sidechaincompress=threshold=0.015:ratio=8:attack=50:release=800[musd]") g.append("[vo][musd]amix=inputs=2:duration=first:normalize=0[mix]") else: g.append("[voice][mus]amix=inputs=2:duration=first:normalize=0[mix]") else: g.append("[voice]anull[mix]") if ausblenden_s > 0: g.append(f"[mix]afade=t=out:st={max(0.0, T - ausblenden_s):.3f}:d={ausblenden_s:.3f}[aout]") else: g.append("[mix]anull[aout]") return ";".join(g) def parse_loudnorm_json(text): m = re.findall(r"\{[^{}]*\"input_i\"[^{}]*\}", text, re.S) if not m: return None return json.loads(m[-1]) def mische_und_schreibe(video_datei, voice, musik, T, ausblenden_s, musik_db, ziel_datei, tmpname, dry=False, duck=True): """Mischt Sprache (+Musik), misst/normalisiert auf -14 LUFS (zweistufig) und schreibt Video (kopiert) + AAC-Audio in ziel_datei.""" eingaben = ["-i", str(video_datei), "-i", str(voice)] idx_m = None if musik: eingaben += ["-stream_loop", "-1", "-i", str(musik)] idx_m = 2 g = audio_graph(1, idx_m, T, ausblenden_s, musik_db, duck) # Durchgang 1: messen g1 = g + f";[aout]loudnorm=I={LUFS_ZIEL}:TP={TP_ZIEL}:LRA={LRA_ZIEL}:print_format=json[o]" p1 = schreibe_graph(g1, tmpname + "_a1.txt") mess = None if not dry: _, err = ffmpeg_lauf(eingaben + ["-/filter_complex", str(p1), "-map", "[o]", "-t", f"{T:.3f}", "-f", "null", "-"], info=True, was="Lautheit messen") mess = parse_loudnorm_json(err) if mess is None: raise Fehler("Lautheitsmessung lieferte kein Ergebnis.") ln = f"loudnorm=I={LUFS_ZIEL}:TP={TP_ZIEL}:LRA={LRA_ZIEL}" if mess and mess["input_i"] not in ("-inf", "inf"): ln += (f":measured_I={mess['input_i']}:measured_TP={mess['input_tp']}" f":measured_LRA={mess['input_lra']}:measured_thresh={mess['input_thresh']}" f":offset={mess['target_offset']}:linear=true") g2 = g + f";[aout]{ln},aresample=48000[afinal]" p2 = schreibe_graph(g2, tmpname + "_a2.txt") ffmpeg_lauf(eingaben + ["-/filter_complex", str(p2), "-map", "0:v", "-map", "[afinal]", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2", "-t", f"{T:.3f}", "-movflags", "+faststart", str(ziel_datei)], dry=dry, was="Mischen/Export") return [p1, p2], mess def finde_audio(ordner, basisname): ordner = Path(ordner) for e in AUDIO_ENDUNGEN: p = ordner / (basisname + e) if p.exists(): return p return None def parse_audio_args(liste, ordner, basis_fn, sprachen): """--audio de=pfad ... und/oder --audio-ordner. Gibt {lang: Path} zurueck.""" erg = {} for eintrag in liste or []: if "=" not in eintrag: raise Fehler(f"--audio erwartet sprache=pfad, bekam: {eintrag}") l, p = eintrag.split("=", 1) l = l.strip().lower() if not Path(p).exists(): raise Fehler(f"Audiodatei fehlt: {p}") erg[l] = Path(p) if ordner: for l in sprachen: if l not in erg: p = finde_audio(ordner, basis_fn(l)) if p: erg[l] = p return erg # ------------------------------------------------------------------------- CSV def lese_csv(pfad): pfad = Path(pfad) if not pfad.exists(): raise Fehler(f"Datei fehlt: {pfad}") text = pfad.read_text(encoding="utf-8-sig") erste = text.splitlines()[0] if text.strip() else "" delim = ";" if erste.count(";") >= erste.count(",") else "," zeilen = [] for row in csv.DictReader(text.splitlines(), delimiter=delim): werte = {(k or "").strip().lower(): (v or "").strip() for k, v in row.items()} erste_spalte = next(iter(werte.values()), "") if erste_spalte.startswith("#") or not any(werte.values()): continue zeilen.append(werte) return zeilen def zeit(sek): return f"{int(sek // 60)}:{sek % 60:05.2f}"