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:
+6
-1
@@ -447,7 +447,12 @@ function updateInventory() {
|
||||
icon = document.createElement('i');
|
||||
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;
|
||||
b.addEventListener('pointerdown', () => { long = false; timer = setTimeout(() => { long = true; command.clickItem(id, { look: true }); }, LONG_PRESS); });
|
||||
b.addEventListener('pointerup', () => clearTimeout(timer));
|
||||
|
||||
@@ -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; }
|
||||
#verbs button:hover, #inventory button:hover { color: var(--ink); }
|
||||
#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;
|
||||
/* 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; }
|
||||
#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; }
|
||||
#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 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; }
|
||||
|
||||
@@ -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.
|
||||
- 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».
|
||||
|
||||
## 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
@@ -1,12 +1,14 @@
|
||||
#!/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.
|
||||
"""Montaggio di una ripresa del gioco (video/SCALETTA.md): raw.mkv + cues.json della ripresa e l'audio annotato dal
|
||||
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/partita-completa
|
||||
|
||||
- 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 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.
|
||||
"""
|
||||
import json
|
||||
@@ -18,10 +20,12 @@ 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"
|
||||
TAGLIO = "crop=1044:760:278:70"
|
||||
FASCIA = 110 # px sopra il gioco per le didascalie
|
||||
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
|
||||
|
||||
|
||||
@@ -39,7 +43,25 @@ def ts(s: float) -> str:
|
||||
def main() -> None:
|
||||
d = Path(sys.argv[1]).expanduser()
|
||||
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())
|
||||
volume = VOLUME
|
||||
raw = d / "raw.mkv"
|
||||
totale = durata(raw)
|
||||
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)
|
||||
righe += [str(i + 1), f"{ts(inizio)} --> {ts(fine)}", "{\\an8}" + c["it"], ""]
|
||||
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")
|
||||
|
||||
# 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).
|
||||
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)
|
||||
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"])]
|
||||
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]"
|
||||
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)}]")
|
||||
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]"
|
||||
finale = OUT / "demo-prototipo.mp4"
|
||||
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",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user