/previz: password protect, add PDF export of the crowd previz
Deploy / deploy (push) Successful in 1m5s
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:
@@ -7,3 +7,5 @@ content/*.mp4
|
||||
content/*.pdf
|
||||
*.md
|
||||
!README.md
|
||||
docker/auth
|
||||
content/crowd
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+41
-23
@@ -162,17 +162,25 @@ const setTips: Tip[] = [
|
||||
function PreviewVideo({ src, poster, label }: { src?: string; poster?: string; label: string }) {
|
||||
if (src) {
|
||||
return (
|
||||
<video
|
||||
src={src}
|
||||
poster={poster}
|
||||
className="aspect-[2.39/1] w-full bg-black object-cover"
|
||||
controls
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
<div>
|
||||
<video
|
||||
src={src}
|
||||
poster={poster}
|
||||
className="aspect-[2.39/1] w-full bg-black object-cover print:hidden"
|
||||
controls
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
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'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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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.
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user