{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://expocut.com/ectpl/spec.json",
  "title": "ExpoCut .ectpl format specification",
  "description": "Machine-readable specification of the .ectpl video-template format used by the ExpoCut mobile editor and the ExpoCut Render Engine. This document describes the container, the JSON document schema, the resource caps, the URI security policy, and the import/inflate/render pipeline. Human-readable companion: https://expocut.com/ectpl.html. Agent doctrine: https://expocut.com/ectpl/doctrine.txt",
  "specVersion": "1.1",
  "updated": "2026-08-09",
  "homepage": "https://expocut.com/ectpl.html",
  "doctrine": "https://expocut.com/ectpl/doctrine.txt",
  "license": "Format documentation free to use and redistribute with attribution to expocut.com.",

  "container": {
    "mediaType": "application/vnd.expocut.template+zip",
    "alternateMediaType": "application/vnd.expocut.template+json",
    "extension": ".ectpl",
    "forms": [
      {
        "form": "zip",
        "magicBytes": "50 4B 03 04",
        "compression": "STORED only (PKZIP method 0). No DEFLATE, no ZIP64, no encryption, no data descriptors.",
        "spec": "PKWARE APPNOTE.TXT 6.3.6",
        "layout": {
          "template.json": "REQUIRED. The template document, at the archive root.",
          "assets/<sha256-16>.<ext>": "OPTIONAL. Bundled media referenced by bundle:// URIs.",
          "thumbnail.<ext>": "OPTIONAL. jpg | jpeg | png | webp. Max 512 KB.",
          "preview.mp4": "OPTIONAL. Silent looping demo clip."
        },
        "entryNameRules": [
          "forward-slash separated",
          "no leading '/'",
          "no backslash",
          "no drive prefix (C:/)",
          "no '..' path segment",
          "no NUL byte",
          "max 1024 characters"
        ]
      },
      {
        "form": "json",
        "description": "A bare template document served or shared as text. No bundled assets; every asset reference must already be resolvable (https: / asset: / data:)."
      }
    ]
  },

  "detection": {
    "order": ["filename extension", "magic bytes", "leading-token probe on first 4096 bytes"],
    "rules": [
      { "signal": "extension .ectpl + PK zip magic", "format": "ectpl", "confidence": 1.0 },
      { "signal": "JSON head matches \"schemaVersion\":\"1.0|1.1\" AND (\"slots\": OR \"mode\":\"template|snapshot\")", "format": "ectpl", "confidence": 1.0 },
      { "signal": "extension .ectpl only", "format": "ectpl", "confidence": 0.7 }
    ]
  },

  "document": {
    "schemaVersion": {
      "current": "1.1",
      "accepted": ["1.0", "1.1"],
      "notes": "1.1 added `mode` and `bundle`. A 1.0 document with no `mode` is read as mode:'template'."
    },
    "modes": {
      "template": "A recipe. Declares slots the user fills; media URIs are stripped and text is slotified. Reusable across projects. Default when `mode` is absent.",
      "snapshot": "A verbatim edit. Every layer property is preserved (media URIs, keyframes, effects, masks, light zones, camera, widget configs). No user-fillable slots required."
    },
    "requiredFields": ["schemaVersion", "id", "name", "category", "aspectRatio", "durationMs", "tracks", "layers"],
    "requiredInTemplateMode": ["slots (>= 1 entry)"],
    "fields": {
      "schemaVersion": { "type": "string", "enum": ["1.0", "1.1"] },
      "mode": { "type": "string", "enum": ["template", "snapshot"], "default": "template" },
      "id": { "type": "string", "constraint": "non-empty, globally unique" },
      "name": { "type": "string" },
      "description": { "type": "string", "optional": true },
      "category": { "type": "string", "enum": ["For You", "Real Estate", "Promo", "Birthday", "Travel", "Food", "Events", "My Templates"] },
      "thumbnail": { "type": "string | number", "optional": true, "notes": "URL, bundle:// path, or a numeric require() id." },
      "previewVideoUri": { "type": "string", "optional": true, "notes": "Silent looping preview. May be an https URL or bundle://preview.mp4." },
      "aspectRatio": { "type": "string", "enum": ["9:16", "4:5", "2:3", "3:4", "1:1", "16:9", "1.91:1", "4:3", "21:9", "2.35:1", "2.39:1"] },
      "resolution": { "type": "string", "optional": true, "notes": "Authored pixel size; used by imported MOGRT templates." },
      "durationMs": { "type": "number", "unit": "milliseconds", "max": 1800000 },
      "slotCounts": { "type": "object", "optional": true, "notes": "Gallery hint only: { video, image, text, audio, logo }." },
      "slots": { "type": "TemplateSlot[]" },
      "tracks": { "type": "TemplateTrack[]", "minItems": 1 },
      "layers": { "type": "LayerPrototype[]", "minItems": 1 },
      "applyBrandingByDefault": { "type": "boolean", "optional": true },
      "voiceover": { "type": "TemplateVoiceover", "optional": true },
      "palettes": { "type": "Palette[]", "optional": true, "notes": "Semantic @role colour tokens. First entry is the authored default." },
      "bundle": { "type": "BundleManifest", "optional": true, "notes": "Present when the template travels inside a zip." }
    }
  },

  "types": {
    "TemplateSlot": {
      "id": { "type": "string", "required": true, "constraint": "unique within the document" },
      "label": { "type": "string", "required": true },
      "kind": { "type": "string", "required": true, "enum": ["video", "image", "video|image", "text", "audio", "logo", "color", "number", "angle", "scale", "point"] },
      "required": { "type": "boolean", "optional": true },
      "preferredDurationMs": { "type": "number", "optional": true },
      "default": { "type": "string", "optional": true, "notes": "Fallback for text slots." },
      "bindFromBrand": { "type": "string", "optional": true, "enum": ["logo", "name", "slogan", "website", "phone", "email", "address"] },
      "defaultBinding": { "type": "SlotValue", "optional": true, "notes": "Pre-bound value so the template previews on a device that never had the author's local file." }
    },
    "SlotValue": {
      "oneOf": [
        { "kind": "media", "fields": ["uri", "remoteUrl?", "posterUri?", "durationMs?", "width?", "height?"] },
        { "kind": "text", "fields": ["value"] },
        { "kind": "color", "fields": ["hex"] },
        { "kind": "number", "fields": ["value"] },
        { "kind": "angle", "fields": ["degrees"] },
        { "kind": "point", "fields": ["x", "y"] },
        { "kind": "scale", "fields": ["x", "y"] }
      ]
    },
    "TemplateTrack": {
      "id": { "type": "string", "required": true },
      "type": { "type": "string", "required": true },
      "name": { "type": "string", "required": true },
      "trackIndex": { "type": "number", "required": true, "notes": "Z-order AND timeline row. Higher renders on top." }
    },
    "LayerPrototype": {
      "description": "Partial<Layer> plus the authoring fields. Any Layer property may appear verbatim.",
      "required": ["id", "type", "startTime", "duration", "trackIndex"],
      "optionalAuthoring": ["slotRef", "lowerThirdLineSlots"],
      "startTime": { "unit": "milliseconds" },
      "duration": { "unit": "milliseconds" },
      "position": { "unit": "percent of canvas, 0-100", "notes": "{x:50,y:50} is dead centre. Layers scale about their own centre." },
      "fontSize": { "unit": "canvas points", "conversion": "fontSize ~= design_px * 0.366 on a 1080-wide reference design" },
      "types": ["video", "audio", "text", "image", "animation", "collage", "lowerthird", "transcript", "shape", "generativeBg", "proceduralFilter", "ticker", "clock", "scoreboard", "poll", "statbar", "quote", "banner", "newsalert", "follower", "likeburst", "comment", "qrcode", "searchbar", "weather", "caption", "confetti", "firemeter", "bar-chart-race", "shapeWidget", "three"]
    },
    "LayerAnimation": {
      "notes": "Optional `keyframes` object on any layer prototype.",
      "tracks": "Track[] — scalar property tracks. Track = { property, keyframes[], timeMode? }",
      "positionTrack": "PositionTrack — grouped 2D motion path { keyframes[], timeMode? }; takes precedence over transform.x / transform.y. Position keyframes may carry spatial tangents `to` / `ti`.",
      "discreteTracks": "DiscreteTrack[] — held-step tracks for non-numeric values { property, keyframes[] }: 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, shader.filters, fitMode, stretchToCanvas",
      "timeMode": "'absolute' (clip-relative microseconds, the default) or 'relative' (normalized to clip duration, stored as t / 1e6 in 0..1)",
      "Keyframe": { "t": "integer MICROSECONDS", "v": "number", "out": "Interp — how the value leaves this keyframe" },
      "Interp": [
        { "type": "hold" },
        { "type": "linear" },
        { "type": "bezier", "fields": ["x1", "y1", "x2", "y2"], "constraint": "x1,x2 in [0,1]; y may overshoot" },
        { "type": "preset", "name": ["ease", "easeIn", "easeOut", "easeInOut", "flow", "jumper", "discer", "bounce", "elastic", "spring"] }
      ],
      "propertyPaths": ["transform.x", "transform.y", "transform.scale", "transform.scaleX", "transform.scaleY", "transform.rotation", "transform.anchorX", "transform.anchorY", "opacity", "color.hueShift", "color.saturation", "color.brightness", "color.contrast", "color.intensity", "fx.blur", "fx.intensity", "mask.rect.x", "mask.rect.y", "mask.rect.width", "mask.rect.height", "mask.feather", "mask.expansion", "mask.rotation", "mask.bandWidth", "mask.gradientSoftness", "mask.contentScale", "mask.contentOffsetX", "mask.contentOffsetY", "mask.contentRotation", "secondaryEffect.intensity", "audio.volume", "filter.id", "filter.intensity", "transition.inIntensity", "transition.outIntensity", "border.width", "border.glowIntensity", "border.cornerRadius", "border.color.r", "border.color.g", "border.color.b", "text.color.r", "text.color.g", "text.color.b", "text.stroke.color.r", "text.stroke.color.g", "text.stroke.color.b", "text.stroke.width", "speed"],
      "unitNotes": {
        "transform.x": "percent of canvas 0-100",
        "transform.rotation": "degrees",
        "opacity": "0-1",
        "mask.rect.*": "normalized 0-1 layer-local",
        "mask.feather": "0-100 in the 1080p reference frame",
        "audio.volume": "0-1",
        "colour channels": "0-255"
      }
    },
    "TemplateVoiceover": {
      "slotId": "string — an audio-kind slot that receives the generated narration",
      "script": "string — may contain TTS script markers",
      "voiceId": "string, optional",
      "speed": "number, optional (~0.8-1.2)"
    },
    "BundleManifest": {
      "version": { "const": "1.0" },
      "title": { "type": "string", "optional": true },
      "assets": { "type": "BundledAsset[]" }
    },
    "BundledAsset": {
      "id": "string — stable; deduped by sha256",
      "kind": { "enum": ["font", "lut", "image", "video", "audio", "data"] },
      "path": "string — path relative to the bundle root, e.g. assets/9f2c….mp4",
      "sizeBytes": "number, optional",
      "sha256": "string, optional — hex; drives de-duplication",
      "mimeType": "string, optional",
      "license": { "spdx": "string, optional", "redistributable": "boolean", "note": "string, optional" }
    }
  },

  "security": {
    "policy": "Enforced at every untrusted boundary: OS file picker, URL import, local-storage reload, MCP import, foreign-format router.",
    "allowedUriSchemes": {
      "https:": "TLS-only remote assets.",
      "asset:": "Bundled app asset, opaque id.",
      "bundled:": "Legacy bundled-asset alias.",
      "bundle:": "In-archive reference. Resolves only against bytes that shipped inside the validated archive.",
      "file:": "ONLY under the app's own /ectpl/ materialisation cache, with no '..' segment. Written by the importer from validated archive bytes, never authored by the template.",
      "data:": "image/* or audio/* only, max 262144 bytes.",
      "number": "A numeric require() id for an app-bundled asset."
    },
    "rejectedUriSchemes": ["http:", "content:", "ftp:", "javascript:", "vbscript:", "blob:", "chrome-extension:", "any scheme-less relative path"],
    "urlBearingFields": ["uri", "posterUri", "remoteUrl", "previewVideoUri", "thumbnail", "src", "audioUri", "videoUri", "imageUri", "logoUri", "maskUri"],
    "structuralRules": [
      "One layer = one object: at the AI/import boundary every layer must occupy its own trackIndex.",
      "Every layers[].slotRef must match a declared slots[].id.",
      "Tree walk is depth-capped at 64 levels."
    ]
  },

  "limits": {
    "templateJsonBytes": { "max": 5242880, "note": "5 MB, checked BEFORE JSON.parse" },
    "layers": { "max": 200 },
    "slots": { "max": 64 },
    "tracks": { "max": 300 },
    "stringFieldChars": { "max": 4096 },
    "durationMs": { "max": 1800000, "note": "30 minutes" },
    "dataUrlBytes": { "max": 262144, "note": "256 KB" },
    "treeDepth": { "max": 64 },
    "thumbnailBytes": { "max": 524288, "note": "512 KB; downscale to ~600 px on the long side" },
    "submittedBundleBytes": { "max": 104857600, "note": "100 MB zipped, enforced client- and server-side" },
    "zipWriterDefaultCeilingBytes": { "max": 268435456, "note": "256 MB uncompressed" },
    "uploadTimeoutMs": 180000,
    "cdnCacheTtlMs": 600000,
    "cdnFetchTimeoutMs": 15000,
    "typicalTemplateJsonBytes": "under 200 KB for a hand-authored or app-saved template"
  },

  "pipeline": {
    "author": [
      "Edit a project in the ExpoCut editor (or hand-author the JSON).",
      "Serialize: projectToTemplate (slotified recipe) or projectToSnapshot (verbatim edit).",
      "Optional privacy pass: strip video/image/audio source URIs, proxies, bakes and remoteSource fallbacks. ON by default for community shares.",
      "Collect every remaining file:// reference from the media-bearing layer fields.",
      "sha256 each asset, de-duplicate, rewrite the layer reference to bundle://assets/<hash16>.<ext>.",
      "Compose the BundleManifest and attach it under `bundle`.",
      "writeZip(template.json + assets/* + thumbnail.*) using STORED only.",
      "Upload as multipart/form-data. A rendered demo MP4 travels as a SEPARATE file part, never inside the zip."
    ],
    "import": [
      "Magic-byte sniff: PK\\x03\\x04 -> zip route, otherwise text route.",
      "probeFormat on the first 4096 bytes.",
      "readZip; require template.json at the archive root.",
      "Parse and validate: schemaVersion, id, slots/layers/tracks arrays, one-layer-per-track, bundle manifest and font licences.",
      "Security check: URI scheme whitelist plus the shape caps above.",
      "materializeBundleAssets: write archive bytes to <cache>/ectpl/<templateId>/… and rewrite every bundle:// URI to file://.",
      "inflateTemplate(template, bindings, brand, assetOverrides) -> ProjectContent { layers, tracks, timelineClips }.",
      "loadProject into the editor store — no special-casing beyond this point."
    ],
    "slotResolutionPriority": ["user binding", "slots[].defaultBinding", "brand profile via bindFromBrand", "slots[].default"],
    "render": [
      "Canvas preview and native export consume the same inflated ProjectContent.",
      "Export runs on-device via AVFoundation (iOS) or MediaCodec (Android).",
      "The desktop Render Engine reads the same .ectpl and is deterministic: same inputs, same pixels."
    ]
  },

  "performance": {
    "designNotes": [
      "STORED-only zip: media is already compressed, DEFLATE buys 1-2% for real CPU cost, and a store-only reader needs no zip-bomb defence.",
      "sha256 de-duplication collapses a proxy and its original into one archive entry.",
      "A rendered demo MP4 is never packed into the zip — pushing multi-MB video through base64 in JS blocks the engine thread for tens of seconds.",
      "Materialised assets land in the OS cache directory so they can be reclaimed; remoteSource.url is the durable re-fetch path.",
      "The security walker is depth-capped and single-pass, so validation is linear in document size."
    ],
    "costDrivers": [
      { "driver": "layer count", "effect": "linear on inflate, layout and per-frame composite", "guidance": "hard cap 200; keep under ~40 for a phone-smooth 9:16 reel" },
      { "driver": "concurrent video layers", "effect": "each costs a decoder", "guidance": "keep simultaneous video decodes low; stagger startTime" },
      { "driver": "generativeBg / proceduralFilter shader layers", "effect": "GPU-bound per frame", "guidance": "one full-frame shader at a time" },
      { "driver": "keyframe density", "effect": "evaluator cost per frame per track", "guidance": "prefer easing presets over dozens of literal keyframes" },
      { "driver": "bundled asset bytes", "effect": "dominates transfer and materialisation time", "guidance": "prefer https remoteUrl over bundling for stock media" }
    ]
  },

  "interfaces": {
    "app": {
      "surface": "ExpoCut mobile editor (iOS / Android)",
      "entryPoints": ["Templates gallery", "Community / CDN gallery", "Import from URL", "OS file picker and share sheet", "Save as Snapshot", "Submit to community"]
    },
    "mcp": {
      "surface": "ExpoCut in-app MCP server (loopback / private network only)",
      "reference": "https://expocut.com/mcp.html",
      "tools": ["detect_format", "import_foreign_file", "import_from_file_system", "import_template_json", "migrate_template", "list_templates", "apply_template", "save_project_as_template", "submit_template", "list_my_submissions", "delete_my_submission", "export_template_to_lottie", "export_template_to_fcpxml", "capture_canvas", "export_project"]
    },
    "engine": {
      "surface": "ExpoCut Render Engine — single-binary CLI for macOS, Windows, Linux",
      "reference": "https://expocut.com/render-engine.html",
      "example": "expocut-engine mp4 templates/just-listed.ectpl --slot title=\"12 Oak Ave\" --slot media_1=photo.jpg --out out/listing.mp4",
      "determinism": "Same inputs produce the same pixels — safe for CI snapshot assertions."
    }
  },

  "interoperability": {
    "importableInto_ectpl": ["Lottie / Bodymovin JSON", "dotLottie", "FCPXML", "xmeml (FCP7 / legacy Premiere)", "OpenTimelineIO", "MOGRT (Premiere / After Effects)", "Adobe .cube LUT", "ASC CDL (.cdl / .ccc)"],
    "exportableFrom_ectpl": ["Lottie JSON (animated keyframes)", "FCPXML (with embedded CDL)"],
    "notImportable": [".aep", ".prproj", ".drp", "CapCut .draft", "VN .vn"]
  },

  "errors": [
    { "message": "Invalid .ectpl: JSON parse failed", "cause": "malformed JSON" },
    { "message": "Invalid .ectpl: missing \"schemaVersion\" field", "cause": "no version marker" },
    { "message": "Unsupported .ectpl schemaVersion \"x\"", "cause": "version outside 1.0 / 1.1" },
    { "message": "Invalid .ectpl: \"slots\" | \"layers\" | \"tracks\" must be an array", "cause": "missing structural array" },
    { "message": "One layer = one object: trackIndex N holds M objects", "cause": "two layers share a trackIndex at the AI/import boundary" },
    { "message": "Disallowed URL at \"path\"", "cause": "URI scheme outside the whitelist" },
    { "message": "Invalid .ectpl bundle: no `template.json` at root", "cause": "zip missing the document" },
    { "message": "Invalid .ectpl bundle: `template.json` still compressed", "cause": "archive was not written STORED" },
    { "message": "Bundle is N bytes, exceeds cap", "cause": "submission over 100 MB" }
  ]
}
