From 5fbdc9e511d73e5ccb13aabf547696bba9a39f80 Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Sat, 3 Oct 2026 12:44:41 +0400 Subject: [PATCH 1/2] Set cards over the video when a style asks for it A style can now choose placement: page or overlay. Overlay draws stat, headline, entity, bullets, quote, compare, change and share cards as a compact panel in the top band under the logo, so the speaker stays in frame. Paper packs use a torn sheet with labelled figures; flat packs use a solid ground block. Every pack still defaults to full pages. --- remotion/src/style/overlay.tsx | 259 +++++++++++++++++++++++++++++++ remotion/src/style/pages.tsx | 12 +- remotion/src/style/styled.tsx | 7 + remotion/src/style/theme.test.ts | 19 ++- remotion/src/style/theme.ts | 7 +- 5 files changed, 296 insertions(+), 8 deletions(-) create mode 100644 remotion/src/style/overlay.tsx diff --git a/remotion/src/style/overlay.tsx b/remotion/src/style/overlay.tsx new file mode 100644 index 00000000..5c41382e --- /dev/null +++ b/remotion/src/style/overlay.tsx @@ -0,0 +1,259 @@ +import React from "react"; +import { interpolate, useCurrentFrame, useVideoConfig } from "remotion"; +import type { Card } from "../cards"; +import { fontOf } from "./theme"; +import type { Theme } from "./theme"; +import { Label, Paper } from "./materials"; +import { progress, Reveal, RevealText, roleFrames } from "./reveal"; +import { Bar, EXIT_FRAMES, paced, planner, tempo } from "./pages"; + +export const OVERLAY_KINDS = new Set([ + "stat", "headline", "entity", "bullets", "quote", "compare", "change", "share", +]); + +type Props = { card: Extract; theme: Theme; s: number; f: number; fps: number }; + +const Eyebrow: React.FC<{ theme: Theme; s: number; f: number; fps: number; at: number; text?: string }> = ({ + theme, s, f, fps, at, text, +}) => (text ? ( + + + +) : null); + +const Line: React.FC<{ theme: Theme; s: number; f: number; fps: number; at: number; size: number; color?: string; children: React.ReactNode }> = ({ + theme, s, f, fps, at, size, color, children, +}) => ( + +
{children}
+
+); + +const Figure: React.FC<{ theme: Theme; s: number; size: number; color?: string; children: React.ReactNode }> = ({ + theme, s, size, color, children, +}) => (theme.surface === "paper" ? ( + +) : ( +
{children}
+)); + +const StatBody: React.FC> = ({ card, theme, s, f, fps }) => { + const next = planner(theme); + const eyebrowAt = card.eyebrow ? next("label") : 0; + const figureAt = next("figure", card.value); + const captionAt = next("strip"); + return ( + <> + + +
+ +
+
+ {card.caption && {card.caption}} + + ); +}; + +const HeadlineBody: React.FC> = ({ card, theme, s, f, fps }) => { + const next = planner(theme); + const eyebrowAt = card.eyebrow ? next("label") : 0; + const leadAt = next("title", card.lead); + const markAt = leadAt + roleFrames(theme, "title", card.lead) + 2; + const subAt = next("strip"); + const mark = Array.isArray(card.emphasis) ? card.emphasis[0] : card.emphasis; + return ( + <> + + +
+ +
+
+ {card.sub && {card.sub}} + + ); +}; + +const EntityBody: React.FC> = ({ card, theme, s, f, fps }) => { + const next = planner(theme); + const mediaAt = card.src ? next("media") : 0; + const nameAt = next("figure", card.name); + const noteAt = next("strip"); + return ( +
+ {card.src && ( + + + + )} +
+ + +
+ +
+
+ {card.note && {card.note}} +
+
+ ); +}; + +const BulletsBody: React.FC> = ({ card, theme, s, f, fps }) => { + const next = planner(theme); + const eyebrowAt = card.eyebrow ? next("title", card.eyebrow) : 0; + const items = card.items.slice(0, 3); + const ats = items.map(() => next("item")); + return ( + <> + + {items.map((item, i) => ( + +
+ {i + 1} + + + +
+
+ ))} + + ); +}; + +const QuoteBody: React.FC> = ({ card, theme, s, f, fps }) => { + const next = planner(theme); + const textAt = next("title", card.text); + const byAt = next("label"); + return ( + <> + +
+ “ + +
+
+ {card.attribution && {card.attribution}} + + ); +}; + +const CompareBody: React.FC> = ({ card, theme, s, f, fps }) => { + const next = planner(theme); + const eyebrowAt = card.eyebrow ? next("label") : 0; + const rows = card.rows.slice(0, 3); + const ats = rows.map(() => next("item")); + const top = Math.max(...rows.map((row) => row.value), 1); + return ( + <> + + {rows.map((row, i) => ( + +
{row.label}
+
+
+ +
+
{row.display ?? String(row.value)}
+
+
+ ))} + + ); +}; + +const ChangeBody: React.FC> = ({ card, theme, s, f, fps }) => { + const next = planner(theme); + const title = card.eyebrow ?? card.label; + const eyebrowAt = title ? next("label") : 0; + const fromAt = next("item"); + const toAt = next("figure", card.to.value) + 4; + return ( + <> + +
+ +
{card.from.value}
+
+ +
→
+
+ +
+ +
+
+
+ + ); +}; + +const ShareBody: React.FC> = ({ card, theme, s, f, fps }) => { + const share = Math.max(0, Math.min(1, card.value)); + const display = card.display ?? `${Math.round(share * 100)}%`; + const next = planner(theme); + const eyebrowAt = card.eyebrow ? next("label") : 0; + const figureAt = next("figure", display); + const barAt = next("item"); + const fill = progress(theme.motion, f, barAt, Math.max(12, theme.motion.frames * 2)); + return ( + <> + + +
+ +
+
+ + +
+ + {card.caption &&
{card.caption}
} + + + ); +}; + +const Body: React.FC<{ card: Card; theme: Theme; s: number; f: number; fps: number }> = ({ card, ...rest }) => { + if (card.kind === "stat") return ; + if (card.kind === "headline") return ; + if (card.kind === "entity") return ; + if (card.kind === "bullets") return ; + if (card.kind === "quote") return ; + if (card.kind === "compare") return ; + if (card.kind === "change") return ; + if (card.kind === "share") return ; + return null; +}; + +export const OverlayCard: React.FC<{ + card: Card; theme: Theme; s: number; start: number; length: number; top: number; +}> = ({ card, theme, s, start, length, top }) => { + const fitted = paced(theme, card, length); + const k = tempo(fitted, card, length); + const f = Math.floor((useCurrentFrame() - start) * k); + const { fps } = useVideoConfig(); + const out = interpolate(f, [length * k - EXIT_FRAMES * k, length * k], [1, 0], { + extrapolateLeft: "clamp", extrapolateRight: "clamp", + }); + const panelIn = progress(fitted.motion, f, 0, 6); + const paper = fitted.surface === "paper"; + const inner = ( +
+ +
+ ); + return ( +
+ {paper ? ( + {inner} + ) : ( +
{inner}
+ )} +
+ ); +}; diff --git a/remotion/src/style/pages.tsx b/remotion/src/style/pages.tsx index 86de58dc..8878a8da 100644 --- a/remotion/src/style/pages.tsx +++ b/remotion/src/style/pages.tsx @@ -22,7 +22,7 @@ type PageProps = { k: number; }; -const planner = (theme: Theme, start = 2) => { +export const planner = (theme: Theme, start = 2) => { let cursor = start; return (role: Role, text?: string) => { const at = cursor; @@ -33,7 +33,7 @@ const planner = (theme: Theme, start = 2) => { }; const CONTENT_TOP = 190; -const EXIT_FRAMES = 8; +export const EXIT_FRAMES = 8; const CONTENT_BOTTOM = 1250; const Page: React.FC<{ @@ -77,7 +77,7 @@ const Page: React.FC<{ ); }; -const Circle: React.FC<{ theme: Theme; f: number; at: number; s: number; inset: number }> = ({ +export const Circle: React.FC<{ theme: Theme; f: number; at: number; s: number; inset: number }> = ({ theme, f, at, s, inset, }) => { const p = progress(theme.motion, f, at, 10); @@ -102,16 +102,16 @@ const figureSize = (value: string) => { return length > 9 ? 150 : length > 6 ? 200 : 280; }; -const tilt = (theme: Theme, k: number) => ({ transform: `rotate(${theme.tilt * k}deg)` }); +export const tilt = (theme: Theme, k: number) => ({ transform: `rotate(${theme.tilt * k}deg)` }); -const titleSize = (text: string, base: number) => { +export const titleSize = (text: string, base: number) => { const length = Array.from(text).length; if (length > 22) return base * 0.66; if (length > 14) return base * 0.8; return base; }; -const Bar: React.FC<{ theme: Theme; seed: number; s: number; color: string; height: number; children?: React.ReactNode }> = ({ +export const Bar: React.FC<{ theme: Theme; seed: number; s: number; color: string; height: number; children?: React.ReactNode }> = ({ theme, seed, s, color, height, children, }) => (theme.surface === "none" ?
{children}
diff --git a/remotion/src/style/styled.tsx b/remotion/src/style/styled.tsx index 2412ecfe..9d12880f 100644 --- a/remotion/src/style/styled.tsx +++ b/remotion/src/style/styled.tsx @@ -10,6 +10,7 @@ import type { Theme } from "./theme"; import { Label, Paper } from "./materials"; import { progress, Reveal } from "./reveal"; import { TakeoverPage } from "./pages"; +import { OVERLAY_KINDS, OverlayCard } from "./overlay"; export const StyledCards: React.FC<{ cards: Card[]; theme: Theme; topInset?: number }> = ({ cards, theme, topInset = 0 }) => { const frame = useCurrentFrame(); @@ -17,6 +18,12 @@ export const StyledCards: React.FC<{ cards: Card[]; theme: Theme; topInset?: num const card = cardAt(cards.filter((c) => theme.layout[c.kind] === "takeover"), frame / fps); if (!card) return null; const start = Math.round(card.start * fps); + if (theme.placement === "overlay" && OVERLAY_KINDS.has(card.kind)) { + return ( + + ); + } return ( diff --git a/remotion/src/style/theme.test.ts b/remotion/src/style/theme.test.ts index cc49a76f..9a47e33d 100644 --- a/remotion/src/style/theme.test.ts +++ b/remotion/src/style/theme.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { ENTRANCES, FONT_IDS, MOTIONS, PACKS, ROLES, resolveTheme } from "./theme"; +import { ENTRANCES, FONT_IDS, MOTIONS, PACKS, PLACEMENTS, ROLES, resolveTheme } from "./theme"; describe("resolveTheme", () => { it("returns null without a style", () => { @@ -90,3 +90,20 @@ describe("fonts picked to match a reference", () => { } }); }); + +describe("card placement", () => { + it("draws cards as full pages unless a style asks otherwise", () => { + for (const pack of Object.keys(PACKS) as (keyof typeof PACKS)[]) { + expect(resolveTheme({ pack })?.placement).toBe("page"); + } + }); + + it("sets cards over the video when the style asks", () => { + expect(resolveTheme({ pack: "swiss", placement: "overlay" })?.placement).toBe("overlay"); + }); + + it("ignores a placement the renderer does not know", () => { + const theme = resolveTheme({ pack: "poster", placement: "corner" as (typeof PLACEMENTS)[number] }); + expect(theme?.placement).toBe("page"); + }); +}); diff --git a/remotion/src/style/theme.ts b/remotion/src/style/theme.ts index 0a237e85..705abc81 100644 --- a/remotion/src/style/theme.ts +++ b/remotion/src/style/theme.ts @@ -2,6 +2,8 @@ import type React from "react"; export type EdgeKind = "straight" | "torn"; export type Layout = "takeover"; +export type Placement = "page" | "overlay"; +export const PLACEMENTS: Placement[] = ["page", "overlay"]; export type Role = "title" | "figure" | "item" | "media" | "label" | "strip" | "caption" | "name"; export type Entrance = | "type" | "scramble" | "wipe" | "wipe-up" | "pop" | "rise" | "fade" | "spring" @@ -75,6 +77,7 @@ export interface Theme { tilt: number; motion: Motion; layout: Record; + placement: Placement; textures: { paper?: string; kraft?: string; tape?: string[]; halftone?: Record }; } @@ -205,6 +208,7 @@ export interface ThemeInput { pack: PackId; motion?: MotionId; fonts?: { display?: FontId; label?: FontId }; + placement?: Placement; overrides?: DeepPartial; textures?: Theme["textures"]; } @@ -254,6 +258,7 @@ const collage: Theme = { tilt: 1.6, motion: MOTIONS.pinned, layout: TAKEOVER, + placement: "page", textures: {}, }; @@ -474,7 +479,7 @@ export const resolveTheme = (input?: ThemeInput | null): Theme | null => { if (!input) return null; const pack: Theme = withFonts(PACKS[input.pack] ?? PACKS.collage, input.fonts); const base = input.motion ? { ...pack, motion: MOTIONS[input.motion] ?? pack.motion } : pack; - const merged = deepMerge(base, input.overrides ?? {}); + const merged = { ...deepMerge(base, input.overrides ?? {}), placement: PLACEMENTS.includes(input.placement as Placement) ? input.placement as Placement : base.placement }; return { ...merged, textures: { ...merged.textures, ...(input.textures ?? {}) } }; }; From 91bfa9ba33869a4d9a15cda4496aaa6d8516b359 Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Sat, 3 Oct 2026 12:53:46 +0400 Subject: [PATCH 2/2] Wait for entity images, show change notes, honour placement in overrides --- remotion/src/style/overlay.tsx | 6 ++++-- remotion/src/style/theme.test.ts | 4 ++++ remotion/src/style/theme.ts | 4 +++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/remotion/src/style/overlay.tsx b/remotion/src/style/overlay.tsx index 5c41382e..9f455ec0 100644 --- a/remotion/src/style/overlay.tsx +++ b/remotion/src/style/overlay.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { interpolate, useCurrentFrame, useVideoConfig } from "remotion"; +import { Img, interpolate, useCurrentFrame, useVideoConfig } from "remotion"; import type { Card } from "../cards"; import { fontOf } from "./theme"; import type { Theme } from "./theme"; @@ -84,7 +84,7 @@ const EntityBody: React.FC> = ({ card, theme, s, f, fps }) => {
{card.src && ( - + )}
@@ -175,6 +175,7 @@ const ChangeBody: React.FC> = ({ card, theme, s, f, fps }) => {
{card.from.value}
+ {card.from.note &&
{card.from.note}
}
→
@@ -183,6 +184,7 @@ const ChangeBody: React.FC> = ({ card, theme, s, f, fps }) => {
+ {card.to.note &&
{card.to.note}
}
diff --git a/remotion/src/style/theme.test.ts b/remotion/src/style/theme.test.ts index 9a47e33d..036a6bcb 100644 --- a/remotion/src/style/theme.test.ts +++ b/remotion/src/style/theme.test.ts @@ -102,6 +102,10 @@ describe("card placement", () => { expect(resolveTheme({ pack: "swiss", placement: "overlay" })?.placement).toBe("overlay"); }); + it("takes a placement given through overrides", () => { + expect(resolveTheme({ pack: "swiss", overrides: { placement: "overlay" } })?.placement).toBe("overlay"); + }); + it("ignores a placement the renderer does not know", () => { const theme = resolveTheme({ pack: "poster", placement: "corner" as (typeof PLACEMENTS)[number] }); expect(theme?.placement).toBe("page"); diff --git a/remotion/src/style/theme.ts b/remotion/src/style/theme.ts index 705abc81..274b47b0 100644 --- a/remotion/src/style/theme.ts +++ b/remotion/src/style/theme.ts @@ -479,7 +479,9 @@ export const resolveTheme = (input?: ThemeInput | null): Theme | null => { if (!input) return null; const pack: Theme = withFonts(PACKS[input.pack] ?? PACKS.collage, input.fonts); const base = input.motion ? { ...pack, motion: MOTIONS[input.motion] ?? pack.motion } : pack; - const merged = { ...deepMerge(base, input.overrides ?? {}), placement: PLACEMENTS.includes(input.placement as Placement) ? input.placement as Placement : base.placement }; + const overridden = deepMerge(base, input.overrides ?? {}); + const wanted = input.placement ?? overridden.placement; + const merged = { ...overridden, placement: PLACEMENTS.includes(wanted) ? wanted : base.placement }; return { ...merged, textures: { ...merged.textures, ...(input.textures ?? {}) } }; };