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, + ); +}