Format specification · v1.1

.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.

application/vnd.expocut.template+zip schemaVersion 1.0 · 1.1 JSON or STORED zip Updated 2026-08-09
On this page
01 · Identity

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.

FormSignatureContainsUsed for
JSONText starting {The template document onlyHand-authoring, CDN-served templates, agent output, API payloads
ZIP50 4B 03 04template.json + assets + thumbnailSharing, 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.

SignalResultConfidence
.ectpl extension + PK zip magicectpl (bundle)1.0
JSON head matching "schemaVersion":"1.0"|"1.1" and "slots": or "mode":"template"|"snapshot"ectpl (document)1.0
.ectpl extension aloneectpl0.7
02 · Container

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:

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.

Reading a bundle yourself? Require 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.
03 · Document

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.

FieldTypeNotes
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 reqstringNon-empty, globally unique.
name reqstringDisplay name in the gallery.
descriptionstringOne line, shown under the name.
category reqenumFor You · Real Estate · Promo · Birthday · Travel · Food · Events · My Templates
thumbnailstring | numberURL, bundle:// path, or a numeric app-asset id.
previewVideoUristringSilent looping preview for the gallery card.
aspectRatio reqenumSee the canvas table below.
resolutionstringAuthored pixel size. Set by imported motion-graphics templates.
durationMs reqnumberMilliseconds. Capped at 1 800 000 (30 min).
slotCountsobjectGallery hint only: { video, image, text, audio, logo }.
slots req*TemplateSlot[]At least one in template mode. May be empty in snapshot mode.
tracks reqTemplateTrack[]At least one.
layers reqLayerPrototype[]At least one. Max 200.
applyBrandingByDefaultbooleanApplies the user's saved brand profile at inflate time.
voiceoverobject{ slotId, script, voiceId?, speed? } — generates narration into an audio slot.
palettesPalette[]Semantic @role colour themes. The first is the authored default; swapping one re-colours text, shapes, gradients and glows at once.
bundleBundleManifestPresent 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
            ] }
        ]
      }
    }
  ]
}
04 · Modes

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 isA blank frame with fillable holesA finished edit preserved property-for-property
Media layersEmpty content, filled from slotsReal URIs, inline vectors, bakes — all intact
SlotsRequired, at least oneOptional; used to expose just the editable bits
TextSlotifiedVerbatim, unless a slotRef opts it in
Effects, masks, camera, keyframesPreservedPreserved
ReuseAny footage, any userSame edit, tweakable
Use it forA montage users re-shoot — travel, promo, listingsA branded, opinionated piece others adjust
Version note. 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.
05 · Canvas

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.

aspectRatioTypical exportWhere it goes
9:161080 × 1920Reels, TikTok, Shorts
4:51080 × 1350Feed video — maximum feed real estate
1:11080 × 1080Square
16:91920 × 1080YouTube, landscape
2:3 · 3:4 · 4:3 · 1.91:1 · 21:9 · 2.35:1 · 2.39:1Also supported; the picker in the app is the same list.

Units, in one table

PropertyUnitRange / note
durationMs, startTime, durationmillisecondsInteger-ish. Same clock as the editor and export settings.
keyframe tmicrosecondsInteger. Chosen so long timelines cannot drift.
position.x / .ypercent0–100. {x:50,y:50} is dead centre.
rotationdegrees
opacity, audio.volumeratio0–1
mask.rect.*normalized0–1, layer-local
mask.featherreference px0–100 in the 1080p reference frame
colour channels8-bit0–255 per channel
fontSizecanvas pointsConvert from a 1080-wide design: fontSize ≈ design_px × 0.366
The unit trap. A single document mixes milliseconds and microseconds. "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

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)
06 · Slots

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

kindAcceptsTypical use
videoA video assetB-roll shot, product clip
imageA stillPhoto, product shot
video|imageEither"Drop anything here" media slot
textA stringHeadline, price, address
audioAn audio assetMusic bed, narration target
logoAn imageBrand mark — usually brand-bound
color#RRGGBBAccent colour driving several layers
numberA numberA count, a percentage, a stat
angleDegreesTilt of a badge or frame
scale{x, y}Size of a placed element
point{x, y}A position the user drags

Slot fields

Resolution priority

Every slot resolves once, before any layer is built, so layers sharing a slot always agree:

user binding slot.defaultBinding brand profile slot.default

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.
07 · Tracks

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 }
One layer = one object. At the AI and import boundary — hand-authored JSON, an agent's output, a template built over MCP — every layer must occupy its own 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.

08 · Layers

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

GroupTypes
Mediavideo · image · audio · collage
Type & captionstext · caption · transcript · lowerthird · quote
Graphicsshape (incl. brush strokes via shapeConfig.type: "custom_path") · shapeWidget · animation (vector animation) · three (live 3D scene)
GenerativegenerativeBg · proceduralFilter · confetti
Broadcast & dataticker · banner · newsalert · clock · scoreboard · poll · statbar · bar-chart-race · firemeter · weather · qrcode · searchbar
Socialfollower · 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.

Never invent an id. Effect, filter, transition, LUT, shape, brush, font and voice ids are enumerated from the running app — through the MCP introspection tools, or from the published catalogues in the Showcase. An invented id does not error; it silently does nothing, which reads as "my change had no effect".
09 · Animation

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:

ShapeFormNote
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

GroupPaths
Transformtransform.x .y .scale .scaleX .scaleY .rotation .anchorX .anchorY
Appearanceopacity · fx.blur · fx.intensity · filter.id · filter.intensity
Colourcolor.hueShift .saturation .brightness .contrast .intensity · text.color.r|g|b · text.stroke.color.r|g|b · border.color.r|g|b
Maskmask.rect.x|y|width|height · mask.feather · .expansion · .rotation · .bandWidth · .gradientSoftness · .contentScale · .contentOffsetX|Y · .contentRotation
Border & textborder.width · .glowIntensity · .cornerRadius · text.stroke.width
Transitionstransition.inIntensity · transition.outIntensity · secondaryEffect.intensity
Audio & timeaudio.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.

10 · Bundle

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.

Rendered demo videos are never packed inside the zip. A demo clip travels as a separate upload part streamed straight from disk. Pushing a multi-megabyte video through an in-memory encode inside the archive blocks the app for tens of seconds — so the format keeps heavy video out of the container entirely and references the published URL instead.
11 · Security

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

SchemeVerdictWhy
https:AllowedTLS-only remote assets.
asset: · bundled:AllowedApp-bundled assets, referenced by opaque id.
bundle:AllowedIn-archive reference. Can only resolve against bytes that shipped inside the already-validated archive.
data:Allowed, cappedimage/* or audio/* only, at most 256 KB.
file:Allowed, scopedOnly 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 idAllowedAn app-bundled asset reference.
http:RejectedCleartext.
any other file:RejectedSandbox read — the core threat.
content: ftp: blob: chrome-extension:RejectedNot a legitimate asset source.
javascript: vbscript:RejectedNever executable, never accepted.
scheme-less relative pathRejectedAmbiguous — 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.

12 · Limits

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.

5 MB
template document, checked before parsing
200
layers
100 MB
shared bundle, zipped
CapValueApplies to
Document size5 242 880 bytes (5 MB)Checked before the JSON is parsed.
Layers200Per document.
Slots64Per document.
Tracks300Per document.
String field length4096 charactersAny string, anywhere — text bodies, labels, URLs.
Duration1 800 000 ms (30 min)durationMs.
Nesting depth64 levelsThe validation walk refuses to go deeper.
data: URL262 144 bytes (256 KB)Per URL, images and audio only.
Thumbnail524 288 bytes (512 KB)Downscale to roughly 600 px on the long side.
Shared bundle104 857 600 bytes (100 MB)Zipped, enforced on both ends.
Archive ceiling268 435 456 bytes (256 MB)Default uncompressed limit for the writer.
Entry name1024 charactersNo absolute paths, backslashes, drive prefixes, NULs or ...
Upload timeout180 sGenerous — a bundle can carry a demo video over a mobile uplink.
Catalogue cache10 min TTL · 15 s fetch timeoutRemote template listings.
Typical documentunder 200 KBObserved size of a hand-authored or app-saved template.
13 · Lifecycle

From an edit, to a file, to a finished video.

Two directions. Neither has a hidden step — this is the whole path.

Packing

edit serialize privacy strip collect local assets hash + dedup rewrite to bundle:// manifest STORED zip publish
  1. The project is serialized either as a slotified recipe or as a verbatim snapshot.
  2. For a public share, the privacy strip clears source footage.
  3. 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.
  4. Each asset is hashed, de-duplicated, and rewritten to bundle://assets/<hash16>.<ext>.
  5. 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 probe read zip validate security check materialise assets inflate project render
  1. Sniff the magic bytes: a PK header takes the zip route, anything else takes the text route.
  2. Probe the first 4 KB to classify the format and produce a confidence.
  3. Read the archive and require template.json at the root.
  4. Validate: schema version, required fields, structural arrays, one-layer-per-track, slot references, and the bundle manifest including font licences.
  5. Security check: URI whitelist plus every cap in the table above.
  6. Materialise: archive bytes are written into the app's /ectpl/<templateId>/ cache and every bundle:// URI is rewritten to the matching local path. Skipping this step is what produces the classic "text loaded but no video" bug.
  7. 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.
  8. Render: from here on there is no special-casing. It is an ordinary project, and the canvas and the export read the same data.
Materialised files live in the cache directory on purpose. The OS can reclaim them under storage pressure. The durable re-fetch path is the layer's remote URL — which is why keeping remoteUrl populated matters more than it looks.
14 · Performance

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.

DriverEffectGuidance
Layer countLinear on inflate, layout and per-frame compositeHard cap is 200. A phone-smooth 9:16 reel usually stays under ~40.
Concurrent video layersEach one costs a decoderStagger startTime so overlapping decodes stay low.
Full-frame shader layersGPU-bound every frameRun one generativeBg or proceduralFilter at a time.
Keyframe densityEvaluator cost per frame, per trackTwo keyframes and an easing preset beat twenty literal ones — and look better.
Bundled asset bytesDominates transfer and materialisation timePrefer an https remote URL over packing bytes, especially for stock media.
Document sizeParse and validation are linear in bytesStay 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.

15 · Integration

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:

StageTools
Identifydetect_format
Bring inimport_foreign_file · import_from_file_system · import_template_json · migrate_template
Uselist_templates · apply_template
Verifycapture_canvas · seek
Authorsave_project_as_template
Publishsubmit_template · list_my_submissions · delete_my_submission
Round-trip outexport_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.

16 · Authoring

Three ways to build one.

Route A — design it in the app (recommended)

  1. Build the edit on the timeline until it looks right.
  2. Save it: Snapshot preserves the edit verbatim, Template slotifies the media and text into a reusable recipe.
  3. 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:

  1. Mode and canvasmode, aspectRatio, durationMs.
  2. Slots — every hole, with required set honestly.
  3. Tracks — one per object, indexes ascending from the background up.
  4. Layers — five required fields each; attach slotRef where a slot drives the layer.
  5. Animation — keyframe tracks, remembering microseconds.
  6. 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.

Verify on pixels, never on JSON. A document can be perfectly valid and still put your headline off-screen. Capture a real frame, measure the offset, correct position and scale, repeat. Every well-aligned template was measured, not eyeballed.
17 · AI doctrine

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.

  1. Choose the mode before writing a single field

    template for anything a user re-shoots; snapshot for a finished, branded piece others tweak. A 1.0 document with no mode is read as template. Never emit a schemaVersion outside 1.0 / 1.1.

  2. 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.

  3. 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.

  4. Make every reference resolve

    Each slotRef must match a declared slot id; slot ids must be unique; required slots must be genuinely required. A dangling reference is refused at import.

  5. Honour the URI whitelist

    Only https:, asset:, bundled:, bundle:, small data: images or audio, and cache-scoped file: URIs. Never hand-write a file:// path — let the packer collect local media and rewrite it.

  6. 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.

  7. 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.

  8. 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".

  9. 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.

  10. 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.

  11. 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

Standard automation flows

# Understand a template you were handed
detect_formatimport_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_settingsexport_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
18 · Interoperability

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.

Not importable, by design: .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.
19 · Errors

What a rejection means.

MessageCauseFix
Invalid .ectpl: JSON parse failedMalformed JSONValidate the document before sending it.
Missing "schemaVersion" fieldNo version markerAdd "schemaVersion": "1.1".
Unsupported .ectpl schemaVersion "x"Version outside 1.0 / 1.1Emit a supported version, or migrate the document.
"slots" / "layers" / "tracks" must be an arrayMissing structural arrayInclude all three, even if a snapshot's slots is empty.
One layer = one object: trackIndex N holds M objectsTwo layers share a trackGive every layer a unique trackIndex.
layers[i].slotRef does not match any declared slotDangling referenceDeclare the slot, or drop the slotRef.
Disallowed URL at "path"Scheme outside the whitelistUse https: or let the packer produce a bundle:// reference.
Template declares N layers — exceeds capOver 200 layersSplit the composition, or flatten decorative layers.
Invalid .ectpl bundle: no `template.json` at rootArchive missing the documentPut template.json at the archive root, not in a subfolder.
`template.json` still compressedArchive was deflatedWrite every entry STORED.
Bundle is N bytes, exceeds capOver 100 MB zippedReference stock media by URL instead of bundling it.
Could not read local asset …Cache file evicted before packingRe-add the asset in the editor, or keep a remoteUrl fallback on the layer.
20 · FAQ

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