= {
+ start: "flex-start", center: "center", end: "flex-end",
+};
+
+const toneOf = (brand: Brand, accent: string, tone: Tone | undefined, fallback: Tone) => {
+ switch (tone ?? fallback) {
+ case "accent": return accent;
+ case "muted": return muted(brand.ink);
+ case "context": return context(brand.ink);
+ default: return brand.ink;
+ }
+};
+
+const source = (src: string) => (src.startsWith("http") ? src : staticFile(src));
+
+type Paint = {
+ brand: Brand;
+ accent: string;
+ /** Reference pixels to device pixels, with the fit shrink already folded in. */
+ unit: number;
+ /** Device pixels, for anything measured against the width it was given. */
+ width: number;
+ /** Device pixels a media block may take, after the fit pass. */
+ mediaHeight: number;
+};
+
+const Piece: React.FC<{ block: Block; paint: Paint }> = ({ block, paint }) => {
+ const { brand, accent, unit } = paint;
+ const { fps } = useVideoConfig();
+
+ if (block.type === "group") {
+ return ;
+ }
+
+ if (block.type === "rule") {
+ return (
+
+ );
+ }
+
+ if (block.type === "text") {
+ const size = sizeOf(block);
+ return (
+
+ {block.text}
+ {block.emphasis && {block.emphasis}}
+
+ );
+ }
+
+ if (block.type === "quote") {
+ const size = sizeOf(block);
+ return (
+ <>
+
+ {"“"}{block.text}{"”"}
+
+ {block.attribution && (
+
+ {block.attribution}
+
+ )}
+ >
+ );
+ }
+
+ if (block.type === "list") {
+ const size = sizeOf(block);
+ return (
+ <>
+ {block.items.map((item, i) => (
+
+ {block.numbered ? (
+
+ {i + 1}
+
+ ) : (
+
+ )}
+
+ {item}
+
+
+ ))}
+ >
+ );
+ }
+
+ if (block.type === "bars") {
+ const top = Math.max(...block.rows.map((row) => row.value)) || 1;
+ return (
+ <>
+ {block.rows.map((row, i) => (
+
+
+
+ {row.label}
+
+
+ {row.display ?? row.value}
+
+
+
+
+ ))}
+ >
+ );
+ }
+
+ if (block.type === "meter") {
+ const share = Math.max(0, Math.min(1, block.value));
+ return (
+ <>
+
+ {block.display ?? `${Math.round(share * 100)}%`}
+
+
+ >
+ );
+ }
+
+ if (block.type === "steps") {
+ const last = block.points.length - 1;
+ return (
+
+ {block.points.map((point, i) => (
+
+ {i > 0 && (
+
+ )}
+
+
+
+ {point.value}
+
+ {point.note && (
+
+ {point.note}
+
+ )}
+
+
+ ))}
+
+ );
+ }
+
+ if (block.type === "chip") {
+ return (
+
+ {block.src && (
+
})
+ )}
+
+
+ {block.name}
+
+ {block.note && (
+
+ {block.note}
+
+ )}
+
+
+ );
+ }
+
+ if (!block.src) return null;
+
+ const Frame = getRemotionEnvironment().isRendering ? OffthreadVideo : Video;
+ return (
+ <>
+ {block.media === "video" ? (
+
+ ) : (
+
+ )}
+ {block.caption && (
+
+ {block.caption}
+
+ )}
+ >
+ );
+};
+
+const Stack: React.FC<{
+ blocks: Block[];
+ layout?: Layout;
+ gap?: Gap;
+ paint: Paint;
+}> = ({ blocks, layout = "stack", gap = "group", paint }) => {
+ const drawn = blocks.filter((block) => block.type !== "group" || block.blocks.length);
+ if (!drawn.length) return null;
+
+ const gutter = GAP_SIZE[gap] * paint.unit;
+ const columns = layout === "grid" ? 2 : drawn.length;
+ const share = layout === "stack"
+ ? paint.width
+ : Math.max(1, (paint.width - gutter * (columns - 1)) / columns);
+
+ return (
+
+ {drawn.map((block, i) => (
+
+ ))}
+
+ );
+};
+
+/**
+ * How tall a media block is drawn, and how much the type has to give.
+ *
+ * Two passes rather than one, because the two shrink at different costs. A
+ * picture cropped a little tighter is still the picture; type shrunk to fit a
+ * picture that kept its full height is a card nobody reads at arm's length.
+ * So the media band gives up its room first, down to the height below which
+ * it stops saying anything, and only then does the type scale.
+ */
+export function fitScene(
+ blocks: Block[],
+ { layout = "stack", gap = "group", room, width = SCENE_WIDTH }: {
+ layout?: Layout; gap?: Gap; room: number; width?: number;
+ },
+): { fit: number; mediaHeight: number } {
+ const asked = MEDIA_HEIGHT.max;
+ const at = (height: number) =>
+ sceneHeight(withMediaHeight(blocks, height), layout, gap, width);
+
+ const wanted = at(asked);
+ if (!(room > 0) || wanted <= room) return { fit: 1, mediaHeight: asked };
+
+ const mediaHeight = Math.max(MEDIA_HEIGHT.min, asked - (wanted - room));
+ const left = at(mediaHeight);
+ return {
+ fit: left <= room ? 1 : Math.max(MIN_FIT, room / left),
+ mediaHeight,
+ };
+}
+
+const withMediaHeight = (blocks: Block[], height: number): Block[] =>
+ blocks.map((block) => {
+ if (block.type === "media") return { ...block, height };
+ if (block.type === "group") {
+ return { ...block, blocks: withMediaHeight(block.blocks, height) };
+ }
+ return block;
+ });
+
+export const Scene: React.FC<{
+ blocks: Block[];
+ layout?: Layout;
+ gap?: Gap;
+ brand: Brand;
+ accent: string;
+ /** Reference pixels to device pixels, before any fit shrink. */
+ scale: number;
+ /** Device pixels the scene has to sit inside. */
+ room: number;
+}> = ({ blocks, layout = "stack", gap = "group", brand, accent, scale, room }) => {
+ const { fit, mediaHeight } = fitScene(blocks, { layout, gap, room: room / scale });
+ const unit = scale * fit;
+
+ return (
+ /*
+ * Clipped at the room it was given, because the shrink has a floor.
+ *
+ * An arrangement that cannot fit even at the smallest size worth reading
+ * is a plan somebody has to fix. What this decides is which way it fails:
+ * cut off at the top, or drawn up across the speaker's face and down
+ * behind the caption pill. The first reads as a card that ran long; the
+ * second reads as a broken render.
+ */
+
+
+
+ );
+};
diff --git a/remotion/src/components/brand.ts b/remotion/src/components/brand.ts
new file mode 100644
index 0000000..66f3d5a
--- /dev/null
+++ b/remotion/src/components/brand.ts
@@ -0,0 +1,23 @@
+/**
+ * The show's colours, or ours when it has not set any.
+ *
+ * Three values rather than a palette: the one colour that means "this is the
+ * thing", what text is set in, and what it is set on. Everything else on a
+ * card is one of those three at a lower opacity, which is what keeps a card
+ * looking like the show rather than like a theme.
+ */
+export type Brand = { accent: string; ink: string; surface: string };
+
+export const DEFAULT_BRAND: Brand = {
+ accent: "#4C9DF5",
+ ink: "#FFFFFF",
+ surface: "#0A0D14",
+};
+
+/** Text that is not the point, and marks that are not the subject. */
+export const muted = (ink: string) => `color-mix(in oklab, ${ink} 62%, transparent)`;
+
+export const context = (ink: string) => `color-mix(in oklab, ${ink} 22%, transparent)`;
+
+/** A meter's unfilled remainder: the fill's own hue, several steps lighter. */
+export const track = (fill: string) => `color-mix(in oklab, ${fill} 22%, transparent)`;
diff --git a/remotion/src/scene.test.ts b/remotion/src/scene.test.ts
new file mode 100644
index 0000000..ede8fdf
--- /dev/null
+++ b/remotion/src/scene.test.ts
@@ -0,0 +1,85 @@
+import { describe, it, expect } from "vitest";
+import { blockHeight, countBlocks, blockDepth, fitScale, hasMedia, sceneHeight } from "./scene";
+import type { Block } from "./scene";
+import { cardAt } from "./cards";
+import type { Card } from "./cards";
+
+const text = (words: string, size: "sm" | "md" | "xl" | "xxl" = "md"): Block =>
+ ({ type: "text", text: words, size });
+
+describe("fitting a scene into the room it was given", () => {
+ it("leaves a card that already fits alone", () => {
+ expect(fitScale([text("Hiring stopped", "xl")], { room: 900 })).toBe(1);
+ });
+
+ it("scales a card down rather than letting it run past its room", () => {
+ const blocks = Array.from({ length: 6 }, (_, i) => text(`A line of some length ${i}`, "xl"));
+ const room = 900;
+ const fit = fitScale(blocks, { room });
+ expect(fit).toBeLessThan(1);
+ expect(sceneHeight(blocks, "stack", "group") * fit).toBeLessThanOrEqual(room + 1);
+ });
+
+ /**
+ * Past the floor the card is clipped rather than shrunk on, and the renderer
+ * answers by giving it the whole frame. Shrinking to fit whatever it is
+ * handed is how a card ends up technically inside its room and unreadable on
+ * a phone held at arm's length.
+ */
+ it("stops shrinking at the size below which the card is small, not fitted", () => {
+ const blocks = Array.from({ length: 20 }, (_, i) => text(`Another long line here ${i}`, "xxl"));
+ expect(fitScale(blocks, { room: 100 })).toBe(0.55);
+ });
+
+ it("measures a wrapped line as taller than one that fits across", () => {
+ const short = blockHeight(text("Short"), 888);
+ const long = blockHeight(
+ text("A sentence long enough to wrap across more than one line of this frame"),
+ 888,
+ );
+ expect(long).toBeGreaterThan(short);
+ });
+
+ it("gives a row the width it actually has rather than the whole frame", () => {
+ const words = "A sentence long enough to wrap when it only has half the frame";
+ const alone = sceneHeight([text(words)], "stack", "group");
+ const beside = sceneHeight(
+ [{ type: "group", layout: "row", blocks: [text(words), text(words)] }],
+ "stack",
+ "group",
+ );
+ expect(beside).toBeGreaterThan(alone);
+ });
+});
+
+describe("what a scene is made of", () => {
+ const nested: Block[] = [
+ text("top"),
+ { type: "group", layout: "row", blocks: [text("left"), { type: "media", media: "image", src: "a.png" }] },
+ ];
+
+ it("counts every block, groups included", () => {
+ expect(countBlocks(nested)).toBe(4);
+ expect(blockDepth(nested)).toBe(2);
+ });
+
+ it("finds a file however deep it sits", () => {
+ expect(hasMedia(nested)).toBe(true);
+ expect(hasMedia([text("nothing here")])).toBe(false);
+ });
+});
+
+describe("the card in front at a moment", () => {
+ it("lets a scene take its window like any other kind", () => {
+ const cards: Card[] = [
+ { kind: "quote", start: 0, end: 3, text: "first" },
+ { kind: "scene", start: 3, end: 6, blocks: [text("second")] },
+ ];
+ expect(cardAt(cards, 1)?.kind).toBe("quote");
+ expect(cardAt(cards, 4)?.kind).toBe("scene");
+ // Half-open, so a card ending where the next begins is a cut.
+ expect(cardAt(cards, 3)?.kind).toBe("scene");
+ expect(cardAt(cards, 6)).toBeNull();
+ });
+});
+
diff --git a/remotion/src/scene.ts b/remotion/src/scene.ts
new file mode 100644
index 0000000..c82f458
--- /dev/null
+++ b/remotion/src/scene.ts
@@ -0,0 +1,240 @@
+export type Tone = "ink" | "accent" | "muted" | "context";
+
+export type Size = "xs" | "sm" | "md" | "lg" | "xl" | "xxl";
+
+export type Align = "start" | "center" | "end";
+
+export type Layout = "stack" | "row" | "grid";
+
+export type Gap = "tight" | "group" | "section";
+
+export type BarRow = {
+ label: string;
+ value: number;
+ display?: string;
+ subject?: boolean;
+};
+
+export type StepPoint = { value: string; note?: string };
+
+type Common = {
+ tone?: Tone;
+ align?: Align;
+ /** Share of a row's width, when the row has more than one child. */
+ grow?: number;
+};
+
+export type Block =
+ | (Common & {
+ type: "text";
+ text: string;
+ /** Set apart in the card's accent, appended to the text. */
+ emphasis?: string;
+ size?: Size;
+ caps?: boolean;
+ })
+ | (Common & { type: "quote"; text: string; attribution?: string; size?: Size })
+ | (Common & { type: "list"; items: string[]; numbered?: boolean; size?: Size })
+ | (Common & { type: "bars"; rows: BarRow[] })
+ | (Common & { type: "meter"; value: number; display?: string })
+ | (Common & { type: "steps"; points: StepPoint[] })
+ | (Common & { type: "chip"; name: string; note?: string; src?: string })
+ | (Common & {
+ type: "media";
+ media: "image" | "video";
+ src?: string;
+ fit?: "fit" | "fill";
+ startAt?: number;
+ caption?: string;
+ /** Reference-canvas height it asks for before the fit pass runs. */
+ height?: number;
+ })
+ | (Common & { type: "rule" })
+ | (Common & { type: "group"; layout?: Layout; gap?: Gap; blocks: Block[] });
+
+export type BlockType = Block["type"];
+
+export const BLOCK_TYPES: readonly BlockType[] = [
+ "text", "quote", "list", "bars", "meter", "steps", "chip", "media", "rule", "group",
+];
+
+/** Authored against the 1080x1920 canvas every other measurement here uses. */
+export const TYPE_SIZE: Record = {
+ xs: 34, sm: 46, md: 58, lg: 68, xl: 84, xxl: 168,
+};
+
+export const GAP_SIZE: Record = { tight: 14, group: 36, section: 76 };
+
+export const MEDIA_HEIGHT = { min: 260, max: 540 };
+
+export const SCENE_WIDTH = 888;
+
+export const MAX_BLOCKS = 14;
+
+export const MAX_DEPTH = 3;
+
+const DEFAULT_SIZE: Partial> = {
+ text: "sm", quote: "lg", list: "md",
+};
+
+export const sizeOf = (block: Block): Size =>
+ ("size" in block && block.size ? block.size : DEFAULT_SIZE[block.type] ?? "sm");
+
+/**
+ * Advance width of DM Sans at its heavier weights, as a fraction of the size.
+ * Wrapping is estimated rather than measured because the same number has to
+ * come out in the browser preview and in a headless render, and only one of
+ * those can measure text before it lays out.
+ */
+const ADVANCE = 0.55;
+
+const CAPS_ADVANCE = 0.68;
+
+const LINE = 1.25;
+
+const lines = (text: string, size: number, width: number, advance = ADVANCE) => {
+ const perLine = Math.max(1, Math.floor(width / (size * advance)));
+ return Math.max(1, Math.ceil(text.length / perLine));
+};
+
+const BAR_ROW = 26 + 8 + 34 * 1.3;
+
+const STEP_ROW = 26 + 58 + 34 * 1.3 + 24;
+
+const CHIP_ROW = 132;
+
+const RULE_ROW = 5;
+
+const METER_ROW = 26;
+
+const listOf = (block: Block): Block[] =>
+ (block.type === "group" ? block.blocks : []);
+
+const gapOf = (block: Block): number =>
+ GAP_SIZE[(block.type === "group" && block.gap) || "tight"];
+
+/**
+ * How tall a block is at the reference canvas, before any fit shrink.
+ *
+ * Deliberately an over-estimate on text: a card drawn a little smaller than it
+ * had to be reads as a design choice, and one whose last line is behind the
+ * caption pill reads as a bug.
+ */
+export function blockHeight(block: Block, width: number): number {
+ switch (block.type) {
+ case "text": {
+ const size = TYPE_SIZE[sizeOf(block)];
+ const text = block.emphasis ? `${block.text} ${block.emphasis}` : block.text;
+ return lines(text, size, width, block.caps ? CAPS_ADVANCE : ADVANCE) * size * LINE;
+ }
+ case "quote": {
+ const size = TYPE_SIZE[sizeOf(block)];
+ const body = lines(block.text, size, width) * size * LINE;
+ return body + (block.attribution ? GAP_SIZE.group + TYPE_SIZE.xs * LINE : 0);
+ }
+ case "list": {
+ const size = TYPE_SIZE[sizeOf(block)];
+ const marker = 46;
+ return block.items.reduce(
+ (total, item, i) =>
+ total + (i ? GAP_SIZE.tight : 0) + lines(item, size, width - marker) * size * LINE,
+ 0,
+ );
+ }
+ case "bars":
+ return block.rows.length * BAR_ROW + Math.max(0, block.rows.length - 1) * 30;
+ case "meter":
+ return TYPE_SIZE.xxl * 0.72 + GAP_SIZE.tight + METER_ROW;
+ case "steps":
+ return STEP_ROW;
+ case "chip":
+ return Math.max(
+ CHIP_ROW,
+ TYPE_SIZE.md * LINE + (block.note ? lines(block.note, TYPE_SIZE.sm, width) * TYPE_SIZE.sm * LINE : 0),
+ );
+ case "media":
+ return (block.height ?? MEDIA_HEIGHT.max)
+ + (block.caption ? TYPE_SIZE.xs * LINE + GAP_SIZE.tight : 0);
+ case "rule":
+ return RULE_ROW;
+ case "group":
+ return groupHeight(block, width);
+ }
+}
+
+function groupHeight(
+ block: Extract, width: number,
+): number {
+ const kids = listOf(block);
+ if (!kids.length) return 0;
+ const gap = gapOf(block);
+ const layout = block.layout ?? "stack";
+
+ if (layout === "stack") {
+ return kids.reduce(
+ (total, kid, i) => total + (i ? gap : 0) + blockHeight(kid, width),
+ 0,
+ );
+ }
+
+ const columns = layout === "grid" ? 2 : kids.length;
+ const share = Math.max(1, (width - gap * (columns - 1)) / columns);
+ if (layout === "row") {
+ return Math.max(...kids.map((kid) => blockHeight(kid, share)));
+ }
+
+ let tallest = 0;
+ let total = 0;
+ kids.forEach((kid, i) => {
+ tallest = Math.max(tallest, blockHeight(kid, share));
+ if (i % columns === columns - 1 || i === kids.length - 1) {
+ total += (total ? gap : 0) + tallest;
+ tallest = 0;
+ }
+ });
+ return total;
+}
+
+export const sceneHeight = (
+ blocks: Block[], layout: Layout, gap: Gap, width = SCENE_WIDTH,
+) => groupHeight({ type: "group", layout, gap, blocks }, width);
+
+/** Never shrunk past this: below it the card is small rather than fitted. */
+export const MIN_FIT = 0.55;
+
+/**
+ * How much a scene has to be scaled down to sit in the room it was given.
+ *
+ * Media is excluded from the shrink because a picture answers a height rather
+ * than asking for one: the band it gets is what the fit pass leaves over.
+ */
+export function fitScale(
+ blocks: Block[],
+ { layout = "stack", gap = "group", room, width = SCENE_WIDTH }: {
+ layout?: Layout; gap?: Gap; room: number; width?: number;
+ },
+): number {
+ if (!(room > 0)) return 1;
+ const wanted = sceneHeight(blocks, layout, gap, width);
+ if (wanted <= room) return 1;
+ return Math.max(MIN_FIT, room / wanted);
+}
+
+export const hasMedia = (blocks: Block[]): boolean =>
+ blocks.some((block) =>
+ block.type === "media" || (block.type === "group" && hasMedia(block.blocks)));
+
+export function countBlocks(blocks: Block[]): number {
+ return blocks.reduce(
+ (total, block) => total + 1 + (block.type === "group" ? countBlocks(block.blocks) : 0),
+ 0,
+ );
+}
+
+export function blockDepth(blocks: Block[]): number {
+ return blocks.reduce(
+ (deepest, block) =>
+ Math.max(deepest, block.type === "group" ? 1 + blockDepth(block.blocks) : 1),
+ 0,
+ );
+}