-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdemo.mjs
More file actions
324 lines (288 loc) · 12 KB
/
Copy pathdemo.mjs
File metadata and controls
324 lines (288 loc) · 12 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
// Record the README demo end to end, without a human driving the viewer.
//
// The demo is an interaction, not a screenshot: load a 500 MB file, watch the
// first rows paint, then scroll *while the index is still building*. That last
// part is the entire argument, and a still frame cannot make it.
//
// Everything runs over the Chrome DevTools Protocol against a Chrome started
// with --remote-debugging-port. Three things this file exists to get right:
//
// * Capture comes from Page.startScreencast, not from a screen recorder.
// Chrome paints through the GPU compositor, which Windows' GDI window
// capture (ffmpeg's gdigrab) cannot see at all — it records a blank white
// rectangle. Screencast asks Chrome for its own frames, so it is immune to
// that, and to occlusion, focus and whatever else is on the desktop.
//
// * The file is delivered with DOM.setFileInputFiles, not a synthetic drag.
// A drag cannot be forged from outside the browser; the file input and the
// drop handler end in the same load path, so the recording exercises the
// real thing.
//
// * Chrome 137+ refuses --load-extension from a command line, an anti-malware
// change that no flag combination defeats. The extension is loaded once by
// hand in the browser UI; it then persists in the profile.
//
// Usage:
// node scripts/demo.mjs
// node scripts/demo.mjs --file fixtures/generated/ndjson-500.0MB.ndjson
// node scripts/demo.mjs --seconds 12
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
const PORT = 9222;
const args = process.argv.slice(2);
const opt = (name, fallback) => {
const i = args.indexOf(name);
return i === -1 ? fallback : args[i + 1];
};
const FILE = resolve(opt("--file", "fixtures/generated/ndjson-500.0MB.ndjson"));
const MP4 = resolve(opt("--mp4", "docs/media/capture.mp4"));
const GIF = resolve(opt("--out", "docs/media/demo.gif"));
const FRAMES = join(tmpdir(), "leviathan-frames");
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const note = (tag, msg) => console.log(` ${tag.padEnd(6)} ${msg}`);
const die = (msg) => {
console.log(`\n FAIL ${msg}\n`);
process.exit(1);
};
// --- 1. A minimal CDP client -------------------------------------------------
//
// Node 25 has a global WebSocket, so this needs no dependency. The project ships
// a zero-dependency core and a 150 KB bundle budget; a browser-automation
// library to record a GIF would be the largest thing in the repository.
class CDP {
#ws;
#id = 0;
#pending = new Map();
#listeners = [];
static async connect(url) {
const c = new CDP();
c.#ws = new WebSocket(url);
await new Promise((res, rej) => {
c.#ws.onopen = res;
c.#ws.onerror = () => rej(new Error(`cannot connect to ${url}`));
});
c.#ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.id && c.#pending.has(msg.id)) {
const { res, rej } = c.#pending.get(msg.id);
c.#pending.delete(msg.id);
msg.error ? rej(new Error(msg.error.message)) : res(msg.result);
} else {
for (const l of c.#listeners) l(msg);
}
};
return c;
}
send(method, params = {}, sessionId) {
const id = ++this.#id;
return new Promise((res, rej) => {
this.#pending.set(id, { res, rej });
this.#ws.send(JSON.stringify({ id, method, params, sessionId }));
});
}
on(fn) {
this.#listeners.push(fn);
}
close() {
this.#ws.close();
}
}
// --- 2. Find the viewer ------------------------------------------------------
//
// The live target list is authoritative; the profile file is the fallback.
// Chrome flushes Preferences to disk lazily, so an extension loaded a minute ago
// may not be in it yet — asking the running browser beats reading what it last
// wrote down.
async function findViewer() {
const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json();
const open = list.find(
(t) => t.type === "page" && /^chrome-extension:\/\/[a-p]{32}\/viewer\.html/.test(t.url),
);
if (open) {
return { id: open.url.match(/^chrome-extension:\/\/([a-p]{32})\//)[1], targetId: open.id };
}
const profile = `${process.env.TEMP}\\lev-demo2\\Default`;
for (const name of ["Preferences", "Secure Preferences"]) {
const path = `${profile}\\${name}`;
if (!existsSync(path)) continue;
let json;
try {
json = JSON.parse(readFileSync(path, "utf8"));
} catch {
continue;
}
for (const [id, v] of Object.entries(json?.extensions?.settings ?? {})) {
if (/^Leviathan$/i.test(v?.manifest?.name ?? "")) return { id, targetId: null };
}
}
return null;
}
// --- 3. Drive ----------------------------------------------------------------
async function main() {
if (!existsSync(FILE)) die(`no such fixture: ${FILE}`);
console.log(`\ndemo: ${FILE}\n`);
const found = await findViewer();
if (!found) {
console.log(" FAIL Leviathan is not loaded in the demo profile.\n");
console.log(" Chrome 137+ blocks --load-extension from a command line, so");
console.log(" this one step cannot be automated. In the Chrome window:");
console.log("");
console.log(" 1. chrome://extensions");
console.log(" 2. turn on 'Developer mode', top right");
console.log(" 3. 'Load unpacked' -> packages\\extension\\dist");
console.log("");
console.log(" It persists in that profile. Then run this again.\n");
process.exit(1);
}
note("ok", `extension ${found.id}`);
const version = await (await fetch(`http://127.0.0.1:${PORT}/json/version`)).json();
const browser = await CDP.connect(version.webSocketDebuggerUrl);
let targetId = found.targetId;
if (!targetId) {
({ targetId } = await browser.send("Target.createTarget", {
url: `chrome-extension://${found.id}/viewer.html`,
}));
await sleep(1500);
}
await browser.send("Target.activateTarget", { targetId });
const { sessionId } = await browser.send("Target.attachToTarget", { targetId, flatten: true });
const send = (m, p) => browser.send(m, p, sessionId);
await send("Page.enable");
await send("DOM.enable");
await send("Runtime.enable");
await sleep(800);
const evaluate = async (expression) => {
const r = await send("Runtime.evaluate", { expression, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text);
return r.result?.value;
};
if ((await evaluate(`!!document.querySelector('#file')`)) !== true) {
die("viewer has no #file input — is dist/ current?");
}
note("ok", "viewer ready");
// --- Capture -------------------------------------------------------------
//
// Frames arrive only when something changes, each with a timestamp. Keeping
// the timestamps and rebuilding the timeline from them means an idle second
// costs one frame rather than twenty-five, and the motion still plays at the
// speed it actually happened.
rmSync(FRAMES, { recursive: true, force: true });
mkdirSync(FRAMES, { recursive: true });
const frames = [];
browser.on((msg) => {
if (msg.method !== "Page.screencastFrame") return;
const { data, sessionId: ack, metadata } = msg.params;
const path = join(FRAMES, `${String(frames.length).padStart(6, "0")}.jpg`);
writeFileSync(path, Buffer.from(data, "base64"));
frames.push({ path, t: metadata.timestamp });
browser.send("Page.screencastFrameAck", { sessionId: ack }, sessionId).catch(() => {});
});
await send("Page.startScreencast", {
format: "jpeg",
quality: 85,
maxWidth: 1280,
everyNthFrame: 1,
});
await sleep(700);
note("ok", "capturing");
// The load. setFileInputFiles reaches the same path a real drop does.
const { root } = await send("DOM.getDocument");
const { nodeId } = await send("DOM.querySelector", { nodeId: root.nodeId, selector: "#file" });
await send("DOM.setFileInputFiles", { files: [FILE], nodeId });
note("ok", "file handed to the viewer");
// Rows are built into #canvas by script, so the static markup says nothing —
// ask for the child count instead of guessing at a class name.
const start = Date.now();
let painted = 0;
while (Date.now() - start < 15000) {
const rows = await evaluate(`document.querySelector('#canvas')?.children.length ?? 0`);
if (rows > 0) {
painted = Date.now() - start;
note("ok", `first rows painted in ${painted} ms (${rows} rows)`);
break;
}
await sleep(40);
}
if (!painted) die("no rows appeared within 15 s — the file did not load");
// Scroll the viewport directly. A synthetic wheel event depends on hit
// testing at a guessed coordinate; setting scrollTop fires the same scroll
// event the virtualiser listens for, and cannot miss.
const scrolled = await evaluate(`
(() => {
const el = document.querySelector('.viewport') ?? document.scrollingElement;
return el ? el.className || 'document' : null;
})()
`);
if (!scrolled) die("no scrollable viewport found");
// Two short bursts rather than one long glide. Every frame of a scroll
// changes every pixel, so motion is the only thing a GIF pays for and a
// still second is nearly free — and the pause between bursts is where a
// viewer actually reads the row count and the memory readout. Starting
// almost immediately is deliberate too: the first burst overlaps indexing,
// which is the part no other viewer survives.
const burst = async (steps) => {
for (let i = 0; i < steps; i++) {
await evaluate(`
(() => {
const el = document.querySelector('.viewport') ?? document.scrollingElement;
el.scrollTop += 90;
})()
`);
await sleep(55);
}
};
await sleep(300);
await burst(20);
const midRows = await evaluate(`document.querySelector('#canvas')?.children.length ?? 0`);
await sleep(700);
await burst(18);
note("ok", `scrolled (${midRows} rows live mid-scroll)`);
await sleep(1200);
await send("Page.stopScreencast");
await sleep(300);
browser.close();
note("ok", `${frames.length} frames`);
if (frames.length < 10) die("too few frames — the page never repainted");
// --- Assemble ------------------------------------------------------------
mkdirSync(dirname(MP4), { recursive: true });
const list = frames
.map((f, i) => {
const next = frames[i + 1];
const d = next ? Math.max(0.02, Math.min(2, next.t - f.t)) : 0.1;
return `file '${f.path.replace(/\\/g, "/")}'\nduration ${d.toFixed(3)}`;
})
.join("\n");
const listPath = join(FRAMES, "list.txt");
writeFileSync(listPath, `${list}\nfile '${frames.at(-1).path.replace(/\\/g, "/")}'\n`);
const enc = spawnSync(
"ffmpeg",
["-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", listPath,
"-vf", "fps=25,scale=trunc(iw/2)*2:trunc(ih/2)*2",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-pix_fmt", "yuv420p", MP4],
{ stdio: "inherit" },
);
if (enc.status !== 0) die("ffmpeg failed to assemble the frames");
note("ok", `captured ${MP4}`);
// Measured, not guessed: at 1200px/25fps/256 colours this take is 15.6 MB.
// Frame rate and palette are nearly free here — the UI is dark and holds few
// distinct colours, so 32 of them band nowhere — and together they take it to
// ~5.9 MB.
//
// The budget is raised for this one artifact rather than the default lowered.
// Below 1000px a screenshot of dense log records stops being readable, and an
// unreadable hero image argues for nothing; 900px only saves 0.7 MB because
// text detail, not frame area, is what costs here. So this is the escape
// hatch make-gif.sh describes, taken deliberately and written down. Drop to
// --width 800 if the repository weight matters more than the legibility.
console.log("");
const gif = spawnSync(
"bash",
["./scripts/make-gif.sh", MP4, GIF,
"--width", "1000", "--fps", "20", "--colors", "32", "--budget", "6500000"],
{ stdio: "inherit" },
);
process.exit(gif.status ?? 0);
}
main().catch((e) => die(e.message));