/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
+2
View File
@@ -7,3 +7,5 @@ content/*.mp4
content/*.pdf
*.md
!README.md
docker/auth
content/crowd
+3
View File
@@ -52,3 +52,6 @@ content/*.mov
# previz source renders (web versions in public/previz)
content/crowd/
# server-side login files for /previz/ (see DEPLOY.md)
docker/auth/
+16
View File
@@ -112,6 +112,22 @@ The workflow is already in the repo (`.gitea/workflows/deploy.yml`). In Gitea, o
Test it: push any commit to `main` and watch **Actions** in the repo. The job should run
`git pull` and `docker compose up -d --build`, then the site updates.
### 8. Password for /previz/
Client previz pages under `/previz/` need a login (HTTP basic auth in `docker/nginx.conf`).
The password file stays on the server and is never committed. Create it once:
```bash
cd /opt/ttvfx_web
mkdir -p docker/auth
printf "production:$(openssl passwd -apr1)\n" > docker/auth/previz.htpasswd # prompts for the password
docker compose up -d --force-recreate
```
`production` is the username. To change the password, run the `printf` line again. nginx reads
the file on every request, so no restart is needed. To add a second login, append another line
with `>>`. If the file is missing, `/previz/` returns a 500 error, so it is never left open.
---
## Day to day
+17
View File
@@ -89,6 +89,23 @@ body {
.reveal { opacity: 1; transform: none; transition: none; }
}
/* Previz pages (/previz/*) print/PDF dark, edge to edge, like the screen version.
Margins come from padding cloned onto every page instead of white @page margins.
The page sets its own @page (landscape, no margin) so it doesn't get the report's. */
@media print {
html:has(.previz), html:has(.previz) body { background: var(--ink) !important; color-scheme: dark; }
.previz {
padding-block: 12mm;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
.previz.grain::after { display: none; }
.previz figure, .previz li, .previz img, .previz dl { break-inside: avoid; }
.previz h2 { break-after: avoid; }
}
/* Printable VFX report (/set/report/view): A4 paper, keep the accent colours */
@media print {
@page { size: A4; margin: 14mm 12mm; background: white; }
+31 -13
View File
@@ -162,10 +162,11 @@ const setTips: Tip[] = [
function PreviewVideo({ src, poster, label }: { src?: string; poster?: string; label: string }) {
if (src) {
return (
<div>
<video
src={src}
poster={poster}
className="aspect-[2.39/1] w-full bg-black object-cover"
className="aspect-[2.39/1] w-full bg-black object-cover print:hidden"
controls
autoPlay
muted
@@ -173,6 +174,13 @@ function PreviewVideo({ src, poster, label }: { src?: string; poster?: string; l
playsInline
preload="metadata"
/>
{/* Paper/PDF: the poster frame and a pointer to the moving version */}
{/* eslint-disable-next-line @next/next/no-img-element */}
{poster && <img src={poster} alt={label} className="hidden aspect-[2.39/1] w-full object-cover print:block" />}
<p className="mt-2 hidden font-mono text-[10px] tracking-[0.2em] text-muted uppercase print:block">
▶ Video: {site.url.replace(/^https?:\/\//, "")}/previz/crowd/
</p>
</div>
);
}
return (
@@ -186,9 +194,9 @@ function PreviewVideo({ src, poster, label }: { src?: string; poster?: string; l
);
}
function TipGrid({ tips }: { tips: Tip[] }) {
function TipGrid({ tips, className = "" }: { tips: Tip[]; className?: string }) {
return (
<ol className="mt-10 grid gap-px border border-line bg-line md:grid-cols-2">
<ol className={`mt-10 grid gap-px border border-line bg-line md:grid-cols-2 ${className}`}>
{tips.map((c, i) => (
<li key={c.title} className="flex gap-4 bg-ink p-5 md:p-6">
<span className="font-mono text-xs text-accent">{String(i + 1).padStart(2, "0")}</span>
@@ -224,10 +232,20 @@ function Step({
export default function CrowdPrevizPage() {
return (
<main className="grain min-h-dvh bg-ink pb-24">
<main className="previz grain min-h-dvh bg-ink pb-24">
{/* Print/PDF: landscape A4, edge to edge. Inline so it beats the global @page rule. */}
<style>{"@page { size: A4 landscape; margin: 0; }"}</style>
<header className="mx-auto flex max-w-6xl items-center gap-3 px-4 pt-6 md:px-8">
<Fox className="h-6 w-6 text-accent" />
<span className="font-mono text-[11px] tracking-[0.25em] text-muted uppercase">{site.name} · Previz</span>
{/* Regenerate with `npm run previz:pdf` after editing this page */}
<a
href={`${dir}/crowd-previz.pdf`}
download
className="-my-2 ml-auto py-2 font-mono text-[11px] tracking-[0.2em] text-accent uppercase hover:text-fg print:hidden"
>
PDF ↓
</a>
</header>
<section className="mx-auto max-w-6xl px-4 pt-16 md:px-8 md:pt-24">
@@ -254,7 +272,7 @@ export default function CrowdPrevizPage() {
</dl>
</section>
<section className="mx-auto mt-20 max-w-6xl px-4 md:mt-28 md:px-8">
<section className="mx-auto mt-20 max-w-6xl px-4 md:mt-28 md:px-8 print:!mt-0 print:break-before-page">
<figure>
<div className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
@@ -282,7 +300,7 @@ export default function CrowdPrevizPage() {
</section>
<div className="mx-auto mt-24 max-w-6xl space-y-24 px-4 md:mt-32 md:space-y-32 md:px-8">
<section className="space-y-10">
<section className="space-y-10 print:!mt-0 print:break-before-page">
<Step index="01" label="Hero shot" title="One drone move, real crowd at the stage">
<p>
The drone starts behind the audience and flies forward toward the truck stage. Height and camera tilt stay
@@ -297,7 +315,7 @@ export default function CrowdPrevizPage() {
<PreviewVideo src={videos.hero} poster={videos.heroPoster} label="Hero shot previz coming soon" />
</section>
<section className="space-y-10">
<section className="space-y-10 print:!mt-0 print:break-before-page">
<Step index="02" label="Crowd plates" title="Same spot, same angle, new arrangement">
<p>
After the hero shot, the drone flies to a single marked position and holds still. No camera movement. The
@@ -326,7 +344,7 @@ export default function CrowdPrevizPage() {
</p>
</section>
<section className="space-y-10">
<section className="space-y-10 print:!mt-0 print:break-before-page">
<Step index="03" label="Composite" title="Plates placed into the moving shot">
<p>
We track the hero drone move in 3D, then place each plate as a flat layer on the ground behind the real
@@ -340,7 +358,7 @@ export default function CrowdPrevizPage() {
<PreviewVideo src={videos.final} poster={videos.finalPoster} label="Final comp proof of concept coming soon" />
</section>
<section>
<section className="print:!mt-0 print:break-before-page">
<SectionLabel index="04">Shot list</SectionLabel>
<h2 className="font-display text-3xl leading-none font-thin uppercase md:text-5xl">Six shots, two setups</h2>
<p className="mt-6 max-w-3xl text-lg leading-relaxed font-light text-muted md:text-xl">
@@ -351,7 +369,7 @@ export default function CrowdPrevizPage() {
</div>
</section>
<section>
<section className="print:!mt-0 print:break-before-page">
<SectionLabel index="05">Crowd direction</SectionLabel>
<h2 className="font-display text-3xl leading-none font-thin uppercase md:text-5xl">Briefing the extras</h2>
<p className="mt-6 max-w-3xl text-lg leading-relaxed font-light text-muted md:text-xl">
@@ -385,16 +403,16 @@ export default function CrowdPrevizPage() {
the comp, so that&apos;s where arms need to stay down.
</figcaption>
</figure>
<TipGrid tips={crowdTips} />
<TipGrid tips={crowdTips} className="print:break-before-page" />
</section>
<section>
<section className="print:!mt-0 print:break-before-page">
<SectionLabel index="06">Camera</SectionLabel>
<h2 className="font-display text-3xl leading-none font-thin uppercase md:text-5xl">Notes for DOP and drone op</h2>
<TipGrid tips={cameraTips} />
</section>
<section>
<section className="print:!mt-0 print:break-before-page">
<SectionLabel index="07">On set</SectionLabel>
<h2 className="font-display text-3xl leading-none font-thin uppercase md:text-5xl">Production and VFX</h2>
<TipGrid tips={setTips} />
+2 -2
View File
@@ -39,7 +39,7 @@ export default function ShotList({ shots, storeKey }: { shots: Shot[]; storeKey:
<button
type="button"
onClick={() => setDone([])}
className="-my-2 py-2 font-mono text-[11px] tracking-[0.2em] text-muted uppercase hover:text-fg"
className="-my-2 py-2 font-mono text-[11px] tracking-[0.2em] text-muted uppercase hover:text-fg print:hidden"
>
Clear ticks
</button>
@@ -78,7 +78,7 @@ export default function ShotList({ shots, storeKey }: { shots: Shot[]; storeKey:
);
})}
</ul>
<p className="mt-3 text-xs text-muted">Ticks are saved on this device only.</p>
<p className="mt-3 text-xs text-muted print:hidden">Ticks are saved on this device only.</p>
</div>
);
}
+4
View File
@@ -5,6 +5,10 @@ services:
restart: unless-stopped
# Login for /previz/ (gitignored; created once on the server, see DEPLOY.md)
volumes:
- ./docker/auth:/etc/nginx/auth:ro
networks:
- npm_default
+10
View File
@@ -23,6 +23,16 @@ server {
add_header Cache-Control "public, max-age=604800";
}
# Client previz pages: password protected (file lives on the server, see DEPLOY.md).
# ^~ beats the media regex above, so nothing in here gets a public cache header
# and Cloudflare never serves it to someone who hasn't logged in.
location ^~ /previz/ {
auth_basic "Two Tales VFX previz";
auth_basic_user_file /etc/nginx/auth/previz.htpasswd;
add_header Cache-Control "private, no-store";
try_files $uri $uri/ $uri.html =404;
}
# Offline worker for /set must always be revalidated so updates roll out
location = /set/sw.js {
add_header Cache-Control "no-cache";
+2 -1
View File
@@ -6,7 +6,8 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"previz:pdf": "next build && node scripts/previz-pdf.mjs crowd"
},
"dependencies": {
"next": "16.3.8",
Binary file not shown.
+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();
}