#!/usr/bin/env python3 """Montaggio di una ripresa del gioco (video/SCALETTA.md): raw.mkv + cues.json della ripresa e l'audio annotato dal copione -> video/out/.mp4. python3 video/monta.py ~/Workspaces/qemu/qemu-iso-lab/artifacts/tour/out/demo-prototipo python3 video/monta.py ~/Workspaces/qemu/qemu-iso-lab/artifacts/tour/out/partita-completa python3 video/monta.py ~/Workspaces/qemu/qemu-iso-lab/artifacts/tour/out/trailer --trailer - 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); - le voci (solo in video/out/-audio.json) tornano al loro istante, sopra la musica tenuta bassa. 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=1044:760:278:70" FASCIA = 110 # px sopra il gioco per le didascalie # I video «Come si gioca» (--tutorial): ai tavoli la pagina usa tutta l'altezza, comandi sotto compresi; didascalie # lunghe anche su tre righe. TAGLIO_TUTORIAL = "crop=1046:900:268:0" FASCIA_TUTORIAL = 132 FONDO = "0x1B1420" # il colore di fondo della pagina del gioco VOLUME = 0.5 # musica della demo-prototipo (senza voci) VOLUME_SOTTO = 0.22 # musica sotto le voci: bassa (Andrea, 25 ottobre) VOLUME_VOCI = 1.0 VOLUME_NARRATORE = 1.15 # Il trailer (--trailer): tutto lo schermo della VM (la scena è a tutto schermo), niente fascia né didascalie (le # scritte sono nella pagina), la musica in primo piano. VOLUME_TRAILER = 0.6 VOLUME_TUTORIAL = 0.07 # musica dei video «Come si gioca»: sottofondo # il narratore dei video «Come si gioca», sopra la musica bassa 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() tutorial = "--tutorial" in sys.argv[2:] trailer = "--trailer" in sys.argv[2:] taglio, fascia = (TAGLIO_TUTORIAL, FASCIA_TUTORIAL) if tutorial else (TAGLIO, FASCIA) meta = json.loads((d / "cues.json").read_text()) nome = meta["clip"] registro = OUT / f"{nome}-audio.json" voci = [] if registro.exists(): # Il registro del copione: {t, music} a ogni cambio di brano, {t, voice} e {t, voiceEnd} per le voci. eventi = json.loads(registro.read_text())["eventi"] musica = {"cambi": [{"t": e["t"], "src": e["music"]} for e in eventi if "music" in e]} aperta = None for e in eventi: if "voice" in e: aperta = {"t": e["t"], "src": e["voice"].split("?")[0]} voci.append(aperta) elif "voiceEnd" in e and aperta: aperta["fine"] = e["t"] aperta = None volume = VOLUME_SOTTO # Nei video «Come si gioca» parla il narratore quasi sempre: la musica resta un sottofondo (Andrea, 26 ottobre: # «troppo alta la musica del gioco»). if (OUT / f"{nome}-narrazione.json").exists(): volume = VOLUME_TUTORIAL if trailer: volume = VOLUME_TRAILER else: musica = json.loads((OUT / "musica.json").read_text()) volume = VOLUME 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 / f"{nome}.it.srt" srt.write_text("\n".join(righe), encoding="utf-8") # Un segmento per brano: dall'istante del cambio al cambio successivo (anche al silenzio), più la dissolvenza. # Il primo brano parte da zero (nel browser aspetta il primo clic, nel video no). tutti = musica["cambi"] primo = next(i for i, c in enumerate(tutti) if c["src"]) tutti[primo]["t"] = -scarto ingressi, catene, pezzi = [], [], [] for i, c in enumerate(tutti): if not c["src"]: continue a = max(0.0, c["t"] + scarto) ultimo = i + 1 >= len(tutti) b = totale if ultimo else tutti[i + 1]["t"] + scarto lungo = max(0.5, b - a + (0 if ultimo else FADE)) ingressi += ["-stream_loop", "-1", "-i", str(GIOCO / c["src"])] n = ingressi.count("-i") # l'ingresso 0 è il video uscita = f",afade=t=out:st={max(0.0, lungo - 3):.3f}:d=3" if ultimo else f",afade=t=out:st={max(0.0, lungo - FADE):.3f}:d={FADE}" catene.append(f"[{n}:a]atrim=0:{lungo:.3f},asetpts=PTS-STARTPTS,volume={volume}," f"afade=t=in:d={FADE if i > primo else 0.01}{uscita},adelay={int(a * 1000)}:all=1[m{len(pezzi)}]") pezzi.append(f"[m{len(pezzi)}]") # Le voci: ognuna dal suo istante, tagliata dove il gioco l'ha fermata (battuta saltata o finita). for v in voci: ingressi += ["-i", str(GIOCO / v["src"])] n = ingressi.count("-i") lungo = f"atrim=0:{v['fine'] - v['t'] + 0.1:.3f}," if "fine" in v else "" catene.append(f"[{n}:a]{lungo}asetpts=PTS-STARTPTS,volume={VOLUME_VOCI},adelay={int((v['t'] + scarto) * 1000)}:all=1[v{len(pezzi)}]") pezzi.append(f"[v{len(pezzi)}]") # Il narratore dei video «Come si gioca» (video/narra.py): ogni frase all'inizio della sua battuta. narrazione = OUT / f"{nome}-narrazione.json" if narrazione.exists(): frasi = {v["id"]: v["src"] for v in json.loads(narrazione.read_text())["voci"]} for c in cues: if c["id"] not in frasi: continue ingressi += ["-i", str(GIOCO / frasi[c["id"]])] n = ingressi.count("-i") catene.append(f"[{n}:a]asetpts=PTS-STARTPTS,volume={VOLUME_NARRATORE},adelay={int((c['start'] + scarto) * 1000)}:all=1[n{len(pezzi)}]") pezzi.append(f"[n{len(pezzi)}]") mix = "".join(pezzi) + f"amix=inputs={len(pezzi)}: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]" if trailer: video = "[0:v]scale=1600:900:flags=lanczos,fade=t=in:d=0.5[v]" finale = OUT / f"{nome}.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()