Edit Spec v1
An edit spec is a JSON document that describes a finished edit: one base
source, a stack of overlay templates, cameras, and sound. It is the only
input an agent needs to produce a video. Zod schemas live in
src/spec/types.ts; full validation (shape + template cross-checks) is
validateSpec / parseSpec from src/spec/validate.ts.
{
"version": 1,
"format": "landscape",
"fps": 60,
"durationSec": 20,
"source": { "type": "video", "src": "your-footage.mp4" },
"overlays": [
{
"template": "speaker-card",
"region": "lower-third",
"time": { "start": "2s", "duration": "9s", "appear": 0.7 },
"props": { "name": "Ana", "role": "Founder", "photo": "ana.png" }
}
]
}This page is the grammar every spec uses. Three parts of it are big enough to have their own page, and you only need them when the edit does: captions, sound cues and music.
The two axes: region and time
Natural language like "on the bottom, at 2/3 of the video, for 3s" compiles
to region: "lower-third", time: { start: "66%", duration: "3s" }.
region(space): a named region (fullscreen,top-banner,lower-third,upper-third,caption-zone,left-panel,right-panel,center,corner-tl/tr/bl/br) or a custom rect in percentages ({ "x": 31, "y": 70, "w": 38, "h": 18 }). Unset uses the template's preferred region.time(timeline): values are seconds (3or"3s"), percentages of the owner timeline ("66%"), or negative seconds from the end ("-2s"). A window is{ start, duration, appear, hold }:appearis how long the entrance choreography takes,holdis how long the finished overlay stays after that.durationabsent +holdpresent makes the windowappear + hold.
Sequence overlays with about 1s between windows so handoffs read cleanly.
Source and the source contract
source is the base layer: { type: "video", src } (options: muted,
fit, position, flipHorizontal, footage-only camera, time-windowed
reframes), { type: "audio", src }, or
{ type: "none" }.
Subject-safe source reframes
source.reframes moves the base footage into a percentage rectangle during a
time window while overlays stay locked to the composition. This is editorial
layout, not camera motion. It is intended for split screens where simply
covering half of the fullscreen source would hide a face or demonstrated
object. Reframe windows may not overlap.
{
"type": "video",
"src": "speaker.mp4",
"fit": "cover",
"position": "center 30%",
"reframes": [
{
"time": { "start": "9s", "duration": "8s" },
"region": { "x": 4, "y": 8, "w": 44, "h": 84 },
"position": "60% center",
"transitionSec": 0.3
}
]
}fit defaults to cover, position to center, and transitionSec to 0.3.
Resolve the position by inspecting the subject across the full window.
Every template declares one relationship with that base source, and validation enforces it:
| Contract | Meaning | Requires |
|---|---|---|
overlay |
Self-contained; draws over whatever the source is. Most templates. | nothing |
annotates-video |
Only makes sense over footage (captions, ticker, recording chrome). | source.type: "video" |
wraps-video |
Renders the base video inside its own layout (video-card). One per spec. | source.type: "video" |
visualizes-audio |
Driven by a standalone audio file (audiogram); receives it as sourceSrc. |
source.type: "audio" |
A template that ships its own footage via props is still overlay: the
contract describes the BASE source only.
Camera motion vs object motion
A camera moves the frame around finished content; object motion is the content moving, and it belongs to the template. A camera never choreographs entrances or exits.
Cameras live at three scopes, location = scope:
spec.camera: the scene; source and every overlay move as one shot.source.camera: footage only; overlays stay locked to their regions.overlay.camera: one overlay's region viewport, on the overlay's own timeline.
Presets: push-in, push-in-out, push-in-fast-out, pull-out,
pan-left/right/up/down, handheld (fields frequency, seed), with
amount, focus {x,y}, time, easing. Scene scope accepts an array of
windows. Full grammar: camera-motion-spec.md.
The motion language
Small on purpose, so mixed templates read as one design system. All three knobs are optional; unset keeps the template's native motion.
reveal(text entrance hint):"fade-up","blur-in","typewriter". Text templates map it to their nearest native mode and may expose richer extensions via props (quote-cardanimateInadds"lines"/"words"; tweet-card adds"paragraphs"/"none").enter(card entrance, renderer-provided, identical on every template):"slide-left","slide-right","spring","mask".exit:"blur-out"(blur + lift, the signature departure),"fade-down","shrink","vanish"(anticipation, then a 0.45s implosion with blur and apopcue: the literal "make it disappear"; the other three run 0.9s). Templates with their ownexitprop (speaker-card) treat the spec value as default, not override.motion(object motion, works on EVERY template). Two families, and the family decides which knobs mean anything.Periodic, oscillating for as long as their window lasts:
"shake"(held in a hand: a slow drift, a mid correction and a fine tremor),"wobble"(a rock),"float"(a bob),"sway-3d"(a slow turn through depth, left and right). Shaped byfrequency, phase-shifted byseed, faded byrampSecandrampOutSec.One-shot, traversing their window exactly once and easing to a stop at both ends:
"skew-right","skew-left","skew-up","skew-down". Each starts at the opposite extreme, passes through flat, and lands in a strong perspective keystone facing the named direction. The window IS the tempo, sofrequency,seedand the ramps are inert for these; usetimeto say when and how long. The pair on one axis chains seamlessly, because the first ends in exactly the pose the second starts from:"motion": [ { "style": "skew-right", "time": { "duration": "4s" } }, { "style": "skew-left", "time": { "start": "4s", "duration": "4s" } } ]Shared knobs:
style,amount(0..1, unset uses the style's own default: 0.2 for the flat styles, 0.7 forsway-3d, 0.175 for theskew-*sweeps, which land near 10deg because a sweep HOLDS its end pose and the card has to stay readable there) andtime. Amplitudes are percentages of the overlay's region box, so oneamountreads the same everywhere. It is its own transform layer, so it stacks withcamera,enter,exitandscale, and an array of motions stacks with itself:{ "template": "quote-card", "enter": "spring", "camera": { "preset": "pull-out", "amount": 0.25 }, "motion": { "style": "shake", "amount": 0.3 } }Use it instead of a
handheldcamera whenever you want the thing to move rather than the view of it: the camera preset zooms the region viewport by 5xamountto hide frame edges, which changes how big a card reads and is meaningless for a transparent cutout. Templates may ship adefaultMotion(the sticker drifts for free);motion: { "style": "none" }turns it off.src/player/shake.ts,src/player/OverlayMotion.tsx.
Sticker border
sticker takes an optional die-cut outline. It is off unless you ask for it,
and it follows the artwork's own alpha edge, not its bounding box, because a
rectangle around a cutout is exactly the frame this template exists to avoid:
{ "template": "sticker", "props": { "src": "pickle", "border": { "width": 10, "color": "white" } } }width is design pixels at a 1080px short edge and means what it says: the
outline is grown one width out from the silhouette in every direction. color
takes a brand token, white / black, or a #rrggbb literal, the same escape
captions open. The border is painted before the contact shadow, so the shadow
is cast by the outlined silhouette instead of crossing it.
Two scaling behaviours worth knowing, because they differ. A camera transforms
the whole overlay after the outline is drawn, so a push-in thickens the edge
along with the art, which is what something belonging to the sticker should do.
sizePct does not: the width resolves against the composition, not the element,
so it is a fixed thickness on the finished frame and a small sticker reads with a
proportionally heavier edge. Drop the width when you shrink the art.
Bundled sticker art
sticker's src takes a bundled library NAME as well as a path or URL, so an
agent with no artwork of its own still has cutouts it can reach:
{ "template": "sticker", "props": { "src": "pickle" } }
{ "template": "sticker", "props": { "src": "/my-brand/mascot.png" } }Names carry no slash and no extension, so they can never collide with a path.
The set is STICKER_LIBRARY in src/templates/sticker/library.ts and the files
live in public/stickers/; provenance is in public/stickers/SOURCES.md. Today
it is one entry, pickle. Everything else about the template is unchanged: the
name only resolves the file.
Sound and music
Cues default on. sound at the spec root sets defaults and each overlay may
override it; a template's own cue is a prop ("sfx": "ding", "sfx": false),
while sound.sounds remaps by cue name for everything in scope. The core palette
is click, pop, whoosh, ding, typewriter. Full rules, including the two
scopes and the bundled library cues: features/sound.md.
music at the spec root is the bed under the whole composition, outside every
camera. volume is linear gain, defaults to 0.08, and is the whole job: a bed
under speech belongs 15 to 20 LU below it, so measure both with ffmpeg -af ebur128 and compute the gain instead of judging it in a preview. validateSpec
rejects a bed above 0.3 while the source audio is unmuted. fadeInSec (1),
fadeOutSec (1.5), trimStartSec and loop (off) are the rest. The arithmetic,
a worked example and the fade/loop reasoning: features/music.md.
Captions
caption-classic is the caption renderer, and captions have a route that starts
before the spec does: forced alignment produces the word times, and a transcriber
never does. Read features/captions.md before authoring
one.
The shape, in brief: pass one composition-relative track of cues and words (the
legacy overlay-relative lines remains valid), position with region, prefer the
named "caption-zone", and configure preset (classic, minimal, editorial,
punch, extruded), grouping, layout, highlight, appearance, styles
and word marks.
{
"template": "caption-classic",
"region": "caption-zone",
"time": {},
"props": {
"preset": "classic",
"track": {
"timebase": "composition",
"cues": [
{
"start": 1.1,
"end": 2.4,
"words": [
{ "text": "Build", "start": 1.18, "end": 1.45 },
{ "text": "OverlayMotion", "start": 1.46, "end": 2.05, "marks": ["buzzword"] }
]
}
]
}
}
}Brand theme
Templates never hardcode style; they read tokens from the active
BrandTheme: colors (primary, onPrimary, surface, onSurface, muted,
background, optional secondary/accent), fonts (heading, body, optional serif), radius,
logoText, and optional style (surface "solid" | "glass", blur up to
100, opacity, gradients, borderColor). Same spec + another theme =
rebranded video. Brand comes from the theme, never from props: no hex colors in
props, and caption style overrides reference these roles.
Formats
vertical 1080×1920, horizontal 1920×1080, landscape 1620×1080,
square 1080×1080. Templates scale through the shared rem() helper, so
one spec renders correctly in all four.
Validation
parseSpec(raw) (throws) or validateSpec(raw) (safe result) from
src/spec/validate.ts. Beyond shape, they validate every template's nested
props, enforce known template slugs, source-contract requirements, at most one
wraps-video overlay, and non-overlapping source-reframe windows. Overlay and
source-reframe windows may not extend past the composition duration.
editSpec.parse alone checks shape only; prefer the checked variants.
