Libraries
Libraries
Story
The storyboard's JSON schema and scene-length rules, as pure functions shared by `storyboard` and every template.
Install
Usage
import { storyFrames } from "./story";
// 5 s of text + 3 s of image, minus a 15-frame fade: 225 frames at 30 fps.
storyFrames({
scenes: [
{ type: "text", text: "Ten words take a viewer about four seconds to read" },
{ type: "image", src: "/shot.png" },
],
});Dependencies
zod
Source
registry/items/story.ts
/**
* @title Story
* @category lib
* @description The storyboard's JSON schema and scene-length rules, as pure functions shared by `storyboard` and every template.
* @tags storyboard, schema, duration, zod
* @example
* import { storyFrames } from "./story";
* // 5 s of text + 3 s of image, minus a 15-frame fade: 225 frames at 30 fps.
* storyFrames({
* scenes: [
* { type: "text", text: "Ten words take a viewer about four seconds to read" },
* { type: "image", src: "/shot.png" },
* ],
* });
*/
import { z } from "zod";
import type { CodeLanguage } from "./code-tokens";
/** Languages the code scene can color. */
export const LANGUAGES = ["ts", "js", "tsx", "py", "bash", "json"] as const satisfies readonly CodeLanguage[];
/** Frames per second when a story sets none. Every template uses it. */
export const STORY_FPS = 30;
/** Frames each transition overlaps its two scenes by, unless the story sets `defaults.transitionFrames`. */
export const TRANSITION_FRAMES = 15;
/** Transition used between scenes that name none. */
export const DEFAULT_TRANSITION = "fade";
/** Characters per second the `code` scene types at. */
export const CODE_CPS = 40;
const common = {
/** Seconds. Wins over the scene's natural length. */
duration: z.number().positive().optional(),
/** Transition into the next scene, or "none" for a hard cut. */
transition: z.string().optional(),
/** Sound effect at the start of the scene, or false for silence. */
sfx: z.union([z.string(), z.literal(false)]).optional(),
/** A background item name, drawn behind the scene. */
background: z.string().optional(),
};
const src = z.string().min(1);
const datum = z.object({ label: z.string(), value: z.number() });
const titleScene = z.object({
type: z.literal("title"),
...common,
title: z.string(),
subtitle: z.string().optional(),
kicker: z.string().optional(),
});
const textScene = z.object({
type: z.literal("text"),
...common,
text: z.string(),
accent: z.array(z.string()).optional(),
});
const bulletsScene = z.object({
type: z.literal("bullets"),
...common,
title: z.string().optional(),
items: z.array(z.object({ text: z.string(), detail: z.string().optional(), badge: z.string().optional() })).min(1),
});
const imageScene = z.object({
type: z.literal("image"),
...common,
src,
caption: z.string().optional(),
zoom: z.boolean().optional(),
});
const videoScene = z.object({
type: z.literal("video"),
...common,
src,
/** Seconds into the file where the scene starts. */
trimStart: z.number().min(0).optional(),
/** Seconds into the file where the scene ends. */
trimEnd: z.number().positive().optional(),
muted: z.boolean().optional(),
});
const deviceScene = z.object({
type: z.literal("device"),
...common,
device: z.enum(["phone", "laptop", "browser"]),
/** Screenshot or screen recording. A themed placeholder screen is drawn when omitted. */
src: src.optional(),
/** Address shown by the `browser` device. */
url: z.string().optional(),
});
const codeScene = z.object({
type: z.literal("code"),
...common,
code: z.string(),
language: z.enum(LANGUAGES).optional(),
title: z.string().optional(),
highlight: z.array(z.number().int().positive()).optional(),
});
const terminalScene = z.object({
type: z.literal("terminal"),
...common,
lines: z.array(z.object({ type: z.enum(["command", "output"]), text: z.string() })).min(1),
title: z.string().optional(),
});
const chartScene = z.object({
type: z.literal("chart"),
...common,
kind: z.enum(["bar", "line", "donut"]),
title: z.string().optional(),
data: z.array(datum).min(1),
});
const statScene = z.object({
type: z.literal("stat"),
...common,
label: z.string(),
value: z.number(),
prefix: z.string().optional(),
suffix: z.string().optional(),
delta: z.number().optional(),
caption: z.string().optional(),
});
const quoteScene = z.object({
type: z.literal("quote"),
...common,
quote: z.string(),
name: z.string(),
role: z.string().optional(),
avatar: src.optional(),
});
const postScene = z.object({
type: z.literal("post"),
...common,
name: z.string(),
handle: z.string().optional(),
text: z.string(),
avatar: src.optional(),
metrics: z
.object({ likes: z.number().optional(), comments: z.number().optional(), shares: z.number().optional() })
.optional(),
});
const ctaScene = z.object({
type: z.literal("cta"),
...common,
title: z.string(),
button: z.string().optional(),
url: z.string().optional(),
});
const logoScene = z.object({ type: z.literal("logo"), ...common, text: z.string(), src: src.optional() });
const leafScenes = [
titleScene,
textScene,
bulletsScene,
imageScene,
videoScene,
deviceScene,
codeScene,
terminalScene,
chartScene,
statScene,
quoteScene,
postScene,
ctaScene,
logoScene,
] as const;
const leafSceneSchema = z.discriminatedUnion("type", leafScenes);
const splitScene = z.object({
type: z.literal("split"),
...common,
left: leafSceneSchema,
right: leafSceneSchema,
labels: z.array(z.string()).max(2).optional(),
});
const sceneSchemas = [...leafScenes, splitScene] as const;
/** One built-in scene. */
export const sceneSchema = z.discriminatedUnion("type", sceneSchemas);
/** Each scene's schema by its `type`, so a story is checked one scene at a time. */
const SCENE_SCHEMAS: Record<string, z.ZodObject> = {};
for (const schema of sceneSchemas) SCENE_SCHEMAS[schema.shape.type.value] = schema;
/** A word-level caption, as `@remotion/captions` and `reelcn-transcribe` write it. */
export const captionSchema = z.object({
text: z.string(),
startMs: z.number(),
endMs: z.number(),
timestampMs: z.number().nullable(),
confidence: z.number().nullable(),
});
export const brandSchema = z.object({
/** Replaces the theme accent. */
accent: z.string().optional(),
/** Logo image; logo scenes show it. */
logo: z.string().optional(),
/** Heading font family. Load it yourself (see the theming guide). */
font: z.string().optional(),
});
/** The story's own fields. Scenes are checked one at a time by `makeStorySchema`, so errors name the scene. */
export const storyShape = z.object({
theme: z.string().optional(),
brand: brandSchema.optional(),
fps: z.number().int().positive().optional(),
audio: z
.object({
music: z.string().optional(),
musicVolume: z.number().min(0).max(1).optional(),
voiceover: z.string().optional(),
/** Captions, or the URL of a JSON file holding them. */
captions: z.union([z.string(), z.array(captionSchema)]).optional(),
})
.optional(),
defaults: z
.object({
transition: z.string().optional(),
transitionFrames: z.number().int().min(0).optional(),
sfx: z.boolean().optional(),
})
.optional(),
scenes: z.array(z.looseObject({ type: z.string() })).min(1),
});
export type Scene = z.infer<typeof sceneSchema>;
export type SceneType = Scene["type"];
export type SceneOf<T extends SceneType> = Extract<Scene, { type: T }>;
/** A scene of a type registered with `defineScene`. */
export type CustomScene = {
type: string;
duration?: number;
transition?: string;
sfx?: string | false;
background?: string;
[field: string]: unknown;
};
/** A story as JSON: what `storySchema` parses and `--props=story.json` passes. */
export type StoryJson = z.infer<typeof storyShape>;
export type Story = Omit<StoryJson, "scenes"> & { scenes: (Scene | CustomScene)[] };
/** The names a story may use. `storyboard` passes the real lists; tests pass their own. */
export type StoryNames = {
themes: readonly string[];
transitions: readonly string[];
backgrounds: readonly string[];
sfx: readonly string[];
};
/** What the rules need to know about a custom scene type. `defineScene` definitions fit this shape. */
export type CustomSceneRule = {
type: string;
/** The scene's own fields; `type` and the shared fields are added. */
schema: z.ZodObject;
/** Seconds, when the scene sets no `duration`. */
// biome-ignore lint/suspicious/noExplicitAny: each custom scene has its own fields.
duration: (scene: any) => number;
};
/** The story schema for a set of names: unknown names and malformed scenes fail with a path like `scenes.2.title`. */
export function makeStorySchema(names: StoryNames, custom: CustomSceneRule[] = []) {
return storyShape.superRefine((story, ctx) => {
const check = (value: unknown, list: readonly string[], what: string, path: PropertyKey[]) => {
if (typeof value === "string" && list.indexOf(value) < 0) {
ctx.addIssue({
code: "custom",
path,
message: `unknown ${what} "${value}"; expected one of ${list.join(", ")}`,
});
}
};
const checkNames = (scene: Record<string, unknown>, path: PropertyKey[]) => {
check(scene.transition, names.transitions.concat("none"), "transition", path.concat("transition"));
check(scene.sfx, names.sfx, "sound effect", path.concat("sfx"));
check(scene.background, names.backgrounds, "background", path.concat("background"));
};
check(story.theme, names.themes, "theme", ["theme"]);
check(story.defaults?.transition, names.transitions, "transition", ["defaults", "transition"]);
story.scenes.forEach((scene, index) => {
const path: PropertyKey[] = ["scenes", index];
let rule: CustomSceneRule | undefined;
for (const candidate of custom) if (candidate.type === scene.type) rule = candidate;
const schema = rule
? rule.schema.extend({ type: z.literal(rule.type), ...common })
: // biome-ignore lint/suspicious/noPrototypeBuiltins: Object.hasOwn is ES2022; registry items target ES2015.
Object.prototype.hasOwnProperty.call(SCENE_SCHEMAS, scene.type)
? SCENE_SCHEMAS[scene.type]
: undefined;
if (!schema) {
const known = Object.keys(SCENE_SCHEMAS).concat(custom.map((r) => r.type));
ctx.addIssue({
code: "custom",
path: path.concat("type"),
message: `unknown scene type "${scene.type}"; expected one of ${known.join(", ")}`,
});
return;
}
const result = schema.safeParse(scene);
if (!result.success) {
for (const issue of result.error.issues) {
ctx.addIssue({ code: "custom", path: path.concat(issue.path), message: issue.message });
}
return;
}
checkNames(scene, path);
if (scene.type === "split") {
checkNames(scene.left as Record<string, unknown>, path.concat("left"));
checkNames(scene.right as Record<string, unknown>, path.concat("right"));
}
});
});
}
const words = (text: string) => text.split(/\s+/).filter(Boolean).length;
/** Seconds to read some text: 2.5 words a second plus one, never under 2 (spec §10.3). */
export const readingSeconds = (...texts: (string | undefined)[]) => Math.max(2, words(texts.join(" ")) / 2.5 + 1);
/**
* Seconds the `terminal` item takes to type these lines with its defaults: 32 characters a second,
* 0.4 s before and 0.3 s after each command, 3 frames (0.1 s) per output line.
*/
export const typingSeconds = (lines: { type: string; text: string }[]) =>
lines.reduce((total, line) => total + (line.type === "command" ? line.text.length / 32 + 0.7 : 0.1), 0);
export const isVideo = (src?: string) => /\.(mp4|webm|mov|m4v)([?#]|$)/i.test(src ?? "");
/** Seconds a caption track covers, plus half a second of air; `fallback` when it is empty. */
export const captionsSeconds = (captions: { endMs: number }[], fallback: number) =>
captions.length > 0 ? captions[captions.length - 1].endMs / 1000 + 0.5 : fallback;
/** A scene's length in seconds (spec §10.3). */
export function sceneSeconds(scene: Scene | CustomScene, custom: CustomSceneRule[] = []): number {
if (scene.duration) return scene.duration;
for (const rule of custom) if (rule.type === scene.type) return rule.duration(scene);
const s = scene as Scene;
switch (s.type) {
case "title":
return readingSeconds(s.title, s.subtitle, s.kicker);
case "text":
return readingSeconds(s.text);
case "bullets":
return readingSeconds(s.title, ...s.items.map((item) => `${item.text} ${item.detail ?? ""}`));
case "quote":
return readingSeconds(s.quote, s.name, s.role);
case "post":
return readingSeconds(s.name, s.text);
case "cta":
return readingSeconds(s.title, s.button, s.url);
case "logo":
return readingSeconds(s.text);
case "image":
return 3;
case "video":
return s.trimEnd !== undefined ? s.trimEnd - (s.trimStart ?? 0) : 5;
case "device":
return isVideo(s.src) ? 5 : 3;
case "chart":
case "stat":
return 4;
case "code":
return s.code.length / CODE_CPS + 1.5;
case "terminal":
return typingSeconds(s.lines) + 1.5;
case "split":
return Math.max(sceneSeconds(s.left, custom), sceneSeconds(s.right, custom));
}
}
/** Frames each scene plays, and the frames each transition overlaps the scene after it (0 for a hard cut). */
export function storyTimeline(story: Story, custom: CustomSceneRule[] = []) {
const fps = story.fps ?? STORY_FPS;
const frames = story.scenes.map((scene) => Math.round(sceneSeconds(scene, custom) * fps));
const overlaps = frames.slice(0, -1).map((length, index) => {
const name = story.scenes[index].transition ?? story.defaults?.transition ?? DEFAULT_TRANSITION;
const wanted = name === "none" ? 0 : (story.defaults?.transitionFrames ?? TRANSITION_FRAMES);
// A transition may not outlast either neighbor; half the shorter one keeps every story valid.
return Math.min(wanted, Math.floor(Math.min(length, frames[index + 1]) / 2));
});
return { frames, overlaps };
}
const sum = (list: number[]) => list.reduce((total, n) => total + n, 0);
/** Total frames of a story: every scene, minus the frames its transitions overlap. */
export function storyFrames(story: Story, custom: CustomSceneRule[] = []): number {
const { frames, overlaps } = storyTimeline(story, custom);
return sum(frames) - sum(overlaps);
}