The project file is plain JSON — open on purpose, the opposite of CapCut's
increasingly obfuscated drafts ([[CapCut desktop]] §2.14). People save it
through the app; agents and pipelines can write it directly or build it
with commands. The TypeScript source of truth is src/lib/project/types.ts;
normalizeProject (src/lib/project/defaults.ts) is the gate every file
passes through, filling defaults and rejecting what cannot be repaired.
| Quantity | Unit |
|---|---|
Clip start, duration, keyframe f, marker frame, fades, animation and transition durations |
integer frames at the project rate ([[ADR-004 Timeline time is integer frames]]) |
fps |
rational { "num": 30000, "den": 1001 } (a bare number is accepted on input) |
Clip in (source offset) |
seconds into the source file where playback begins |
speed, speedCurve.points[].speed |
playback rate, 1 = normal (curves 0.1..10) |
speedCurve.points[].f |
frames from the clip's start, fractional allowed |
transform.x / .y |
fraction of canvas width/height from center (0 = centered; y grows downward) |
transform.scale |
1 = fit inside the canvas; the horizontal scale when scaleY is present |
transform.anchorX / .anchorY |
optional anchor point, shares of the picture's width/height from its center (absent = center); the position places it, scale and rotation turn about it |
transform.scaleY |
optional vertical scale (CapCut's Uniform scale off); absent = uniform. Older readers ignore it and draw the picture uniform |
opacity, volume |
0..1 and linear gain (1 = 0 dB, up to 4 = +12 dB) |
adjust.* |
-100..100 (one-sided ones 0..100), including the grade's hsl and wheels |
adjust.lut.intensity, filter.intensity |
0..1 |
adjust.curves.* points |
[input, output], both 0..1 |
Everything not given gets its default. This is a complete file:
{
"fps": 30,
"assets": [{ "id": "a1", "src": "C:/media/interview.mp4", "duration": 42.5 }],
"tracks": [
{ "kind": "video", "clips": [
{ "type": "video", "assetId": "a1", "start": 0, "duration": 300, "in": 4 },
{ "type": "video", "assetId": "a1", "start": 300, "duration": 150, "in": 20.5 }
]},
{ "kind": "video", "clips": [
{ "type": "text", "text": "Chapter one", "start": 30, "duration": 90,
"transform": { "y": 0.3 }, "animIn": { "type": "rise", "duration": 15 } }
]}
]
}Project { schema: 1, id, name, width, height, canvasFixed?, fps, sampleRate, background,
assets: Asset[], mediaFolders?: string[], tracks: Track[], markers: Marker[], luts?: LutFile[],
compounds?: Compound[], brand?: Brand }
Brand { name?, colors: { primary, secondary, accent, background, text },
fonts: { heading, body }, logos?: { main?, light?, dark?, icon? } (image asset ids),
url?, tagline? } (set_brand; a logo whose asset is gone is dropped on load)
Asset { id, kind: video|audio|image, name, src, proxy?, playbackProxy?, duration,
width?, height?, fps?, hasVideo, hasAudio, rotation?,
sticker?, folder?, favorite?, speech?, role?, sound?, musicGrid? } (sticker: the library sticker an image was drawn from;
role: dialog|music|effects|ambience, what a file the app made is;
sound: { id, seed? }, the built-in effect or ambience bed it was made from;
musicGrid: { bpm, firstDownbeat, sections?: [{ at, label }] }, a song's
grid in its own seconds, given by set_music_grid or kept by
generate_music; analysis reads the song through it: ADR-101)
Speech { text, caption?, voice, speed, pronounce?: { word: respelling },
quality?: best|draft, finish?: natural|clean|raw }
(on a speech file the app read: the line as written, pause marks included;
review_voiceover compares against it, retake_voiceover reads it again)
LutFile { id, name, src, kind: 1d|3d, size } (the .cube stays in its file)
Track { id, kind: video|audio, name, main?, clips: Clip[],
muted, hidden, locked, volume, pan? (-1..1, added to each clip's),
solo?, height?: small|large, role? } (role, audio tracks only: dialog|music|effects|ambience,
the stem it is; absent, it is judged by its clips' files: ADR-098)
Compound { id, name, tracks: Track[], markers: Marker[] } (a nested timeline)
Marker { id, frame, label, color, duration?: frames, chapter?: boolean, follows?: { clip, at } } (follows: a song's beat,
bar, part or drop on a video or audio clip, at that second of its file;
the marker moves with the clip, through a compound clip that plays it
once the clip is inside one, and goes when nothing plays it: ADR-101,
ADR-106)
Clip = video | audio | image | text | color | adjustment | compound (see types.ts)
every clip may carry group?: a shared id that selects and moves together
every clip may carry labelColor?: red | orange | yellow | green | cyan
| blue | purple | pink (a timeline color label)
video and audio clips may carry eq?: { low?, mid?, high? } (dB)
video and audio clips may carry fill?: left | right (one channel on both)
video and audio clips may carry highpass?, lowpass?, echo?, reverb?,
tapeStop? (sound design; see Sound design below)
text clips may carry wordTimes?: number[] (frames from the clip's start
each word is spoken) and style.highlight?: color of the spoken word
text styles may carry case?: upper | lower | title (shown so; the
text itself is kept as typed), stroke2?: { color, width } (a second
outline), glow?: { color, blur, strength? }, gradient?: { from, to,
angle } (fills instead of color), accent?: the color the
highlightSweep and underlineWipe animations draw in, and
weight?: 100..900 (wins over bold; absent: 700 bold, 400 not)
animIn, animOut and animLoop may carry unit?: letter | word | line,
stagger?: 0..1 and order?: forward | backward | center | random:
on a text clip the animation then plays per unit (src/lib/kinetic.ts)
video clips may carry stabilize?: { level, from, rate, x[], y[], zoom }
video clips may carry frameBlend?: true (mix neighboring source frames)
(per-source-frame moves onto the smooth path; src/lib/analysis/stabilize.ts)
visual clips may carry trackMatte?, motionBlur?, edge?, wiggle?,
shadow?, border? and color clips shape? (see Graphics)
Rules normalizeProject enforces: clips on a track are sorted and never
overlap; audio clips only on audio tracks and nothing else on them; every
assetId exists; exactly one video track is main (the bottom one if
none is marked). Tracks are listed top to bottom; the top video track draws
last. The same rules hold inside every compound's tracks; every
compoundId exists, and no compound plays itself, even through others.
Video and audio clips may carry these effects ([[ADR-060 Sound design rings past the clip and is measured as exported]]); each absent means off, so older projects open unchanged:
{ "type": "audio", "assetId": "a2", "start": 0, "duration": 300, "in": 0, "speed": 1,
"lowpass": { "cutoff": 400, "resonance": 0 },
"reverb": { "size": 0.9, "decay": 6, "mix": 0.4 },
"echo": { "time": 0.3, "feedback": 0.5, "mix": 0.5 },
"tapeStop": { "duration": 1.5 },
"keyframes": { "lowpass.cutoff": [{ "f": 135, "v": 400 }, { "f": 150, "v": 20000 }] } }highpass/lowpass:cutoffin Hz (20..20000),resonancein dB (0..24); 24 dB per octave;makeup?in dB (-24..24, absent 0), a gain after the filter making up what it takes out (set_audio_filter keep_loudness, ADR-114). Keyframes onlowpass.cutoffandhighpass.cutoffmove on a log scale between keys.reverb:size0..1,decayseconds to fall 60 dB (0.1..10),mix0..1 wet share at equal power (keyframeable asreverb.mix).echo:timeseconds between repeats (0.01..2),feedback0..0.9,mix0..1 (keyframeable asecho.mix).tapeStop:durationseconds (0.05..10) over which the sound slows to a stop at the clip's end.- The loader clamps each number into its range, fills a missing one with its default, and drops an effect that is not an object. Compound clips take none yet: they are dropped when a project is opened.
A compound clip (CapCut's Create compound clip, Premiere's nested sequence)
plays a nested timeline from compounds the way a video clip plays a file
([[ADR-037 Compound clips are nested timelines composited to a texture]]):
"tracks": [{ "kind": "video", "main": true, "clips": [
{ "type": "compound", "compoundId": "k1", "start": 60, "duration": 90, "in": 0.5, "speed": 1,
"opacity": 0.8, "volume": 1, "pan": 0, "fadeIn": 0, "fadeOut": 0, "muted": false } ] }],
"compounds": [{ "id": "k1", "name": "Intro", "markers": [], "tracks": [
{ "kind": "video", "main": true, "clips": [ { "type": "video", "assetId": "a1", "start": 0, "duration": 150, "in": 4 } ] },
{ "kind": "audio", "clips": [ { "type": "audio", "assetId": "a2", "start": 0, "duration": 150, "in": 0 } ] } ] }]inis seconds into the nested timeline andspeedits rate, so the clip above shows the compound's frames 15 to 104. Frames past the nested timeline's end show nothing.- A compound clip has every visual and sound setting a video clip has (transform, crop, opacity, blend, adjust, effects, mask, key, animations, transitions, volume, pan, fades). They apply to the nested timeline's composited picture and mix.
- Speed curves,
reverseandfreezeare not supported on compound clips yet; they are dropped when a project is opened. - Compounds share the project's frame rate and canvas; their frames, keyframes and markers are their own timeline's. A compound may hold compound clips of other compounds, never of itself.
- Several compound clips may play one compound: editing it changes them all. A compound nothing plays is dropped when the editor saves an edit.
- Absent
compoundsmeans none, so projects from before compound clips open unchanged.
Asset.playbackProxy?: { src, width, height, size, fingerprint } is a
preview-only cache reference. size is "half", "quarter" or "720p";
width and height are positive integer display dimensions (at least 2).
src and fingerprint are nonempty strings. Malformed objects are dropped
at load. Absent means the existing source behavior.
On desktop the fingerprint hashes the original path, byte size and modified
time. Cache filenames include it and the chosen size. File existence and
fingerprint checks belong to this machine, not to the saved document: a
moved/replaced original makes the copy stale, and a missing cache on another
computer simply falls back to proxy ?? src. Relinking keeps the reference
so its stale badge explains what happened. Undoing creation removes only the
reference; Delete proxy removes the reference and its cached file, so Undo
of deletion restores a missing reference until it is recreated.
Only preview pictures and thumbnails/filmstrips may read playbackProxy.
Export, export stills, preview_frame/contact sheets, analysis and scopes
always read the original or the separate, full-resolution Asset.proxy
decode fallback. Desktop collected copies omit playback cache references. The global Proxy
playback preference is never in a project.
See [[ADR-131 Playback proxies are purpose scoped and disposable cache]].
Media folders are flat virtual names (1–80 trimmed characters), independent
of disk paths. Missing mediaFolders means none; missing asset folder means
Unfiled and missing favorite means false. Normalization merges case-insensitive
folder duplicates, recovers referenced folders and discards invalid favorites.
These optional schema-1 fields do not change rendering. Older builds can read
the edits but may discard folder definitions on saving (ADR-042).
Shape layers, track mattes, motion blur, edges, wiggle, drop shadows and borders are optional fields (ADR-056 to ADR-059, ADR-062, ADR-069, ADR-070, ADR-099); absent means off, so files without them load unchanged.
{ "type": "color", "color": "#0f1218", "start": 60, "duration": 90,
"transform": { "x": -0.22, "y": 0.3, "scale": 1, "rotation": 0, "flipX": false, "flipY": false },
"shape": { "kind": "rectangle", "width": 0.36, "height": 0.12, "radius": 0.3,
"stroke": { "color": "#ffcc00", "width": 4 }, "draw": 1 },
"motionBlur": { "shutter": 180 },
"shadow": { "color": "#000000", "blur": 60, "x": 0, "y": 24, "opacity": 0.5 },
"border": { "color": "#ffffff", "width": 8, "opacity": 1 } },
{ "type": "color", "color": "#6a5cff", "start": 0, "duration": 240,
"shape": { "kind": "ellipse", "width": 0.6, "height": 0.8, "feather": 120,
"gradient": { "kind": "radial", "from": "#6a5cffcc", "to": "#6a5cff00", "angle": 0, "radius": 0.9 } } },
{ "type": "video", "assetId": "a1", "start": 0, "duration": 75, "in": 0,
"trackMatte": { "clipId": "c_title", "mode": "alpha" },
"edge": "mirror",
"wiggle": { "x": { "frequency": 3, "amplitude": 0.0073, "seed": 12 },
"rotation": { "frequency": 2, "amplitude": 0.6, "seed": 12, "strength": 0.5 } } }shape(color clips only; the clip'scoloris the fill):kindrectangle,ellipseorline;width/heightfractions of the canvas (a line iswidthlong, horizontal before rotation; its height is unused);radius0..1 of half the shorter side (rectangles; 1 is a pill);fill: falsefor an outline only;gradient: { from, to, angle }replaces the fill color (degrees, 0 left to right, 90 top to bottom; a line's color too); withkind: "radial"it runs fromfromat the center totoat the rim, an ellipse fitting the box,xandymoving the center by fractions of the box from its middle (absent 0) andradiusa fraction of its half size (absent 1, its sides); older builds draw a radial gradient as a linear one;featherpixels at 1080p of soft edge: the shape blurred by a Gaussian of sigma a quarter of it, so the edge falls off centered on the outline and a feather wider than the shape dims its middle (absent 0, crisp; older builds draw it crisp, and builds before ADR-099 as a Gaussian of the distance to the edge, which creased on wide feathers);stroke: { color, width }centered on the outline, width in pixels at 1080p (a line's thickness; 8 when absent);draw0..1 the share of the outline or line drawn (absent: all), from the top center clockwise or a line's left end. Colors are CSS:#rgb,#rrggbb,#rrggbbaa,rgb(),rgba()or a common name. Older builds draw such a clip as a full-canvas solid.trackMatte: { clipId?, mode }:modealpha,alphaInverted,lumaorlumaInverted;clipIda visual clip of the same timeline, absent for whatever is on the video track directly above at each frame. A named matte is never drawn; a track-above matte only while it cuts.normalizeProjectdrops a named matte whose clip is missing, is the clip itself, or has no picture.motionBlur: { shutter }: degrees, 1..720 (180 is After Effects' and film's). 0 or less is dropped.edge:mirrororrepeat.wiggle: per valuex,y(canvas fractions),rotation(degrees) orscale(scale factor), each{ frequency (Hz), amplitude, seed, strength? (0..1, absent 1) }.shadow: { color, blur, x, y, opacity }(any visual clip): a drop shadow cast by the finished picture.blur0..600 is twice the Gaussian's sigma,xandythe fall (y down), all pixels at 1080p;opacity0..1 multiplies the color's alpha. Missing numbers load as the defaults (60, 0, 24, 0.5), out-of-range ones clamped; anything that is not an object is dropped.border: { color, width, opacity }(any visual clip): a border drawn outside the finished picture's edge (its alpha after mask, crop, effects and matte), before its shadow.width0..200 pixels at 1080p, whatever the clip's scale;opacity0..1 multiplies the color's alpha and the clip's opacity. Missing numbers load as the defaults (8, 1), out-of-range ones clamped; anything that is not an object is dropped. Older builds draw no border.
A visual or adjustment clip's adjust holds the Basic sliders and,
optionally, the pro grade ([[ADR-020 The grade lives in adjust, in
Lumetri's order]]). Every part may be left out; left out means unchanged.
"adjust": {
"exposure": 0, "contrast": 10,
"hsl": { "red": { "hue": 0, "saturation": -40, "lightness": 0 } },
"curves": { "luma": [[0, 0], [0.25, 0.2], [0.75, 0.82], [1, 1]] },
"wheels": { "lift": { "r": -3, "g": 1, "b": 6, "y": 0 }, "gain": { "r": 0, "g": 0, "b": 0, "y": 8 } },
"lut": { "id": "lut_1", "intensity": 0.8 }
}hslbands:red orange yellow green cyan blue purple magenta, each{ hue, saturation, lightness }.curveschannels:luma(applies to R, G and B alike),r,g,b; points are interpolated with a monotone cubic and held flat past the first and last point.wheels:lift,gamma,gain,offset, each{ r, g, b, y }— r/g/b the color balance, y the luminance ring.lut.idmust name an entry of the project'sluts. LUT files are not embedded:srcpoints at the .cube the wayAsset.srcpoints at media.
Any numeric parameter can hold keyframes by path:
"keyframes": {
"transform.scale": [{ "f": 0, "v": 1 }, { "f": 60, "v": 1.2, "ease": "easeInOut" }],
"effects.fx_ab12.amount": [{ "f": 0, "v": 0 }, { "f": 30, "v": 80 }],
"adjust.wheels.gain.y": [{ "f": 0, "v": 0 }, { "f": 45, "v": 12 }]
}Mask numbers animate the same way (mask.x, mask.y, mask.width,
mask.height, mask.rotation, mask.feather, mask.radius), as CapCut
keys every mask parameter; crop edges do not. So do a shape's
(shape.width, shape.height, shape.radius, shape.stroke.width,
shape.draw, shape.feather, shape.gradient.angle, shape.gradient.x,
shape.gradient.y, shape.gradient.radius), a drop shadow's
(shadow.blur, shadow.x, shadow.y, shadow.opacity), a border's
(border.width, border.opacity) and a wiggle's
(wiggle.x.strength, wiggle.x.amplitude, and the same for y,
rotation and scale). A picture's tilt keys as
transform.rotateX, transform.rotateY and transform.perspective:
degrees (CSS's rotateX and rotateY) and the camera's distance in pixels
at 1080p (absent 2000), all optional in transform; older builds draw the
picture flat. A title's style.size and style.letterSpacing
key as numbers; style.color keys hold the color as a 0xRRGGBB number
(#ff3366 is 16724838) and move channel by channel; keyed colors are
opaque ([[ADR-071 Keyed title styles redraw the title]]). Older builds
draw such a title at its stored style.
"crop": { "left": 0.25, "top": 0, "right": 0.25, "bottom": 0.1 },
"mask": { "shape": "rectangle", "x": 0, "y": -0.1, "width": 0.5, "height": 0.4,
"rotation": 15, "feather": 0.05, "invert": false, "radius": 0.3 }croptrims fractions of the source from each edge, before flips, scale and rotation; the part kept stays where the uncropped picture showed it, mirrored with a flip ([[ADR-048 Crop is cut from the source and edited on a copy]]). Each pair leaves at least 5% (set_crop).mask.shapeislinear,mirror,circle,rectangle,heartorstar.x/ymove its center from the visible (cropped) picture's center andwidth/heightsize it, as fractions of that picture's width and height;rotationturns it rigidly, in degrees clockwise;feather0..1 softens it;invertkeeps the outside. Linear keeps what is above its line and ignores the size; mirror keeps a bandheighttall.radiusis optional and only forrectangle: 0 (or absent) square, 1 rounds each corner by half the shorter side.heart,starandradiuscame later and are additive: older files load unchanged, and older readers draw no mask for the new shapes ([[ADR-049 Masks turn rigidly and grow shapes without new fields]]).
ease shapes the curve leaving that keyframe: linear, hold, easeIn,
easeOut, easeInOut, spring, elastic, bounce (overshoot and
settle, ADR-062), or a CSS-style bezier [x1, y1, x2, y2]. A missing
ease is linear, and so is a name a reader does not know. The editor's other presets (Smooth, Fast in, Overshoot,
Anticipate) and hand-dragged curves are stored as beziers, so anything that
evaluates CSS cubic-bezier() reproduces them. A path never holds two keys
on one frame. Commands: add_keyframe, move_keyframe,
set_keyframe_ease, remove_keyframes.
Video and audio clips play their source at speed (0.5 = slow motion; the
clip is as long as its source span divided by the speed). A speedCurve
replaces that with a rate that varies, CapCut's Curve speed
([[ADR-027 Speed curves are exponential and anchored to clip frames]]):
{ "type": "video", "assetId": "a1", "start": 0, "duration": 59, "in": 2, "speed": 1,
"speedCurve": { "preset": "montage", "points": [
{ "f": 0, "speed": 0.8 }, { "f": 14.75, "speed": 3 }, { "f": 29.5, "speed": 0.5 },
{ "f": 44.25, "speed": 3 }, { "f": 59, "speed": 0.8 } ] } }- The source time at clip frame
risinplus∫₀ʳ speedframes of the project rate, in seconds. Between points the rate changes exponentially — a straight line on a log axis — and it holds before the first and after the last. - Points are anchored to the clip like keyframes: trimming leaves them in place (they may lie outside the clip), so the frames that stay keep their picture.
reverse: trueplays backward fromin, which is then the latest source time read.changePitch: truelets the pitch follow the speed (CapCut's Change pitch); absent, the pitch is kept at any speed.presetnames the tile it came from (custom,montage,hero,bullet,jumpCut,flashIn,flashOut); it does not change playback.
A video clip whose background was removed (CapCut's Video > Remove BG > Auto removal) carries a reference to its matte ([[ADR-033 Mattes are made once and stored as grayscale video]]):
{ "type": "video", "assetId": "a1", "start": 0, "duration": 180, "in": 0,
"cutout": { "enabled": true, "src": "934da601-matte-talk.mp4", "from": 0, "to": 6,
"model": "onnx-community/ormbg-ONNX" } }srcis a grayscale video (luma = alpha) stored like imported media: an OPFS key on the web, an app-data path on desktop. It is not embedded.from/toare the source seconds the matte covers; matte time 0 is source timefrom. Frames outside it play with their background.enabled: falsekeeps the matte for an instant On again.modelnames the model that made it (a Hugging Face id), for the record.- Absent means no background removal.
normalizeProjectdrops a broken reference (nosrc,tonot afterfrom), and on any clip that is not video. Files without it load unchanged. Making a matte needs the app (remove_backgroundover MCP,window.videoEditor.ai.removeBackground);set_background_removalturns an existing one off and on.
Effects, transitions, filters and animations are referenced by name; the
lists live in src/lib/catalog.ts (e.g. effects blur, glitch, vhs;
transitions dissolve, wipeLeft, zoomIn; filters teal, noir;
animations fade, pop, slideUp, and loops pulse, shake; for
titles only, the kinetic typewriter, letterRise, letterFade,
wordPop, wordRise, wordSlide, lineRise, highlightSweep and
underlineWipe).
Agents usually find commands easier than raw JSON: seconds instead of
frames, every argument validated with a readable error. See the registry in
src/lib/commands.ts and window.videoEditor.commands() for the live list
with JSON Schemas ([[ADR-006 Every edit is a named command with a JSON Schema]]).
Project.masterVolume is linear master gain (default 1). Track.color is
one of the clip-label palette names; a clip label overrides it. Track pan,
solo and height survive normalization. Display preferences are not project
fields. Asset.animated marks an ImageDecoder-backed image; duration
then contains one loop in seconds. The image clip can last longer and loop.
Collected JSON uses relative src and proxy paths beside the project file,
including LUTs and nested cutout media; opening it creates a fresh id.
ImageClip.imageIn optionally stores the animation phase in source seconds; trims and splits preserve it.
Marker duration is an optional nonnegative integer frame count; absent or
zero means a point. chapter is an optional boolean, absent meaning false.
Normalization drops invalid values at both root and compound boundaries.
Ranges follow their start when follows is present. Chapter-enabled MP4
exports include only explicit chapter markers; these fields do not change
rendered pictures or project duration. Edit-point selections are session
state and never enter the project file.
Track.inserts? on audio tracks and Sound.inserts? on audio, video and
compound clips are ordered arrays of { id, type, on, params }. Absent
means no inserts. Types: eq, compressor, gate, deesser, limiter,
reverb, echo; unknown types are dropped. The loader caps a chain at 32
and an EQ at eight bands, cleans each parameter and makes ids unique.
Gate parameters: threshold (-90..0 dBFS), ratio (1..100), attack
(0.0001..0.2 s), hold (0..1 s), release (0.005..2 s), range (0..96 dB).
De-esser: low (2000..16000 Hz), high (2500..20000 Hz, above low),
amount (0..24 dB). Limiter: ceiling (-24..0 dBFS), release (0.01..2 s).
EQ: bands, each { type, freq, gain, q, on }; types peaking, lowshelf,
highshelf, highpass, lowpass, notch; frequency 20..20000 Hz, gain -24..24 dB,
Q 0.1..30. Shelves use slope 1. Other processors use their legacy settings.
Track inserts precede track volume/pan. Clip inserts follow the legacy
chain and precede volume/fades; legacy echo/reverb keep their original
post-fader position when no clip inserts are present.