Skip to content

Commit 2002e03

Browse files
authored
Merge pull request #146 from httpsphl/feat/mascot-reel
feat(app): mascot reel page and a recorder for videos
2 parents 44ebf27 + 83d761a commit 2002e03

8 files changed

Lines changed: 524 additions & 19 deletions

File tree

‎CLAUDE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ pnpm check # tsc --noEmit + biome check + vitest run
4545
pnpm bundle # instalador NSIS com o sidecar (spec 15.4), em target\release\bundle\nsis
4646
pnpm bundle:setup # tela de instalação com esse NSIS dentro (spec 15.7), em target\release\bundle\setup
4747
pnpm local # compila em release e troca o app e o botloftd do Botloft instalado, sem instalador; `pnpm local --restore` volta o anterior
48+
pnpm reel # grava o carrossel dos mascotes (?reel, com o pnpm dev rodando) em .dev/reel/reel.mp4; --blur, --seconds, --size, --url
4849
```
4950

5051
O `pnpm local` usa os dados reais (`%LOCALAPPDATA%\Botloft`), não os de dev. As migrations novas rodam no banco real, e um daemon mais antigo deixa de abrir esse banco. O atualizador continua oferecendo releases de versão maior.

‎app/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"bundle": "node scripts/sidecar.mjs && tauri build --config src-tauri/tauri.bundle.conf.json",
1414
"bundle:setup": "node scripts/setup.mjs",
1515
"local": "node scripts/local.mjs",
16+
"reel": "node scripts/reel.mjs",
1617
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json",
1718
"lint": "biome check .",
1819
"format": "biome check --write .",

‎app/scripts/reel.mjs‎

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
// Records the mascot reel (src/dev/Reel.tsx) to a video: opens the page in
2+
// a headless Edge or Chrome with its clock stopped (reelClock.js), moves it
3+
// a sixtieth of a second at a time and takes a picture of each frame over the
4+
// DevTools protocol, then has ffmpeg (on the PATH) join them at 60 frames a
5+
// second. The frames come out the same however slow the computer is. Run
6+
// `pnpm dev` first.
7+
//
8+
// pnpm reel 12 s at 1920x1080 into .dev/reel/reel.mp4
9+
// pnpm reel --seconds 30 --size 1080x1920 --beat 2600
10+
// pnpm reel --blur blends each frame with its neighbors
11+
// pnpm reel --url http://localhost:1456 --out clip.mp4
12+
//
13+
// REEL_BROWSER points to another Chromium browser.
14+
15+
import { execFileSync, spawn } from "node:child_process";
16+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
17+
import { tmpdir } from "node:os";
18+
import { dirname, join, resolve } from "node:path";
19+
import { fileURLToPath } from "node:url";
20+
21+
const app = join(dirname(fileURLToPath(import.meta.url)), "..");
22+
23+
function option(name, fallback) {
24+
const at = process.argv.indexOf(`--${name}`);
25+
return at === -1 ? fallback : (process.argv[at + 1] ?? fallback);
26+
}
27+
28+
const seconds = Number(option("seconds", "12"));
29+
const [width, height] = option("size", "1920x1080").split("x").map(Number);
30+
const base = option("url", "http://localhost:1420");
31+
const beat = option("beat", "");
32+
const blur = process.argv.includes("--blur");
33+
const out = resolve(option("out", join(app, "..", ".dev", "reel", "reel.mp4")));
34+
const url = `${base.replace(/\/$/, "")}/?reel${beat ? `&beat=${beat}` : ""}`;
35+
36+
const BROWSERS = {
37+
win32: [
38+
"C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
39+
"C:/Program Files/Google/Chrome/Application/chrome.exe",
40+
],
41+
darwin: [
42+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
43+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
44+
],
45+
linux: ["/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"],
46+
};
47+
const browserPath =
48+
process.env.REEL_BROWSER ?? (BROWSERS[process.platform] ?? []).find((path) => existsSync(path));
49+
if (!browserPath) {
50+
throw new Error("no Chromium browser found: set REEL_BROWSER");
51+
}
52+
53+
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
54+
55+
/** Waits until `read` returns something, for up to 15 s. */
56+
async function until(read) {
57+
for (let tries = 0; tries < 150; tries++) {
58+
try {
59+
const value = await read();
60+
if (value) return value;
61+
} catch {}
62+
await sleep(100);
63+
}
64+
throw new Error("the browser did not start");
65+
}
66+
67+
/**
68+
* A picture of the page as it is. Now and then the browser never answers
69+
* one; a fresh paint (the clock's corner pixel, stepped by nothing) and
70+
* another try get it going again.
71+
*/
72+
async function picture(cdp) {
73+
for (let tries = 1; ; tries++) {
74+
try {
75+
const shot = { format: "jpeg", quality: 95 };
76+
return (await cdp.send("Page.captureScreenshot", shot, 5_000)).data;
77+
} catch (error) {
78+
if (tries === 5) throw error;
79+
await cdp.send("Runtime.evaluate", { expression: "__reelStep(0)", awaitPromise: true });
80+
}
81+
}
82+
}
83+
84+
/** A small DevTools protocol client over the page's WebSocket. */
85+
function devtools(socket) {
86+
let next = 0;
87+
const waiting = new Map();
88+
const listeners = new Map();
89+
socket.addEventListener("message", ({ data }) => {
90+
const message = JSON.parse(data);
91+
if (message.id !== undefined) {
92+
const call = waiting.get(message.id);
93+
waiting.delete(message.id);
94+
if (message.error) call?.fail(new Error(message.error.message));
95+
else call?.done(message.result);
96+
} else {
97+
for (const listen of listeners.get(message.method) ?? []) listen(message.params);
98+
}
99+
});
100+
return {
101+
send(method, params = {}, wait = 60_000) {
102+
const id = ++next;
103+
socket.send(JSON.stringify({ id, method, params }));
104+
// A call the browser never answers fails instead of hanging the run.
105+
return new Promise((done, fail) => {
106+
const timer = setTimeout(() => fail(new Error(`${method} got no answer`)), wait);
107+
waiting.set(id, {
108+
done: (result) => {
109+
clearTimeout(timer);
110+
done(result);
111+
},
112+
fail: (error) => {
113+
clearTimeout(timer);
114+
fail(error);
115+
},
116+
});
117+
});
118+
},
119+
on(method, listen) {
120+
listeners.set(method, [...(listeners.get(method) ?? []), listen]);
121+
},
122+
once(method) {
123+
return new Promise((done) => this.on(method, done));
124+
},
125+
};
126+
}
127+
128+
const work = mkdtempSync(join(tmpdir(), "botloft-reel-"));
129+
const profile = join(work, "profile");
130+
const frames = join(work, "frames");
131+
mkdirSync(frames, { recursive: true });
132+
133+
const browser = spawn(
134+
browserPath,
135+
[
136+
"--headless=new",
137+
"--remote-debugging-port=0",
138+
`--user-data-dir=${profile}`,
139+
`--window-size=${width},${height}`,
140+
"--force-device-scale-factor=1",
141+
// Windows takes the headless window for one covered by others and stops
142+
// painting it after a moment.
143+
"--disable-features=CalculateNativeWinOcclusion",
144+
"--disable-backgrounding-occluded-windows",
145+
"--disable-renderer-backgrounding",
146+
"--hide-scrollbars",
147+
"--mute-audio",
148+
"--no-first-run",
149+
"about:blank",
150+
],
151+
{ stdio: "ignore" },
152+
);
153+
154+
try {
155+
const port = await until(
156+
() => readFileSync(join(profile, "DevToolsActivePort"), "utf8").split("\n")[0],
157+
);
158+
const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
159+
const page = targets.find((target) => target.type === "page");
160+
const socket = new WebSocket(page.webSocketDebuggerUrl);
161+
await new Promise((done) => socket.addEventListener("open", done));
162+
const cdp = devtools(socket);
163+
164+
await cdp.send("Page.enable");
165+
// A page Windows thinks is out of sight paints about once a second.
166+
await cdp.send("Page.bringToFront");
167+
await cdp.send("Emulation.setFocusEmulationEnabled", { enabled: true });
168+
await cdp.send("Emulation.setDeviceMetricsOverride", {
169+
width,
170+
height,
171+
deviceScaleFactor: 1,
172+
mobile: false,
173+
});
174+
// The page's clock stands still from the start (reelClock.js), on the
175+
// reel's own dark rather than the app's light background.
176+
const clock = readFileSync(join(app, "scripts", "reelClock.js"), "utf8");
177+
const dark =
178+
"const sheet = new CSSStyleSheet();" +
179+
"sheet.replaceSync('html, body { background: #050505 !important; }');" +
180+
"document.adoptedStyleSheets = [sheet];";
181+
await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source: `${clock}\n${dark}` });
182+
const loaded = cdp.once("Page.loadEventFired");
183+
await cdp.send("Page.navigate", { url });
184+
await loaded;
185+
await until(async () => {
186+
const { result } = await cdp.send("Runtime.evaluate", {
187+
expression: "document.querySelector('.reel-place') !== null",
188+
});
189+
return result.value;
190+
});
191+
192+
const total = Math.round(seconds * 60);
193+
for (let index = 0; index < total; index++) {
194+
if (index > 0) {
195+
await cdp.send("Runtime.evaluate", {
196+
expression: `__reelStep(${1000 / 60})`,
197+
awaitPromise: true,
198+
});
199+
}
200+
const data = await picture(cdp);
201+
const name = `frame_${String(index + 1).padStart(5, "0")}.jpg`;
202+
writeFileSync(join(frames, name), Buffer.from(data, "base64"));
203+
if ((index + 1) % 60 === 0) {
204+
process.stdout.write(`${(index + 1) / 60} s of ${seconds}\r`);
205+
}
206+
}
207+
await cdp.send("Browser.close").catch(() => {});
208+
209+
mkdirSync(dirname(out), { recursive: true });
210+
const filters = [...(blur ? ["tmix=frames=3:weights=1 2 1"] : []), "format=yuv420p"];
211+
execFileSync(
212+
"ffmpeg",
213+
[
214+
"-y",
215+
"-v",
216+
"error",
217+
...["-framerate", "60", "-i", join(frames, "frame_%05d.jpg")],
218+
...["-vf", filters.join(","), "-c:v", "libx264", "-crf", "14", "-preset", "slow"],
219+
...["-movflags", "+faststart", out],
220+
],
221+
{ stdio: "inherit" },
222+
);
223+
console.log(out);
224+
} finally {
225+
// The browser runs as a tree of processes; on Windows killing the first
226+
// one leaves the rest holding the profile folder.
227+
if (process.platform === "win32") {
228+
try {
229+
execFileSync("taskkill", ["/pid", String(browser.pid), "/t", "/f"], { stdio: "ignore" });
230+
} catch {}
231+
}
232+
browser.kill();
233+
await sleep(300);
234+
rmSync(work, { recursive: true, force: true });
235+
}

‎app/scripts/reelClock.js‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Runs in the reel page before anything else (scripts/reel.mjs reads it as
2+
// text): the page's clock stands still until the recorder moves it, one
3+
// frame at a time. Timers, animation frames, Date.now and performance.now
4+
// follow it, and so do CSS animations and transitions, held paused at the
5+
// time they have played to. The page then looks the same at a given time on
6+
// any computer, however long each frame takes to capture.
7+
8+
(() => {
9+
const real = {
10+
setTimeout: window.setTimeout.bind(window),
11+
requestAnimationFrame: window.requestAnimationFrame.bind(window),
12+
};
13+
const epoch = Date.now();
14+
let now = 0;
15+
let nextId = 1;
16+
const timers = new Map();
17+
const frames = new Map();
18+
19+
const delay = (ms) => Math.max(0, Number(ms) || 0);
20+
window.setTimeout = (run, ms, ...args) => {
21+
const id = nextId++;
22+
timers.set(id, { at: now + delay(ms), every: 0, run: () => run(...args) });
23+
return id;
24+
};
25+
window.setInterval = (run, ms, ...args) => {
26+
const id = nextId++;
27+
const every = Math.max(1, delay(ms));
28+
timers.set(id, { at: now + every, every, run: () => run(...args) });
29+
return id;
30+
};
31+
window.clearTimeout = (id) => timers.delete(id);
32+
window.clearInterval = (id) => timers.delete(id);
33+
window.requestAnimationFrame = (run) => {
34+
const id = nextId++;
35+
frames.set(id, run);
36+
return id;
37+
};
38+
window.cancelAnimationFrame = (id) => frames.delete(id);
39+
performance.now = () => now;
40+
Date.now = () => epoch + now;
41+
42+
/** When each animation started, on this clock. */
43+
const started = new WeakMap();
44+
function hold() {
45+
for (const animation of document.getAnimations()) {
46+
if (!started.has(animation)) started.set(animation, now);
47+
animation.pause();
48+
animation.currentTime = now - started.get(animation);
49+
}
50+
}
51+
52+
/** Lets the page's own work (React's renders) run in real time. */
53+
const settle = () => new Promise((done) => real.setTimeout(done, 0));
54+
55+
/** Moves the clock `ms` on, firing what falls due on the way, in order. */
56+
window.__reelStep = async (ms) => {
57+
const until = now + ms;
58+
for (;;) {
59+
let next;
60+
for (const [id, timer] of timers) {
61+
if (timer.at <= until && (!next || timer.at < next.timer.at)) next = { id, timer };
62+
}
63+
if (!next) break;
64+
now = next.timer.at;
65+
if (next.timer.every) next.timer.at += next.timer.every;
66+
else timers.delete(next.id);
67+
try {
68+
next.timer.run();
69+
} catch (error) {
70+
console.error(error);
71+
}
72+
await settle();
73+
}
74+
now = until;
75+
const due = [...frames.values()];
76+
frames.clear();
77+
for (const run of due) run(now);
78+
await settle();
79+
await settle();
80+
hold();
81+
// A picture waits for the next painted frame, and a step where nothing
82+
// moved paints none: a pixel in the corner, too dark to see, flips
83+
// shade every step so there always is one.
84+
if (!tick.isConnected) document.body.append(tick);
85+
tick.style.background = tick.style.background === "rgb(5, 5, 5)" ? "#060606" : "#050505";
86+
await new Promise((done) => real.requestAnimationFrame(() => done()));
87+
};
88+
const tick = document.createElement("div");
89+
tick.style.cssText = "position: fixed; left: 0; top: 0; width: 1px; height: 1px;";
90+
})();

0 commit comments

Comments
 (0)