weavesignal/tests/demo-video/run.mjs
The WeaveSignal contributors 16833c96ae The demo video's tooling, the video on the website, and a Cloudron changelog the listing can read
- tests/demo-video/: the script, designed voices, the run through the real desk and the edit that
  make the website's demo video (the media themselves are not in git).
- The home page plays the video, with its credits.
- deploy/cloudron/CHANGELOG.md: one line per entry; CloudronVersions.json rebuilt.
2026-11-04 16:22:47 +00:00

633 lines
29 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: 2026 The WeaveSignal contributors
//
// Films the demo video's show (tests/demo-video/script.json) on the demo stack
// (tests/demo-video/run-stack.sh), through the real desk. Every character is a person in the
// room, joined the way a real one would (Eilidh, Gregor and Finlay by invite links, Niall and
// Jessie from the player page's call-in, Shona with a screener's room token), and their
// microphone is their designed-voice stem (tests/demo-video/synth.sh), played into the page's
// microphone at the step that says the line. Eilidh's desk and Shona's screener are the
// studio app's browser build (1,920 × 1,080, its text size at 120 %), each signed in as that member of staff, driven by keys and clicks
// at the script's steps, with an injected cursor, a ring on every click and a keycap for every
// key; Gregor's invite page and Niall's player page (390 px, touch) are filmed too.
//
// Recorded into target/demo-video/run/ (OUT): each filmed page as JPEG frames from Chromium's
// screencast with their wall-clock times (frames/<page>/), every room track the desk publishes
// or hears (a `listener` monitor in the studio room and one in each caller's booth, by
// MediaRecorder: audio/<identity>.webm), the listener stream as listeners get it
// (audio/stream.ogg), stills of every page at the end of each moment (stills/), and
// timeline.json: when each step, line, key and click really happened. edit.sh makes the video.
//
// tests/demo-video/run-stack.sh # in one shell; then, in another:
// PLAYWRIGHT_CORE=/path/to/node_modules/playwright-core CHROMIUM=/path/to/chrome node tests/demo-video/run.mjs
//
// Playwright is not a dependency of the repository (see tests/m2/drive.mjs).
import { createRequire } from 'node:module';
import { execFileSync, spawn } from 'node:child_process';
import { appendFileSync, createWriteStream, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
const { chromium } = require(process.env.PLAYWRIGHT_CORE ?? 'playwright-core');
const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, '..', '..');
const OUT = process.env.OUT ?? join(root, 'target/demo-video/run/show');
const STEMS = process.env.STEMS ?? join(root, 'target/demo-video/stems');
const CONTROL = process.env.CONTROL ?? 'http://127.0.0.1:28310';
const WEB = process.env.WEB ?? 'http://127.0.0.1:28430';
const STUDIO = process.env.STUDIO ?? 'http://127.0.0.1:28420/';
const STREAM = process.env.STREAM ?? 'http://127.0.0.1:28812/programme.opus';
const TOKEN = process.env.ADMIN_TOKEN ?? 'demo-admin-test-only';
const PASSWORD = 'a-long-test-only-phrase';
const script = JSON.parse(readFileSync(join(here, 'script.json'), 'utf8'));
rmSync(OUT, { recursive: true, force: true });
for (const d of ['frames', 'audio', 'stills']) mkdirSync(join(OUT, d), { recursive: true });
const t0 = Date.now();
const log = (...a) => console.log(((Date.now() - t0) / 1000).toFixed(2).padStart(7), ...a);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const timeline = { started: t0, events: [] };
const mark = (e) => {
const ev = { t: Date.now(), ...e };
timeline.events.push(ev);
return ev;
};
const save = () => writeFileSync(join(OUT, 'timeline.json'), JSON.stringify(timeline, null, 1));
// ---------------------------------------------------------------- the control plane
const admin = { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' };
async function api(method, path, body, headers = admin) {
const res = await fetch(`${CONTROL}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
const text = await res.text();
let json = {};
try {
json = JSON.parse(text);
} catch {}
return { status: res.status, body: json };
}
const view = async () => (await api('GET', '/api/v1/studio')).body;
const ids = {}; // character -> room identity
const src = (v, who) => v.sources?.find((s) => s.identity === ids[who]);
const caller = (v, who) => v.callers?.find((c) => c.identity === ids[who]);
let dumpsBefore = 0;
const predicates = {
eilidh_on_air: (v) => src(v, 'eilidh')?.on_air,
gregor_on_air: (v) => src(v, 'gregor')?.on_air,
niall_waiting: (v) => caller(v, 'niall')?.present,
niall_screening: (v) => caller(v, 'niall')?.state === 'screening',
niall_ready: (v) => caller(v, 'niall')?.state === 'ready',
niall_cued: (v) => src(v, 'niall')?.cue,
niall_on_air: (v) => src(v, 'niall')?.on_air,
niall_gone: (v) => !caller(v, 'niall') && !src(v, 'niall'),
jessie_waiting: (v) => caller(v, 'jessie')?.present,
jessie_screening: (v) => caller(v, 'jessie')?.state === 'screening',
jessie_ready: (v) => caller(v, 'jessie')?.state === 'ready',
jessie_cued: (v) => src(v, 'jessie')?.cue,
jessie_on_air: (v) => src(v, 'jessie')?.on_air,
jessie_gone: (v) => !caller(v, 'jessie') && !src(v, 'jessie'),
dumped: (v) => (v.desk?.delay?.dumps ?? 0) > dumpsBefore,
playout_down: (v) => v.desk?.playout && !v.desk.playout.connected,
playout_up: (v) => v.desk?.playout?.connected,
auto: (v) => v.mode === 'auto',
};
async function until(name, ms = 25_000) {
const pred = predicates[name];
if (!pred) throw new Error(`no predicate ${name}`);
const start = Date.now();
for (;;) {
const v = await view();
if (pred(v)) return Date.now() - start;
if (Date.now() - start > ms) throw new Error(`timed out waiting for ${name}`);
await sleep(100);
}
}
// ---------------------------------------------------------------- the browser
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM,
args: ['--autoplay-policy=no-user-gesture-required', '--use-fake-ui-for-media-stream', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows'],
});
// The microphone: the page's getUserMedia gets a track from an AudioContext, into which
// __say(id) plays that line's stem (decoded in advance, so it starts at once).
function voiceInit() {
const ac = new AudioContext({ sampleRate: 48000 });
const dest = ac.createMediaStreamDestination();
const buffers = {};
// A real microphone's room tone, about -66 dBFS: a desk hears a live link even between
// lines (a digitally silent track reads as a link that brings no audio).
const tone = ac.createBuffer(1, ac.sampleRate * 3, ac.sampleRate);
const data = tone.getChannelData(0);
for (let i = 0; i < data.length; i++) data[i] = (Math.random() * 2 - 1) * 0.0009;
const bed = ac.createBufferSource();
bed.buffer = tone;
bed.loop = true;
const soft = ac.createBiquadFilter();
soft.type = 'lowpass';
soft.frequency.value = 3000;
bed.connect(soft).connect(dest);
bed.start();
window.__voice = {
async load(list) {
for (const id of list) {
const r = await fetch(`/__stems/${id}.wav`);
buffers[id] = await ac.decodeAudioData(await r.arrayBuffer());
}
return Object.keys(buffers).length;
},
say(id) {
return new Promise((resolve) => {
const s = ac.createBufferSource();
s.buffer = buffers[id];
s.connect(dest);
const at = Date.now();
s.onended = () => resolve({ at, ms: Date.now() - at });
ac.resume().then(() => s.start());
});
},
track: () => dest.stream.getAudioTracks()[0],
};
navigator.mediaDevices.getUserMedia = async () => {
await ac.resume();
return new MediaStream([dest.stream.getAudioTracks()[0].clone()]);
};
}
// What a viewer of the video needs to see: a cursor that moves, a ring where it clicks or a
// finger taps, and each key pressed as a keycap at the bottom centre for about a second (a
// held key stays up until it is released). Keys typed into a text field are not shown, only
// Esc and the Ctrl chords that still act from one.
function overlayInit({ touch }) {
const names = { Control: '⌃', Shift: '⇧', Alt: '⌥', Delete: 'Del', Escape: 'Esc', ' ': 'Space', Enter: '⏎', ArrowUp: '↑', ArrowDown: '↓', ArrowLeft: '←', ArrowRight: '→' };
const style = `
#__ov-cursor{position:fixed;left:0;top:0;width:22px;height:22px;pointer-events:none;z-index:2147483647;transform:translate(-3px,-2px);filter:drop-shadow(0 1px 2px rgba(0,0,0,.6))}
.__ov-ring{position:fixed;width:44px;height:44px;margin:-22px 0 0 -22px;border-radius:50%;border:3px solid #ffd166;pointer-events:none;z-index:2147483646;animation:__ovring .6s ease-out forwards}
@keyframes __ovring{from{transform:scale(.4);opacity:1}to{transform:scale(1.5);opacity:0}}
#__ov-keys{position:fixed;left:50%;bottom:28px;transform:translateX(-50%);display:flex;gap:10px;pointer-events:none;z-index:2147483647}
.__ov-key{font:600 26px/1 system-ui,'Noto Sans',sans-serif;color:#f4f4f6;background:rgba(18,20,28,.88);border:1px solid rgba(255,255,255,.35);border-bottom-width:4px;border-radius:9px;padding:12px 18px;min-width:30px;text-align:center;box-shadow:0 6px 18px rgba(0,0,0,.45);transition:opacity .25s}
.__ov-key small{display:block;font-size:12px;font-weight:500;opacity:.75;margin-top:4px;letter-spacing:.04em}`;
const install = () => {
if (document.getElementById('__ov-keys')) return;
const s = document.createElement('style');
s.textContent = style;
document.documentElement.appendChild(s);
const keys = document.createElement('div');
keys.id = '__ov-keys';
document.documentElement.appendChild(keys);
if (!touch) {
const c = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
c.id = '__ov-cursor';
c.setAttribute('viewBox', '0 0 22 22');
c.innerHTML = '<path d="M3 2 L3 18 L7.5 13.8 L10.5 20.5 L13.4 19.2 L10.5 12.6 L16.5 12.6 Z" fill="#fff" stroke="#111" stroke-width="1.4" stroke-linejoin="round"/>';
c.style.left = '-40px';
document.documentElement.appendChild(c);
addEventListener('mousemove', (e) => { c.style.left = `${e.clientX / z()}px`; c.style.top = `${e.clientY / z()}px`; }, true);
}
};
// The studio app zooms its root (its text size, ⌃+): what is placed at a pointer's
// viewport position is divided by that zoom.
const z = () => parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
const ring = (x, y) => {
x /= z();
y /= z();
const r = document.createElement('div');
r.className = '__ov-ring';
r.style.left = `${x}px`;
r.style.top = `${y}px`;
document.documentElement.appendChild(r);
setTimeout(() => r.remove(), 700);
};
addEventListener('mousedown', (e) => ring(e.clientX, e.clientY), true);
addEventListener('touchstart', (e) => { for (const t of e.changedTouches) ring(t.clientX, t.clientY); }, true);
const held = new Map();
const label = (e) => {
let k = names[e.key] ?? (e.key.length === 1 ? e.key.toUpperCase() : e.key);
if (e.code?.startsWith('Digit')) k = e.code.slice(5);
if (e.code?.startsWith('Key')) k = e.code.slice(3);
return (e.ctrlKey && e.key !== 'Control' ? '⌃' : '') + (e.shiftKey && e.key !== 'Shift' && !e.ctrlKey ? '⇧' : '') + k;
};
addEventListener('keydown', (e) => {
install();
if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key) || e.repeat) return;
const f = document.activeElement;
const inField = f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA' || f.isContentEditable);
if (inField && !e.ctrlKey && e.key !== 'Escape') return;
const box = document.getElementById('__ov-keys');
const el = document.createElement('div');
el.className = '__ov-key';
el.textContent = label(e);
box.appendChild(el);
held.set(e.code, { el, at: Date.now() });
setTimeout(() => {
const h = held.get(e.code);
if (h?.el === el && h.up) fade(el);
if (h?.el === el && !h.up) {
el.innerHTML = `${el.textContent}<small>HELD</small>`;
}
if (h?.el !== el) fade(el);
}, 1000);
}, true);
const fade = (el) => { el.style.opacity = '0'; setTimeout(() => el.remove(), 260); };
addEventListener('keyup', (e) => {
const h = held.get(e.code);
if (!h) return;
h.up = true;
if (Date.now() - h.at >= 1000) fade(h.el);
}, true);
document.addEventListener('DOMContentLoaded', install);
}
const stemRoute = async (route) => {
const id = decodeURIComponent(new URL(route.request().url()).pathname.split('/').pop().replace(/\.wav$/, ''));
try {
await route.fulfill({ status: 200, contentType: 'audio/wav', body: readFileSync(join(STEMS, `${id}.wav`)) });
} catch {
await route.fulfill({ status: 404, body: '' });
}
};
const pages = {}; // name -> { page, ctx, touch, mouse }
async function newPage(name, { width = 1920, height = 1080, scale = 1, zoom = null, phone = false, voice = false, overlay = false } = {}) {
const ctx = await browser.newContext({
viewport: { width, height },
deviceScaleFactor: phone ? 2 : scale,
isMobile: phone,
hasTouch: phone,
locale: 'en-GB',
timezoneId: 'Europe/London',
colorScheme: 'dark',
permissions: voice ? ['microphone'] : [],
});
await ctx.addInitScript((z) => {
try {
localStorage.setItem('weavesignal.theme', 'dark');
if (z) localStorage.setItem('weavesignal.zoom', String(z)); // the studio app's own text size (⌃+)
} catch {}
}, zoom);
if (voice) {
await ctx.addInitScript(voiceInit);
await ctx.route('**/__stems/*', stemRoute);
}
if (overlay) await ctx.addInitScript(overlayInit, { touch: phone });
const page = await ctx.newPage();
page.on('pageerror', (e) => log(`[${name}] page error`, e.message));
page.on('console', (m) => m.type() === 'error' && !/favicon|ERR_ABORTED/.test(m.text()) && log(`[${name}] console`, m.text().slice(0, 160)));
pages[name] = { name, page, ctx, phone, scale: phone ? 2 : scale, mouse: { x: width / 2, y: height / 2 } };
return pages[name];
}
// Chromium's screencast, every frame with its wall-clock time.
const casts = [];
async function film(p) {
const dir = join(OUT, 'frames', p.name);
mkdirSync(dir, { recursive: true });
const index = createWriteStream(join(dir, 'frames.jsonl'));
const cdp = await p.ctx.newCDPSession(p.page);
let n = 0;
cdp.on('Page.screencastFrame', (f) => {
const file = `${String(n++).padStart(6, '0')}.jpg`;
writeFileSync(join(dir, file), Buffer.from(f.data, 'base64'));
index.write(`${JSON.stringify({ file, t: f.metadata.timestamp * 1000 })}\n`);
cdp.send('Page.screencastFrameAck', { sessionId: f.sessionId }).catch(() => {});
});
const vp = p.page.viewportSize();
// The screencast sends CSS pixels whatever the device scale: the page's own size.
await cdp.send('Page.startScreencast', { format: 'jpeg', quality: 92, maxWidth: vp.width, maxHeight: vp.height, everyNthFrame: 1 });
casts.push({ cdp, index, p, frames: () => n });
}
// The monitors: a `listener` token hears every track (ADR-0118); each track to a file.
const monitors = [];
async function monitor(name, booth) {
const tok = await api('POST', '/api/v1/room/tokens', { identity: `demo-monitor-${name}`, name: 'Demo monitor', role: 'listener', ...(booth ? { booth } : {}) });
if (tok.status !== 201) throw new Error(`monitor token ${tok.status}`);
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto(`${WEB}/`);
await page.addScriptTag({ path: join(root, 'apps/web/node_modules/livekit-client/dist/livekit-client.umd.js') });
await page.exposeFunction('__chunk', (who, b64) => appendFileSync(join(OUT, 'audio', `${who}.webm`), Buffer.from(b64, 'base64')));
await page.exposeFunction('__started', (who, at) => mark({ kind: 'track', monitor: name, identity: who, at }));
await page.evaluate(async ({ url, token }) => {
const { Room, RoomEvent } = window.LivekitClient;
const room = new Room();
window.__recs = [];
const seen = new Set();
room.on(RoomEvent.TrackSubscribed, (track, _pub, who) => {
if (track.kind !== 'audio' || seen.has(who.identity)) return;
seen.add(who.identity);
const el = track.attach();
el.volume = 0.001;
document.body.appendChild(el);
const rec = new MediaRecorder(new MediaStream([track.mediaStreamTrack]), { mimeType: 'audio/webm;codecs=opus', audioBitsPerSecond: 160000 });
const queue = [];
let busy = Promise.resolve();
rec.ondataavailable = (e) => {
busy = busy.then(async () => {
const b = new Uint8Array(await e.data.arrayBuffer());
let s = '';
for (let i = 0; i < b.length; i += 0x8000) s += String.fromCharCode(...b.subarray(i, i + 0x8000));
await window.__chunk(who.identity, btoa(s));
});
queue.push(busy);
};
rec.onstart = () => window.__started(who.identity, Date.now());
rec.start(1000);
window.__recs.push({ rec, done: () => busy });
});
await room.connect(url, token, { autoSubscribe: true });
window.__room = room;
}, { url: tok.body.url, token: tok.body.token });
monitors.push({ name, page, ctx });
log('monitor', name, booth ?? 'studio');
}
async function stopMonitors() {
for (const m of monitors) {
await m.page.evaluate(async () => {
for (const r of window.__recs) {
await new Promise((res) => {
r.rec.addEventListener('stop', res, { once: true });
r.rec.stop();
});
await r.done();
}
}).catch((e) => log('monitor stop', e.message));
}
}
// ---------------------------------------------------------------- driving
const KEYROWS = { on: '123456', cue: 'qwerty', talk: 'asdfgh' };
async function resolveKey(spec, pageName) {
const m = /^(caller|cue|on|talk):(\w+)$/.exec(spec);
if (!m) return spec;
const v = await view();
if (m[1] === 'caller') {
const i = v.callers.findIndex((c) => c.identity === ids[m[2]]);
if (i < 0) throw new Error(`${m[2]} is not in the queue`);
return String(i + 1);
}
const s = src(v, m[2]) ?? v.sources.find((x) => x.identity === ids[m[2]]);
if (!s) throw new Error(`${m[2]} has no slot`);
return KEYROWS[m[1]][s.slot - 1];
}
async function moveTo(p, x, y) {
const from = p.mouse;
const steps = Math.max(8, Math.round(Math.hypot(x - from.x, y - from.y) / 30));
await p.page.mouse.move(x, y, { steps });
p.mouse = { x, y };
}
function target(p, what) {
if (what === 'submit') return p.page.locator('form button[type="submit"]');
if (/^[#.[]/.test(what)) return p.page.locator(what).first();
return p.page.getByRole('button', { name: what }).first();
}
async function line(step) {
const p = pages[`mic-${step.who}`] ?? pages[step.who];
const ev = mark({ kind: 'line', id: step.line, who: step.who, text: step.text, ...(step.swear ? { swear: step.swear } : {}) });
const played = p.page.evaluate((id) => window.__voice.say(id), step.line);
ev.done = played.then((r) => {
ev.at = r.at;
ev.ms = r.ms;
return r;
});
return ev.done;
}
const pendingLines = {};
async function run(step, moment) {
const p = step.page ? pages[step.page] : null;
if (step.show) mark({ kind: 'show', page: step.show, moment });
if (step.overlay !== undefined) mark({ kind: 'overlay', overlay: step.overlay, moment });
if (step.line) {
const done = line(step);
pendingLines[step.line] = done;
if (!step.async) await done;
}
if (step.wait_line) await pendingLines[step.wait_line];
if (step.key) {
const key = await resolveKey(step.key, step.page);
mark({ kind: 'key', key, page: step.page, moment });
await p.page.keyboard.press(key);
}
if (step.hold) {
const key = await resolveKey(step.hold, step.page);
mark({ kind: 'hold', key, page: step.page, moment });
step._held = key;
held[step.hold] = key;
await p.page.keyboard.down(key);
}
if (step.release) {
const key = held[step.release];
mark({ kind: 'release', key, page: step.page, moment });
await p.page.keyboard.up(key);
}
if (step.type) {
mark({ kind: 'type', text: step.type, page: step.page, moment });
await p.page.keyboard.type(step.type, { delay: 55 });
}
if (step.fill) {
const el = p.page.locator(step.fill);
await el.tap();
mark({ kind: 'type', text: step.text, page: step.page, moment });
await el.pressSequentially(step.text, { delay: 60 });
}
if (step.click || step.tap) {
const el = target(p, step.click ?? step.tap);
await el.waitFor({ timeout: 20_000 });
await el.scrollIntoViewIfNeeded();
const b = await el.boundingBox();
const x = b.x + b.width / 2;
const y = b.y + b.height / 2;
if (p.phone) {
mark({ kind: 'tap', what: step.tap ?? step.click, page: step.page, x, y, moment });
await p.page.touchscreen.tap(x, y);
// Once the call is placed, the phone keeps its call banner in view, as a person would
// scroll back to it: the page itself leaves the form's scroll where it was.
if ((step.tap ?? step.click) === 'submit') await p.page.evaluate(() => {
const show = () => {
const b = document.querySelector('.banner');
if (b) window.scrollTo({ top: Math.max(0, b.getBoundingClientRect().top + window.scrollY - 150), behavior: 'smooth' });
};
setTimeout(show, 500);
new MutationObserver(() => setTimeout(show, 200)).observe(document.body, { attributes: true, subtree: true, attributeFilter: ['data-phase'] });
});
} else {
await moveTo(p, x, y);
await sleep(150);
mark({ kind: 'click', what: step.click, page: step.page, x, y, moment });
await p.page.mouse.down();
await sleep(60);
await p.page.mouse.up();
}
}
if (step.stack) {
mark({ kind: 'stack', op: step.stack, moment });
execFileSync(join(here, 'run-stack.sh'), [step.stack], { stdio: 'inherit' });
}
if (step.mode) {
const settings = (await api('GET', '/api/v1/playout/settings')).body;
const r = await api('PUT', '/api/v1/playout/settings', { ...settings, mode: step.mode });
mark({ kind: 'mode', mode: step.mode, status: r.status, moment });
if (r.status !== 200) throw new Error(`mode ${step.mode}: ${r.status}`);
}
if (step.until) {
const ms = await until(step.until);
mark({ kind: 'until', what: step.until, ms, moment });
}
if (step.background) {
const bg = (async () => {
for (const s of step.background) await run(s, moment);
})().catch((e) => log('background step failed', e.message));
background.push(bg);
}
if (step.gap) await sleep(step.gap * 1000);
}
const held = {};
const background = [];
async function stills(tag) {
for (const p of Object.values(pages)) {
await p.page.screenshot({ path: join(OUT, 'stills', `${tag}-${p.name}.png`) }).catch(() => {});
}
}
// ---------------------------------------------------------------- the people
for (const [username, who, role] of [['eilidh', 'eilidh', 'presenter'], ['shona', 'shona', 'screener']]) {
const r = await api('POST', '/api/v1/staff', { username, name: script.cast[who].name, role, password: PASSWORD });
if (![201, 409].includes(r.status)) throw new Error(`staff ${username}: ${r.status}`);
}
const session = async (username) => (await api('POST', '/api/v1/sessions', { username, password: PASSWORD }, { 'Content-Type': 'application/json' })).body.token;
const linesOf = (who) => [...script.moments.flatMap((m) => m.steps.flatMap((s) => [s, ...(s.background ?? [])]))].filter((s) => s.line && s.who === who).map((s) => s.line);
async function invite(who, role, filmIt) {
const inv = (await api('POST', '/api/v1/invites', { role, name: script.cast[who].name })).body;
const name = filmIt ? who : `mic-${who}`;
const p = await newPage(name, filmIt ? { width: 390, height: 844, phone: true, voice: true, overlay: true } : { width: 1280, height: 800, scale: 1.5, voice: true });
await p.page.goto(`${WEB}${inv.link}`);
await p.page.getByRole('button', { name: 'Join the studio' }).waitFor({ timeout: 20_000 });
log('voice', who, await p.page.evaluate((l) => window.__voice.load(l), linesOf(who)), 'lines');
return { p, identity: JSON.parse(Buffer.from(inv.link.split('.')[1], 'base64url').toString()).id };
}
async function joinRoom(p) {
await p.page.getByRole('button', { name: 'Join the studio' }).click();
for (let i = 0; i < 100; i++) {
const ph = await p.page.locator('.banner').getAttribute('data-phase').catch(() => null);
if (ph && ph !== 'none' && ph !== 'joining') return ph;
await sleep(200);
}
throw new Error(`${p.name} did not join`);
}
// The listener stream, from before the show: what listeners heard.
const stream = spawn('curl', ['-sN', '-o', join(OUT, 'audio', 'stream.ogg'), STREAM], { stdio: 'ignore' });
mark({ kind: 'stream', at: Date.now() });
// Eilidh's microphone and Finlay's, by invite; Shona's, by a screener's token.
const e = await invite('eilidh', 'presenter', false);
ids.eilidh = e.identity;
await joinRoom(e.p);
const f = await invite('finlay', 'dj', false);
ids.finlay = f.identity;
await joinRoom(f.p);
{
const p = await newPage('mic-shona', { width: 900, height: 900, voice: true });
await p.page.goto(`${WEB}/`);
await p.page.addScriptTag({ path: join(root, 'apps/web/node_modules/livekit-client/dist/livekit-client.umd.js') });
log('voice', 'shona', await p.page.evaluate((l) => window.__voice.load(l), linesOf('shona')), 'lines');
const tok = await api('POST', '/api/v1/room/tokens', { identity: 'shona', name: script.cast.shona.name, role: 'screener' });
if (tok.status !== 201) throw new Error(`screener token ${tok.status}`);
ids.shona = 'shona';
await p.page.evaluate(async ({ url, token }) => {
const { Room } = window.LivekitClient;
const room = new Room();
await room.connect(url, token, { autoSubscribe: false });
await room.localParticipant.publishTrack(window.__voice.track(), { name: 'microphone', source: 'microphone' });
window.__room = room;
}, { url: tok.body.url, token: tok.body.token });
}
const g = await invite('gregor', 'guest', true);
ids.gregor = g.identity;
for (const who of ['niall', 'jessie']) {
const p = await newPage(who, { width: 390, height: 844, phone: true, voice: true, overlay: true });
await p.page.goto(`${WEB}/`);
await p.page.getByRole('button', { name: 'Listen live' }).waitFor({ timeout: 20_000 });
log('voice', who, await p.page.evaluate((l) => window.__voice.load(l), linesOf(who)), 'lines');
}
// The studio app twice: Eilidh at the desk, Shona at the screener.
const desk = await newPage('desk', { overlay: true, zoom: 1.2 });
await desk.page.goto(`${STUDIO}?token=${await session('eilidh')}`);
await desk.page.locator('[data-panel="desk.stage"]').waitFor({ timeout: 20_000 });
const shona = await newPage('shona', { overlay: true, zoom: 1.2 });
await shona.page.goto(`${STUDIO}?token=${await session('shona')}`);
await shona.page.locator('[data-panel="desk.stage"]').waitFor({ timeout: 20_000 });
await shona.page.keyboard.press('Control+2');
await shona.page.locator('[data-panel="screener.queue"]').waitFor();
// Off air before the show (a presenter is on air from the moment they join), and the
// delay full: quiet passages rebuild it at twice their speed.
await api('POST', '/api/v1/desk/commands', { op: 'on_air', identity: ids.eilidh, on: false });
for (let i = 0; i < 120; i++) {
const v = await view();
if (v.desk?.delay && !v.desk.delay.rebuilding && v.desk.playout?.connected && v.seats?.screener) break;
await sleep(500);
}
dumpsBefore = (await view()).desk?.delay?.dumps ?? 0;
await monitor('studio');
// Niall listens on his phone before he calls.
await pages.niall.page.getByRole('button', { name: 'Listen live' }).tap();
await sleep(4000);
await stills('pre');
for (const p of [desk, shona, g.p, pages.niall]) await film(p);
await sleep(1500);
mark({ kind: 'roll' });
log('rolling');
// The booths: a monitor joins each caller's booth as soon as the page knows who it is.
const boothWatch = setInterval(async () => {
for (const who of ['niall', 'jessie']) {
if (ids[who] || ids[`_${who}`]) continue;
const call = await pages[who].page.evaluate(() => JSON.parse(localStorage.getItem('weavesignal.call') ?? 'null')).catch(() => null);
if (call?.identity) {
ids[`_${who}`] = true;
ids[who] = call.identity;
mark({ kind: 'identity', who, identity: call.identity });
monitor(`booth-${who}`, call.identity).catch((err) => log('booth monitor', err.message));
}
}
}, 150);
let failed = null;
try {
for (const m of script.moments) {
mark({ kind: 'moment', id: m.id, title: m.title, caption: m.caption });
log('moment', m.id);
for (const s of m.steps) await run(s, m.id);
await Promise.all(background.splice(0));
await stills(m.id);
save();
}
} catch (err) {
failed = err;
log('FAILED', err.message);
await stills('failed');
}
mark({ kind: 'end' });
await sleep(6000); // the stream runs on behind the desk
clearInterval(boothWatch);
for (const c of casts) {
await c.cdp.send('Page.stopScreencast').catch(() => {});
c.index.end();
log('frames', c.p.name, c.frames());
}
await stopMonitors();
await sleep(500);
stream.kill('SIGTERM');
mark({ kind: 'stopped' });
timeline.ids = ids;
timeline.dumpsBefore = dumpsBefore;
for (const ev of timeline.events) delete ev.done;
save();
await browser.close();
log(failed ? 'failed' : 'done', OUT);
process.exit(failed ? 1 : 0);