/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 content/*.pdf
*.md *.md
!README.md !README.md
docker/auth
content/crowd
+3
View File
@@ -52,3 +52,6 @@ content/*.mov
# previz source renders (web versions in public/previz) # previz source renders (web versions in public/previz)
content/crowd/ 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 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. `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 ## Day to day
+17
View File
@@ -89,6 +89,23 @@ body {
.reveal { opacity: 1; transform: none; transition: none; } .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 */ /* Printable VFX report (/set/report/view): A4 paper, keep the accent colours */
@media print { @media print {
@page { size: A4; margin: 14mm 12mm; background: white; } @page { size: A4; margin: 14mm 12mm; background: white; }
+41 -23
View File
@@ -162,17 +162,25 @@ const setTips: Tip[] = [
function PreviewVideo({ src, poster, label }: { src?: string; poster?: string; label: string }) { function PreviewVideo({ src, poster, label }: { src?: string; poster?: string; label: string }) {
if (src) { if (src) {
return ( return (
<video <div>
src={src} <video
poster={poster} src={src}
className="aspect-[2.39/1] w-full bg-black object-cover" poster={poster}
controls className="aspect-[2.39/1] w-full bg-black object-cover print:hidden"
autoPlay controls
muted autoPlay
loop muted
playsInline loop
preload="metadata" 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 ( 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 ( 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) => ( {tips.map((c, i) => (
<li key={c.title} className="flex gap-4 bg-ink p-5 md:p-6"> <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> <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() { export default function CrowdPrevizPage() {
return ( 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"> <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" /> <Fox className="h-6 w-6 text-accent" />
<span className="font-mono text-[11px] tracking-[0.25em] text-muted uppercase">{site.name} · Previz</span> <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> </header>
<section className="mx-auto max-w-6xl px-4 pt-16 md:px-8 md:pt-24"> <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> </dl>
</section> </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> <figure>
<div className="relative"> <div className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
@@ -282,7 +300,7 @@ export default function CrowdPrevizPage() {
</section> </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"> <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"> <Step index="01" label="Hero shot" title="One drone move, real crowd at the stage">
<p> <p>
The drone starts behind the audience and flies forward toward the truck stage. Height and camera tilt stay 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" /> <PreviewVideo src={videos.hero} poster={videos.heroPoster} label="Hero shot previz coming soon" />
</section> </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"> <Step index="02" label="Crowd plates" title="Same spot, same angle, new arrangement">
<p> <p>
After the hero shot, the drone flies to a single marked position and holds still. No camera movement. The 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> </p>
</section> </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"> <Step index="03" label="Composite" title="Plates placed into the moving shot">
<p> <p>
We track the hero drone move in 3D, then place each plate as a flat layer on the ground behind the real 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" /> <PreviewVideo src={videos.final} poster={videos.finalPoster} label="Final comp proof of concept coming soon" />
</section> </section>
<section> <section className="print:!mt-0 print:break-before-page">
<SectionLabel index="04">Shot list</SectionLabel> <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> <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"> <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> </div>
</section> </section>
<section> <section className="print:!mt-0 print:break-before-page">
<SectionLabel index="05">Crowd direction</SectionLabel> <SectionLabel index="05">Crowd direction</SectionLabel>
<h2 className="font-display text-3xl leading-none font-thin uppercase md:text-5xl">Briefing the extras</h2> <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"> <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. the comp, so that&apos;s where arms need to stay down.
</figcaption> </figcaption>
</figure> </figure>
<TipGrid tips={crowdTips} /> <TipGrid tips={crowdTips} className="print:break-before-page" />
</section> </section>
<section> <section className="print:!mt-0 print:break-before-page">
<SectionLabel index="06">Camera</SectionLabel> <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> <h2 className="font-display text-3xl leading-none font-thin uppercase md:text-5xl">Notes for DOP and drone op</h2>
<TipGrid tips={cameraTips} /> <TipGrid tips={cameraTips} />
</section> </section>
<section> <section className="print:!mt-0 print:break-before-page">
<SectionLabel index="07">On set</SectionLabel> <SectionLabel index="07">On set</SectionLabel>
<h2 className="font-display text-3xl leading-none font-thin uppercase md:text-5xl">Production and VFX</h2> <h2 className="font-display text-3xl leading-none font-thin uppercase md:text-5xl">Production and VFX</h2>
<TipGrid tips={setTips} /> <TipGrid tips={setTips} />
+2 -2
View File
@@ -39,7 +39,7 @@ export default function ShotList({ shots, storeKey }: { shots: Shot[]; storeKey:
<button <button
type="button" type="button"
onClick={() => setDone([])} 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 Clear ticks
</button> </button>
@@ -78,7 +78,7 @@ export default function ShotList({ shots, storeKey }: { shots: Shot[]; storeKey:
); );
})} })}
</ul> </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> </div>
); );
} }
+4
View File
@@ -5,6 +5,10 @@ services:
restart: unless-stopped restart: unless-stopped
# Login for /previz/ (gitignored; created once on the server, see DEPLOY.md)
volumes:
- ./docker/auth:/etc/nginx/auth:ro
networks: networks:
- npm_default - npm_default
+10
View File
@@ -23,6 +23,16 @@ server {
add_header Cache-Control "public, max-age=604800"; 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 # Offline worker for /set must always be revalidated so updates roll out
location = /set/sw.js { location = /set/sw.js {
add_header Cache-Control "no-cache"; add_header Cache-Control "no-cache";
+2 -1
View File
@@ -6,7 +6,8 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"previz:pdf": "next build && node scripts/previz-pdf.mjs crowd"
}, },
"dependencies": { "dependencies": {
"next": "16.3.8", "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();
}