Add /previz/crowd: drone crowd shot explainer for production
Deploy / deploy (push) Successful in 43s

Hidden (noindex, unlinked) page walking production through the crowd
extension: hero drone move, four locked-off crowd plates, comp on cards.
Includes hero and comp previz videos, a tickable shot list (saved per
device), crowd direction, DOP/drone notes and an on-set checklist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-10-17 06:09:08 +02:00
parent 4ac1817425
commit 35b263ba31
12 changed files with 499 additions and 0 deletions
+3
View File
@@ -49,3 +49,6 @@ content/screen_markers/
# video masters
content/*.mov
# previz source renders (web versions in public/previz)
content/crowd/
+412
View File
@@ -0,0 +1,412 @@
import type { Metadata, Viewport } from "next";
import Fox from "@/components/Fox";
import SectionLabel from "@/components/SectionLabel";
import ShotList, { type Shot } from "@/components/previz/ShotList";
import { site } from "@/content/site";
// Pre-shoot explainer for production: how the drone crowd shot gets built.
// Shared by link only. Not linked from the site, kept out of search and the sitemap.
export const metadata: Metadata = {
title: "Drone crowd shot: previz",
description: "How the drone crowd shot is planned, shot and composited.",
robots: { index: false, follow: false },
};
export const viewport: Viewport = {
themeColor: "#0a0a0b",
};
const dir = "/previz/crowd";
// Drop the renders in public/previz/crowd/ and fill these in when they're done.
const videos: { hero?: string; heroPoster?: string; final?: string; finalPoster?: string } = {
hero: `${dir}/hero-previz.mp4`,
heroPoster: `${dir}/hero-previz.jpg`,
final: `${dir}/final-comp.mp4`,
finalPoster: `${dir}/final-comp.jpg`,
};
// Plate A sits nearest the stage, D furthest back (order in the 3D layout).
const plates = [
{ id: "A", src: `${dir}/plate-a.jpg`, where: "Nearest the stage, just behind the real crowd" },
{ id: "B", src: `${dir}/plate-b.jpg`, where: "Second row back" },
{ id: "C", src: `${dir}/plate-c.jpg`, where: "Third row back" },
{ id: "D", src: `${dir}/plate-d.jpg`, where: "Back of the crowd, where the drone starts" },
];
// Labels pinned to the front edge of each block in layout-3d.jpg (percent of the image).
const layoutLabels = [
{ id: "Hero", x: 68, y: 52, hero: true },
{ id: "A", x: 64, y: 61 },
{ id: "B", x: 54, y: 75 },
{ id: "C", x: 45.5, y: 80 },
{ id: "D", x: 34, y: 79 },
];
// In shooting order: hero first, then the plate drone setup.
const shots: Shot[] = [
{
id: "hero",
name: "Hero drone move",
setup: "Hero drone",
notes: "Behind the crowd to the truck stage. Constant height and tilt. Real crowd placed close to the stage.",
},
{
id: "clean",
name: "Clean plate",
setup: "Plate position",
notes: "Empty block area, no people, from the locked plate position. Roll a few seconds before the crowd walks in.",
},
...plates.map((p) => ({
id: `plate-${p.id.toLowerCase()}`,
name: `Crowd plate ${p.id}`,
setup: "Plate position",
notes: `Locked off, crowd fills the marked block. ${p.id === "A" ? "Mix people up from their hero shot spots" : "Everyone swaps places from the last plate"}. Roll longer than the hero shot.`,
})),
];
type Tip = { title: string; body: string };
// For the 1st AD to brief the extras before each plate
const crowdTips: Tip[] = [
{
title: "Front two rows: arms below the shoulders",
body: "The front of each block (the stage side) overlaps the block in front of it in the comp. Arms up there cut across other people and are very hard to cut out. Clap, fist pump, bounce, but keep hands low. Everyone behind can go big.",
},
{
title: "Face the stage, not the drone",
body: "Same direction as the real crowd in the hero shot. Nobody looks up at the drone or points at it.",
},
{
title: "Stay inside the cones",
body: "Fill the marked block and don't drift out of it. Don't line up neatly on the edges either; a slightly ragged edge blends better.",
},
{
title: "Keep it going until cut",
body: "Same energy as the hero crowd from action to cut. No easing off when it feels long; we need the full plate.",
},
{
title: "New spot every plate",
body: "Nobody keeps their place or their neighbours. Mix front to back and side to side between plates.",
},
{
title: "Swap the memorable stuff",
body: "Bright hats, jackets, scarves and bags get swapped or taken off between plates, so the same person doesn't show up four times.",
},
{
title: "No flags, signs or poles",
body: "Anything held up high crosses into the next block. Keep them for the real crowd in the hero shot only.",
},
{
title: "Leave nothing on the grass",
body: "No bags, bottles or jackets on the ground inside or near the block, and clear it fully for the clean plate.",
},
];
// For the DOP and drone operator
const cameraTips: Tip[] = [
{
title: "Plates: fast shutter, minimal motion blur",
body: "Around 1/100 to 1/200 (90° or narrower) on the crowd plates so waving arms stay sharp and can be cut out. We add matching motion blur in comp. Use ND to hold exposure.",
},
{
title: "Hero: normal shutter, slow and steady",
body: "180° is fine for the hero move. Keep the move slow, straight and constant speed with no yaw, so the track is clean and blur stays low.",
},
{
title: "Everything manual and locked",
body: "Same lens, resolution, frame rate, ISO, white balance and colour profile for the hero and every plate. No auto exposure, auto ISO or auto white balance.",
},
{
title: "Same image settings throughout",
body: "Keep lens distortion correction, digital zoom and stabilisation crop in the same mode for every take. Changing them changes the framing.",
},
{
title: "Best quality the drone can record",
body: "Highest resolution and bitrate, 10-bit log. The plates get scaled down into the shot, so extra resolution keeps people crisp.",
},
{
title: "Lock the plate position",
body: "One spot, one height, one gimbal tilt for every plate and the clean plate. Tilt matches the hero shot. Let the drone settle before calling action.",
},
{
title: "Roll long",
body: "Each plate runs longer than the hero shot so we can offset timing and stop blocks moving in sync. Pre-roll and post-roll on the hero too.",
},
{
title: "Log the numbers",
body: "Height, gimbal tilt, lens and position for the hero start, hero end and the plate spot. A screenshot of the drone screen is perfect.",
},
];
// Production and VFX on the day
const setTips: Tip[] = [
{
title: "Mark the block on the ground",
body: "Cones or tape at the four corners of the crowd rectangle so every plate fills the same area.",
},
{
title: "Grab a clean plate",
body: "A few seconds of the empty block area from the same spot, so we can blend the ground under the crowd.",
},
{
title: "Watch the sun",
body: "Shoot the plates straight after the hero shot. Shadows must fall the same way, so no big gap in time and ideally no passing cloud.",
},
{
title: "Measure the field",
body: "Ten minutes with a tape before we wrap: stage, real crowd and block corners, plus the hero drone height. It lets us line the plates up in the tracked shot.",
},
];
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"
/>
);
}
return (
<div className="relative flex aspect-[2.39/1] w-full items-center justify-center overflow-hidden border border-dashed border-line bg-ink-2">
<div className="absolute inset-x-0 top-0 h-px animate-scan bg-accent/30" aria-hidden="true" />
<div className="px-6 text-center">
<p className="font-mono text-[11px] tracking-[0.25em] text-accent uppercase">Rendering</p>
<p className="mt-2 text-sm text-muted md:text-base">{label}</p>
</div>
</div>
);
}
function TipGrid({ tips }: { tips: Tip[] }) {
return (
<ol className="mt-10 grid gap-px border border-line bg-line md:grid-cols-2">
{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>
<div>
<p className="text-lg">{c.title}</p>
<p className="mt-1.5 text-sm leading-relaxed text-muted md:text-base">{c.body}</p>
</div>
</li>
))}
</ol>
);
}
function Step({
index,
label,
title,
children,
}: {
index: string;
label: string;
title: string;
children: React.ReactNode;
}) {
return (
<div>
<SectionLabel index={index}>{label}</SectionLabel>
<h2 className="font-display text-3xl leading-none font-thin uppercase md:text-5xl">{title}</h2>
<div className="mt-6 max-w-3xl space-y-4 text-lg leading-relaxed font-light text-muted md:text-xl">{children}</div>
</div>
);
}
export default function CrowdPrevizPage() {
return (
<main className="grain min-h-dvh bg-ink pb-24">
<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>
</header>
<section className="mx-auto max-w-6xl px-4 pt-16 md:px-8 md:pt-24">
<p className="font-mono text-xs tracking-[0.3em] text-accent uppercase">VFX plan for production</p>
<h1 className="mt-5 font-display text-[clamp(2.2rem,8vw,5.5rem)] leading-[0.9] font-thin uppercase">
Drone crowd shot
</h1>
<p className="mt-8 max-w-3xl text-xl leading-relaxed font-light md:text-2xl">
One continuous drone move from behind the crowd toward the truck stage. We film one real crowd near the stage,
then build the rest of the audience from four locked-off plates of the same people, rearranged each time.
</p>
<dl className="mt-12 grid grid-cols-2 gap-px border border-line bg-line md:grid-cols-4">
{[
{ k: "Hero shot", v: "1 drone move" },
{ k: "Crowd plates", v: "4, locked off" },
{ k: "Drone setups", v: "2" },
{ k: "Extras needed", v: "One crowd block" },
].map((m) => (
<div key={m.k} className="bg-ink px-4 py-5">
<dt className="font-mono text-[10px] tracking-[0.2em] text-muted uppercase">{m.k}</dt>
<dd className="mt-1.5 text-lg">{m.v}</dd>
</div>
))}
</dl>
</section>
<section className="mx-auto mt-20 max-w-6xl px-4 md:mt-28 md:px-8">
<figure>
<div className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={`${dir}/layout-3d.jpg`} alt="3D layout: real crowd at the stage, four plate blocks behind it" className="w-full" />
{layoutLabels.map((l) => (
<span
key={l.id}
className={`absolute -translate-x-1/2 -translate-y-1/2 border px-1.5 py-0.5 font-mono text-[10px] font-semibold tracking-[0.15em] uppercase shadow-lg md:px-2.5 md:py-1 md:text-xs ${
l.hero ? "border-fg bg-fg text-ink" : "border-fg/70 bg-ink/85 text-fg"
}`}
style={{ left: `${l.x}%`, top: `${l.y}%` }}
>
{/* Phones: short tags so they don't collide */}
<span className="md:hidden">{l.hero ? "Real" : l.id}</span>
<span className="hidden md:inline">{l.hero ? "Real crowd" : `Plate ${l.id}`}</span>
</span>
))}
</div>
<figcaption className="mt-4 max-w-3xl text-sm leading-relaxed text-muted md:text-base">
The full layout from above. The block at the stage is the real crowd, filmed in the hero shot. The four
blocks behind it are added in post, each one a separate plate. The orange frames are those plates sitting in
the scene.
</figcaption>
</figure>
</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">
<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
constant for the whole move, which keeps the angle on the crowd consistent and makes the added blocks hold
up.
</p>
<p>
The real crowd stands close to the stage, where the camera ends up closest and sees the most detail. The
empty ground behind them is where the plates go.
</p>
</Step>
<PreviewVideo src={videos.hero} poster={videos.heroPoster} label="Hero shot previz coming soon" />
</section>
<section className="space-y-10">
<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
same group of people fills a rectangle on the ground and we roll.
</p>
<p>
Between takes, everyone swaps places. Each take becomes one block of crowd, so four takes make four
different-looking blocks from the same people.
</p>
</Step>
<div className="grid gap-2 md:grid-cols-2">
{plates.map((p) => (
<figure key={p.id} className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={p.src} alt={`Plate ${p.id}`} loading="lazy" className="w-full" />
<figcaption className="absolute inset-x-0 bottom-0 flex items-baseline gap-3 bg-gradient-to-t from-ink/90 to-transparent px-4 pt-10 pb-3">
<span className="font-mono text-xs font-semibold tracking-[0.2em] text-fg uppercase">Plate {p.id}</span>
<span className="text-xs text-muted md:text-sm">{p.where}</span>
</figcaption>
</figure>
))}
</div>
<p className="max-w-3xl text-sm leading-relaxed text-muted md:text-base">
Colours are only there to tell the blocks apart in the previz. On the day it&apos;s the same extras every
time; only their positions change.
</p>
</section>
<section className="space-y-10">
<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
crowd, back to front. The drone moves, the layers stay put in the scene, and the crowd fills the field.
</p>
<p>
The proof of concept below was rendered exactly this way: the four plates projected onto cards inside the
tracked shot.
</p>
</Step>
<PreviewVideo src={videos.final} poster={videos.finalPoster} label="Final comp proof of concept coming soon" />
</section>
<section>
<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">
Tick them off as they&apos;re in the can.
</p>
<div className="mt-10">
<ShotList shots={shots} storeKey="ttvfx-previz-crowd-shots" />
</div>
</section>
<section>
<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">
For the 1st AD to run through before the first plate, and again between plates.
</p>
<figure className="mt-10">
<div className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={`${dir}/plate-a.jpg`} alt="Plate A with the front two rows of the crowd marked" loading="lazy" className="w-full" />
{/* Front two rows of the block in plate-a.jpg (percent of the image) */}
<div
className="absolute border-2 border-dashed border-fg"
style={{
left: "18.5%",
top: "46%",
width: "62.5%",
height: "12%",
backgroundImage:
"repeating-linear-gradient(135deg, rgb(255 255 255 / 0.28) 0 2px, transparent 2px 9px)",
}}
/>
<span className="absolute top-[46%] left-1/2 -translate-x-1/2 -translate-y-[calc(100%+6px)] border border-fg bg-ink/90 px-2 py-1 font-mono text-[9px] font-semibold tracking-[0.15em] whitespace-nowrap text-fg uppercase md:text-xs">
Front 2 rows · hands low
</span>
<span className="absolute top-[5%] left-1/2 -translate-x-1/2 font-mono text-[9px] tracking-[0.2em] whitespace-nowrap text-fg/80 uppercase md:text-[11px]">
↑ Stage this way
</span>
</div>
<figcaption className="mt-4 max-w-3xl text-sm leading-relaxed text-muted md:text-base">
The crowd faces away from the drone, toward the stage. The rows nearest the stage overlap the next block in
the comp, so that&apos;s where arms need to stay down.
</figcaption>
</figure>
<TipGrid tips={crowdTips} />
</section>
<section>
<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>
<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} />
</section>
<footer className="border-t border-line pt-8 font-mono text-[11px] tracking-[0.2em] text-muted uppercase">
{site.name} · Questions:{" "}
<a href={`mailto:${site.email}`} className="text-accent hover:text-fg">
{site.email}
</a>
</footer>
</div>
</main>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useEffect, useState } from "react";
export type Shot = { id: string; name: string; setup: string; notes: string };
// Ticks live in this device's localStorage only (static site, no server).
export default function ShotList({ shots, storeKey }: { shots: Shot[]; storeKey: string }) {
const [done, setDone] = useState<string[] | null>(null);
// Storage is read after mount so the static HTML matches on first paint
useEffect(() => {
let saved: string[] = [];
try {
saved = JSON.parse(localStorage.getItem(storeKey) ?? "[]");
} catch {}
setDone(Array.isArray(saved) ? saved : []); // eslint-disable-line react-hooks/set-state-in-effect
}, [storeKey]);
useEffect(() => {
if (!done) return;
try {
localStorage.setItem(storeKey, JSON.stringify(done));
} catch {}
}, [done, storeKey]);
const ticked = done ?? [];
const count = shots.filter((s) => ticked.includes(s.id)).length;
const toggle = (id: string) =>
setDone((d) => ((d ?? []).includes(id) ? (d ?? []).filter((x) => x !== id) : [...(d ?? []), id]));
return (
<div>
<div className="flex items-center justify-between gap-4 border border-b-0 border-line px-4 py-3 md:px-5">
<p className="font-mono text-xs tracking-[0.2em] text-muted uppercase">
<span className="text-fg">{count}</span> / {shots.length} shot
{count === shots.length && <span className="ml-3 text-fg">· All done</span>}
</p>
{count > 0 && (
<button
type="button"
onClick={() => setDone([])}
className="-my-2 py-2 font-mono text-[11px] tracking-[0.2em] text-muted uppercase hover:text-fg"
>
Clear ticks
</button>
)}
</div>
<ul className="divide-y divide-line border border-line">
{shots.map((s, i) => {
const on = ticked.includes(s.id);
return (
<li key={s.id}>
<label className="flex cursor-pointer items-start gap-4 p-4 transition-colors hover:bg-ink-2 md:gap-5 md:p-5">
<input type="checkbox" checked={on} onChange={() => toggle(s.id)} className="peer sr-only" />
<span
aria-hidden="true"
className={`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center border-2 text-base leading-none font-bold peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-accent ${
on ? "border-fg bg-fg text-ink" : "border-muted"
}`}
>
{on && "✓"}
</span>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="font-mono text-xs text-accent">{String(i + 1).padStart(2, "0")}</span>
<span className={`text-lg ${on ? "text-muted line-through" : ""}`}>{s.name}</span>
<span className="font-mono text-[10px] tracking-[0.2em] text-muted uppercase">{s.setup}</span>
{on && (
<span className="font-mono text-[10px] font-semibold tracking-[0.2em] text-fg uppercase">Done</span>
)}
</span>
<span className={`mt-1.5 block text-sm leading-relaxed text-muted md:text-base ${on ? "opacity-60" : ""}`}>
{s.notes}
</span>
</span>
</label>
</li>
);
})}
</ul>
<p className="mt-3 text-xs text-muted">Ticks are saved on this device only.</p>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB