|
| 1 | +// Captures the storyboard and timeline shots the movie-trailer use-case page uses. |
| 2 | +// |
| 3 | +// node scripts/screenshot-trailer-surfaces.mjs [--only storyboard,timeline] [--headed] |
| 4 | +// |
| 5 | +// Set PLAYWRIGHT_CHROMIUM_PATH to use a Chromium already on the machine. |
| 6 | +// |
| 7 | +// Both frames come out of the real editor components, not a mockup: the |
| 8 | +// storyboard cast drives `StoryboardBoard` and the timeline cast drives the |
| 9 | +// timeline editor, replayed at a fixed millisecond through /demo.html. The |
| 10 | +// media in both is the SCRAPHEART trailer already shipped on the marketing |
| 11 | +// site — the six `trailer-shot-*.png` stills, and segments of |
| 12 | +// `movie_trailer_example.mp4` pinned under demo/public/casts/promo. |
| 13 | +// |
| 14 | +// Backend-free by construction: the casts are authored, tRPC is cut, and the |
| 15 | +// stills are inline data URIs, so a re-run reproduces the same frames on any |
| 16 | +// machine with no server and no credits spent. |
| 17 | + |
| 18 | +import { spawn, spawnSync } from "node:child_process"; |
| 19 | +import fs from "node:fs"; |
| 20 | +import path from "node:path"; |
| 21 | +import { fileURLToPath } from "node:url"; |
| 22 | +import { chromium } from "playwright"; |
| 23 | +import sharp from "sharp"; |
| 24 | + |
| 25 | +const WEB = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); |
| 26 | +const ROOT = path.resolve(WEB, ".."); |
| 27 | +const OUT = path.join(ROOT, "marketing/public"); |
| 28 | +const PORT = 5199; |
| 29 | +const BASE = `http://localhost:${PORT}`; |
| 30 | + |
| 31 | +/** The pinned clips the timeline cast resolves, staged where the page looks. */ |
| 32 | +const PROMO_ASSETS = path.join(ROOT, "demo/public/casts/promo"); |
| 33 | +const STAGED_ASSETS = path.join(WEB, "public/demo-assets/promo-timeline"); |
| 34 | + |
| 35 | +/** |
| 36 | + * `atMs` is the frame. Storyboard: past the last keyframe, so all six cards |
| 37 | + * carry their still. Timeline: after the cut is assembled, so the four takes |
| 38 | + * sit on the video track with the score running under them. |
| 39 | + */ |
| 40 | +const SHOTS = [ |
| 41 | + { |
| 42 | + id: "storyboard", |
| 43 | + url: `${BASE}/demo.html?doc=storyboard-assistant&t=24000&bare=1`, |
| 44 | + file: "trailer-storyboard.webp", |
| 45 | + // Tall enough for all six cards, with no dead space under them. |
| 46 | + width: 1600, |
| 47 | + height: 1230, |
| 48 | + }, |
| 49 | + { |
| 50 | + id: "timeline", |
| 51 | + url: `${BASE}/demo.html?timeline=promo-timeline&t=7500&bare=1&assets=/demo-assets/promo-timeline`, |
| 52 | + file: "trailer-timeline.webp", |
| 53 | + width: 1600, |
| 54 | + height: 1000, |
| 55 | + }, |
| 56 | +]; |
| 57 | + |
| 58 | +const args = process.argv.slice(2); |
| 59 | +const argValue = (flag) => { |
| 60 | + const i = args.indexOf(flag); |
| 61 | + return i >= 0 ? args[i + 1] : undefined; |
| 62 | +}; |
| 63 | +const only = argValue("--only")?.split(",").map((s) => s.trim()); |
| 64 | +const entries = SHOTS.filter((s) => !only || only.includes(s.id)); |
| 65 | + |
| 66 | +async function waitForServer(url, timeoutMs = 90000) { |
| 67 | + const start = Date.now(); |
| 68 | + while (Date.now() - start < timeoutMs) { |
| 69 | + try { |
| 70 | + const res = await fetch(url); |
| 71 | + if (res.ok) return; |
| 72 | + } catch { |
| 73 | + // still booting |
| 74 | + } |
| 75 | + await new Promise((r) => setTimeout(r, 400)); |
| 76 | + } |
| 77 | + throw new Error(`Vite did not come up at ${url}`); |
| 78 | +} |
| 79 | + |
| 80 | +fs.rmSync(STAGED_ASSETS, { recursive: true, force: true }); |
| 81 | +fs.cpSync(PROMO_ASSETS, STAGED_ASSETS, { recursive: true }); |
| 82 | + |
| 83 | +// A crashed earlier run can leave its dev server holding the port; without |
| 84 | +// this the next run screenshots a stale bundle instead of failing. |
| 85 | +spawnSync("pkill", ["-f", `vite --port ${PORT}`]); |
| 86 | + |
| 87 | +const vite = spawn("npx", ["vite", "--port", String(PORT), "--strictPort"], { |
| 88 | + cwd: WEB, |
| 89 | + stdio: ["ignore", "pipe", "pipe"], |
| 90 | +}); |
| 91 | +const viteLog = []; |
| 92 | +vite.stderr.on("data", (d) => viteLog.push(String(d))); |
| 93 | + |
| 94 | +try { |
| 95 | + await waitForServer(BASE); |
| 96 | + // A sandbox whose Playwright browsers predate the installed version can |
| 97 | + // point at its own Chromium with PLAYWRIGHT_CHROMIUM_PATH. |
| 98 | + const browser = await chromium.launch({ |
| 99 | + headless: !args.includes("--headed"), |
| 100 | + executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH || undefined, |
| 101 | + }); |
| 102 | + |
| 103 | + for (const shot of entries) { |
| 104 | + const page = await browser.newPage({ |
| 105 | + viewport: { width: shot.width, height: shot.height }, |
| 106 | + deviceScaleFactor: 2, |
| 107 | + colorScheme: "dark", |
| 108 | + }); |
| 109 | + page.on("pageerror", (e) => console.error(`[page] ${shot.id}: ${e.message}`)); |
| 110 | + // The casts need no backend. Cutting tRPC keeps a NodeTool server that |
| 111 | + // happens to run on this machine out of a frame that has to reproduce. |
| 112 | + await page.route( |
| 113 | + (url) => url.pathname.startsWith("/trpc/"), |
| 114 | + (route) => route.abort() |
| 115 | + ); |
| 116 | + await page.goto(shot.url, { waitUntil: "domcontentloaded" }); |
| 117 | + console.log(`· ${shot.id}: loaded, waiting for the surface`); |
| 118 | + const ready = await page.waitForSelector("[data-ready]", { timeout: 60000 }); |
| 119 | + if ((await ready.getAttribute("data-ready")) === "error") { |
| 120 | + throw new Error(`${shot.id}: ${await page.locator("pre").innerText()}`); |
| 121 | + } |
| 122 | + await page.waitForSelector("[data-demo-player]", { timeout: 30000 }); |
| 123 | + // Every still decoded before the capture. |
| 124 | + await page.waitForFunction( |
| 125 | + () => Array.from(document.images).every((i) => i.complete && i.naturalWidth > 0), |
| 126 | + undefined, |
| 127 | + { timeout: 30000 } |
| 128 | + ); |
| 129 | + // Pinned clips too, when the surface has any. A clip whose codec this |
| 130 | + // browser refuses is reported rather than fatal: the tracks still render, |
| 131 | + // and the frame says plainly whether the preview came up. |
| 132 | + const unpainted = await page |
| 133 | + .waitForFunction( |
| 134 | + () => |
| 135 | + Array.from(document.querySelectorAll("video")).every((v) => v.readyState >= 2), |
| 136 | + undefined, |
| 137 | + { timeout: 20000 } |
| 138 | + ) |
| 139 | + .then(() => 0) |
| 140 | + .catch(async () => |
| 141 | + page.evaluate( |
| 142 | + () => |
| 143 | + Array.from(document.querySelectorAll("video")).filter((v) => v.readyState < 2) |
| 144 | + .length |
| 145 | + ) |
| 146 | + ); |
| 147 | + if (unpainted) { |
| 148 | + console.warn(`! ${shot.id}: ${unpainted} clip(s) never painted`); |
| 149 | + } |
| 150 | + await page.waitForTimeout(1200); |
| 151 | + |
| 152 | + const file = path.join(OUT, shot.file); |
| 153 | + const info = await sharp(await page.locator("[data-demo-player]").screenshot()) |
| 154 | + .webp({ quality: 88 }) |
| 155 | + .toFile(file); |
| 156 | + console.log(`✓ ${path.relative(ROOT, file)} (${info.width}×${info.height})`); |
| 157 | + await page.close(); |
| 158 | + } |
| 159 | + |
| 160 | + await browser.close(); |
| 161 | +} catch (error) { |
| 162 | + process.stderr.write(viteLog.join("")); |
| 163 | + throw error; |
| 164 | +} finally { |
| 165 | + vite.kill("SIGTERM"); |
| 166 | + fs.rmSync(STAGED_ASSETS, { recursive: true, force: true }); |
| 167 | +} |
0 commit comments