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>
495 lines
18 KiB
Python
495 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Gemeinsame Funktionen der Schnitt-Pipeline (Pino, Noa und Lulu).
|
|
|
|
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}"
|