Prima ripresa del 23/10 (8:34) con la catena del cameraman di qemu-iso-lab; il risultato resta in video/out/, fuori da git. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UdeKd62opy9PKgpQXtVctF
86 lines
4.1 KiB
Python
86 lines
4.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Montaggio della demo (video/SCALETTA.md): raw.mkv + cues.json della ripresa e video/out/musica.json
|
|
-> video/out/demo-prototipo.mp4.
|
|
|
|
python3 video/monta.py ~/Workspaces/qemu/qemu-iso-lab/artifacts/tour/out/demo-prototipo
|
|
|
|
- ritaglia lo schermo 1600x900 della VM sull'area del gioco e lascia sopra una fascia per le didascalie;
|
|
- le didascalie (cues.json, solo «it») restano a schermo il tempo di leggerle, non fino alla successiva;
|
|
- la colonna sonora si rimonta dai cambi di brano registrati dal copione, con le dissolvenze del gioco (1,2 s).
|
|
La ripresa parte qualche secondo prima del copione (ffmpeg si avvia prima): lo scarto si misura dalla durata.
|
|
"""
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
VIDEO = Path(__file__).resolve().parent
|
|
GIOCO = VIDEO.parent
|
|
OUT = VIDEO / "out"
|
|
# Area del gioco nello schermo della VM (Chromium a tutto schermo, zoom 125 %): misurata con «convert -trim».
|
|
TAGLIO = "crop=1076:780:262:60"
|
|
FASCIA = 110 # px sopra il gioco per le didascalie
|
|
FONDO = "0x1B1420" # il colore di fondo della pagina del gioco
|
|
VOLUME = 0.5
|
|
FADE = 1.2
|
|
|
|
|
|
def durata(f: Path) -> float:
|
|
return float(subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(f)],
|
|
capture_output=True, text=True, check=True).stdout)
|
|
|
|
|
|
def ts(s: float) -> str:
|
|
s = max(0.0, s)
|
|
h, m = int(s // 3600), int(s % 3600 // 60)
|
|
return f"{h:02}:{m:02}:{s % 60:06.3f}".replace(".", ",")
|
|
|
|
|
|
def main() -> None:
|
|
d = Path(sys.argv[1]).expanduser()
|
|
meta = json.loads((d / "cues.json").read_text())
|
|
musica = json.loads((OUT / "musica.json").read_text())
|
|
raw = d / "raw.mkv"
|
|
totale = durata(raw)
|
|
cues = meta["cues"]
|
|
# rec.mjs ferma ffmpeg 1,2 s dopo la fine dell'ultima didascalia: quello che avanza è l'anticipo dell'inizio.
|
|
scarto = max(0.0, totale - (cues[-1]["end"] + 1.2))
|
|
print(f"durata {totale:.1f} s, scarto d'inizio {scarto:.2f} s")
|
|
|
|
righe = []
|
|
for i, c in enumerate(cues):
|
|
inizio = c["start"] + scarto
|
|
lettura = max(4.0, len(c["it"]) / 14 + 1.5)
|
|
fine = min(c["end"] + scarto if c["end"] is not None else inizio + lettura, inizio + lettura)
|
|
righe += [str(i + 1), f"{ts(inizio)} --> {ts(fine)}", "{\\an8}" + c["it"], ""]
|
|
OUT.mkdir(exist_ok=True)
|
|
srt = OUT / "demo-prototipo.it.srt"
|
|
srt.write_text("\n".join(righe), encoding="utf-8")
|
|
|
|
# Un segmento per brano: dall'istante del cambio al successivo, più la dissolvenza in uscita.
|
|
# Il primo brano parte da zero (nel browser aspetta il primo clic, nel video no).
|
|
cambi = [c for c in musica["cambi"] if c["src"]]
|
|
cambi[0]["t"] = -scarto
|
|
ingressi, catene = [], []
|
|
for i, c in enumerate(cambi):
|
|
a = c["t"] + scarto
|
|
b = (cambi[i + 1]["t"] + scarto) if i + 1 < len(cambi) else totale
|
|
lungo = b - a + (FADE if i + 1 < len(cambi) else 0)
|
|
ingressi += ["-stream_loop", "-1", "-i", str(GIOCO / c["src"])]
|
|
uscita = f",afade=t=out:st={lungo - FADE:.3f}:d={FADE}" if i + 1 < len(cambi) else f",afade=t=out:st={lungo - 3:.3f}:d=3"
|
|
catene.append(f"[{i + 1}:a]atrim=0:{lungo:.3f},asetpts=PTS-STARTPTS,volume={VOLUME},"
|
|
f"afade=t=in:d={FADE if i else 0.01}{uscita},adelay={int(a * 1000)}:all=1[m{i}]")
|
|
mix = "".join(f"[m{i}]" for i in range(len(cambi))) + f"amix=inputs={len(cambi)}:normalize=0:duration=longest[a]"
|
|
stile = f"FontName=DejaVu Sans,FontSize=11,Bold=1,MarginV=4,Outline=1,Shadow=0,PrimaryColour=&H007AD2FF"
|
|
video = f"[0:v]{TAGLIO},pad=iw:ih+{FASCIA}:0:{FASCIA}:{FONDO},subtitles={srt}:force_style='{stile}'[v]"
|
|
finale = OUT / "demo-prototipo.mp4"
|
|
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(raw), *ingressi,
|
|
"-filter_complex", ";".join([video, *catene, mix]), "-map", "[v]", "-map", "[a]",
|
|
"-t", f"{totale:.3f}", "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p",
|
|
"-c:a", "aac", "-b:a", "160k", "-movflags", "+faststart", str(finale)], check=True)
|
|
print(f"-> {finale} ({durata(finale) / 60:.1f} min)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|