.ectpl
The portable video template format behind ExpoCut. One file describes a whole video — canvas, timeline, layers, animation, branding and the holes a person or a machine fills in. The phone editor and the desktop Render Engine both read it, and both produce the same pixels.
A recipe for a video, not a video.
An .ectpl file carries no frames. It carries the instructions that produce frames — which is why it is a few hundred kilobytes instead of a few hundred megabytes, why one file can generate a thousand different videos, and why a machine can write one.
Declarative
Plain JSON. No scripts, no expressions, no executable payload. Everything is data an importer can validate before anything renders.
Resolution-independent
Positions are percentages of the canvas, so the same numbers land identically at 720p, 1080p and 4K.
Deterministic
The same file plus the same inputs produces the same pixels — on the phone, on a laptop, and in CI.
Two physical forms
Both carry the same document. The zip form exists so a template can travel with its own media, fonts and thumbnail.
| Form | Signature | Contains | Used for |
|---|---|---|---|
| JSON | Text starting { | The template document only | Hand-authoring, CDN-served templates, agent output, API payloads |
| ZIP | 50 4B 03 04 | template.json + assets + thumbnail | Sharing, community uploads, offline transfer, share-sheet round-trips |
How it is detected
Detection runs in a fixed order — filename extension, then magic bytes, then a leading-token probe over the first 4 KB. The probe never throws; it returns a format and a confidence so an importer can explain an unsupported file instead of failing silently.
| Signal | Result | Confidence |
|---|---|---|
.ectpl extension + PK zip magic | ectpl (bundle) | 1.0 |
JSON head matching "schemaVersion":"1.0"|"1.1" and "slots": or "mode":"template"|"snapshot" | ectpl (document) | 1.0 |
.ectpl extension alone | ectpl | 0.7 |
Anatomy of the archive.
The bundle is a standard PKZIP archive written to APPNOTE 6.3.6 — readable by every mainstream unzip tool — with one deliberate restriction: every entry is STORED, never deflated.
my-template.ectpl ← a zip file ├── template.json REQUIRED — the document, at the archive root ├── thumbnail.jpg optional — jpg | jpeg | png | webp, ≤ 512 KB ├── preview.mp4 optional — silent looping demo └── assets/ ├── 9f2c4a1b7e05d3f8.mp4 named by the first 16 hex of its sha256 ├── 1a77be90c4d21f6e.jpg └── e30b5c8812af74d9.mp3
Why STORED and never DEFLATE
This is a performance decision, not an oversight, and it has four consequences worth understanding if you are writing an .ectpl yourself:
- The media inside is already compressed. JPEG, MP4 and MP3 give DEFLATE roughly 1–2% for real CPU cost on a phone.
- The document is mostly numbers and short strings — it compresses well, but it is typically under 200 KB anyway, so the saving is noise.
- No compression means no decompression. A reader can map an entry straight out of the archive, which keeps import fast and allocation-free.
- A store-only reader has no zip-bomb surface to defend against: the compressed size and the uncompressed size are the same number.
Entry-name rules
Both the writer and the reader enforce the same rules, so anything ExpoCut writes also validates on the way back in. An entry name must be forward-slash separated, at most 1024 characters, and must not be absolute, contain a backslash, a drive prefix, a NUL byte, or a .. segment.
template.json at the root. If it is missing, or if it is compressed rather than stored, the archive is not a valid .ectpl bundle and should be rejected rather than repaired.The template document.
Everything a video needs, in one object. Required fields are marked; everything else is optional and defaults to something safe at inflate time.
| Field | Type | Notes |
|---|---|---|
schemaVersion req | "1.0" | "1.1" | Anything else is refused at import. Writers emit 1.1. |
mode | "template" | "snapshot" | Absent means template — the pre-1.1 behaviour. |
id req | string | Non-empty, globally unique. |
name req | string | Display name in the gallery. |
description | string | One line, shown under the name. |
category req | enum | For You · Real Estate · Promo · Birthday · Travel · Food · Events · My Templates |
thumbnail | string | number | URL, bundle:// path, or a numeric app-asset id. |
previewVideoUri | string | Silent looping preview for the gallery card. |
aspectRatio req | enum | See the canvas table below. |
resolution | string | Authored pixel size. Set by imported motion-graphics templates. |
durationMs req | number | Milliseconds. Capped at 1 800 000 (30 min). |
slotCounts | object | Gallery hint only: { video, image, text, audio, logo }. |
slots req* | TemplateSlot[] | At least one in template mode. May be empty in snapshot mode. |
tracks req | TemplateTrack[] | At least one. |
layers req | LayerPrototype[] | At least one. Max 200. |
applyBrandingByDefault | boolean | Applies the user's saved brand profile at inflate time. |
voiceover | object | { slotId, script, voiceId?, speed? } — generates narration into an audio slot. |
palettes | Palette[] | Semantic @role colour themes. The first is the authored default; swapping one re-colours text, shapes, gradients and glows at once. |
bundle | BundleManifest | Present only when the template travels inside a zip. |
// The smallest valid .ectpl — one slot, one track, one animated layer. { "schemaVersion": "1.1", "mode": "template", "id": "quote-card-01", "name": "Quote Card", "category": "Promo", "aspectRatio": "9:16", "durationMs": 5000, "slots": [ { "id": "headline", "label": "Headline", "kind": "text", "required": true, "default": "Say something true" } ], "tracks": [ { "id": "t0", "type": "overlay", "name": "Headline", "trackIndex": 0 } ], "layers": [ { "id": "l0", "type": "text", "trackIndex": 0, "startTime": 0, "duration": 5000, // milliseconds "slotRef": "headline", "content": "Say something true", "position": { "x": 0, "y": 44 }, // percent of canvas "textFullWidth": true, "textAlign": "center", "fontSize": 34, "scale": 1, "rotation": 0, "keyframes": { "tracks": [ { "property": "opacity", "keyframes": [ { "t": 0, "v": 0, "out": { "type": "preset", "name": "easeOut" } }, { "t": 400000, "v": 1 } // MICROSECONDS — 0.4 s ] } ] } } ] }
A recipe, or the actual edit.
This is the first decision, and it changes every field that follows. Pick wrong and the file is either uselessly generic or impossible to reuse.
mode: "template" | mode: "snapshot" | |
|---|---|---|
| What it is | A blank frame with fillable holes | A finished edit preserved property-for-property |
| Media layers | Empty content, filled from slots | Real URIs, inline vectors, bakes — all intact |
| Slots | Required, at least one | Optional; used to expose just the editable bits |
| Text | Slotified | Verbatim, unless a slotRef opts it in |
| Effects, masks, camera, keyframes | Preserved | Preserved |
| Reuse | Any footage, any user | Same edit, tweakable |
| Use it for | A montage users re-shoot — travel, promo, listings | A branded, opinionated piece others adjust |
mode and bundle arrived with schema 1.1. A 1.0 document is still valid and is read as mode: "template", its original behaviour. Both versions inflate without data loss.The stage, and how you place things on it.
aspectRatio and durationMs define the stage. Everything else is positioned as a percentage of that stage, never in pixels — which is exactly why one template renders correctly at every export resolution.
| aspectRatio | Typical export | Where it goes |
|---|---|---|
9:16 | 1080 × 1920 | Reels, TikTok, Shorts |
4:5 | 1080 × 1350 | Feed video — maximum feed real estate |
1:1 | 1080 × 1080 | Square |
16:9 | 1920 × 1080 | YouTube, landscape |
2:3 · 3:4 · 4:3 · 1.91:1 · 21:9 · 2.35:1 · 2.39:1 | — | Also supported; the picker in the app is the same list. |
Units, in one table
| Property | Unit | Range / note |
|---|---|---|
durationMs, startTime, duration | milliseconds | Integer-ish. Same clock as the editor and export settings. |
keyframe t | microseconds | Integer. Chosen so long timelines cannot drift. |
position.x / .y | percent | 0–100. {x:50,y:50} is dead centre. |
rotation | degrees | |
opacity, audio.volume | ratio | 0–1 |
mask.rect.* | normalized | 0–1, layer-local |
mask.feather | reference px | 0–100 in the 1080p reference frame |
| colour channels | 8-bit | 0–255 per channel |
fontSize | canvas points | Convert from a 1080-wide design: fontSize ≈ design_px × 0.366 |
"duration": 5000 is five seconds. "t": 400000 is four tenths of a second. Get this backwards and you either animate in a single frame or wedge the encoder with a timeline three orders of magnitude too long.Two things that surprise everyone
Layers scale about their own centre
At scale ≠ 1 a layer's visual creeps toward the canvas centre (when shrinking) or away from it (when growing). That is why a logo often sits at a slightly negative y — you push the anchor up to counter the drift. Do not try to solve this in your head; place it, look at a real frame, measure, correct.
Text has two placement modes
textFullWidth: true— the text box spans the whole canvas width andtextAlignplaces the glyphs. Setposition.x: 0; onlyymatters. This is how you centre a headline.textFullWidth: false— a free box anchored byx. Use it for a left-aligned column or a label that sits beside another element.
Matching a source design (a poster, a Figma frame, a screenshot) is a direct conversion:
x% = px_x / designWidth × 100
y% = px_y / designHeight × 100
fontSize = px_fontsize × 0.366 (on a 1080-wide reference)
The holes a person — or a script — fills.
A slot declares one editable input. A layer opts into it with slotRef. Many layers can share one slot, which is how a single "Brand colour" input re-tints six elements at once.
Slot kinds
| kind | Accepts | Typical use |
|---|---|---|
video | A video asset | B-roll shot, product clip |
image | A still | Photo, product shot |
video|image | Either | "Drop anything here" media slot |
text | A string | Headline, price, address |
audio | An audio asset | Music bed, narration target |
logo | An image | Brand mark — usually brand-bound |
color | #RRGGBB | Accent colour driving several layers |
number | A number | A count, a percentage, a stat |
angle | Degrees | Tilt of a badge or frame |
scale | {x, y} | Size of a placed element |
point | {x, y} | A position the user drags |
Slot fields
id,label,kind— required. Ids must be unique within the document.required— when true and unfilled, the layer that depends on it is dropped and the project reports the slot as unfilled.default— fallback for text slots.preferredDurationMs— a UX hint: "this slot looks best at about this length".bindFromBrand— auto-fills from the user's saved brand profile:logo,name,slogan,website,phone,email,address.defaultBinding— a pre-bound value carrying the asset's original remote URL, so a template previews correctly on a device that never had the author's local file.
Resolution priority
Every slot resolves once, before any layer is built, so layers sharing a slot always agree:
Slot values
{ "kind": "media", "uri": "…", "remoteUrl": "…", "posterUri": "…",
"durationMs": 0, "width": 0, "height": 0 }
{ "kind": "text", "value": "…" }
{ "kind": "color", "hex": "#7C5CFF" }
{ "kind": "number", "value": 42 }
{ "kind": "angle", "degrees": -6 }
{ "kind": "point", "x": 50, "y": 42 }
{ "kind": "scale", "x": 1, "y": 1 }
remoteUrl is the durable path. A local cache file can be evicted by the OS at any time. When a slot value or a layer carries a remote URL, the template survives that eviction — the importer refetches instead of showing an empty layer.One index, two meanings.
trackIndex is both the timeline row a layer occupies and its z-order in the composite. Higher renders on top.
{ "id": "t3", "type": "overlay", "name": "Headline", "trackIndex": 3 }
trackIndex. Two layers sharing a track means two objects stacked on one row, which is the single most common failure in machine-authored templates. The importer rejects it with the offending index and the ids involved.Sequential clips are the case people get wrong: three shots that play one after another still need three distinct trackIndex values, not one shared row.
Everything visible or audible.
A layer prototype is a partial layer: the five required fields plus any property the editor's layer model supports. There is no parallel data model — what you write here is what the editor holds after inflate.
Always required
id string unique within the document type enum see the catalogue below startTime number milliseconds from the start of the timeline duration number milliseconds trackIndex number z-order and timeline row — unique per layer
Common optional properties
slotRef, name, content, position, scale / scaleX / scaleY, rotation, blur, overlayColor, colorGradeDelta, keyframes, cameraEnabled, z (parallax depth, −1…+1), plus a per-type config object.
Layer types
| Group | Types |
|---|---|
| Media | video · image · audio · collage |
| Type & captions | text · caption · transcript · lowerthird · quote |
| Graphics | shape (incl. brush strokes via shapeConfig.type: "custom_path") · shapeWidget · animation (vector animation) · three (live 3D scene) |
| Generative | generativeBg · proceduralFilter · confetti |
| Broadcast & data | ticker · banner · newsalert · clock · scoreboard · poll · statbar · bar-chart-race · firemeter · weather · qrcode · searchbar |
| Social | follower · likeburst · comment |
Widget types carry a matching config object — tickerConfig, scoreboardConfig, pollConfig, barChartRaceConfig, and so on. A generativeBg or proceduralFilter layer names its shader through effectId, which is a real id from the live effects catalogue. A brush-stroke layer is a shape whose shapeConfig sets type: "custom_path" plus the stroke geometry (pathData, pathViewW, pathViewH) — bundled brush ids come from the list_brushes catalogue.
Keyframes.
Any layer may carry a keyframes object. The model is deliberately vector-animation-shaped, which is what makes mechanical Lottie import and export possible in both directions.
"keyframes": { "tracks": [ // scalar property tracks { "property": "transform.scale", "timeMode": "absolute", // or "relative" (0..1 of clip duration) "keyframes": [ { "t": 0, "v": 1.0, "out": { "type": "preset", "name": "bounce" } }, { "t": 600000, "v": 1.18 } ] } ], "positionTrack": { … }, // grouped 2D motion path, wins over transform.x/.y "discreteTracks": [ … ] // held-step values that must not tween }
Interpolation
out describes how a value leaves its keyframe toward the next one. The last keyframe's out is meaningless. Four shapes:
| Shape | Form | Note |
|---|---|---|
| Hold | { "type": "hold" } | Step — no interpolation. |
| Linear | { "type": "linear" } | The default when out is absent. |
| Bezier | { "type": "bezier", "x1":…, "y1":…, "x2":…, "y2":… } | CSS-compatible. x in [0,1]; y may overshoot. |
| Preset | { "type": "preset", "name": "…" } | ease · easeIn · easeOut · easeInOut · flow · jumper · discer · bounce · elastic · spring |
Animatable properties
| Group | Paths |
|---|---|
| Transform | transform.x .y .scale .scaleX .scaleY .rotation .anchorX .anchorY |
| Appearance | opacity · fx.blur · fx.intensity · filter.id · filter.intensity |
| Colour | color.hueShift .saturation .brightness .contrast .intensity · text.color.r|g|b · text.stroke.color.r|g|b · border.color.r|g|b |
| Mask | mask.rect.x|y|width|height · mask.feather · .expansion · .rotation · .bandWidth · .gradientSoftness · .contentScale · .contentOffsetX|Y · .contentRotation |
| Border & text | border.width · .glowIntensity · .cornerRadius · text.stroke.width |
| Transitions | transition.inIntensity · transition.outIntensity · secondaryEffect.intensity |
| Audio & time | audio.volume · speed (integrated into a time-remap curve the preview and encoder both consume) |
Held-step tracks cover values you switch rather than tween: text.fontWeight, text.italic, text.fontFamily, text.align, text.transform, border.pattern, border.glowMode, mask.shape, mask.invert, mask.mirrorAxis, transition.in.id, transition.out.id, shape.fillStyle, fitMode.
Assets that travel with the template.
When a template is packed into a zip, every local asset is hashed, de-duplicated, stored under assets/, and referenced from the layers as bundle://assets/<hash>.<ext>. The manifest that describes them lives under the document's bundle key.
"bundle": { "version": "1.0", "title": "Just Listed", "assets": [ { "id": "a_9f2c4a1b7e05", "kind": "video", // font | lut | image | video | audio | data "path": "assets/9f2c4a1b7e05d3f8.mp4", "sizeBytes": 4812993, "sha256": "9f2c4a1b7e05d3f8…", "mimeType": "video/mp4" }, { "id": "font-1", "kind": "font", "path": "assets/inter.ttf", "license": { "spdx": "OFL-1.1", "redistributable": true } } ] }
De-duplication
Assets are keyed by sha256. A proxy and its original, or the same clip used by six layers, collapse into a single archive entry — the packer rewrites every reference to the one path. This is usually the difference between a 40 MB bundle and a 200 MB one.
Font licensing
Fonts are the one asset kind with a licence gate, because many font files simply cannot be redistributed. A bundled font must either be marked redistributable: true or carry an SPDX id from the open list:
OFL-1.1 · OFL-1.0 · Apache-2.0 · MIT · BSD-2-Clause · BSD-3-Clause · CC0-1.0 · Unlicense
Anything else is surfaced as a licence issue on the compatibility report rather than silently shipped. Other asset kinds default to permissible.
A template is untrusted input.
Anyone can hand you an .ectpl file. It could point a layer at a private file on your device, or at a URL that auto-downloads arbitrary bytes the moment you tap "use". So every import boundary — the OS file picker, a URL import, a share sheet, an MCP call, a reload from local storage — runs the same policy before the document reaches the editor.
URI scheme whitelist
| Scheme | Verdict | Why |
|---|---|---|
https: | Allowed | TLS-only remote assets. |
asset: · bundled: | Allowed | App-bundled assets, referenced by opaque id. |
bundle: | Allowed | In-archive reference. Can only resolve against bytes that shipped inside the already-validated archive. |
data: | Allowed, capped | image/* or audio/* only, at most 256 KB. |
file: | Allowed, scoped | Only inside the app's own /ectpl/ materialisation cache, with no .. segment. Those URIs are written by the importer from validated archive bytes — never authored by the template. |
| numeric id | Allowed | An app-bundled asset reference. |
http: | Rejected | Cleartext. |
any other file: | Rejected | Sandbox read — the core threat. |
content: ftp: blob: chrome-extension: | Rejected | Not a legitimate asset source. |
javascript: vbscript: | Rejected | Never executable, never accepted. |
| scheme-less relative path | Rejected | Ambiguous — resolves differently on every host. |
Every URL-bearing field is walked: uri, posterUri, remoteUrl, previewVideoUri, thumbnail, src, audioUri, videoUri, imageUri, logoUri, maskUri — at any depth, up to 64 levels of nesting.
Privacy: the media strip
When a template is packed for public or community distribution, source footage is stripped by default. Video, image and audio layers lose their content URIs, proxies, freeze and reverse bakes, and their remote-source fallbacks. Everything that makes the template a template — structure, fonts, effects, transitions, positions, colours, keyframes, natural aspect ratios — survives untouched.
The receiver gets the design and must supply their own media before creating a project. Turning the strip off is a deliberate choice for content the author explicitly meant to publish verbatim: stock-only compositions, brand demos, their own product footage.
Every cap, in one place.
These are enforced, not advisory. A document that crosses one is refused with a message naming the field — which makes them a useful pre-flight checklist for anything generating templates programmatically.
| Cap | Value | Applies to |
|---|---|---|
| Document size | 5 242 880 bytes (5 MB) | Checked before the JSON is parsed. |
| Layers | 200 | Per document. |
| Slots | 64 | Per document. |
| Tracks | 300 | Per document. |
| String field length | 4096 characters | Any string, anywhere — text bodies, labels, URLs. |
| Duration | 1 800 000 ms (30 min) | durationMs. |
| Nesting depth | 64 levels | The validation walk refuses to go deeper. |
data: URL | 262 144 bytes (256 KB) | Per URL, images and audio only. |
| Thumbnail | 524 288 bytes (512 KB) | Downscale to roughly 600 px on the long side. |
| Shared bundle | 104 857 600 bytes (100 MB) | Zipped, enforced on both ends. |
| Archive ceiling | 268 435 456 bytes (256 MB) | Default uncompressed limit for the writer. |
| Entry name | 1024 characters | No absolute paths, backslashes, drive prefixes, NULs or ... |
| Upload timeout | 180 s | Generous — a bundle can carry a demo video over a mobile uplink. |
| Catalogue cache | 10 min TTL · 15 s fetch timeout | Remote template listings. |
| Typical document | under 200 KB | Observed size of a hand-authored or app-saved template. |
From an edit, to a file, to a finished video.
Two directions. Neither has a hidden step — this is the whole path.
Packing
- The project is serialized either as a slotified recipe or as a verbatim snapshot.
- For a public share, the privacy strip clears source footage.
- Every remaining local reference is collected from the known media-bearing fields — content, vector source, source media, media fill, proxy, and the freeze / reverse bake originals.
- Each asset is hashed, de-duplicated, and rewritten to
bundle://assets/<hash16>.<ext>. - The manifest is attached, the archive is written STORED, and a rendered demo clip — if there is one — is uploaded as a separate streamed part.
Unpacking
- Sniff the magic bytes: a PK header takes the zip route, anything else takes the text route.
- Probe the first 4 KB to classify the format and produce a confidence.
- Read the archive and require
template.jsonat the root. - Validate: schema version, required fields, structural arrays, one-layer-per-track, slot references, and the bundle manifest including font licences.
- Security check: URI whitelist plus every cap in the table above.
- Materialise: archive bytes are written into the app's
/ectpl/<templateId>/cache and everybundle://URI is rewritten to the matching local path. Skipping this step is what produces the classic "text loaded but no video" bug. - Inflate: slots resolve, layers get fresh ids so two copies of the same template never collide, and the result is a normal project — layers, tracks and timeline clips.
- Render: from here on there is no special-casing. It is an ordinary project, and the canvas and the export read the same data.
remoteUrl populated matters more than it looks.What the format costs, and what it buys.
Format decisions here were made against a phone, not a workstation. Each one trades a theoretical saving for predictable behaviour on a device that also has to decode video.
No compression in the container
Media is already compressed, so DEFLATE buys 1–2% for real CPU. Store-only means no encoder allocations, no decompression on read, and no zip-bomb surface.
Hash-based de-duplication
A clip reused six times, or a proxy alongside its original, is one entry in the archive. Bundle size tracks distinct media, not layer count.
Heavy video never enters the container
A rendered demo streams from disk as its own upload part. Encoding a multi-megabyte video in memory inside the archive stalls the app for tens of seconds — so the format does not do it.
Single-pass, depth-capped validation
The security walk visits each value once and refuses to recurse past 64 levels, so validation cost is linear in document size with a hard ceiling.
What actually costs you frames
The caps tell you what will be refused. This tells you what will be slow well before you reach them.
| Driver | Effect | Guidance |
|---|---|---|
| Layer count | Linear on inflate, layout and per-frame composite | Hard cap is 200. A phone-smooth 9:16 reel usually stays under ~40. |
| Concurrent video layers | Each one costs a decoder | Stagger startTime so overlapping decodes stay low. |
| Full-frame shader layers | GPU-bound every frame | Run one generativeBg or proceduralFilter at a time. |
| Keyframe density | Evaluator cost per frame, per track | Two keyframes and an easing preset beat twenty literal ones — and look better. |
| Bundled asset bytes | Dominates transfer and materialisation time | Prefer an https remote URL over packing bytes, especially for stock media. |
| Document size | Parse and validation are linear in bytes | Stay well under the 5 MB cap; a good template is under 200 KB. |
Impact
The practical consequence of all of this is leverage. One .ectpl file plus a spreadsheet is a video pipeline: the design cost is paid once, and every additional video costs a render. Because the format is declarative and the renderer is deterministic, the same file is safe to version in git, to diff in review, to assert against a reference frame in CI, and to hand to an agent that has never seen your project.
How the app and the engine use it.
In the mobile app
The editor is the reference implementation. A template enters through the Templates gallery, the community gallery, an import from URL, the OS file picker, or the share sheet — and leaves through Save as Snapshot or a community submission. Once inflated it is an ordinary project: the same timeline, the same effects, the same export path to on-device 4K.
Over MCP, for agents
ExpoCut's in-app MCP server exposes the whole lifecycle as typed tools, on a loopback or private network only. The MCP reference has the full surface; these are the template-relevant ones:
| Stage | Tools |
|---|---|
| Identify | detect_format |
| Bring in | import_foreign_file · import_from_file_system · import_template_json · migrate_template |
| Use | list_templates · apply_template |
| Verify | capture_canvas · seek |
| Author | save_project_as_template |
| Publish | submit_template · list_my_submissions · delete_my_submission |
| Round-trip out | export_template_to_lottie · export_template_to_fcpxml |
On a desktop, in bulk
The Render Engine is a single binary for macOS, Windows and Linux that reads the same .ectpl files and emits MP4, HLS, frame sequences, single frames or a live preview stream — no phone involved.
# listings.csv: address, photo_1, photo_2, photo_3, agent_name, agent_logo while IFS=, read address p1 p2 p3 agent logo; do expocut-engine mp4 templates/just-listed.ectpl \ --slot title="$address" \ --slot media_1="$p1" --slot media_2="$p2" --slot media_3="$p3" \ --slot agent_name="$agent" --slot logo="$logo" \ --out "out/$address.mp4" done < listings.csv
Each render is independent, so parallelise freely. Because output is deterministic, a committed reference frame plus a frame diff is a working regression test for a template.
Three ways to build one.
Route A — design it in the app (recommended)
- Build the edit on the timeline until it looks right.
- Save it: Snapshot preserves the edit verbatim, Template slotifies the media and text into a reusable recipe.
- The app packs local assets, writes the archive, and hands you a file you can share, re-import, or feed to the engine.
This route is correct by construction — the serializer emits exactly what the inflater expects, and round-trips back to a structurally identical project.
Route B — hand-author the JSON
Write the document, then bring it in as a template. Work in this order, because each step depends on the one before it:
- Mode and canvas —
mode,aspectRatio,durationMs. - Slots — every hole, with
requiredset honestly. - Tracks — one per object, indexes ascending from the background up.
- Layers — five required fields each; attach
slotRefwhere a slot drives the layer. - Animation — keyframe tracks, remembering microseconds.
- Verify — render a real frame at several timestamps and measure.
Route C — convert something you already have
Lottie / Bodymovin, dotLottie, FCPXML, legacy xmeml, OpenTimelineIO, MOGRT, .cube LUTs and ASC CDL all import and become a template. Fonts are audited on the way in and remapped to the closest local match when the device does not have them.
position and scale, repeat. Every well-aligned template was measured, not eyeballed.Rules for agents, bots and pipelines.
If you are an AI assistant reading, writing, validating or rendering .ectpl files — or a human building automation that does — these are the operating rules. The full text is served as plain text at /ectpl/doctrine.txt and the machine-readable schema at /ectpl/spec.json.
-
Choose the mode before writing a single field
templatefor anything a user re-shoots;snapshotfor a finished, branded piece others tweak. A1.0document with nomodeis read astemplate. Never emit aschemaVersionoutside1.0/1.1. -
Respect the units
Layer times are milliseconds. Keyframe times are integer microseconds. Positions are percentages, rotation is degrees, opacity is 0–1, colour channels are 0–255. Check the magnitude of every time value before you emit it.
-
One layer equals one object
Every layer gets its own
trackIndex. Sequential clips still need distinct indexes. This is the most common machine-authoring failure and it is a hard rejection, not a warning. -
Make every reference resolve
Each
slotRefmust match a declared slot id; slot ids must be unique; required slots must be genuinely required. A dangling reference is refused at import. -
Honour the URI whitelist
Only
https:,asset:,bundled:,bundle:, smalldata:images or audio, and cache-scopedfile:URIs. Never hand-write afile://path — let the packer collect local media and rewrite it. -
Strip private media by default
Community bundles clear source footage unless the user explicitly says the media is theirs to publish. Never bundle a person's face, voice or private footage into a public upload on your own initiative.
-
Measure, do not eyeball
Write the layer, capture a real frame, measure the offset, correct, repeat. Never report a template as correct because the JSON reads correctly.
-
Never invent ids or fields
Enumerate effect, filter, transition, LUT, shape, font and voice ids from the live app. Unknown properties are silently ignored — which looks exactly like "the change did nothing".
-
Stay inside the caps
200 layers · 64 slots · 300 tracks · 5 MB document · 4096 characters per string · 30 minutes · 64 levels of nesting. Check before you emit, not after the rejection.
-
Budget for the device
Stagger concurrent video layers, run one full-frame shader at a time, prefer easing presets over dense keyframes, prefer remote URLs over bundled bytes.
-
Confirm before publishing
Reading, filling and rendering are ordinary work — just do them. Publishing to the community feed, deleting a submission, and overwriting an author's file are not. Say plainly what will become public, and wait.
Machine endpoints
/ectpl/spec.json →
The format as data: container, fields, enums, caps, URI policy, pipeline stages, error strings.
/ectpl/doctrine.txt →
These rules in plain text, with the standard automation flows and a minimum viable template.
/llms-full.txt →
Every ExpoCut capability and MCP tool schema in one file, for agents that need the whole surface.
Standard automation flows
# Understand a template you were handed detect_format → import_foreign_file → inspect slots → report what the user must supply # Fill and render on the phone apply_template { templateId, bindings } → capture_canvas → verify → set_export_settings → export_project # Fill and render in bulk on a desktop expocut-engine mp4 template.ectpl --slot key=value … --out file.mp4 # one row of a CSV per render; independent and deterministic # Author something new build in the editor over MCP → save_project_as_template # (or import_template_json for a hand-authored document) → verify frames → submit_template ONLY when the user asks to publish
What comes in, what goes out.
Imports into .ectpl
Lottie / Bodymovin JSON · dotLottie · FCPXML · xmeml (legacy Final Cut / Premiere) · OpenTimelineIO · MOGRT · Adobe .cube LUT · ASC CDL (.cdl / .ccc)
Exports out of .ectpl
Lottie JSON with animated keyframes · FCPXML with embedded CDL · and of course MP4, HLS, frame sequences and single frames through the render path.
.aep, .prproj, .drp, CapCut .draft and VN .vn are binary or schema-less vendor formats. Export a Lottie or an FCPXML from the source tool instead — the importer says so rather than failing silently.What a rejection means.
| Message | Cause | Fix |
|---|---|---|
| Invalid .ectpl: JSON parse failed | Malformed JSON | Validate the document before sending it. |
| Missing "schemaVersion" field | No version marker | Add "schemaVersion": "1.1". |
| Unsupported .ectpl schemaVersion "x" | Version outside 1.0 / 1.1 | Emit a supported version, or migrate the document. |
| "slots" / "layers" / "tracks" must be an array | Missing structural array | Include all three, even if a snapshot's slots is empty. |
| One layer = one object: trackIndex N holds M objects | Two layers share a track | Give every layer a unique trackIndex. |
| layers[i].slotRef does not match any declared slot | Dangling reference | Declare the slot, or drop the slotRef. |
| Disallowed URL at "path" | Scheme outside the whitelist | Use https: or let the packer produce a bundle:// reference. |
| Template declares N layers — exceeds cap | Over 200 layers | Split the composition, or flatten decorative layers. |
| Invalid .ectpl bundle: no `template.json` at root | Archive missing the document | Put template.json at the archive root, not in a subfolder. |
| `template.json` still compressed | Archive was deflated | Write every entry STORED. |
| Bundle is N bytes, exceeds cap | Over 100 MB zipped | Reference stock media by URL instead of bundling it. |
| Could not read local asset … | Cache file evicted before packing | Re-add the asset in the editor, or keep a remoteUrl fallback on the layer. |
Questions people actually ask.
Is an .ectpl file a video?
No. It is the recipe that produces one — a few hundred kilobytes of instructions rather than a few hundred megabytes of frames. That is what lets a single file generate a thousand different videos, and what makes it small enough to version in git.
Can I open one without ExpoCut?
You can read one with any tool: the zip form opens in any unzip utility and the document inside is plain JSON. Rendering it needs either the ExpoCut app or the Render Engine, because the layout, effects and encoder behaviour are what make the output match.
Why microseconds for keyframes but milliseconds for layers?
Keyframe times are integers in microseconds so a long timeline cannot accumulate floating-point drift. Layer times are milliseconds because they match the editor's clock and the export settings. Two units in one document is a real trap — 400000 in a keyframe is 0.4 seconds; 5000 in a duration is 5 seconds.
Does sharing a template share my footage?
Not by default. The privacy strip clears source media from video, image and audio layers before packing, and drops the remote fallbacks with it. The design survives; the footage does not. You can opt out for content you meant to publish verbatim.
Can an AI write these?
Yes — that is what section 17 is for. The format is declarative JSON with a published machine-readable spec and doctrine. An agent can hand-author a document, drive the in-app MCP server to build and verify it against a real frame, and then render it in bulk from a CLI.
What happens if a bundled asset goes missing?
Materialised assets live in the cache directory, so the OS can reclaim them. When that happens the layer falls back to its remote URL if one is set — which is why keeping remoteUrl populated is worth the bytes.
Will a 1.0 template still work?
Yes. Both 1.0 and 1.1 are accepted and inflate without data loss. A 1.0 document with no mode is read as template, which was its original behaviour.
Can I use the format commercially?
The format documentation on this page is free to use and redistribute with attribution. Commercial use of templates you build depends on your ExpoCut plan and on the licences of any media and fonts you bundle — see plans and the licence rules in section 10.
One file. A thousand videos.
Design it once on the phone, fill it from a spreadsheet, render it anywhere.
Get the Render Engine Read the machine spec