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:
@@ -0,0 +1 @@
|
||||
out/
|
||||
@@ -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».
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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()
|
||||
Executable
+14
@@ -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/'
|
||||
Reference in New Issue
Block a user