Carte che volano: dalla mano al tavolo e, finita la presa, sul mucchietto di chi ha preso (briscola, tressette, scopa; Andrea: «non si capisce quando vengono buttate le carte»). Il valzer della balera ai tavoli da carte (tools/valzer_carte.py: fisarmonica musette, basso, melodia di mandolino col tremolo), musica per primo piano; video «Come si gioca»: cache del Chromium della VM spenta, narratore e personaggi a turno, musica di sottofondo, pronunce del narratore

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
This commit is contained in:
2026-10-26 18:17:23 +01:00
co-authored by Claude Opus 5.5
parent 7e1e6f6144
commit 538bf6cedb
19 changed files with 263 additions and 43 deletions
+24 -1
View File
@@ -22,10 +22,16 @@ 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
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
@@ -42,6 +48,8 @@ def ts(s: float) -> str:
def main() -> None:
d = Path(sys.argv[1]).expanduser()
tutorial = "--tutorial" 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"
@@ -59,6 +67,10 @@ def main() -> None:
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
else:
musica = json.loads((OUT / "musica.json").read_text())
volume = VOLUME
@@ -105,9 +117,20 @@ def main() -> None:
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]"
video = f"[0:v]{taglio},pad=iw:ih+{fascia}:0:{fascia}:{FONDO},subtitles={srt}:force_style='{stile}'[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]",
+4 -1
View File
@@ -36,7 +36,10 @@ def main() -> None:
# XTTS dice «punto» se la frase finisce col punto: si toglie, come fa narrate.py del tour.
lavori = [{"text": c["it"].rstrip(". "), "out": str(cartella / f"{c['id']}.wav"), "lang": "it", "speaker": NARRATORE} for c in cues]
(cartella / "lavori.json").write_text(json.dumps(lavori, ensure_ascii=False, indent=1))
pron = ["--pronounce", str(PRONUNCIA)] if PRONUNCIA.exists() else []
# Le pronunce del gioco più quelle del narratore, in un file solo per `mbot parla`.
regole = {k: v for f in (PRONUNCIA, VIDEO / "pronuncia-narratore.json") if f.exists() for k, v in json.loads(f.read_text()).items() if not k.startswith("_")}
(cartella / "pronuncia.json").write_text(json.dumps(regole, ensure_ascii=False, indent=1))
pron = ["--pronounce", str(cartella / "pronuncia.json")]
subprocess.run(["mbot", "parla", "--jobs", str(cartella / "lavori.json"), *pron], check=True)
ascolto = [{"file": l["out"], "expect": l["text"]} for l in lavori]
(cartella / "ascolto.json").write_text(json.dumps(ascolto, ensure_ascii=False, indent=1))
+7
View File
@@ -0,0 +1,7 @@
{
"_nota": "Pronunce in più per il narratore dei video «Come si gioca» (video/narra.py), oltre a tools/pronuncia.json. L'accento scritto sposta quello di XTTS (Andrea, 26 ottobre: «piccola» e «volta» con l'accento sbagliato).",
"piccola": "pìccola",
"piccole": "pìccole",
"volta": "vòlta",
"volte": "vòlte"
}
+7 -3
View File
@@ -13,7 +13,7 @@ export const cues = [
c("pesca", "Chi prende pesca per primo ed esce alla mano dopo. E non c'è l'obbligo di rispondere al seme"),
c("consiglio", "Un consiglio: su un liscio rispondi con un liscio, e i carichi dell'avversario prendili con una briscola piccola"),
c("tastiera", "Si gioca toccando la carta, oppure coi pulsanti sotto il tavolo, o con le frecce e Invio"),
c("conto", "Finito il mazzo si contano le prese, una carta alla volta. Vince chi arriva a sessantuno punti, e sessanta a sessanta è patta"),
c("conto", "Finito il mazzo si contano le prese, una carta alla volta. Vince chi arriva a sessantuno punti; sessanta a sessanta è pari, e non vince nessuno"),
c("fine", "Poi si torna all'oratorio, e si può chiedere la rivincita. Buona briscola!"),
];
@@ -21,6 +21,7 @@ export const setup = base;
export async function run(d) {
const p = d.page, r = regista(d, "tutorial-briscola");
// Il registro dell'audio parte con la ripresa: monta.py rimette le voci del gioco al loro istante.
await r.ascolta();
await r.vaiA("oratorio", [470, 300]);
await r.cue("dove");
@@ -30,12 +31,14 @@ export async function run(d) {
await r.scegli("Con le toscane.");
await r.cue("livello");
await r.scegli("Seria.");
// Una mano fissa, la stessa a ogni ripresa.
// Una mano fissa, la stessa a ogni ripresa: giocando attento, Andrea vince 68 a 52.
await p.evaluate(async () => {
const { newBriscola } = await import("/src/engine/briscola.js");
game.closeup = { id: "briscola", ...newBriscola(91421, { level: "seria", deck: "toscane" }) };
game.closeup = { id: "briscola", ...newBriscola(8, { level: "seria", deck: "toscane" }) };
});
await sleep(800);
// Il tavolo ha la sua musica: se non parte, il gioco servito nella VM è vecchio.
await p.waitForFunction(() => window.music?.want === "assets/audio/carte.mp3", null, { timeout: 10000 });
await r.cue("tavolo");
await r.muovi([150, 160], 1.0); await sleep(600); await r.muovi([320, 280], 1.0);
@@ -69,6 +72,7 @@ export async function run(d) {
await p.waitForFunction(() => game.closeup?.step === "result", null, { timeout: 60000 });
await sleep(2500);
await r.cue("fine");
await r.aspetta(); // la battuta del giocatore, uscendo, dopo quella del narratore
await d.click(p.locator("#closeup-close"), 0.6);
await r.fermi(800);
await r.fine();
+15 -5
View File
@@ -15,6 +15,11 @@ export function narrazione(clip) {
export async function setup(d) {
const p = d.page;
// Niente cache: il Chromium della VM teneva i moduli JavaScript di una ripresa prima (il valzer non partiva).
const cdp = await p.context().newCDPSession(p);
await cdp.send("Network.enable");
await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
await cdp.send("Network.clearBrowserCache");
await p.goto(URL_GIOCO);
await p.evaluate(() => localStorage.clear());
await p.reload();
@@ -26,13 +31,16 @@ export function regista(d, clip) {
const p = d.page, voce = narrazione(clip);
let inizio = 0, dura = 0;
const r = {
// Una battuta del narratore: prima si lascia finire quella di prima.
// Una battuta del narratore: prima si lascia finire quella di prima, e chi sta parlando nel gioco.
async cue(id) {
const resto = inizio + dura + 350 - Date.now();
if (resto > 0) await sleep(resto);
await r.aspetta();
for (let i = 0; i < 300 && (await p.evaluate(() => !!game.speech || !!(window.voices?.audio && !window.voices.audio.paused && !window.voices.audio.ended))); i++) await sleep(100);
await sleep(250);
await d.cue(id);
inizio = Date.now(); dura = voce[id] ?? 0;
},
// Prima di far parlare un personaggio, il narratore deve aver finito: le due voci non si sovrappongono.
async aspetta() { const resto = inizio + dura + 350 - Date.now(); if (resto > 0) await sleep(resto); },
async fine() { const resto = inizio + dura + 600 - Date.now(); if (resto > 0) await sleep(resto); },
// Puntatore vero sulla scena (coordinate 640×360).
async punto(x, y) {
@@ -54,7 +62,7 @@ export function regista(d, clip) {
}
await sleep(pausa);
},
async scegli(testo) { await d.click(p.locator("#choices li", { hasText: testo }), 0.6); await r.fermi(); },
async scegli(testo) { await r.aspetta(); await d.click(p.locator("#choices li", { hasText: testo }), 0.6); await r.fermi(); },
// Partita nuova per Campomigliaio, introduzione saltata, dritti nella scena del gioco.
async vaiA(scena, at) {
await d.click(p.locator("#new-game"));
@@ -62,10 +70,12 @@ export function regista(d, clip) {
await d.click(p.locator('#rione [data-rione="campomigliaio"]'));
await sleep(800);
if (await p.isVisible("#skip-intro")) await d.click(p.locator("#skip-intro"), 0.5);
await p.evaluate(([s, a]) => { game.go(s, a); }, [scena, at]);
// Le battute d'apertura di Andrea (il palio sparito) qui non servono: si saltano tutte.
await p.evaluate(([s, a]) => { for (let i = 0; i < 50 && (game.speech || game.queue.length); i++) { game.skip(); game.update(0.05); } game.go(s, a); }, [scena, at]);
await r.fermi(1200);
},
async parlaCon(id) {
await r.aspetta();
const [x, y] = await p.evaluate((id) => { const h = game.hotspot(id); return [h.box[0] + h.box[2] / 2, h.box[1] + h.box[3] / 2]; }, id);
await r.clicca([x, y]);
await r.fermi();