Skip to main content

max / makenotwork

21.2 KB · 514 lines History Blame Raw
1 #!/usr/bin/env node
2 //
3 // Capture the three landing-carousel frames from one run.
4 //
5 // The frames have to agree on viewport, scale, theme, crop and aspect ratio.
6 // Shooting them by hand, or one at a time, is what makes a carousel look wrong,
7 // so this produces all three from a single browser session and a single set of
8 // constants. A re-capture after a UI change is one command.
9 //
10 // node scripts/capture-landing-carousel.mjs
11 // BASE=https://testnot.work node scripts/capture-landing-carousel.mjs
12 //
13 // Output lands in static/images/shots/. Nothing is swapped into landing.rs
14 // automatically: the captures are reviewed before they ship (GoingsOn 7f3da540).
15 //
16 // SOURCE IS ALWAYS testnot.work. It carries a seeded catalog and no production
17 // data, so nothing real leaks into a public marketing asset and the shots stay
18 // reshootable. Never point this at prod or at a dev box.
19 //
20 // WHY CDP AND NOT --screenshot. Plain `chrome --screenshot` is viewport-only, so
21 // a storefront grid taller than the window falls below the fold. Driving the
22 // DevTools protocol lets each frame be clipped to the same element at the same
23 // aspect ratio, which is how the three end up agreeing by construction rather
24 // than by luck. Node 22+ ships a global WebSocket, so this needs no packages.
25 //
26 // THE LIBRARY FRAME NEEDS A SESSION. /library is 401 to anonymous, so the run
27 // logs in as the demo buyer the seed creates (src/seed/buyer.rs, GoingsOn
28 // 839a8e5a option B). Pass its password, the same value testnot's
29 // TESTNOT_BUYER_PASSWORD carries:
30 //
31 // CAPTURE_BUYER_PASSWORD=... node scripts/capture-landing-carousel.mjs
32 //
33 // It logs in through the real form, so the session is a real session and CSRF
34 // is satisfied by reading the token off the page rather than by faking one.
35 // CAPTURE_SESSION_COOKIE still works as an escape hatch when you already hold a
36 // cookie and would rather not put a password in the environment.
37 //
38 // With neither, the script captures the first two frames, says plainly that the
39 // third is missing, and exits non-zero. Two frames out of three is not a
40 // carousel, and a green exit would say otherwise.
41
42 import { spawn } from 'node:child_process';
43 import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
44 import { tmpdir } from 'node:os';
45 import path from 'node:path';
46 import process from 'node:process';
47
48 const BASE = (process.env.BASE ?? 'https://testnot.work').replace(/\/+$/, '');
49 const CHROME = process.env.CHROME ?? `${process.env.HOME}/.local/bin/chrome-for-testing`;
50 const OUT_DIR = process.env.OUT_DIR ?? path.join(import.meta.dirname, '..', 'static', 'images', 'shots');
51 const SESSION_COOKIE = process.env.CAPTURE_SESSION_COOKIE ?? '';
52 const SESSION_COOKIE_NAME = process.env.CAPTURE_SESSION_COOKIE_NAME ?? 'id';
53 const BUYER_PASSWORD = process.env.CAPTURE_BUYER_PASSWORD ?? '';
54 // Matches HANDLE in src/seed/buyer.rs. Override only if that changes.
55 const BUYER_LOGIN = process.env.CAPTURE_BUYER_LOGIN ?? 'demo_collector';
56 const CAN_AUTH = Boolean(SESSION_COOKIE || BUYER_PASSWORD);
57
58 // Shared frame geometry. Every frame is clipped to these, which is the whole
59 // point of one script: change a number here and all three move together.
60 const VIEWPORT = { width: 1440, height: 1600 };
61 const SCALE = 2; // 2x for a crisp asset on retina displays
62 const ASPECT = Number(process.env.CAPTURE_ASPECT ?? 3 / 2); // the carousel's frame ratio
63 // Every frame is cropped to exactly this many CSS pixels wide, then to ASPECT.
64 // Deriving the width from each page's own container instead produced a 2400px
65 // storefront next to a 1600px item page: same ratio, different effective zoom,
66 // so text and controls changed size between slides. A fixed width is what makes
67 // the three interchangeable.
68 const CROP_WIDTH = Number(process.env.CAPTURE_WIDTH ?? 1280);
69 const SETTLE_MS = 1200; // after load + fonts, for lazy images and islands
70
71 // Output format. These are 2x captures 2560px wide, and as PNG the item frame
72 // weighed 3.8 MB, which is not a thing to put on a landing page that is trying
73 // to look fast. WebP at 90 carries the same crop at a fraction of that and is
74 // supported everywhere the site is. Chrome encodes it, so there is no build
75 // step and no second tool. CAPTURE_FORMAT=png restores the old behaviour.
76 const FORMAT = process.env.CAPTURE_FORMAT ?? 'webp';
77 const QUALITY = Number(process.env.CAPTURE_QUALITY ?? 90);
78
79 // The element each frame is cropped to, unless the frame names its own.
80 // `.container` is the outer content wrapper on all three pages, so the three
81 // crops share their left/right margins.
82 //
83 // FRAMING, decided 2026-08-07: frame 1 shows the storefront header *and* the
84 // first row of items. "Your storefront: sell anything digital" is a claim about
85 // a shop, and a crop holding only the title and blurb could be any page; a crop
86 // holding only the grid loses whose shop it is. Showing both costs a taller
87 // frame than 16:10, which is why the ratio is 3:2 rather than the wider crop the
88 // first draft used.
89 //
90 // The knobs stay env vars so a re-tune after a design change is a flag, not an
91 // edit: CAPTURE_ASPECT, CAPTURE_WIDTH, and a per-frame `selector`.
92 const DEFAULT_SELECTOR = '.container';
93
94 /**
95 * The three frames, in carousel order. Paths are resolved against BASE.
96 * `alt` mirrors what landing.rs claims the frame shows; keep them in step.
97 */
98 // The storefront both public frames are shot from.
99 //
100 // It has to be a project that actually SELLS. The captions are "sell anything
101 // digital" and "every sale is yours. 0% platform fee", and the seeded
102 // `restored-reels-vol-1` is the free / name-your-price project by design: all
103 // five of its items price at Free, so both slides argued for the fee on work
104 // that carries no fee. `deskriver-suite` sells at $8. Override with
105 // CAPTURE_STOREFRONT.
106 const STOREFRONT = process.env.CAPTURE_STOREFRONT ?? '/p/deskriver-suite';
107
108 const FRAMES = [
109 {
110 name: 'storefront',
111 path: STOREFRONT,
112 alt: "A creator's storefront on Makenotwork, titled and described, above a row of their work in cover art",
113 needsAuth: false,
114 },
115 {
116 name: 'item',
117 // Item URLs carry a uuid that changes every reseed, so the item frame
118 // resolves one from the storefront at capture time rather than pinning an
119 // id that dies on the next seed.
120 //
121 // A storefront links a logged-out visitor to `/purchase/<id>`, not to the
122 // item page. That is a two-step checkout interstitial titled "Confirm
123 // Purchase", not the page this frame's caption describes, so rewrite it to
124 // `/i/<id>`: the item page is public (200 to anonymous) and carries the
125 // price, the buy button and the download details the alt text promises.
126 // Override the whole thing with CAPTURE_ITEM_PATH.
127 resolvePath: async (send) => {
128 if (process.env.CAPTURE_ITEM_PATH) return process.env.CAPTURE_ITEM_PATH;
129 const { result } = await send('Runtime.evaluate', {
130 expression: `(() => {
131 // Prefer an item the storefront prices, so the frame shows a sale.
132 const priced = [...document.querySelectorAll('a[href^="/i/"], a[href^="/purchase/"]')]
133 .find((a) => /\\$\\s*\\d/.test(a.closest('article, li, .item-card, div')?.textContent ?? ''));
134 const a = priced ?? document.querySelector('a[href^="/i/"], a[href^="/purchase/"]');
135 if (!a) return '';
136 // /purchase/<id> and /i/<id> share the id; the item page is the frame.
137 return new URL(a.href).pathname.replace(/^\\/purchase\\//, '/i/');
138 })()`,
139 returnByValue: true,
140 });
141 return result.value;
142 },
143 from: STOREFRONT,
144 alt: 'An item page showing its cover art, an $8 price, and a buy button, beside what the purchase includes',
145 needsAuth: false,
146 },
147 {
148 name: 'library',
149 path: '/library',
150 alt: 'A buyer library listing what they have bought, each row with its creator, type, purchase date and a button to open it',
151 needsAuth: true,
152 },
153 ];
154
155 /** Wait, without pulling in a timers import at every call site. */
156 const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
157
158 /** Launch headless Chrome and return its DevTools browser WebSocket URL. */
159 async function launchChrome(userDataDir) {
160 const child = spawn(
161 CHROME,
162 [
163 '--headless',
164 '--disable-gpu',
165 '--hide-scrollbars',
166 '--no-sandbox',
167 '--no-first-run',
168 '--remote-debugging-port=0',
169 `--user-data-dir=${userDataDir}`,
170 '--force-color-profile=srgb',
171 'about:blank',
172 ],
173 { stdio: ['ignore', 'ignore', 'pipe'] },
174 );
175
176 const wsUrl = await new Promise((resolve, reject) => {
177 let buffered = '';
178 const timer = setTimeout(
179 () => reject(new Error(`Chrome did not report a DevTools endpoint.\n${buffered}`)),
180 20_000,
181 );
182 child.stderr.on('data', (chunk) => {
183 buffered += chunk;
184 const match = buffered.match(/DevTools listening on (ws:\/\/\S+)/);
185 if (match) {
186 clearTimeout(timer);
187 resolve(match[1]);
188 }
189 });
190 child.on('exit', (code) => {
191 clearTimeout(timer);
192 reject(new Error(`Chrome exited (${code}) before listening.\n${buffered}`));
193 });
194 });
195
196 return { child, wsUrl };
197 }
198
199 /**
200 * A minimal CDP client: id-tagged requests over one socket, flat-mode sessions.
201 */
202 async function connect(wsUrl) {
203 const socket = new WebSocket(wsUrl);
204 await new Promise((resolve, reject) => {
205 socket.addEventListener('open', resolve, { once: true });
206 socket.addEventListener('error', () => reject(new Error(`cannot connect to ${wsUrl}`)), {
207 once: true,
208 });
209 });
210
211 let nextId = 1;
212 const pending = new Map();
213 const listeners = new Set();
214
215 socket.addEventListener('message', (event) => {
216 const message = JSON.parse(event.data);
217 if (message.id && pending.has(message.id)) {
218 const { resolve, reject } = pending.get(message.id);
219 pending.delete(message.id);
220 if (message.error) reject(new Error(`${message.error.message} (${message.error.code})`));
221 else resolve(message.result);
222 return;
223 }
224 for (const listener of listeners) listener(message);
225 });
226
227 const send = (method, params = {}, sessionId) =>
228 new Promise((resolve, reject) => {
229 const id = nextId++;
230 pending.set(id, { resolve, reject });
231 socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
232 });
233
234 const once = (method, sessionId, timeoutMs = 30_000) =>
235 new Promise((resolve, reject) => {
236 const timer = setTimeout(() => {
237 listeners.delete(listener);
238 reject(new Error(`timed out waiting for ${method}`));
239 }, timeoutMs);
240 const listener = (message) => {
241 if (message.method === method && (!sessionId || message.sessionId === sessionId)) {
242 clearTimeout(timer);
243 listeners.delete(listener);
244 resolve(message.params);
245 }
246 };
247 listeners.add(listener);
248 });
249
250 return { socket, send, once };
251 }
252
253 /**
254 * Sign in as the demo buyer through the real login form.
255 *
256 * Submitting the page's own form would go through htmx, which swaps the
257 * response into an error div instead of navigating. So this reads the CSRF
258 * token and field names off that form and posts a plain detached copy, which
259 * the handler treats as a full-page POST and answers with a redirect. Reading
260 * the token rather than minting one is the point: the session that results is
261 * the same session a person would get.
262 *
263 * Returns null on success, or a string saying what went wrong.
264 */
265 async function logIn(call, awaitLoad) {
266 await call('Page.navigate', { url: `${BASE}/login` });
267 await awaitLoad();
268
269 const submitted = await call('Runtime.evaluate', {
270 expression: `(() => {
271 const src = document.querySelector('form.login-form');
272 if (!src) return 'no login form at /login';
273 const form = document.createElement('form');
274 form.method = 'post';
275 form.action = '/login';
276 const put = (name, value) => {
277 const input = document.createElement('input');
278 input.type = 'hidden';
279 input.name = name;
280 input.value = value;
281 form.appendChild(input);
282 };
283 // Carry every hidden field the real form has (the CSRF token above all),
284 // then set the credentials.
285 for (const [name, value] of new FormData(src).entries()) {
286 if (name !== 'login' && name !== 'password') put(name, value);
287 }
288 put('login', ${JSON.stringify(BUYER_LOGIN)});
289 put('password', ${JSON.stringify(BUYER_PASSWORD)});
290 document.body.appendChild(form);
291 form.submit();
292 return '';
293 })()`,
294 returnByValue: true,
295 });
296 if (submitted.result.value) return submitted.result.value;
297 await awaitLoad();
298
299 // Confirm rather than assume: a wrong password re-renders the login form with
300 // a 200, so status alone proves nothing.
301 const check = await call('Runtime.evaluate', {
302 expression: `document.querySelector('form.login-form') ? 'rejected' : ''`,
303 returnByValue: true,
304 });
305 return check.result.value ? 'credentials rejected' : null;
306 }
307
308 /**
309 * Clip rectangle for a frame: CROP_WIDTH x (CROP_WIDTH / ASPECT), centred
310 * horizontally on the element and anchored at its top, because the top of these
311 * pages is the part worth showing. Every frame gets the same rectangle, which is
312 * what lets the carousel cross-fade without anything appearing to resize.
313 */
314 function clipFor(box) {
315 const width = CROP_WIDTH;
316 const height = width / ASPECT;
317 return { x: Math.max(0, box.x + (box.width - width) / 2), y: box.y, width, height };
318 }
319
320 async function main() {
321 const userDataDir = await mkdtemp(path.join(tmpdir(), 'mnw-capture-'));
322 await mkdir(OUT_DIR, { recursive: true });
323 const { child, wsUrl } = await launchChrome(userDataDir);
324 const { socket, send, once } = await connect(wsUrl);
325
326 const missing = [];
327 try {
328 const { targetId } = await send('Target.createTarget', { url: 'about:blank' });
329 const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
330 const call = (method, params) => send(method, params, sessionId);
331
332 await call('Page.enable');
333 await call('Network.enable');
334 await call('Emulation.setDeviceMetricsOverride', {
335 width: VIEWPORT.width,
336 height: VIEWPORT.height,
337 deviceScaleFactor: SCALE,
338 mobile: false,
339 });
340
341 if (SESSION_COOKIE) {
342 const { host } = new URL(BASE);
343 await call('Network.setCookie', {
344 name: SESSION_COOKIE_NAME,
345 value: SESSION_COOKIE,
346 domain: host,
347 path: '/',
348 httpOnly: true,
349 secure: BASE.startsWith('https'),
350 });
351 }
352
353 // Log in LAZILY, immediately before the first frame that needs a session.
354 //
355 // Signing in up front changes the two anonymous frames, which is the whole
356 // reason they are anonymous. The storefront links an item the visitor can
357 // reach: to a stranger that is `/purchase/<id>`, carrying the price and the
358 // buy button frame 2's caption promises, and to the demo buyer, who owns
359 // nine of the catalogue, it is `/i/<id>`, the owned-item page with a
360 // download button instead. Frame 2 would have quietly advertised "every
361 // sale is yours" over a page with nothing for sale.
362 let signedIn = Boolean(SESSION_COOKIE);
363 let loginFailure = null;
364 const ensureSignedIn = async () => {
365 if (signedIn || loginFailure || !BUYER_PASSWORD) return;
366 loginFailure = await logIn(call, () => once('Page.loadEventFired', sessionId));
367 if (loginFailure) {
368 console.error(` login: ${loginFailure}`);
369 } else {
370 signedIn = true;
371 console.error(` login: signed in as ${BUYER_LOGIN}`);
372 }
373 };
374
375 for (const frame of FRAMES) {
376 if (frame.needsAuth) {
377 if (!CAN_AUTH) {
378 console.error(
379 ` ${frame.name}: skipped, needs CAPTURE_BUYER_PASSWORD (or CAPTURE_SESSION_COOKIE)`,
380 );
381 missing.push(frame.name);
382 continue;
383 }
384 await ensureSignedIn();
385 if (!signedIn) {
386 console.error(` ${frame.name}: skipped, not signed in`);
387 missing.push(frame.name);
388 continue;
389 }
390 }
391
392 // Resolve the URL, navigating to a source page first when the frame's
393 // target is only discoverable from one.
394 let target = frame.path;
395 if (frame.resolvePath) {
396 await call('Page.navigate', { url: BASE + frame.from });
397 await once('Page.loadEventFired', sessionId);
398 target = await frame.resolvePath(call);
399 if (!target) {
400 console.error(` ${frame.name}: skipped, no item link on ${frame.from}`);
401 missing.push(frame.name);
402 continue;
403 }
404 }
405
406 const url = BASE + target;
407 await call('Page.navigate', { url });
408 await once('Page.loadEventFired', sessionId);
409 await call('Runtime.evaluate', {
410 expression: 'document.fonts.ready.then(() => true)',
411 awaitPromise: true,
412 });
413
414 // Wait for every image to finish, and for the reveal animation the cards
415 // ride in on to land. `load` fires before lazy covers arrive, and the item
416 // grid fades in, so the first storefront capture caught the cards at part
417 // opacity: three grey rectangles reading "Sample", "Audio", "Video" and no
418 // artwork at all, on the one slide whose whole job is showing the goods.
419 //
420 // Scroll the grid into view first: a lazy image below the fold never
421 // starts loading, and the crop is taller than the viewport's first screen.
422 await call('Runtime.evaluate', {
423 expression: `(async () => {
424 // Everything here races a deadline. An HTMX indicator spins forever,
425 // so its animation's finished promise never settles, and a lazy image
426 // that 404s can leave a pending decode: without the cap the run hangs
427 // instead of shooting a slightly early frame.
428 const capped = (p, ms) => Promise.race([p, new Promise((r) => setTimeout(r, ms))]);
429 window.scrollTo(0, document.body.scrollHeight);
430 await new Promise((r) => setTimeout(r, 250));
431 window.scrollTo(0, 0);
432 await capped(Promise.all([...document.images].map((i) => i.complete
433 ? Promise.resolve()
434 : new Promise((r) => { i.addEventListener('load', r, {once: true});
435 i.addEventListener('error', r, {once: true}); }))), 8000);
436 await capped(Promise.all(
437 document.getAnimations().map((a) => a.finished.catch(() => {}))), 2000);
438 return true;
439 })()`,
440 awaitPromise: true,
441 });
442 await sleep(SETTLE_MS);
443
444 const selector = frame.selector ?? DEFAULT_SELECTOR;
445 const { result } = await call('Runtime.evaluate', {
446 expression: `(() => {
447 const el = document.querySelector(${JSON.stringify(selector)});
448 if (!el) return null;
449 const r = el.getBoundingClientRect();
450 return { x: r.x + scrollX, y: r.y + scrollY, width: r.width, height: r.height };
451 })()`,
452 returnByValue: true,
453 });
454 if (!result.value) {
455 // Say what the page actually was. A missing selector on an authed frame
456 // is almost always the session, not the markup, and "container not
457 // present" sends you reading templates instead of checking the login.
458 const { result: landed } = await call('Runtime.evaluate', {
459 expression: `({ title: document.title, href: location.href })`,
460 returnByValue: true,
461 });
462 const where = landed.value ?? {};
463 console.error(
464 ` ${frame.name}: skipped, ${selector} not present at ${url}` +
465 ` (landed on ${where.href ?? '?'}, titled ${JSON.stringify(where.title ?? '?')})`,
466 );
467 missing.push(frame.name);
468 continue;
469 }
470
471 const clip = { ...clipFor(result.value), scale: SCALE };
472 const shot = await call('Page.captureScreenshot', {
473 format: FORMAT,
474 // PNG ignores quality; WebP and JPEG do not.
475 ...(FORMAT === 'png' ? {} : { quality: QUALITY }),
476 clip,
477 captureBeyondViewport: true,
478 fromSurface: true,
479 });
480 const file = path.join(OUT_DIR, `${frame.name}.${FORMAT}`);
481 const bytes = Buffer.from(shot.data, 'base64');
482 await writeFile(file, bytes);
483 console.error(
484 ` ${frame.name}: ${Math.round(clip.width * SCALE)}x${Math.round(clip.height * SCALE)}, ` +
485 `${Math.round(bytes.length / 1024)}KB -> ${path.relative(process.cwd(), file)}`,
486 );
487 }
488 } finally {
489 socket.close();
490 // Wait for Chrome to actually exit before removing its profile: kill() only
491 // sends the signal, and a still-flushing process repopulates the directory
492 // under rm(). A leftover temp profile is not worth failing a good capture
493 // over, so cleanup errors are swallowed.
494 const exited = new Promise((resolve) => child.once('exit', resolve));
495 child.kill();
496 await Promise.race([exited, sleep(5_000)]);
497 await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
498 }
499
500 if (missing.length > 0) {
501 console.error(
502 `\n${missing.length} of ${FRAMES.length} frames missing (${missing.join(', ')}). ` +
503 'The carousel needs all three; nothing has been swapped into landing.rs.',
504 );
505 process.exit(1);
506 }
507
508 console.error(`\nAll ${FRAMES.length} frames captured from ${BASE}.`);
509 console.error(`Next: review them (GoingsOn 7f3da540), then point landing.rs at the .${FORMAT} files`);
510 console.error('and delete static/images/shots/placeholder-*.svg.');
511 }
512
513 await main();
514