Video della partita completa (pomeriggio → sera) con voci e musiche rimontate dal registro audio del copione; inventario: nomi lunghi su due righe dentro il pulsante

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
This commit is contained in:
2026-10-25 17:36:06 +01:00
co-authored by Claude Opus 5.5
parent 32a3d8a7c5
commit 1fabb6e543
5 changed files with 249 additions and 22 deletions
+6 -1
View File
@@ -447,7 +447,12 @@ function updateInventory() {
icon = document.createElement('i'); icon = document.createElement('i');
icon.style.background = item.icon?.fill ?? '#ccc'; icon.style.background = item.icon?.fill ?? '#ccc';
} }
b.append(icon, item.name); // Il nome in uno span: i nomi lunghi («stecco e gomma») vanno a capo su due righe strette, non su tre.
const name = document.createElement('span');
// Dopo l'apostrofo si può andare a capo («chiave dell'» / «oratorio»): le parole restano intere.
name.textContent = item.name.replace(/'/g, "'\u200b");
if (item.name.length > 10) name.className = 'long';
b.append(icon, name);
let timer = null, long = false; let timer = null, long = false;
b.addEventListener('pointerdown', () => { long = false; timer = setTimeout(() => { long = true; command.clickItem(id, { look: true }); }, LONG_PRESS); }); b.addEventListener('pointerdown', () => { long = false; timer = setTimeout(() => { long = true; command.clickItem(id, { look: true }); }, LONG_PRESS); });
b.addEventListener('pointerup', () => clearTimeout(timer)); b.addEventListener('pointerup', () => clearTimeout(timer));
+3 -1
View File
@@ -51,13 +51,15 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
padding: .35em .2em; font-size: clamp(9.36px, 1.22vw, 13.68px); font-weight: bold; cursor: pointer; } padding: .35em .2em; font-size: clamp(9.36px, 1.22vw, 13.68px); font-weight: bold; cursor: pointer; }
#verbs button:hover, #inventory button:hover { color: var(--ink); } #verbs button:hover, #inventory button:hover { color: var(--ink); }
#verbs button[aria-pressed="true"] { color: var(--bg); background: var(--accent); border-color: var(--accent); } #verbs button[aria-pressed="true"] { color: var(--bg); background: var(--accent); border-color: var(--accent); }
#inventory { display: grid; grid-template-columns: repeat(3, 1fr); grid-auto-rows: minmax(2.4em, auto); gap: 4px; align-content: start; #inventory { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-auto-rows: minmax(2.4em, auto); gap: 4px; align-content: start;
min-height: 5.4em; padding: 4px; background: #231a29; border: 2px solid #4b3a55; border-radius: 4px; min-height: 5.4em; padding: 4px; background: #231a29; border: 2px solid #4b3a55; border-radius: 4px;
/* L'inventario non allunga mai il pannello: è alto quanto i verbi, e se gli oggetti sono tanti si scorre. */ /* L'inventario non allunga mai il pannello: è alto quanto i verbi, e se gli oggetti sono tanti si scorre. */
contain: size; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: #6a557a transparent; } contain: size; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: #6a557a transparent; }
#inventory:empty::before { content: 'Inventario'; grid-column: 1 / -1; align-self: center; text-align: center; color: var(--dim); opacity: .45; #inventory:empty::before { content: 'Inventario'; grid-column: 1 / -1; align-self: center; text-align: center; color: var(--dim); opacity: .45;
font-size: clamp(9.36px, 1.22vw, 13.68px); font-weight: bold; letter-spacing: .08em; } font-size: clamp(9.36px, 1.22vw, 13.68px); font-weight: bold; letter-spacing: .08em; }
#inventory button { display: flex; align-items: center; gap: .35em; justify-content: center; color: var(--ink); } #inventory button { display: flex; align-items: center; gap: .35em; justify-content: center; color: var(--ink); }
#inventory button { min-width: 0; overflow: hidden; }
#inventory button span.long { min-width: 0; font-size: .72em; line-height: 1.15; text-align: left; }
#inventory button[aria-pressed="true"] { border-color: var(--accent); } #inventory button[aria-pressed="true"] { border-color: var(--accent); }
#inventory i { width: .8em; height: .8em; border-radius: 50% 50% 50% 0; flex: none; } #inventory i { width: .8em; height: .8em; border-radius: 50% 50% 50% 0; flex: none; }
#inventory img { width: 1.9em; height: 1.9em; flex: none; image-rendering: pixelated; } #inventory img { width: 1.9em; height: 1.9em; flex: none; image-rendering: pixelated; }
+18
View File
@@ -49,3 +49,21 @@ e scrive `video/out/demo-prototipo.mp4` (escluso da git).
- Le battute del gioco durano `sayTime` (circa 18 caratteri al secondo): il copione non le salta, aspetta che finiscano. - 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. - 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». - Il salvataggio sta nel `localStorage` del profilo della VM: il `setup` del copione lo svuota, sennò compare «Continua».
## La partita completa (25 ottobre 2026)
Copione `video/partita-completa.mjs`: il primo capitolo intero, dal pomeriggio alla sera (circa 10 minuti e mezzo),
seguendo `src/story/solution.js` capitolo per capitolo, più il libro dell'oratorio in primo piano e i vecchietti
che a sera sparlano di Andrea. Si gira con gli stessi comandi della demo (`mbot studio up`, `mbot studio session`,
`prepara-ripresa.sh`, `rec.mjs … --dry`, di nuovo `prepara-ripresa.sh`, `rec.mjs …`), poi:
```zsh
python3 video/monta.py ~/Workspaces/qemu/qemu-iso-lab/artifacts/tour/out/partita-completa
```
- **L'audio**: la VM registra solo lo schermo. Il copione annota dentro la pagina ogni cambio di musica e ogni voce
che parte (con l'orologio della VM) in `video/out/partita-completa-audio.json`; `monta.py` rimonta la colonna
sonora dai file del gioco: musica bassa (`VOLUME_SOTTO`), voci piene. Il fruscio della pagina non c'è.
- **Il ritaglio** (`TAGLIO` in `monta.py`) va rimisurato quando cambia l'impaginazione: `convert -trim` su una
schermata della prova a vuoto.
- **Per Telegram** (50 MB al massimo): due passate x264 `-tune animation` a 470 kbit/s → circa 45 MB.
+54 -19
View File
@@ -1,12 +1,14 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Montaggio della demo (video/SCALETTA.md): raw.mkv + cues.json della ripresa e video/out/musica.json """Montaggio di una ripresa del gioco (video/SCALETTA.md): raw.mkv + cues.json della ripresa e l'audio annotato dal
-> video/out/demo-prototipo.mp4. copione -> video/out/<clip>.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/demo-prototipo
python3 video/monta.py ~/Workspaces/qemu/qemu-iso-lab/artifacts/tour/out/partita-completa
- ritaglia lo schermo 1600x900 della VM sull'area del gioco e lascia sopra una fascia per le didascalie; - 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; - 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 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/<clip>-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. La ripresa parte qualche secondo prima del copione (ffmpeg si avvia prima): lo scarto si misura dalla durata.
""" """
import json import json
@@ -18,10 +20,12 @@ VIDEO = Path(__file__).resolve().parent
GIOCO = VIDEO.parent GIOCO = VIDEO.parent
OUT = VIDEO / "out" OUT = VIDEO / "out"
# Area del gioco nello schermo della VM (Chromium a tutto schermo, zoom 125 %): misurata con «convert -trim». # Area del gioco nello schermo della VM (Chromium a tutto schermo, zoom 125 %): misurata con «convert -trim».
TAGLIO = "crop=1076:780:262:60" TAGLIO = "crop=1044:760:278:70"
FASCIA = 110 # px sopra il gioco per le didascalie FASCIA = 110 # px sopra il gioco per le didascalie
FONDO = "0x1B1420" # il colore di fondo della pagina del gioco FONDO = "0x1B1420" # il colore di fondo della pagina del gioco
VOLUME = 0.5 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
FADE = 1.2 FADE = 1.2
@@ -39,7 +43,25 @@ def ts(s: float) -> str:
def main() -> None: def main() -> None:
d = Path(sys.argv[1]).expanduser() d = Path(sys.argv[1]).expanduser()
meta = json.loads((d / "cues.json").read_text()) 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
else:
musica = json.loads((OUT / "musica.json").read_text()) musica = json.loads((OUT / "musica.json").read_text())
volume = VOLUME
raw = d / "raw.mkv" raw = d / "raw.mkv"
totale = durata(raw) totale = durata(raw)
cues = meta["cues"] cues = meta["cues"]
@@ -54,26 +76,39 @@ def main() -> None:
fine = min(c["end"] + scarto if c["end"] is not None else inizio + lettura, inizio + lettura) 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"], ""] righe += [str(i + 1), f"{ts(inizio)} --> {ts(fine)}", "{\\an8}" + c["it"], ""]
OUT.mkdir(exist_ok=True) OUT.mkdir(exist_ok=True)
srt = OUT / "demo-prototipo.it.srt" srt = OUT / f"{nome}.it.srt"
srt.write_text("\n".join(righe), encoding="utf-8") srt.write_text("\n".join(righe), encoding="utf-8")
# Un segmento per brano: dall'istante del cambio al successivo, più la dissolvenza in uscita. # 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). # 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"]] tutti = musica["cambi"]
cambi[0]["t"] = -scarto primo = next(i for i, c in enumerate(tutti) if c["src"])
ingressi, catene = [], [] tutti[primo]["t"] = -scarto
for i, c in enumerate(cambi): ingressi, catene, pezzi = [], [], []
a = c["t"] + scarto for i, c in enumerate(tutti):
b = (cambi[i + 1]["t"] + scarto) if i + 1 < len(cambi) else totale if not c["src"]:
lungo = b - a + (FADE if i + 1 < len(cambi) else 0) 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"])] 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" n = ingressi.count("-i") # l'ingresso 0 è il video
catene.append(f"[{i + 1}:a]atrim=0:{lungo:.3f},asetpts=PTS-STARTPTS,volume={VOLUME}," 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}"
f"afade=t=in:d={FADE if i else 0.01}{uscita},adelay={int(a * 1000)}:all=1[m{i}]") catene.append(f"[{n}:a]atrim=0:{lungo:.3f},asetpts=PTS-STARTPTS,volume={volume},"
mix = "".join(f"[m{i}]" for i in range(len(cambi))) + f"amix=inputs={len(cambi)}:normalize=0:duration=longest[a]" 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)}]")
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" 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]" video = f"[0:v]{TAGLIO},pad=iw:ih+{FASCIA}:0:{FASCIA}:{FONDO},subtitles={srt}:force_style='{stile}'[v]"
finale = OUT / "demo-prototipo.mp4" finale = OUT / f"{nome}.mp4"
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(raw), *ingressi, subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(raw), *ingressi,
"-filter_complex", ";".join([video, *catene, mix]), "-map", "[v]", "-map", "[a]", "-filter_complex", ";".join([video, *catene, mix]), "-map", "[v]", "-map", "[a]",
"-t", f"{totale:.3f}", "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", "-t", f"{totale:.3f}", "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p",
+167
View File
@@ -0,0 +1,167 @@
// Copione del video «La Leggenda del Regolo · la partita completa» (primo capitolo, dal pomeriggio alla sera).
// Si gira come demo-prototipo.mjs (video/SCALETTA.md, «Come si rigira»), poi: python3 video/monta.py <cartella> .
// Segue la soluzione vera (src/story/solution.js), capitolo per capitolo, con due aggiunte: il libro dell'oratorio
// in primo piano e, a sera, i vecchietti che sparlano di Andrea.
// L'audio non si registra (la VM prende solo lo schermo): la pagina annota ogni cambio di musica e ogni voce che
// parte, col suo tempo, in out/partita-completa-audio.json; monta.py rimonta la colonna sonora dai file del gioco.
import { mkdirSync, writeFileSync } from "node:fs";
import { solution, route } from "../src/story/solution.js";
const c = (id, it) => ({ id, it, en: it, top: true });
export const title = { it: "La Leggenda del Regolo · la partita completa", en: "La Leggenda del Regolo · la partita completa" };
export const cues = [
c("titolo", "La Leggenda del Regolo — il primo capitolo, dall'inizio alla fine"),
c("intro", "Si corre per Campomigliaio, i blu. L'introduzione, sopra il panorama del Mugello"),
c("piazza", "Pomeriggio, Piazza Colonna. I vecchietti della panchina sanno sempre tutto"),
c("fortezza", "Sotto la Fortezza, impronte strane. E il sole comincia a calare"),
c("pieve", "La Pieve: l'indovinello di San Pietro apre la porta dell'oratorio"),
c("libro", "Nell'oratorio, sulle tavolate, un libro dimenticato. Bello spesso"),
c("tosca", "Al tennis della chiesa c'è la Tosca: ghiacciolo al limone, inzuppato nella cedrata"),
c("chiavetta", "Stecco e gomma ripescano una chiavetta. Nella casetta dell'area feste, una torcia"),
c("sera", "È sera. In piazza, intanto, si chiacchiera"),
c("finale", "Di corsa alla Fortezza: la torcia nella fessura del bastione"),
c("fine", "Fine del primo capitolo"),
];
const URL_GIOCO = "http://127.0.0.1:8000/";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const at = (verb, target) => solution.findIndex(([v, t]) => v === verb && t === target);
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;
// --- L'audio, annotato dentro la pagina (orologio della VM, a ogni fotogramma): musica voluta e voce in corso.
await p.evaluate(() => {
window.__t0 = Date.now();
window.__audio = [];
let music = undefined, voice = null;
const t = () => (Date.now() - window.__t0) / 1000;
const path = (src) => decodeURI(new URL(src, location.href).pathname.slice(1));
const look = () => {
if (window.music && window.music.want !== music) { music = window.music.want; window.__audio.push({ t: t(), music: music ?? null }); }
const a = window.voices?.audio, now = a && !a.paused && !a.ended ? a : null;
if (now !== voice) {
if (voice) window.__audio.push({ t: t(), voiceEnd: true });
if (now) window.__audio.push({ t: t(), voice: path(now.src) });
voice = now;
}
requestAnimationFrame(look);
};
look();
});
// --- 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 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 ascoltano).
// Un dialogo aperto o un primo piano fermo aspettano il giocatore: lì ci si ferma.
const fermi = async (pausa = 600) => {
await sleep(150);
for (let i = 0; i < 1500; i++) {
const ok = await p.evaluate(() => {
if (game.speech || game.queue.length && !game.closeup || game.walking || game.reach > 0) return false;
return !!game.dialogue || !!game.closeup || (!game.busy && !game.task);
});
if (ok) 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);
const scegli = async (testo) => { await d.click(p.locator("#choices li", { hasText: testo }), 0.6); await fermi(); };
// Un passo della soluzione, come lo farebbe un giocatore: il verbo (se il clic da solo non basta), l'oggetto, la cosa.
const passo = async ([verb, target, item]) => {
if (verb === "choose") return scegli(target);
const smart = await p.evaluate(([v, t]) => game.defaultVerb(t) === v, [verb, target]);
if (item) { await verbo(verb); await oggetto(item); }
else if (!smart) await verbo(verb);
if (await p.evaluate((t) => !!game.story.items[t], target)) await oggetto(target);
else await clicca(await centro(target));
await fermi(verb === "walk" ? 900 : 700);
};
const passi = async (lista) => { for (const s of lista) await passo(s); };
// 1. Titolo, rione, introduzione (una riga per clic).
await d.cue("titolo");
await d.hover(p.locator("#title .dedication"), 1.2);
await sleep(4000);
await d.click(p.locator("#new-game"));
await sleep(2500);
await d.click(p.locator('#rione [data-rione="campomigliaio"]'));
await d.cue("intro");
for (let i = 0; i < 8 && (await p.isVisible("#intro")); i++) {
await sleep(4200);
if (await p.isVisible("#intro")) await d.click(p.locator("#intro-text"), 0.5);
}
await fermi(800);
// 2-7. La soluzione, a capitoli.
const capitoli = [
["piazza", at("take", "cosa_luccicante")],
["fortezza", at("look", "fessura")],
["pieve", at("take", "gomma_terra")],
];
let da = 0;
for (const [cue, fino] of capitoli) {
await d.cue(cue);
await passi(solution.slice(da, fino + 1));
da = fino + 1;
}
// Il libro dell'oratorio: preso in mano, si apre, si gira la pagina, «FINE»; poi si chiude.
await d.cue("libro");
await verbo("take");
await clicca(await centro("libro_dimenticato"));
await p.waitForFunction(() => game.closeup?.step === "cover", null, { timeout: 30000 });
await sleep(2800);
await clicca([320, 200]);
await sleep(2600);
await clicca([320, 200]);
await p.waitForFunction(() => game.closeup?.lines && !game.closeup.lines.length && !game.speech, null, { timeout: 30000 });
await sleep(1500);
await d.click(p.locator("#closeup-close"), 0.6);
await fermi();
for (const [cue, fino] of [["tosca", at("use", "gomma", "stecco")], ["chiavetta", at("open", "casetta")]]) {
await d.cue(cue);
await passi(solution.slice(da, fino + 1));
da = fino + 1;
}
// 8. Sera: una deviazione in piazza, dove i vecchietti parlano di Andrea prima di accorgersi che è lì.
await d.cue("sera");
await passi(route("area_feste", "piazza"));
await passo(["talk", "vecchietti"]);
await scegli("Ho una torcia");
await scegli("Ci vediamo dopo");
// 9. Il finale: la torcia nella fessura.
await d.cue("finale");
await passi(route("piazza", "fortezza"));
await passi(solution.slice(-1));
await p.waitForSelector("#ending:not([hidden])", { timeout: 60000 });
await d.cue("fine");
await d.hover(p.locator("#ending .dedication"), 1.2);
await sleep(7000);
const audio = await p.evaluate(() => window.__audio);
const out = new URL("./out/", import.meta.url);
mkdirSync(out, { recursive: true });
writeFileSync(new URL("partita-completa-audio.json", out), JSON.stringify({ eventi: audio }, null, 2));
}