From aca6945e493c457f14ab8c8fb7587dd07f88d515 Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Sun, 6 Sep 2026 17:21:04 +0400 Subject: [PATCH] Build a card out of blocks when no preset is its shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card kinds are eight layouts somebody drew in advance, and a moment that is not a figure, a list or a quote got the nearest one worn the wrong way round or nothing at all. The moments that came out worst are the ones with nothing in them a camera could point at: a system seizing up, ten years compressed into a phrase, trust eroding. A talking head carries those least well and there is no figure in them to hang a preset on, so they were skipped. `scene` is the ninth kind and the one with no shape of its own: a layout and a list of blocks. text, quote, list, bars, meter, steps, chip, media, rule, and group to nest them side by side. Fourteen blocks, three deep. The set is closed on purpose — unlimited arrangements, not unlimited marks, because a plan that can name its own shapes can draw something nobody in the show would sign off. An arrangement nobody has seen before does not know how tall it is, which every preset did because a person sized it. So it is measured: wrapping is estimated from the advance width rather than laid out, because the same number has to come out in a browser preview and in a headless render and only one of those can measure text before it lays out. Media gives up its band first, down to the height below which a picture says nothing, and only then does the type scale — a picture cropped tighter is still the picture, where type shrunk to fit one is a card nobody reads at arm's length. The shrink has a floor of 0.55; a scene that would go past it takes the whole frame rather than sharing it with the speaker at a size that reads as a mistake. Older plans are untouched: the eight presets draw exactly what they drew, and an engine that has never heard of `scene` already drops a kind it cannot draw rather than painting an empty slab over the speaker. --- remotion/src/cards.ts | 15 + remotion/src/components/Cards.tsx | 77 +++-- remotion/src/components/Scene.tsx | 558 ++++++++++++++++++++++++++++++ remotion/src/components/brand.ts | 23 ++ remotion/src/scene.test.ts | 85 +++++ remotion/src/scene.ts | 240 +++++++++++++ 6 files changed, 970 insertions(+), 28 deletions(-) create mode 100644 remotion/src/components/Scene.tsx create mode 100644 remotion/src/components/brand.ts create mode 100644 remotion/src/scene.test.ts create mode 100644 remotion/src/scene.ts diff --git a/remotion/src/cards.ts b/remotion/src/cards.ts index 934b684..8595541 100644 --- a/remotion/src/cards.ts +++ b/remotion/src/cards.ts @@ -1,3 +1,4 @@ +import type { Block, Gap, Layout } from "./scene"; import type { Motion } from "./motion"; interface CardBase { @@ -85,6 +86,20 @@ export type Card = src?: string; }) | (CardBase & { kind: "quote"; text: string; attribution?: string }) + /** + * Blocks, arranged. The kind that is not a kind. + * + * Everything above is a layout somebody drew once and named. This is the + * same atoms with the arrangement left open, so a moment that does not + * happen to be a figure or a list still gets a card built for it rather + * than the nearest preset with the wrong shape. + */ + | (CardBase & { + kind: "scene"; + layout?: Layout; + gap?: Gap; + blocks: Block[]; + }) | (CardBase & { kind: "image"; /** A URL the renderer can reach, or a path inside the bundle. */ diff --git a/remotion/src/components/Cards.tsx b/remotion/src/components/Cards.tsx index 99c1115..4486f17 100644 --- a/remotion/src/components/Cards.tsx +++ b/remotion/src/components/Cards.tsx @@ -8,6 +8,11 @@ import type { CaptionStyle } from "../types"; import { MOTION, motionAt } from "../motion"; import { cardAt } from "../cards"; import type { Card } from "../cards"; +import { context, DEFAULT_BRAND, muted, track } from "./brand"; +import type { Brand } from "./brand"; +import { fitScene, Scene } from "./Scene"; +import { MIN_FIT } from "../scene"; +import type { Block } from "../scene"; /** * A card keeps the person on screen. @@ -24,25 +29,8 @@ import type { Card } from "../cards"; * cutaway to a different video. */ -/** - * 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. */ -const muted = (ink: string) => `color-mix(in oklab, ${ink} 62%, transparent)`; -const context = (ink: string) => `color-mix(in oklab, ${ink} 22%, transparent)`; +export type { Brand } from "./brand"; +export { DEFAULT_BRAND } from "./brand"; /** * A three-step rhythm, so grouping reads from the gaps rather than from rules. @@ -58,10 +46,6 @@ const GAP = { tight: 14, group: 36, section: 76 }; */ const MARK = { bar: 26, cap: 8, gap: 30, rule: 5, dot: 26 }; -/** A meter's unfilled remainder: the fill's own hue, several steps lighter. */ -const track = (fill: string) => - `color-mix(in oklab, ${fill} 22%, transparent)`; - /** Authored for the 1920-tall canvas, like every other measurement here. */ const TYPE = { label: 34, body: 46, item: 58, quote: 68, lead: 84, figure: 168 }; @@ -271,6 +255,20 @@ const CardBody: React.FC<{ const MUTED = muted(brand.ink); const CONTEXT = context(brand.ink); + if (card.kind === "scene") { + return ( + + ); + } + if (card.kind === "stat") { return ( <> @@ -685,9 +683,15 @@ const SpeakerBand: React.FC<{ src: string; startFrom: number; faceY?: number | n */ const KNOWN_KINDS = new Set([ "stat", "headline", "bullets", "compare", "change", "share", "entity", "quote", - "image", "video", + "image", "video", "scene", ]); +/** A block that would put something on screen, rather than an empty group. */ +const drawsSomething = (block: Block): boolean => + (block.type === "group" + ? block.blocks.some(drawsSomething) + : block.type !== "media" || Boolean(block.src)); + export const Cards: React.FC<{ cards: Card[]; videoSrc?: string; @@ -712,10 +716,12 @@ export const Cards: React.FC<{ * quote branch and draw an empty slab over the speaker, which is the one * failure that looks deliberate. */ - const usable = cards.filter((c) => ( - KNOWN_KINDS.has(c.kind) - && (c.kind !== "image" && c.kind !== "video" ? true : Boolean(c.src)) - )); + const usable = cards.filter((c) => { + if (!KNOWN_KINDS.has(c.kind)) return false; + if (c.kind === "image" || c.kind === "video") return Boolean(c.src); + if (c.kind === "scene") return c.blocks.some(drawsSomething); + return true; + }); const card = cardAt(usable, frame / fps); if (!card) return null; @@ -760,9 +766,24 @@ export const Cards: React.FC<{ // Asked of the union rather than of one kind: only some cards have a caption // at all, and the fit arithmetic runs before anything has narrowed to one. const hasCaption = "caption" in card && Boolean(card.caption); + /* + * A scene keeps the speaker while it can still be read beside one. + * + * The presets know their own height because somebody sized them. An + * arrangement nobody has seen before is measured instead, and one that would + * have to be squashed to the floor of the fit takes the whole frame rather + * than sharing it at a size nobody reads at arm's length. + */ + const sceneShares = card.kind !== "scene" + || fitScene(card.blocks, { + layout: card.layout, + gap: card.gap, + room: bodyRoom(room - speakerTakes) / s, + }).fit > MIN_FIT; const withSpeaker = card.speaker !== null && Boolean(videoSrc) && room >= headNeeds + && sceneShares && (!SHOWS_A_FILE.has(card.kind) || mediaBand(card.kind, bodyRoom(room - speakerTakes), s, hasCaption) >= MEDIA_MIN * s); diff --git a/remotion/src/components/Scene.tsx b/remotion/src/components/Scene.tsx new file mode 100644 index 0000000..b279cc3 --- /dev/null +++ b/remotion/src/components/Scene.tsx @@ -0,0 +1,558 @@ +import React from "react"; +import { + getRemotionEnvironment, Img, OffthreadVideo, Video, staticFile, useVideoConfig, +} from "remotion"; +import { FONT } from "../types"; +import { context, muted, track } from "./brand"; +import type { Brand } from "./brand"; +import { + GAP_SIZE, MEDIA_HEIGHT, MIN_FIT, SCENE_WIDTH, TYPE_SIZE, sceneHeight, sizeOf, +} from "../scene"; +import type { Align, Block, Gap, Layout, Size, Tone } from "../scene"; + +/** + * Blocks, drawn. + * + * Every preset card in this folder is a layout somebody chose once. This draws + * whatever arrangement it is handed, which is the same set of marks with the + * arrangement left to the plan. The one thing it does that a preset never had + * to is fit: a preset knows how tall it is because a person sized it, and an + * arrangement nobody has seen before does not. + */ + +const WEIGHT: Record = { + xs: 600, sm: 400, md: 500, lg: 500, xl: 600, xxl: 700, +}; + +const TRACKING: Record = { + xs: 1.5, sm: 0, md: 0, lg: -0.5, xl: -1, xxl: -4, +}; + +const LINE_HEIGHT: Record = { + xs: 1.3, sm: 1.3, md: 1.25, lg: 1.25, xl: 1.12, xxl: 0.95, +}; + +const MARK = { bar: 26, cap: 8, gap: 30, rule: 5, dot: 26 }; + +const FLEX: Record = { + 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, + ); +}