Compositions
A video is a folder: brief.md, storyboard.md, video.json, composition/ (index.html, main.js,
styles.css) and audio/. npx demovie new <slug> --type … --style … scaffolds one. The composition is a web
page: index.html loads /__demovie/clock.js (a virtual clock) and the runtime CSS, and main.js imports the
runtime, builds a GSAP timeline and calls v.ready(). The renderer seeks it frame by frame.
Served paths: /__demovie/* (runtime, clock, CSS, GSAP, fonts, SFX), /brand/*, /captures/*, /assets/*,
/audio/* (the video's audio folder), /video.json and /glossary.json; composition-relative files resolve
normally and .ts files are transpiled on the fly. Nothing else is reachable: every other request is blocked.
Rules that keep videos true and renderable:
- Product UI only through
screen()with a capture id; targets are element ids from the capture'selements.json. - Everything is a pure function of time: tweens on
v.timelineor the helpers,v.css()for CSS animations,v.onSeek()for canvas,v.random(seed)instead ofMath.random, no timers afterready(). - Declare every shot with
v.shot(id, start, end, { kind }); mark the CTA withdata-dm-cta.
npx demovie preview <slug> serves a player with scrubbing, frame stepping, safe-area and grid guides, a QA overlay
and hot reload. See styles for the presets and the reference compositions in examples/compositions.
Runtime API#
Compositions import the runtime as an ES module served by demovie: import { createVideo, screen, … } from "/__demovie/runtime.js".
index.html must load /__demovie/clock.js first (the virtual clock) and /__demovie/runtime.css.
Everything visible must be a pure function of time: build tweens on v.timeline (or with these helpers) and call v.ready() last.
Functions and values#
cursor#
A truthful cursor: every target comes from an element map, and it follows the screen's camera (SPEC §10.3).
export function cursor(v: Video, o: {
style?: "mac" | "pointer" | "dot";
size?: number;
} = {}): CursorHandle;Example:
const pointer = cursor(v, { style: "mac" });
pointer.moveTo(app, "button:new-project", { at: 8.9, duration: 0.85 });
pointer.click({ at: 9.85 }); // QA DM-G01 checks the click lands inside the elementcallout#
A labelled pointer next to a real element (stage overlay; follows the camera).
export function callout(s: ScreenHandle, elementId: string, o: {
label: string;
at: number;
duration: number;
side?: "top" | "right" | "bottom" | "left";
}): void;Example:
callout(app, "generic:project-checklist", { label: "Checklist", at: 15.5, duration: 2 });captions#
Burned-in captions from the voice manifest (SPEC §11.5). Only burns in for formats in video.json captions.burnIn.
export function captions(v: Video, o: {
style?: "clean" | "bold" | "karaoke";
position?: "bottom" | "top";
} = {}): void;Example:
captions(v, { style: "karaoke" }); // burns in for formats in video.json captions.burnInlogo#
The brand logo (wordmark or mark) with a soft entrance at at.
export function logo(v: Video, o: {
at: number;
variant?: "mark" | "wordmark";
parent?: HTMLElement;
}): HTMLElement;Example:
logo(v, { at: 30.55, variant: "wordmark", parent: card.querySelector(".end-logo") });text#
const text: {
/** Staggered reveal of a text element by char, word or line. */
reveal(target: HTMLElement, o: { at: number; by?: "char" | "word" | "line"; stagger?: number; duration?: number; ease?: string; from?: RevealFrom; }): void;
/** Count from → to. Values must come from the brief, seed data or captures (QA DM-G04 checks the final value). */
counter(target: HTMLElement, o: { at: number; from: number; to: number; duration: number; format?: NumberFormatOptions; }): void;
};Example:
text.reveal(h1, { at: 0.3, by: "word", stagger: 0.06, duration: 0.8, ease: "expo.out" });
text.counter(el, { at: 2, from: 0, to: 9, duration: 1.2 }); // numbers must exist in brief/seed/capturestransition#
Transitions between Shot containers (SPEC §10.3).
const transition: {
crossfade(a: Shot, b: Shot, o: { at: number; duration?: number; }): void;
wipe(a: Shot, b: Shot, o: { at: number; duration?: number; direction?: "left" | "right" | "up" | "down"; }): void;
push(a: Shot, b: Shot, o: { at: number; duration?: number; direction?: "left" | "right" | "up" | "down"; }): void;
zoomThrough(a: Shot, b: Shot, o: { at: number; duration?: number; target?: Rect; }): void;
colorFlash(a: Shot, b: Shot, o: { at: number; duration?: number; color?: string; }): void;
};Example:
transition.crossfade(intro, product, { at: 3.1, duration: 0.5 });
transition.push(product, dashboard, { at: 20.1, duration: 0.6, direction: "left" });typeText#
Overlay typed text in the input's captured font and box (SPEC §10.3).
export function typeText(s: ScreenHandle, elementId: string, text: string, o: {
at: number;
cps?: number;
caret?: boolean;
}): void;Example:
typeText(app, "textbox:project-name", "Q4 Launch", { at: 11.45, cps: 14 });inspect#
Registries plus visible text/layout at the current t (SPEC §12).
export function inspect(): InspectResult;Example:
// QA calls this; agents rarely need it directly
const state = window.__DEMOVIE__.inspect(); // texts, shots, screens, cursor, clicks, fonts…screen#
Show a real capture inside a CSS-drawn device frame (SPEC §10.3).
export function screen(v: Video, o: ScreenOptions): ScreenHandle;Example:
const app = screen(v, { capture: "routes/app-projects@desktop", parent: shot.el, device: "browser" });
app.focus("link:q3-launch", { at: 4.8, duration: 1.2, scale: 1.4 }); // ids come from elements.json
app.highlight("link:q3-launch", { at: 6.6, duration: 1.6, style: "ring" });
app.swap("flows/create-project@desktop/created", { at: 13.2, transition: "crossfade" });createVideo#
Create the video: load video.json/brand/glossary/beats, size the stage, register fonts, preload captures.
export function createVideo(opts: {
stage?: HTMLElement;
} = {}): Promise<Video>;Example:
const v = await createVideo(); // reads video.json, brand, glossary, beats; sizes the stage
const intro = v.shot("intro", 0, 3.4, { kind: "title" });
// … build the timeline …
v.ready(); // always lastFORMATS#
const FORMATS: Record<FormatId, { width: number; height: number; safe: Insets; }>;Example:
const { width, height, safe } = FORMATS["9:16"]; // 1080×1920, safe-area insets in %RUNTIME_VERSION#
const RUNTIME_VERSION: "0.1.0";Example:
console.info(`demovie runtime ${RUNTIME_VERSION}`);registerVideoElement#
Register a
export function registerVideoElement(video: HTMLVideoElement, start = 0): void;Example:
// for <video> assets imported with `demovie add`: keep playback a pure function of t
registerVideoElement(videoEl, 4); // the clip's t = 0 is the video's 4 sTypes#
CursorHandle#
interface CursorHandle {
moveTo(
s: ScreenHandle,
elementId: string,
o: { at: number; duration?: number; ease?: string; anchor?: "center" | [number, number] },
): void;
click(o: { at: number; ripple?: boolean }): void;
show(o: { at: number }): void;
hide(o: { at: number }): void;
}Device#
type Device = "browser" | "laptop" | "phone" | "tablet" | "none";ScreenHandle#
interface ScreenHandle {
id: string;
el: HTMLElement;
capture: CaptureState;
/**
* Rect of an element in stage px at the screen's base transform (no camera, no user tweens). With several ids, the
* box around all of them, e.g. a column heading and its last card.
*/
rect(...elementIds: [string, ...string[]]): Rect;
focus(
target: string | Rect,
o: { at: number; duration?: number; scale?: number; padding?: number; ease?: string },
): void;
reset(o: { at: number; duration?: number; ease?: string }): void;
/**
* Ring, glow or spotlight an element — or a Rect in stage px at the base transform (the space of rect()), e.g. a row
* framed from two element rects when the row itself has no element.
*/
highlight(target: string | Rect, o: { at: number; duration?: number; style?: "ring" | "glow" | "dim-others" }): void;
swap(
capture: string,
o: { at: number; duration?: number; transition?: "cut" | "crossfade" | "slide-left" | "slide-up" },
): void;
scroll(to: number | string, o: { at: number; duration?: number; ease?: string }): void;
}ScreenOptions#
interface ScreenOptions {
capture: string;
parent?: HTMLElement;
device?: Device;
width?: number;
image?: "screen" | "full";
shadow?: boolean;
tilt?: { x: number; y: number };
/** Top-left position in stage px (default: centered in the safe area). */
x?: number;
y?: number;
}Beats#
interface Beats {
bpm: number;
offset: number;
beats: number[];
bars: number[];
sections: { name: string; start: number; end: number }[];
}Brand#
interface Brand {
name: string;
tagline: string | null;
url: string | null;
colors: { light: BrandColors; dark: BrandColors | null };
fonts: { heading: BrandFont | null; body: BrandFont | null; mono: BrandFont | null };
radius: { sm: string | null; md: string | null; lg: string | null };
shadows: string[];
logo: { mark: string | null; wordmark: string | null; onDark: string | null };
}CaptureElement#
One captured element (subset of elements.json).
interface CaptureElement {
id: string;
role: string;
name: string;
tag: string;
text?: string;
bbox: Rect;
visible: boolean;
interactive: boolean;
style: {
fontFamily: string;
fontSize: number;
fontWeight: number;
color: string;
background: string | null;
radius: number;
lineHeight?: number;
paddingLeft?: number;
paddingTop?: number;
};
value?: string;
placeholder?: string;
}CaptureState#
A loaded capture state (SPEC §6.5).
interface CaptureState {
id: string;
url: string;
path: string;
title: string | null;
viewport: { width: number; height: number; deviceScaleFactor: number };
dpr: number;
image: { width: number; height: number };
fullPage: { width: number; height: number } | null;
/** The color scheme the page was captured in (phone frames match their status bar to it). */
colorScheme: "light" | "dark";
elements: CaptureElement[];
screenUrl: string;
fullUrl: string | null;
}DemovieHandle#
interface DemovieHandle {
version: string;
ready: boolean;
error?: string;
meta: { fps: number; duration: number; width: number; height: number; format: FormatId; scale: number };
seek(t: number): Promise<void>;
inspect(): InspectResult;
}FormatId#
type FormatId = "16:9" | "9:16" | "1:1" | "4:5";Glossary#
interface Glossary {
productName: string;
tagline: string | null;
features: { term: string; source: string | null }[];
uiLabels: string[];
entities: string[];
people: string[];
ctaUrl: string | null;
avoid: string[];
}Insets#
interface Insets {
top: number;
right: number;
bottom: number;
left: number;
}InspectResult#
interface InspectResult {
t: number;
format: FormatId;
width: number;
height: number;
safe: Insets;
texts: TextBox[];
shots: {
id: string;
start: number;
end: number;
kind: ShotKind;
active: boolean;
screens: number;
window: [number, number];
}[];
screens: {
id: string;
capture: string;
captures: string[];
bbox: Rect;
visible: boolean;
cameraScale: number;
/** Image pixels per output pixel; > 1 means upscaled (DM-G05). */
upscale: number;
}[];
cursor: { visible: boolean; x: number; y: number }[];
clicks: { at: number; screen: string; elementId: string; point: { x: number; y: number }; rect: Rect | null }[];
highlights: { screen: string; elementId: string; rect: Rect }[];
logos: { bbox: Rect; visible: boolean }[];
animations: { registered: number; unregistered: { name: string; target: string }[] };
strayTweens: { targets: string; duration: number }[];
timersAfterReady: number;
console: { level: string; text: string }[];
fonts: { declared: string[]; loaded: string[]; failed: string[] };
images: { broken: string[] };
transitions: { at: number; end: number; kind: string }[];
}Rect#
interface Rect {
x: number;
y: number;
width: number;
height: number;
}Shot#
interface Shot {
id: string;
start: number;
end: number;
kind: ShotKind;
el: HTMLElement;
}ShotKind#
type ShotKind = "product" | "title" | "text" | "logo" | "other";TextBox#
interface TextBox {
id: string;
text: string;
words: number;
bbox: Rect;
fontSize: number;
fontFamily: string;
fontWeight: number;
color: string;
opacity: number;
clipped: boolean;
overflowing: boolean;
ui: boolean;
caption: boolean;
counter: boolean;
role: "text" | "logo" | "cta";
}Video#
interface Video {
meta: VideoJson;
format: FormatId;
width: number;
height: number;
fps: number;
duration: number;
scale: number;
stage: HTMLElement;
safe: Insets;
unit: number;
brand: Brand;
glossary: Glossary | null;
beats?: Beats;
voice?: VoiceManifest;
gsap: Gsap;
timeline: Timeline;
shot(id: string, start: number, end: number, o?: { kind?: ShotKind; el?: HTMLElement }): Shot;
css(
el: Element,
keyframes: Keyframe[],
o: { start: number; duration: number; easing?: string; fill?: FillMode },
): void;
onSeek(fn: (t: number) => void): void;
random(seed: string | number): () => number;
beat(n: number): number;
bar(n: number): number;
ready(): void;
}VideoJson#
video.json as the runtime sees it (SPEC §6.6).
interface VideoJson {
slug: string;
title: string;
type: string;
fps: number;
duration: number;
formats: FormatId[];
style: string;
poster?: number | null;
status?: string;
audio?: {
music?: { src: string; gain?: number; beats?: string | null } | null;
voice?: { manifest: string } | null;
sfx?: { name: string; at: number; gain?: number }[];
};
captions?: { enabled?: boolean; burnIn?: FormatId[] };
captures?: string[];
qa?: { ignore?: string[] };
}VoiceManifest#
interface VoiceManifest {
provider: string;
voice: string;
lines: {
id: string;
text: string;
start: number;
duration: number;
file: string;
words: WordTiming[];
estimated?: boolean;
}[];
}