Video dimostrativo: scaletta, copione, preparazione della VM e montaggio

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
This commit is contained in:
2026-10-23 13:26:43 +02:00
co-authored by Claude Opus 5.5
parent 5a07b5effc
commit e3354be352
5 changed files with 360 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
out/
+51
View File
@@ -0,0 +1,51 @@
# Video dimostrativo del prototipo — scaletta
Prima ripresa: 23 ottobre 2026, sul gioco com'era quel giorno (37 test verdi, scene fino al Trebbio).
Si rifà ogni volta che il prototipo cresce: si aggiorna il copione `video/demo-prototipo.mjs`, poi gli stessi comandi.
## Cosa si vede (durata attesa 7-8 minuti, tetto 10)
Solo sottotitoli in italiano in alto (le battute del gioco sono già nei fumetti) e la colonna sonora del gioco
rimontata sopra il video (la VM registra lo schermo, non l'audio).
| # | Capitolo | Cosa fa l'omino | Didascalia |
| --- | --- | --- | --- |
| 1 | Titolo | schermata del titolo con la dedica, musica del titolo | titolo del gioco, «demo del prototipo» |
| 2 | Introduzione | le quattro righe sopra il panorama, un clic per riga | — |
| 3 | Piazza Colonna | arriva, guarda la colonna e il manifesto, prova ad andare al Fiocco di Neve | come si gioca: clic = azione più sensata, i verbi a destra |
| 4 | I vecchietti | dialogo: le due leggende del Regolo, il Palio di quest'anno, «niente di strano stanotte?» | i dialoghi a scelte |
| 5 | La squama | guarda sotto la panchina, prende la squama, la guarda, la offre ai vecchietti | l'inventario, «dai… a» |
| 6 | Campeggio e Bosco ai Frati | due scene di passaggio, bandierine e tiglio | il paese a gradini |
| 7 | Pieve e oratorio | chiesa, campanile, statua; palco e tavolate dell'oratorio | — |
| 8 | Villa Adami e la salita | la villa, il leone di terracotta, la strada della scuola | — |
| 9 | Fortezza | bastione, porta incatenata, le impronte | — |
| 10 | Trebbio | viale dei cipressi, la torre fa da psicologo e dà l'indizio | gli aiuti del castello |
| 11 | Il finale | torna alla Fortezza, usa la squama sulle impronte, schermata di fine | fine del prototipo, dedica |
## Come si rigira
Dalla cartella `~/Workspaces/qemu/qemu-iso-lab` (la catena del «cameraman», gemello `30_Note/video-dimostrativi.md`):
```zsh
virsh --connect qemu:///system start lubuntu-studio
IP=$(virsh --connect qemu:///system domifaddr lubuntu-studio --source lease | awk '/ipv4/{sub("/.*","",$4); print $4}')
TOUR_VM=manzolo@$IP tools/tour/session.sh
# copia del gioco nella VM e server su 127.0.0.1:8000 (SRC= per girare su una copia ferma)
~/Workspaces/misc/LeggendaDelRegolo/video/prepara-ripresa.sh manzolo@$IP
PW=$(ls -d ~/.npm/_npx/*/node_modules/playwright | head -1)/index.mjs
TOUR_VM=manzolo@$IP PLAYWRIGHT_MODULE=$PW node tools/tour/rec.mjs ~/Workspaces/misc/LeggendaDelRegolo/video/demo-prototipo.mjs --dry # prova a vuoto
~/Workspaces/misc/LeggendaDelRegolo/video/prepara-ripresa.sh manzolo@$IP # azzera prima della ripresa vera
TOUR_VM=manzolo@$IP PLAYWRIGHT_MODULE=$PW node tools/tour/rec.mjs ~/Workspaces/misc/LeggendaDelRegolo/video/demo-prototipo.mjs
python3 ~/Workspaces/misc/LeggendaDelRegolo/video/monta.py artifacts/tour/out/demo-prototipo
virsh --connect qemu:///system shutdown lubuntu-studio
```
`monta.py` accorcia le didascalie, lancia `build.py`, rimonta la musica dai cambi registrati in `musica.json`
e scrive `video/out/demo-prototipo.mp4` (escluso da git).
## Trappole
- Il gioco va servito **dentro** la VM (`prepara-ripresa.sh`): niente rete fra host e VM da sistemare.
- Le battute del gioco durano `sayTime` (circa 18 caratteri al secondo): il copione non le salta, aspetta che finiscano.
- Una pressione più lunga di 450 ms sulla scena vale «guarda»: i clic di `mv.py` sono brevi, non usare trascinamenti.
- Il salvataggio sta nel `localStorage` del profilo della VM: il `setup` del copione lo svuota, sennò compare «Continua».
+209
View File
@@ -0,0 +1,209 @@
// Copione del video «La Leggenda del Regolo · demo del prototipo» (scaletta in video/SCALETTA.md).
// Si gira con la catena di qemu-iso-lab (tools/tour/rec.mjs) sulla VM lubuntu-studio, dopo video/prepara-ripresa.sh.
// Solo sottotitoli in italiano: «en» ripete l'italiano perché la catena vuole due lingue. Le battute del gioco
// restano nei fumetti; le didascalie stanno in alto e spiegano come si gioca.
// Ogni cambio di brano (music.current) finisce in out/musica.json: monta.py ci rimonta la colonna sonora.
import { mkdirSync, writeFileSync } from "node:fs";
const c = (id, it) => ({ id, it, en: it, top: true });
export const title = { it: "La Leggenda del Regolo · demo del prototipo", en: "La Leggenda del Regolo · demo del prototipo" };
export const cues = [
c("titolo", "La Leggenda del Regolo — demo del prototipo, ottobre 2026"),
c("intro", "L'introduzione: un clic per riga, sopra il panorama del paese"),
c("piazza", "Piazza Colonna. Un clic fa l'azione più sensata; a destra i verbi, come nelle vecchie avventure"),
c("guarda", "Si sceglie un verbo, poi la cosa: «Guarda colonna»"),
c("gelato", "Le uscite si prendono con un clic. Questa però porta in tentazione"),
c("vecchietti", "I vecchietti della panchina: un clic su di loro e si parla"),
c("scelte", "I dialoghi sono a scelte. Il Regolo? Ognuno ha la sua leggenda"),
c("panchina", "Un indizio: guardiamo sotto la panchina"),
c("squama", "Prendi: la cosa luccicante finisce nell'inventario"),
c("inventario", "Gli oggetti si guardano, si usano con le cose, si danno alle persone"),
c("giro", "Ora un giro del paese a gradini, uno sfondo per ogni posto vero"),
c("campeggio", "L'ingresso del campeggio e, più su, il Bosco ai Frati"),
c("pieve", "Dall'altra parte della piazza, la Pieve di San Pietro"),
c("oratorio", "Dietro la porta, il cortile dell'oratorio, pronto per la festa"),
c("villa", "Si sale: Villa Adami, gialla come un tuorlo"),
c("salita", "La salita col leone di terracotta, verso la Fortezza"),
c("fortezza", "Sotto le mura della Fortezza: porta incatenata e strane impronte"),
c("trebbio", "Bloccati? Lungo il viale dei cipressi c'è il Castello del Trebbio"),
c("psicologo", "Il castello fa da psicologo: ascolta e dà un indizio in base a quanto si è fatto"),
c("ritorno", "Il consiglio è chiaro: si torna alla Fortezza"),
c("finale", "Usa la squama con le impronte..."),
c("fine", "Fine del prototipo: il resto della storia è ancora da scrivere"),
];
const URL_GIOCO = "http://127.0.0.1:8000/";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function setup(d) {
const p = d.page;
await p.goto(URL_GIOCO);
await p.evaluate(() => localStorage.clear());
await p.reload();
await p.waitForSelector("#title:not([hidden])");
await sleep(1500);
}
export async function run(d) {
const p = d.page;
// --- Colonna sonora: si annota ogni cambio di brano, con il tempo dall'inizio della ripresa.
const t0 = Date.now(), musica = [];
let fermo = false;
const ascolta = (async () => {
let ultimo = null;
while (!fermo) {
try {
const src = await p.evaluate(() => window.music?.want ?? null);
if (src !== ultimo) { musica.push({ t: (Date.now() - t0) / 1000, src }); ultimo = src; }
} catch {}
await sleep(250);
}
})();
// --- Puntatore vero sulla scena (coordinate 640×360) e sui pulsanti.
const punto = async (x, y) => {
const r = await p.locator("#scene").boundingBox(), k = await p.evaluate(() => devicePixelRatio);
return [(r.x + (x / 640) * r.width) * k, (r.y + (y / 360) * r.height) * k];
};
const verso = async ([x, y], dur = 0.8) => { const [a, b] = await punto(x, y); d.vm(`DISPLAY=:0 ~/lab/video/mv.py ${a} ${b} ${dur}`); };
const clicca = async ([x, y], dur = 0.8) => { const [a, b] = await punto(x, y); d.vm(`DISPLAY=:0 ~/lab/video/mv.py ${a} ${b} ${dur} click`); await sleep(300); };
const centro = (id) => p.evaluate((id) => { const h = game.hotspot(id); return [h.box[0] + h.box[2] / 2, h.box[1] + h.box[3] / 2]; }, id);
// Aspetta che l'omino abbia finito di camminare e di parlare (le battute non si saltano: si leggono).
const fermi = async (pausa = 600) => {
await sleep(150);
for (let i = 0; i < 1200; i++) {
const ok = await p.evaluate(() => !game.speech && !game.busy && !game.walking && !game.queue.length && !game.task && !game.dialogue);
const dialogo = await p.evaluate(() => !!game.dialogue && !game.speech && !game.queue.length);
if (ok || dialogo) break;
await sleep(100);
}
await sleep(pausa);
};
const verbo = (v) => d.click(p.locator(`#verbs [data-verb="${v}"]`), 0.6);
const oggetto = (id) => d.click(p.locator(`#inventory [data-item="${id}"]`), 0.6);
// Un'azione completa: [verbo], [oggetto], la cosa; poi si aspetta.
const fai = async (v, id, item) => {
if (v) await verbo(v);
if (item) await oggetto(item);
await clicca(await centro(id));
await fermi();
};
const sfiora = async (id, ms = 900) => { await verso(await centro(id)); await sleep(ms); };
const scegli = async (testo) => { await d.click(p.locator("#choices li", { hasText: testo }), 0.6); await fermi(); };
const vai = async (uscita) => { await clicca(await centro(uscita)); await fermi(900); };
// 1. Titolo.
await d.cue("titolo");
await d.hover(p.locator("#title .dedication"), 1.2);
await sleep(4500);
await d.click(p.locator("#new-game"));
// 2. Introduzione: una riga per clic.
await d.cue("intro");
for (let i = 0; i < 6 && (await p.isVisible("#intro")); i++) {
await sleep(4200);
if (await p.isVisible("#intro")) await d.click(p.locator("#intro-text"), 0.5);
}
// 3. Piazza Colonna: le battute d'arrivo, poi colonna e manifesto.
await d.cue("piazza");
await fermi(800);
await sfiora("colonna");
await d.cue("guarda");
await fai("look", "colonna");
await fai("look", "manifesto");
await d.cue("gelato");
await fai(null, "verso_gelateria");
// 4. I vecchietti.
await d.cue("vecchietti");
await sfiora("vecchietti");
await fai(null, "vecchietti");
await d.cue("scelte");
await scegli("chi ha preso il palio");
await scegli("Palio di quest");
await scegli("niente di strano stanotte");
await scegli("Ci vediamo dopo");
// 5. La squama.
await d.cue("panchina");
await fai("look", "panchina");
await d.cue("squama");
await fai("take", "cosa_luccicante");
await d.cue("inventario");
await verbo("look");
await oggetto("squama");
await fermi();
await fai("give", "vecchietti", "squama");
// 6. Il giro: campeggio e Bosco ai Frati.
await d.cue("giro");
await clicca([300, 330]);
await fermi(400);
await d.cue("campeggio");
await vai("verso_campeggio");
await fai("look", "bandiere");
await vai("bosco_ai_frati");
await fai("look", "tiglio");
await vai("giu_campeggio");
await vai("verso_piazza");
// 7. Pieve e oratorio.
await d.cue("pieve");
await vai("verso_pieve");
await fai("look", "chiesa");
await fai("look", "campanile");
await fai("talk", "statua");
await d.cue("oratorio");
await vai("oratorio");
await fai("look", "palco");
await fai("use", "tavolate");
await vai("verso_pieve");
// 8. Villa Adami e la salita.
await d.cue("villa");
await vai("salita_villa_adami");
await fai("open", "villa");
await d.cue("salita");
await vai("salita");
await fai("talk", "leone");
await fai(null, "verso_scuola");
// 9. Fortezza.
await d.cue("fortezza");
await vai("su_fortezza");
await fai("look", "mura");
await fai("open", "porta");
await fai("look", "impronte");
// 10. Trebbio.
await d.cue("trebbio");
await vai("sentiero_trebbio");
await fai("look", "cipressi");
await vai("al_trebbio");
await fai("look", "torre");
await d.cue("psicologo");
await fai(null, "torre");
await scegli("bloccato");
await scegli("un po' di pace");
// 11. Il finale.
await d.cue("ritorno");
await vai("verso_fortezza");
await vai("alla_fortezza");
await d.cue("finale");
await fai("use", "impronte", "squama");
await p.waitForSelector("#ending:not([hidden])", { timeout: 30000 });
await d.cue("fine");
await d.hover(p.locator("#ending .dedication"), 1.2);
await sleep(7000);
fermo = true;
await ascolta;
const out = new URL("./out/", import.meta.url);
mkdirSync(out, { recursive: true });
writeFileSync(new URL("musica.json", out), JSON.stringify({ durata: (Date.now() - t0) / 1000, cambi: musica }, null, 2));
}
+85
View File
@@ -0,0 +1,85 @@
#!/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()
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# Porta il gioco nella VM delle riprese e lo serve su 127.0.0.1:8000 (video/SCALETTA.md).
# video/prepara-ripresa.sh manzolo@<ip> # la cartella del progetto così com'è
# SRC=/percorso/copia video/prepara-ripresa.sh manzolo@<ip> # una copia ferma (il gioco cambia mentre si gira)
# Si rilancia prima di ogni ripresa: ricopia e riavvia il server; il salvataggio lo svuota il copione.
set -eu
VM=${1:?uso: prepara-ripresa.sh utente@host}
SRC=${SRC:-$(cd "$(dirname "$0")/.." && pwd)}
rsync -a --delete --exclude node_modules --exclude assets/raw --exclude assets/generated --exclude prompts \
--exclude .git --exclude video --exclude __pycache__ "$SRC"/ "$VM":lab/regolo/
# Due ssh: nella stessa riga di comando «pkill -f» troverebbe anche «http.server 8000» e ucciderebbe sé stesso.
ssh -o BatchMode=yes "$VM" 'pkill -f "http.server 800[0]"; true'
ssh -o BatchMode=yes "$VM" 'cd ~/lab/regolo && setsid -f python3 -m http.server 8000 --bind 127.0.0.1 >/tmp/regolo-http.log 2>&1 </dev/null
curl -s --retry 20 --retry-delay 1 --retry-connrefused -o /dev/null -w "gioco servito: %{http_code}\n" http://127.0.0.1:8000/'