From a9fb8a5d0fbfaed6e6219b4919c1ce156defd522 Mon Sep 17 00:00:00 2001 From: perhaps-aksh Date: Tue, 20 Oct 2026 09:40:14 +0530 Subject: [PATCH] chore(demo): tools to record and cut the demo video - vvd-screenrec.sh: record the VVD screen (1080p + audio) via the emulator console. - vvd-keys.sh: press remote keys without a screenshot. - phone-rec.sh + record-phone-pw.mjs: a phone-sized player recorded with Playwright in WSL, driven line by line from a control file. - demo-bot.ts: a scripted second player driven from a control file. - voiceover.ts: the narration as Edge TTS clips, one per script section. - make-demo.sh: cut TV and phone recordings to the voice-over timings (TV and phone side by side), title card, ducked game audio. - sheet.sh / rec-info.sh: contact sheets and timing info for review. - ask-bot.ts: player names configurable (ASK_NAME, ASK_NAME2). --- apps/player/scripts/phone-rec.sh | 8 +++++ apps/player/scripts/record-phone-pw.mjs | 44 +++++++++++++++++++++++ apps/server/scripts/demo-bot.ts | 41 +++++++++++++++++++++ apps/server/scripts/voiceover.ts | 33 +++++++++++++++++ apps/tv/scripts/make-demo.sh | 48 +++++++++++++++++++++++++ apps/tv/scripts/rec-info.sh | 6 ++++ apps/tv/scripts/sheet.sh | 12 +++++++ apps/tv/scripts/vvd-keys.sh | 8 +++++ apps/tv/scripts/vvd-screenrec.sh | 20 +++++++++++ 9 files changed, 220 insertions(+) create mode 100644 apps/player/scripts/phone-rec.sh create mode 100644 apps/player/scripts/record-phone-pw.mjs create mode 100644 apps/server/scripts/demo-bot.ts create mode 100644 apps/server/scripts/voiceover.ts create mode 100644 apps/tv/scripts/make-demo.sh create mode 100644 apps/tv/scripts/rec-info.sh create mode 100644 apps/tv/scripts/sheet.sh create mode 100644 apps/tv/scripts/vvd-keys.sh create mode 100644 apps/tv/scripts/vvd-screenrec.sh diff --git a/apps/player/scripts/phone-rec.sh b/apps/player/scripts/phone-rec.sh new file mode 100644 index 0000000..63f39b2 --- /dev/null +++ b/apps/player/scripts/phone-rec.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# phone-rec.sh : record the player app at phone size (Playwright in WSL, ~/phonerec). +# Drive it by appending JS lines to /cmd.txt; touch /stop to finish -> /phone.webm +set -e +mkdir -p "$2"; : > "$2/cmd.txt"; rm -f "$2/stop" +cp /mnt/d/RoomRiddle/apps/player/scripts/record-phone-pw.mjs ~/phonerec/ +cd ~/phonerec +exec node record-phone-pw.mjs "$1" "$2" diff --git a/apps/player/scripts/record-phone-pw.mjs b/apps/player/scripts/record-phone-pw.mjs new file mode 100644 index 0000000..7b17faf --- /dev/null +++ b/apps/player/scripts/record-phone-pw.mjs @@ -0,0 +1,44 @@ +// Dev tool (runs in WSL with Playwright): a recorded phone. Opens the player app +// at phone size and records a video. Each new line in /cmd.txt is +// JavaScript run in the page; touch /stop to finish. +// node record-phone-pw.mjs +// Writes /phone.webm and /t0.txt (epoch ms when recording began). +import {chromium} from 'playwright'; +import {existsSync, readFileSync, writeFileSync, renameSync, mkdirSync} from 'node:fs'; + +const [url, dir] = process.argv.slice(2); +mkdirSync(dir, {recursive: true}); +const sleep = ms => new Promise(r => setTimeout(r, ms)); +const browser = await chromium.launch(); +const context = await browser.newContext({ + viewport: {width: 390, height: 844}, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + recordVideo: {dir, size: {width: 780, height: 1688}}, +}); +const page = await context.newPage(); +const t0 = Date.now(); +writeFileSync(`${dir}/t0.txt`, String(t0)); +await page.goto(url); +console.log(`[phone] recording ${url} (t0=${t0})`); +const control = `${dir}/cmd.txt`; +if (!existsSync(control)) writeFileSync(control, ''); +let done = 0; +while (!existsSync(`${dir}/stop`)) { + const lines = readFileSync(control, 'utf8').split('\n').filter(Boolean); + for (; done < lines.length; done++) { + try { + const v = await page.evaluate(lines[done]); + console.log(`[phone] ${Date.now() - t0}ms ${lines[done].slice(0, 70)} -> ${JSON.stringify(v ?? null).slice(0, 120)}`); + } catch (e) { + console.log(`[phone] ${Date.now() - t0}ms ERROR ${String(e).slice(0, 160)}`); + } + } + await sleep(150); +} +const video = page.video(); +await context.close(); +renameSync(await video.path(), `${dir}/phone.webm`); +await browser.close(); +console.log('[phone] saved phone.webm'); diff --git a/apps/server/scripts/demo-bot.ts b/apps/server/scripts/demo-bot.ts new file mode 100644 index 0000000..e1e58b0 --- /dev/null +++ b/apps/server/scripts/demo-bot.ts @@ -0,0 +1,41 @@ +/** + * Dev tool for recording demos: one scripted phone ("Kabir") that joins a room + * and then performs whatever lines are appended to a control file: + * DISCOVER_CLUE | SUBMIT_ANSWER | REQUEST_HINT | ADVANCE_SCENE + * npx tsx apps/server/scripts/demo-bot.ts [name] [wsUrl] + */ +import {existsSync, readFileSync, writeFileSync} from 'node:fs'; +import {WebSocket} from 'ws'; +import {RoomClient, initialRoomView, memorySessionStore, reduceRoomView, type RoomView, type SocketLike} from '@roomriddle/client'; +import type {ClientMessage} from '@roomriddle/shared-types'; + +const [code, control, name = 'Kabir', url = 'ws://127.0.0.1:4000/ws'] = process.argv.slice(2); +if (!code || !control) throw new Error('usage: demo-bot.ts [name] [wsUrl]'); +let view: RoomView = initialRoomView; +const client = new RoomClient({url, store: memorySessionStore(), createSocket: u => new WebSocket(u) as unknown as SocketLike, backoff: [200]}); +client.onMessage(m => { + view = reduceRoomView(view, m); + if (m.type === 'ERROR') console.log(`[${name}] ERROR ${m.code}: ${m.message}`); +}); +client.send({type: 'JOIN_ROOM', roomCode: code, playerName: name}); +if (!existsSync(control)) writeFileSync(control, ''); +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); +let done = 0; +console.log(`[${name}] joining ${code}; control ${control}`); +for (;;) { + const lines = readFileSync(control, 'utf8').split('\n').map(l => l.trim()).filter(Boolean); + for (; done < lines.length; done++) { + const [type, a = '', b = ''] = lines[done]!.split(/\s+/); + if (type === 'QUIT') process.exit(0); + let message: ClientMessage | null = null; + if (type === 'DISCOVER_CLUE') message = {type, clueId: a}; + else if (type === 'SUBMIT_ANSWER') message = {type, puzzleId: a, answer: b}; + else if (type === 'REQUEST_HINT') message = {type, puzzleId: a}; + else if (type === 'ADVANCE_SCENE') message = {type}; + if (message) { + client.send(message); + console.log(`[${name}] ${lines[done]} (scene ${view.room?.gameState?.currentSceneId ?? '-'})`); + } + } + await sleep(150); +} diff --git a/apps/server/scripts/voiceover.ts b/apps/server/scripts/voiceover.ts new file mode 100644 index 0000000..f1d3a4a --- /dev/null +++ b/apps/server/scripts/voiceover.ts @@ -0,0 +1,33 @@ +// Voice-over for the demo video: one Edge TTS clip per segment, placed at its start time. +// Usage: npx tsx apps/server/scripts/voiceover.ts +import {mkdirSync, writeFileSync} from 'node:fs'; +import {MsEdgeTTS, OUTPUT_FORMAT} from 'msedge-tts'; + +const SEGMENTS: {at: number; end: number; text: string}[] = [ + {at: 0, end: 15, text: "Friday night. Everyone's on the couch, and everyone has a phone. Room Riddle turns the biggest screen in the house into a crime scene, and every phone into a detective's notebook."}, + {at: 15, end: 35, text: 'There are five complete cases: a noir train mystery, a gothic manor, a tech thriller, a family temple adventure, and an all-ages riddle story. You can filter by age group or story type, so the whole family finds the right case in seconds.'}, + {at: 35, end: 55, text: "One press on Quick Start opens a room. Phones scan the QR code. No app, no account. Up to six investigators, and you're in."}, + {at: 55, end: 85, text: 'This is The Last Train. Midnight, platform two, and the conductor has vanished. You explore the painted scene with the Fire TV remote. Every clue has its own close-up, and every line is spoken aloud, with subtitles. The platform clock says eleven fifty-nine. Remember that.'}, + {at: 85, end: 115, text: 'Now the best part. Pick a suspect on your phone, and ask anything in your own words. Inspector Voss answers on the TV, in character, and in his own voice. He can dodge, and he can lie, but he can only talk about what your group has already discovered. A guarded AI on free models, so he can never spoil the ending.'}, + {at: 115, end: 140, text: 'Clues lead to puzzles. Anyone can answer from their phone, hints cost points, and the server checks every answer, so nobody can cheat. Everything you find is pinned in the case journal, on the TV, and on every phone.'}, + {at: 140, end: 155, text: "When you're ready, the group names the truth together, and the case closes the way you solved it."}, + {at: 155, end: 165, text: 'Room Riddle. Built with React Native for Vega, on Fire TV. Gather. Investigate. Unravel the mystery.'}, +]; + +const out = process.argv[2] ?? 'voiceover'; +mkdirSync(out, {recursive: true}); +const tts = new MsEdgeTTS(); +await tts.setMetadata('en-GB-RyanNeural', OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3); +for (const [i, s] of SEGMENTS.entries()) { + const {audioStream} = tts.toStream(s.text, {rate: '-2%'}); + const chunks: Buffer[] = []; + await new Promise((resolve, reject) => { + audioStream.on('data', (c: Buffer) => chunks.push(c)); + audioStream.on('end', () => resolve()); + audioStream.on('error', reject); + }); + writeFileSync(`${out}/seg${i}.mp3`, Buffer.concat(chunks)); + console.log(`seg${i} at ${s.at}s (window ${s.end - s.at}s) ${Buffer.concat(chunks).length} B`); +} +writeFileSync(`${out}/segments.json`, JSON.stringify(SEGMENTS, null, 1)); +tts.close(); diff --git a/apps/tv/scripts/make-demo.sh b/apps/tv/scripts/make-demo.sh new file mode 100644 index 0000000..8916bfa --- /dev/null +++ b/apps/tv/scripts/make-demo.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# make-demo.sh: cut the recordings in /tmp/rec into the demo video, timed to the voice-over. +set -e +R=/tmp/rec; W=$R/edit; mkdir -p $W; cd $W +VO=/mnt/d/Corpus/RoomRiddle/Assets/recordings/vo/voiceover.m4a +OUT=/mnt/d/Corpus/RoomRiddle/Assets/recordings/room-riddle-demo.mp4 +ENC="-c:v libx264 -preset medium -crf 19 -pix_fmt yuv420p -r 30 -c:a aac -ar 48000 -ac 2 -b:a 160k" + +# clip [speed] — TV shot, 1920x1080, game audio kept. +clip() { + local sp=${5:-1} + ffmpeg -v error -y -ss "$3" -t "$(python3 -c "print($4*$sp)")" -i "$2" \ + -filter_complex "[0:v]setpts=PTS/$sp,scale=1920:1080,fps=30[v];[0:a]atempo=$sp,aresample=48000[a]" \ + -map "[v]" -map "[a]" -t "$4" $ENC "$1" +} +# withphone — TV and phone side by side (nothing covered). +withphone() { + ffmpeg -v error -y -ss "$3" -t "$4" -i "$2" -ss "$6" -t "$4" -i "$5" -filter_complex \ + "[0:v]scale=1408:792,fps=30[tv];[1:v]crop=390:844:0:0,scale=-2:1000,fps=30,pad=iw+8:ih+8:4:4:color=0xC9A45C[ph];color=c=0x0b0d12:s=1920x1080:r=30[bg];[bg][tv]overlay=16:(H-h)/2:shortest=1[b1];[b1][ph]overlay=W-w-16:(H-h)/2[v];[0:a]aresample=48000[a]" \ + -map "[v]" -map "[a]" -t "$4" $ENC "$1" +} + +clip s0.mp4 $R/tvA.webm 0 15 0.7333 +clip s1a.mp4 $R/tvA.webm 16 10 +clip s1b.mp4 $R/tvA.webm 34 10 +clip s2a.mp4 $R/tvA.webm 49 10 +withphone s2b.mp4 $R/tvB.webm 24 10 $R/phone/phone.webm 24.7 +clip s3a.mp4 $R/tvB.webm 72 15 +clip s3b.mp4 $R/tvB.webm 122 15 +withphone s4.mp4 $R/tvC.webm 0 30 $R/phone/phone.webm 190.1 +withphone s5a.mp4 $R/tvC.webm 96 12 $R/phone/phone.webm 286.1 +withphone s5b.mp4 $R/tvE.webm 68 13 $R/phone2/phone.webm 110.5 +clip s6a.mp4 $R/tvD.webm 42 6 +clip s6b.mp4 $R/tvD.webm 53 9 + +# Title card: the README banner, with the repo link, silent audio, gentle fade. +ffmpeg -v error -y -loop 1 -t 13 -i /mnt/d/RoomRiddle/docs/images/banner.jpg -f lavfi -t 13 -i anullsrc=r=48000:cl=stereo -filter_complex \ + "[0:v]scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:color=0x0b0d12,drawtext=fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf:text='github.com/perhaps-aksh/RoomRiddle':fontcolor=0xE8D9B0:fontsize=40:x=(w-tw)/2:y=h-120,fade=t=in:st=0:d=0.8,fade=t=out:st=12:d=1,fps=30[v]" \ + -map "[v]" -map 1:a $ENC s7.mp4 + +printf "file '%s'\n" s0.mp4 s1a.mp4 s1b.mp4 s2a.mp4 s2b.mp4 s3a.mp4 s3b.mp4 s4.mp4 s5a.mp4 s5b.mp4 s6a.mp4 s6b.mp4 s7.mp4 > list.txt +ffmpeg -v error -y -f concat -safe 0 -i list.txt -c copy body.mp4 +# Game audio under the voice-over (ducked), voice-over on top. +ffmpeg -v error -y -i body.mp4 -i "$VO" -filter_complex \ + "[0:a]volume=0.35[g];[1:a]volume=1.6[vo];[g][vo]amix=inputs=2:duration=first:normalize=0,alimiter=limit=0.95[a]" \ + -map 0:v -map "[a]" -c:v copy -c:a aac -b:a 192k -movflags +faststart "$OUT" +for f in s*.mp4; do printf '%s %s\n' "$f" "$(ffprobe -v error -show_entries format=duration -of csv=p=0 $f)"; done +echo "OUT $(ffprobe -v error -show_entries format=duration -of csv=p=0 "$OUT") $OUT" diff --git a/apps/tv/scripts/rec-info.sh b/apps/tv/scripts/rec-info.sh new file mode 100644 index 0000000..643fed6 --- /dev/null +++ b/apps/tv/scripts/rec-info.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# rec-info.sh: durations and start times of the demo recordings in /tmp/rec +cd /tmp/rec +for f in tvA tvB tvC tvD; do echo "$f start=$(cat $f.start) dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 $f.webm)"; done +echo "phone t0=$(cat phone/t0.txt) dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 phone/phone.webm)" +ls -la phone diff --git a/apps/tv/scripts/sheet.sh b/apps/tv/scripts/sheet.sh new file mode 100644 index 0000000..930e03d --- /dev/null +++ b/apps/tv/scripts/sheet.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# sheet.sh