/previz: password protect, add PDF export of the crowd previz
Deploy / deploy (push) Successful in 1m5s

- nginx basic auth on /previz/ (login file lives on the server, mounted
  from docker/auth/, see DEPLOY.md step 8); private, no-store so
  Cloudflare never caches it
- Print styles for previz pages: dark, A4 landscape, one section a page;
  videos print as poster frames with a link
- scripts/previz-pdf.mjs (npm run previz:pdf) prints the page to
  public/previz/crowd/crowd-previz.pdf via Edge/Chrome DevTools
- "PDF" download link on the page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-10-17 06:21:25 +02:00
parent 35b263ba31
commit 22bc903041
11 changed files with 200 additions and 26 deletions
+103
View File
@@ -0,0 +1,103 @@
// Prints a /previz page from the static export to a PDF next to its assets.
// npm run build && node scripts/previz-pdf.mjs crowd
// Drives Edge/Chrome over the DevTools protocol instead of --print-to-pdf: Chrome sizes the
// print layout from the paper it is given, not from CSS @page, so the paper (A4 landscape)
// has to be passed here for the desktop layout to kick in.
import { spawn } from "node:child_process";
import { createReadStream, existsSync, mkdtempSync, statSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { extname, join, resolve } from "node:path";
const slug = process.argv[2] ?? "crowd";
const out = resolve("out");
const dest = resolve("public/previz", slug, `${slug}-previz.pdf`);
const browser =
process.env.CHROME ??
[
"C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
"C:/Program Files/Google/Chrome/Application/chrome.exe",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/usr/bin/google-chrome",
].find(existsSync);
if (!browser) throw new Error("No Chrome/Edge found; set CHROME=/path/to/browser");
if (!existsSync(join(out, "previz", slug, "index.html"))) throw new Error("Run `npm run build` first");
const types = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript", ".jpg": "image/jpeg", ".png": "image/png", ".svg": "image/svg+xml", ".mp4": "video/mp4", ".woff2": "font/woff2", ".txt": "text/plain" };
const server = createServer((req, res) => {
let file = join(out, decodeURIComponent(new URL(req.url, "http://x").pathname));
if (existsSync(file) && statSync(file).isDirectory()) file = join(file, "index.html");
if (!file.startsWith(out) || !existsSync(file)) return res.writeHead(404).end();
res.writeHead(200, { "Content-Type": types[extname(file)] ?? "application/octet-stream" });
createReadStream(file).pipe(res);
}).listen(0);
const port = server.address().port;
const proc = spawn(browser, [
"--headless=new",
"--disable-gpu",
"--remote-debugging-port=0",
`--user-data-dir=${mkdtempSync(join(tmpdir(), "previz-pdf-"))}`,
"about:blank",
]);
const wsUrl = await new Promise((ok, fail) => {
let buf = "";
proc.stderr.on("data", (d) => {
buf += d;
const m = buf.match(/DevTools listening on (ws:\/\/\S+)/);
if (m) ok(m[1]);
});
proc.on("exit", () => fail(new Error("Browser exited early")));
});
// Attach to the blank tab and talk to it
const targets = await (await fetch(wsUrl.replace("ws://", "http://").replace(/\/devtools\/browser\/.*/, "/json"))).json();
const ws = new WebSocket(targets.find((t) => t.type === "page").webSocketDebuggerUrl);
await new Promise((ok) => ws.addEventListener("open", ok, { once: true }));
let id = 0;
const pending = new Map();
const events = [];
ws.addEventListener("message", ({ data }) => {
const msg = JSON.parse(data);
if (msg.id && pending.has(msg.id)) {
const { ok, fail } = pending.get(msg.id);
pending.delete(msg.id);
if (msg.error) fail(new Error(msg.error.message));
else ok(msg.result);
} else if (msg.method) events.push(msg.method);
});
const send = (method, params = {}) =>
new Promise((ok, fail) => {
pending.set(++id, { ok, fail });
ws.send(JSON.stringify({ id, method, params }));
});
try {
await send("Page.enable");
await send("Page.navigate", { url: `http://localhost:${port}/previz/${slug}/` });
while (!events.includes("Page.loadEventFired")) await new Promise((r) => setTimeout(r, 100));
// Lazy images and fonts: load everything, then give layout a beat
await send("Runtime.evaluate", {
expression: `Promise.all([document.fonts.ready, ...[...document.images].map(i => (i.loading = "eager", i.decode().catch(() => {})))])`,
awaitPromise: true,
});
await new Promise((r) => setTimeout(r, 500));
const { data } = await send("Page.printToPDF", {
landscape: true,
paperWidth: 8.27, // A4, inches (landscape swaps them)
paperHeight: 11.69,
marginTop: 0,
marginBottom: 0,
marginLeft: 0,
marginRight: 0,
printBackground: true,
scale: 0.68, // lays out ~1650px wide: each section fits a page
});
writeFileSync(dest, Buffer.from(data, "base64"));
console.log(`Wrote ${dest} (${Math.round(Buffer.byteLength(data, "base64") / 1024)} KB)`);
} finally {
ws.close();
proc.kill();
server.close();
}