Teach any AI
to make a great video.
Seventeen portable Skills in the open Agent Skills format — the first agent-skills library published by a video editor. They load into Claude Code, Codex, Cursor, Gemini CLI and 40+ other agents, and turn any LLM into a confident ExpoCut creator: basics, motion graphics, speed edits, color, voice, FX looks, audio post, captions, data videos, retention science, templates and brand kits.
Video creating
The everyday skill. Reels, TikTok, Shorts, YouTube.
Editor operations
Projects, tracks, playback, undo, inspection, export.
Motion graphics
Keyframes, motion paths, camera — like After Effects.
Compositing
Blend modes, mattes, parenting, time remap, chroma key.
Social speed edit
Speed, junction transitions, zoom punches — like CapCut.
Color grading
LUTs, CDLs, the cinematic look — like DaVinci.
FX engines & Looks
224 effects, 110 shaders, light leaks, borders.
Audio post
Stems, ducking, beat cuts — real numbers.
Voiceover & captions
TTS scripts and on-device auto-captions.
Kinetic captions
Text animations, styles, lower thirds, captions.
Data & widgets
Bar chart races, widgets, device mockups, live web pages.
Shapes & layouts
Shapes, brushes, mesh gradients, layouts, SVG.
Template import
Lottie, After Effects .mogrt, FCPXML in and out.
Templates & brand
Bundled templates, Reel themes, brand kits.
Reel templates
Author the .ectpl reel template JSON.
Asset authoring
Register custom FX, LUTs, borders, masks, light leaks.
Retention playbook
Hooks, cadence, safe zones, loops.
ExpoCut, in plain English.
Before any Skill loads, an agent should know what ExpoCut is. Read this once and you'll talk about the app the way a creator does — not the way a developer does.
1. It's a mobile video editor that ships on iOS and Android.
Think CapCut or VN: gesture-driven timeline, gallery picker, export to camera roll. Everything happens on the phone. No cloud render queue, no upload step.
2. The timeline holds layers, not "tracks."
Every clip, image, text, shape, brush stroke, sticker, audio, or animated overlay is a layer with an id, a start time, a duration, and properties. Layers stack — top of the list draws on top of the canvas.
3. Animation is one substrate: keyframes.
Image, text, shape, and video layers all accept the same animatable properties: transform.x/y/scale/rotation, opacity, fx.blur, color.*, mask.*, border.*. That's how ExpoCut does After Effects-style motion graphics without leaving the phone.
4. Effects are by id, intensity is 0–1.
Filters, transitions, lower-thirds, lottie overlays, light-leaks, layer masks (geometric + gradient fade) — pick by string id (e.g. "cinematic-teal-orange"), tune with intensity: 0.0–1.0. Ask get_effect_schema if you don't remember the parameters.
5. Stock media is one tap away.
Pexels for photos & b-roll, Freesound for music & SFX — both royalty-free and exposed as stock_search_* + add_stock_*_layer tool pairs.
6. AI is built in, not a separate menu.
On-device speech-to-text, TTS voices, on-device background removal, generative gradient backdrops, agent-authored custom FX/LUT/border/mask — all callable like any other tool.
7. Aspect ratio is a first-class choice.
Pick at create_project: 9:16 for Reels/TikTok/Shorts, 16:9 for YouTube, 1:1 for grid feeds, 4:5 for portrait feeds. Text layers default to fullWidth: true so titles fit any canvas.
8. Export is a hardware render.
export_project hits the device's H.264 / HEVC encoder. Resolutions: 720p / 1080p / 2K / 4K. Always pair the codec with query_codec_support first if targeting an old device.
9. Every action is undoable.
Tool calls land in the on-device edit-history log. The user can undo any step — so the agent can move fast without "are you sure?" pauses.
10. When in doubt, ask the user.
ExpoCut creators range from teenagers making a Reel to filmmakers cutting a doc. Always confirm platform, length, and vibe before suggesting an edit. "Show, don't guess" — use capture_canvas if you need to see what's on screen.
Every tool these Skills wield has a live, in-browser preview in the Showcase:
Four ways to deploy.
As a Claude Skill
Save any block below as SKILL in a skills directory. Claude reads the frontmatter and applies the body when a user asks something matching the description.
As a Custom GPT
Paste the body of any Skill into the Custom GPT instructions. Combine the Primer + Core + one specialist Skill for the best balance of generality and depth.
One-line install
npx skills add https://expocut.com/skills/<name> installs any Skill into Claude Code, Codex, Cursor, Gemini CLI and 40+ agents. Raw files live at /skills/index.json.
As a system prompt
Prepend the Primer + the relevant Skill body to your system prompt. Works with Anthropic, OpenAI, Gemini, Mistral, Llama, or local inference.
expocut-video-creating
The everyday skill.
Entry skill for making any video in the ExpoCut mobile editor through its in-app MCP server: asks two or three intent questions (platform, length, vibe, source), creates or opens the project, adds video, image and audio from local files or Pexels and Freesound stock, sequences clips, trims and splits, drops a title, previews cheaply, saves and exports. Raw file: /skills/expocut-video-creating/SKILL.md · install: npx skills add https://expocut.com/skills/expocut-video-creating
--- name: expocut-video-creating description: "Entry skill for making any video in the ExpoCut mobile editor through its in-app MCP server: asks two or three intent questions (platform, length, vibe, source), creates or opens the project, adds video, image and audio from local files or Pexels and Freesound stock, sequences clips, trims and splits, drops a title, previews cheaply, saves and exports. Use whenever someone says make a video, reel, TikTok, Short, YouTube video, promo, montage, slideshow, edit my clips, add stock footage, add music, put a title on it, trim, split, export, render, or wants to start. Also the router: it names all sixteen expocut-* specialists (editor-ops, motion-graphics, compositing, social-speed-edit, color-grading, fx-looks, audio-post, voice-narration, kinetic-captions, data-widgets, shapes-layouts, template-import, templates-brand, reel-templates, asset-authoring, retention-playbook) with trigger phrases. Not for keyframes, grading, captions, stems or template authoring alone; open that sibling." license: Free to use and redistribute with attribution to expocut.com. compatibility: Works standalone as guidance; becomes hands-on when paired with the ExpoCut in-app MCP server (private/loopback network only). metadata: author: ExpoCut (expotechin.com) version: "3.0.0" homepage: https://expocut.com/skill.html --- # Creating a video with ExpoCut You are the editor sitting inside the user's ExpoCut project, driving it through the in-app MCP server. Every call changes the live project on their phone, so work in small, previewable steps, use only the ids the tools return, and confirm before removing anything. ## When to use / hand off Use this skill for the everyday flow: intent, project, media, cuts, a title, preview, export. The moment a request becomes specialised, open the sibling skill instead of improvising here. | open | when the user says | | --- | --- | | expocut-editor-ops | open, rename, duplicate or delete a project; undo, go back, checkpoint; what is on the canvas, screenshot the frame; hide or mute a track; export settings, 4K, 60 fps, is the export done; which layer fields can I patch; seconds versus milliseconds | | expocut-motion-graphics | animate, keyframes, easing, move, grow, spin over time, motion path, camera move, parallax, animated mask | | expocut-compositing | blend mode, track matte, alpha, adjustment layer, parent a layer, freeze frame, reverse, time remap, crop, anchor, contain or cover, chroma key, green screen, mask, fade on edge, detach audio from video | | expocut-social-speed-edit | punchy, TikTok or CapCut style, speed ramp, zoom punch, transition between two clips, whip pan, cut on the beat | | expocut-color-grading | LUT, CDL, cinematic grade, teal and orange, match colour across clips, brightness, saturation, hue, light region | | expocut-fx-looks | effect, shader background, VHS, glitch, film grain, light leak, glow, border, vignette, procedural filter | | expocut-audio-post | stems, remove vocals, duck the music, beat detection, loudness, LUFS, remove silence, audio effects, audio fades | | expocut-voice-narration | voiceover, narrate this, TTS, read the script, transcribe, captions from the narration | | expocut-kinetic-captions | captions, subtitles, karaoke text, animated text, typewriter, lower third, fonts, text style, Urdu or Arabic, video in text | | expocut-data-widgets | chart race, counter, countdown, scoreboard, poll, ticker, QR code, weather card, phone mockup | | expocut-shapes-layouts | shape, gradient, mesh, brush stroke, split screen, layout, SVG logo, shape widget | | expocut-template-import | Lottie, FCPXML, .mogrt, OTIO, export to Lottie or FCPXML, font fallback, codec support | | expocut-templates-brand | template, reel, theme, brand kit, brand profile, logo, community submission | | expocut-reel-templates | write or fix a .ectpl reel template JSON | | expocut-asset-authoring | register a custom effect, LUT, border preset, mask shape, light leak or fade mask | | expocut-retention-playbook | hook, go viral, watch time, retention, best length, safe zone, seamless loop | ## Before you start Ask at most three short questions, then act: 1. Platform and length: Reels/TikTok/Shorts are 9:16 and 7 to 60 s; YouTube is 16:9; grid posts are 1:1 or 4:5. This fixes `aspectRatio` and the target duration. 2. Vibe: cinematic, upbeat social, vlog, tutorial, promo. This picks pacing, music and the title look. 3. Source: the user's own clips (they must already be on the phone; tools take `file://` URIs or absolute paths) or stock (Pexels video/photos, Freesound music), and whether they want music. Then check state: `get_active_project` never throws (`projectId: null` means nothing is open); `list_projects` shows what exists. Conventions every call obeys: - `startTime`, `duration`, `mediaOffsetSec`, `*Sec` arguments are seconds. Keyframe `timeMs` and `fadeInMs`/`fadeOutMs` are milliseconds. Positions `x`/`y` are the layer's top-left corner in percent of the canvas (0..100). - A new layer always lands in front (`trackIndex 0`) and pushes everything else back. Add backgrounds first, or send them back with `reorder_layer { layerId, position: "back" }`. - Layer ids are generated (`video_m2k1…`, `text_m2k1…`). Never guess one; use the id the add call returned, or `list_layers`. - Every layer tool needs an open project; `capture_canvas`, `preview_filmstrip`, `render_still` and `export_project` also need the editor screen mounted. `open_project` navigates there. ## Core workflow 1. Project. New: `create_project { name: "Coffee promo", aspectRatio: "9:16", fps: 30 }` returns `id`; then `open_project { id }`. Existing: `list_projects`, then `open_project { id }`. Opening reloads the project from disk, so open once, not before every step. 2. Frame. `get_canvas_info` reports aspect, fps, duration, counts and playhead without an image. Set the output early, because it also sizes the canvas and the editor's own default is 480p/low: `set_export_settings { resolution: "1080x1920", quality: "high", format: "mp4", fps: 30 }`. 3. Background (optional, first so it stays behind everything): `add_shape_layer { shape: "rectangle", gradientColors: ["#0F0F1E", "#2D1B69"], gradientDirection: "vertical", stretchToCanvas: true, startTime: 0, duration: 15 }`. 4. Media. Video and image layers fit the screen by default (`stretchToCanvas: true`, fill); pass `stretchToCanvas: false` to letterbox. - Own clips: `add_video_layer { uri: "/var/mobile/…/IMG_0101.mp4", startTime: 0, duration: 4.5, mediaOffsetSec: 2 }`. `duration` defaults to 5 s, not the file length, so pass the real length or the trim you want. `add_image_layer { uri, startTime, duration }` (5 s default). `add_audio_layer { uri, duration, volume }` requires `duration`. - Stock video: `stock_search_videos { query: "coffee pour", orientation: "portrait", perPage: 8, minDuration: 5 }`, then `add_stock_video_layer { videoId: 6963395, startTime: 0, duration: 4, qualityHint: "best" }` (id is an example). Duration defaults to the Pexels clip length. `stock_popular_videos` and `stock_curated_photos` browse without a query. - Stock photo: `stock_search_photos { query: "latte art", orientation: "portrait", color: "brown" }`, then `add_stock_image_layer { photoId: 36620444, startTime: 4, duration: 3 }`. - Several clips in one call: `add_clip_sequence` (see recipe 2). It places clips back to back, adds a push-in, an out-transition and an optional label per clip, and returns the real ids. 5. Music. `stock_search_music { query: "upbeat acoustic", category: "music", sort: "downloads_desc", minDuration: 15, maxDuration: 60 }` returns `id`, `duration`, `license` and a preview url; then `add_stock_music_layer { soundId: 123456, startTime: 0, duration: 15, volume: 0.6, fadeInMs: 300, fadeOutMs: 1200 }`. Freesound clips are the public preview MP3s (fine for social); quote the `license` field to the user. Silence a clip's own sound with `mute_video_audio { layerId, muted: true }`. 6. Cuts. `trim_layer { layerId, mediaOffsetSec: 2, durationSec: 4, startTimeSec: 0 }` (any subset); `split_layer { layerId, atSec: 2 }` (relative to the layer start; the second half is a new id); `set_layer_speed { layerId, speed: 2, keepPitch: true }` (rescales the timeline duration); `reorder_layer { layerId, position: "back" }`; `remove_layer { id }` only after the user agrees. Cut-level transitions between two clips belong to expocut-social-speed-edit; a simple entrance on one layer is `set_layer_transition { layerId, in: { id: "fadeIn", durationSec: 0.4 } }`. 7. Title. `add_text_layer { text: "Wake up different.", fontSize: 72, fontWeight: "700", textColor: "#FFFFFF", fontFamily: "avenir-next", verticalAnchor: "center", startTime: 0.4, duration: 3, transitionIn: "fadeIn", transitionInDuration: 0.4, textShadowColor: "#000000", textShadowBlur: 24 }`. Text spans the full width and centres by default; `verticalAnchor` is top (y 12), center or bottom (y 86). Duration defaults to 3 s. Edit later with `update_text { layerId, text, fontSize }`. Fonts come from `list_fonts`; captions, lower thirds and text animation live in expocut-kinetic-captions. 8. Preview. `describe_canvas { timeSec: 2 }` is free and lists every visible layer with its box in percent, top-most first; use it first. `capture_canvas { timeSec: 2 }` returns the frame as an image (default 512 px wide, `grid: true` overlays a 10 percent grid). `preview_filmstrip { fromSec: 0, toSec: 15, frames: 8 }` shows pacing across the whole piece. 9. Ship. `save_project` (writes projects.json now; the editor also autosaves every 30 s while it is on screen), then `export_project`. It blocks until the file is written, can take minutes, needs the phone awake and the editor mounted, and returns `{ uri }`. `get_render_status` from a second connection reports `isExporting` and `lastExportUri`. ## Tools you will use | tool | what for | key params (units) | | --- | --- | --- | | create_project | new empty project, returns id | name*, aspectRatio "9:16"/"16:9"/"1:1"/"4:5", fps | | open_project | load + navigate to the editor | id* | | list_projects / get_active_project | find what exists / what is open | none | | set_export_settings | output and canvas size | resolution "1080x1920" or "1080p", quality low/medium/high, format mp4/mov, fps 1..120 | | get_canvas_info / describe_canvas | frame facts / what is visible at a time, no image | timeSec (s) | | add_video_layer / add_image_layer / add_audio_layer | local files | uri* file://, startTime (s), duration (s, required for audio), mediaOffsetSec, volume 0..1, stretchToCanvas | | stock_search_videos / stock_search_photos | Pexels search | query*, orientation landscape/portrait/square, size, minDuration/maxDuration (s), perPage | | stock_popular_videos / stock_curated_photos | Pexels feeds without a query | page, perPage, minDuration/maxDuration | | add_stock_video_layer / add_stock_image_layer | download + place | videoId or photoId (or url), startTime, duration, qualityHint best/sd | | stock_search_music / stock_trending_music | Freesound search / feed | query, category music/sfx/ambient/vocals/nature/electronic/cinematic/all, sort, minDuration/maxDuration | | add_stock_music_layer | download + place audio | soundId (or url), startTime, duration (default full clip), volume 0..1, fadeInMs/fadeOutMs | | add_clip_sequence | N clips back to back with push-in, transitions, labels | clips[] {uri or videoId or stockQuery, durationSec*, label}, transitionStyle, transitionDurationSec, pushIn, labelPosition, labelFontSize, labelFontFamily | | trim_layer / split_layer / set_layer_speed | cuts | layerId*, mediaOffsetSec/durationSec/startTimeSec; atSec; speed 0.1..100 | | reorder_layer / remove_layer | z-order / delete | layerId*, position front/back/number; id* | | mute_video_audio / set_layer_fade | clip sound off / fade envelope | layerId*, muted; fadeInMs, fadeOutMs | | add_text_layer / update_text | title / edit it | text*, fontSize, fontWeight, textColor, fontFamily, verticalAnchor, startTime, duration, transitionIn/Out (ids from list_transitions), transitionInDuration (s) | | set_layer_transition | entrance/exit on one layer | layerId*, in {id, durationSec}, out {id, durationSec} | | capture_canvas / preview_filmstrip | look at the frame / at pacing | timeSec, maxWidth, grid; fromSec, toSec, frames 1..12 | | save_project / export_project / get_render_status | persist / render / poll | none | ## Recipes ### 1. Fifteen-second stock reel with music and a title (9:16) ``` create_project { name: "Morning brew", aspectRatio: "9:16", fps: 30 } // → id open_project { id: "proj_3f9a…" } // example id set_export_settings { resolution: "1080x1920", quality: "high", format: "mp4", fps: 30 } stock_search_videos { query: "coffee pour slow motion", orientation: "portrait", perPage: 6, minDuration: 5 } add_stock_video_layer { videoId: 6963395, startTime: 0, duration: 5 } // clip A (example id) add_stock_video_layer { videoId: 5386411, startTime: 5, duration: 5 } // clip B add_stock_video_layer { videoId: 7438482, startTime: 10, duration: 5 } // clip C set_layer_transition { layerId: "video_…B", in: { id: "dissolve", durationSec: 0.5 } } set_layer_transition { layerId: "video_…C", in: { id: "dissolve", durationSec: 0.5 } } stock_search_music { query: "warm acoustic morning", category: "music", minDuration: 15 } add_stock_music_layer { soundId: 123456, startTime: 0, duration: 15, volume: 0.6, fadeOutMs: 1500 } add_text_layer { text: "Wake up different.", fontSize: 72, fontWeight: "700", textColor: "#FFF3D6", fontFamily: "avenir-next", verticalAnchor: "center", startTime: 0.5, duration: 3.5, transitionIn: "fadeIn", transitionInDuration: 0.5, textShadowColor: "#000000", textShadowBlur: 24 } describe_canvas { timeSec: 1.5 } capture_canvas { timeSec: 1.5 } save_project export_project // → { uri } ``` Stock clips mute nothing by themselves; if the footage carries noisy audio, run `mute_video_audio { layerId, muted: true }` on each clip before exporting. ### 2. The user's own clips as a labelled montage ``` add_clip_sequence { clips: [ { uri: "/var/mobile/…/IMG_0101.mp4", durationSec: 4, label: "Kitchen" }, { uri: "/var/mobile/…/IMG_0102.mp4", durationSec: 3.5, label: "Living room" }, { stockQuery: "city skyline sunset", durationSec: 4 } ], startTimeSec: 0, transitionStyle: "dissolve", transitionDurationSec: 0.5, labelPosition: { x: 8, y: 80 }, labelFontSize: 44, labelFontFamily: "avenir-next", labelDurationSec: 3 } ``` The result lists each clip's `videoLayerId` and `labelLayerId` plus warnings (a hard cut, a label clamped to its clip). Clips are placed back to back from `startTimeSec`; a `stockQuery` takes the first landscape Pexels result at SD quality. Pass `pushIn: false` to skip the slow zoom. Pass a `labelFontFamily` from `list_fonts`; the built-in default is not a bundled font id. Then add the title and music from recipe 1 and preview with `preview_filmstrip { fromSec: 0, toSec: 11.5, frames: 8 }`. ### 3. Quick fix on an existing project ``` list_projects // pick the id by name open_project { id: "proj_…" } list_layers // ids, types, startTime/duration in seconds trim_layer { layerId: "video_…", mediaOffsetSec: 1.2, durationSec: 6 } split_layer { layerId: "video_…", atSec: 3 } // second half gets a new id update_text { layerId: "text_…", text: "Now open in Lahore", fontSize: 64 } describe_canvas { timeSec: 3.5 } save_project export_project ``` ## Pitfalls - `add_video_layer` and `add_image_layer` default to 5 s regardless of the file; `add_audio_layer` refuses to guess and requires `duration`. Stock video layers default to the Pexels length. - Seconds everywhere on tool arguments, but `list_layers` reports seconds while `get_layer` reports the stored milliseconds. Do not paste a `get_layer` startTime into a patch; see expocut-editor-ops. - New layers go to the front. A background added last hides the whole video; send it back with `reorder_layer { layerId, position: "back" }`. - `add_text_layer` uses `verticalAnchor` or `y`, not both; `x` is ignored while `fullWidth` is true (the default). Pass `fullWidth: false` with `x` for a left-aligned block. - `remove_layer` and `delete_project` have no confirmation and no built-in undo. Take `save_history_checkpoint { label: "before cleanup" }` before automated passes (expocut-editor-ops). - `open_project` replaces the in-memory session with the saved copy. Unsaved MCP edits are lost if you reopen; call `save_project` first. - `export_project`, stock downloads and `preview_filmstrip` take seconds to minutes and need the phone unlocked with ExpoCut in front. Prefer `describe_canvas` over images while iterating. - `add_clip_sequence` writes its push-in and label keyframes at 0..duration ms for every clip, while the canvas evaluates keyframes on the project timeline; on clips after the first, check the motion with `preview_filmstrip` and re-author with `keyframe_add` at timeline times if it looks static. - The junction ids quoted in `set_junction_transition` differ from `list_transitions`; leave cut transitions to expocut-social-speed-edit rather than guessing. ## Reference - Full tool list and conventions: https://expocut.com/mcp.html - Siblings named in the routing table above; expocut-editor-ops for undo, inspection and export details, expocut-retention-playbook for hooks, pacing and safe zones.
expocut-motion-graphics
After Effects, on the phone.
Animate layers in the ExpoCut mobile editor through its MCP server - keyframes on 58 properties (position, scale, rotation, opacity, blur, colour, mask geometry, border, text colour) with hold, linear, bezier and preset easing, 25 motion-path presets (arc, loop, spiral, heart), a project virtual camera with per-layer parallax depth, one-call mask animations, and in/out layer transitions including GPU shader wipes. Raw file: /skills/expocut-motion-graphics/SKILL.md · install: npx skills add https://expocut.com/skills/expocut-motion-graphics
--- name: expocut-motion-graphics description: "Animate layers in the ExpoCut mobile editor through its MCP server - keyframes on 58 properties (position, scale, rotation, opacity, blur, colour, mask geometry, border, text colour) with hold, linear, bezier and preset easing, 25 motion-path presets (arc, loop, spiral, heart), a project virtual camera with per-layer parallax depth, one-call mask animations, and in/out layer transitions including GPU shader wipes. Use when the user says animate, keyframe, ease, pop in, bounce, overshoot, Ken Burns, push in, parallax, camera move, orbit, follow a path, mask wipe, reveal, spin, wiggle, loop, \"After Effects style\", \"make it move\", logo sting, animated badge, or wants a photo slideshow with motion. Do not use for text animation presets, typewriter or captions (use expocut-kinetic-captions), for cut-to-cut junction transitions, speed ramps or beat cuts (use expocut-social-speed-edit), or for blend modes, track mattes, parenting rigs and time remap (use expocut-compositing)." license: Free to use and redistribute with attribution to expocut.com. compatibility: Works standalone as guidance; becomes hands-on when paired with the ExpoCut in-app MCP server (private/loopback network only). metadata: author: ExpoCut (expotechin.com) version: "3.0.0" homepage: https://expocut.com/skill.html --- # Motion graphics with ExpoCut You are the motion designer. You animate layers that already exist in the user's open project by writing keyframe tracks, motion paths, camera moves and transitions. Every call edits the live project on the phone; it is undoable in the app, but you still inspect before you write and checkpoint before a big pass. ## When to use / hand off - Text entrance, exit and loop presets, typewriter, karaoke captions: expocut-kinetic-captions (set_text_animation, set_typewriter). This skill still handles keyframes on text layers. - The cut between two adjacent clips (junction transitions), speed presets, zoom punches, beat cuts: expocut-social-speed-edit (set_junction_transition, set_layer_speed). - Blend modes, track mattes, adjustment, null and parent layers, time remap, chroma key, fit mode, crop, static masks: expocut-compositing. - Shader looks and filters: expocut-fx-looks. Colour: expocut-color-grading. - Project lifecycle, undo, canvas inspection tools in depth: expocut-editor-ops. ## Before you start 1. A project must be open: get_active_project, then list_layers for the real ids (`text0`, `image1`, `video0`). Never invent an id. 2. get_canvas_info for canvas size, fps and total duration; get_layer { layerId: "image0" } for the layer's `position` (top-left, canvas percent 0..100), `scale`, `rotation`, `opacity`, `startTime` and `duration` (both milliseconds on the layer object). 3. keyframe_list { layerId: "image0" } before adding anything. You may be layering on top of an existing plan, and create_mask_animation clears mask, opacity and filter tracks by default. 4. Catalogs: list_keyframe_properties { layerType: "image" }, list_motion_path_presets { }, list_transitions { }. Only ids from these exist. 5. save_history_checkpoint { label: "before motion pass" } so the user can undo the whole pass with one undo. ## The keyframe model (the facts that decide correctness) - `timeMs` is timeline time in milliseconds, the same clock as the playhead, not time since the layer started. A layer that starts at 2 s needs its keyframes at 2000 and later. Internally times are integer microseconds: keyframe_list returns `t: 2000000` for 2 s, and keyframe_set_animation takes that same shape, so multiply ms by 1000 there. - Adding a keyframe at a time that already has one on that property replaces it. - Values are absolute, not deltas: `transform.scale` 1.2 means scale 1.2, `transform.x` 40 means the layer's top-left sits at 40% of the canvas width. - `interp` describes how the value leaves that keyframe toward the next. Exact shapes: `{ type: "linear" }` (default), `{ type: "hold" }`, `{ type: "bezier", x1, y1, x2, y2 }` (x in 0..1, y may overshoot), `{ type: "preset", name: "easeOut" }` with name one of ease, easeIn, easeOut, easeInOut, bounce, elastic, spring. `{ type: "easeOut" }` is rejected. Before the first keyframe and after the last, the value holds. - Layer types: image, text, shape and video share transform, opacity, fx, color, mask, border, filter and transition tracks; `text.*` is text only; `shape.fillStyle` is shape only; `audio.volume` is video and audio. Lottie, widget and lower-third layers reject keyframes with a PropertyMismatch error. - Discrete (held-step) values such as font weight or mask shape go through keyframe_add_discrete, never keyframe_add. Most-used properties (full table with units in references/keyframe-properties.md): | property | unit / range | notes | | --- | --- | --- | | transform.x, transform.y | canvas percent 0..100 | top-left corner, same as `position` | | transform.scale | multiplier, 1 = authored size | pivots on the anchor (default centre) | | transform.scaleX, transform.scaleY | multiplier, negative flips | stack with scale | | transform.rotation | degrees, clockwise | shortest-arc interpolation | | transform.anchorX, transform.anchorY | 0..1 layer-local | 0.5, 0.5 = centre | | opacity | 0..1 | | | fx.blur | px gaussian radius | fake motion blur, focus pulls | | color.saturation, color.brightness, color.contrast | -1..1 offsets, 0 = neutral | same scale as set_layer_color_adjust | | color.hueShift | degrees | | | mask.rect.x/y/width/height | 0..1 layer-local | wipes and reveals | | mask.feather | px at 1080p | | | border.width, border.glowIntensity | px at 1080p, 0..100 | | ## Core workflow 1. Inspect: get_layer, keyframe_list, describe_canvas { timeSec: 0 } for the stacking order. 2. Checkpoint: save_history_checkpoint. 3. Author. For a full plan use one keyframe_set_animation { layerId, animation } call (tracks with `t` in microseconds); for a few keys use keyframe_add per key. Put the easing on the keyframe the motion leaves, not the one it arrives at. 4. Refine easing by re-adding the keyframe with keyframe_add at the same timeMs and value with the new interp (see Pitfalls for why not keyframe_set_interp). 5. Preview cheaply: describe_canvas { timeSec: 0.4 } for geometry, then preview_filmstrip { fromSec: 0, toSec: 1, frames: 6 } to see the motion, and capture_canvas { timeSec: 0.4 } for one detailed frame. Both image tools need the editor mounted on the project (open_project does that). 6. save_project. ## Tools you will use | tool | what for | key params | | --- | --- | --- | | keyframe_add | one scalar keyframe | layerId, property (dot path), timeMs, value, interp | | keyframe_add_discrete | held-step keyframe | property (text.fontWeight, mask.shape, transition.in.id, ...), timeMs, value (string or boolean) | | keyframe_set_animation | replace the whole plan | animation: { tracks: [{ property, keyframes: [{ t: microseconds, v, out }] }], discreteTracks, positionTrack }; `{ tracks: [] }` clears | | keyframe_list | read the plan | returns tracks with `t` in microseconds | | keyframe_remove_at | delete a column | removes every track's keyframe at timeMs (1 ms tolerance) | | keyframe_clear | reset | omit property to wipe all animation; confirm with the user first | | keyframe_snapshot_at | freeze the current pose as keyframes | captures x, y, scale, rotation, opacity only | | list_keyframe_properties | catalog | layerType filter | | set_layer_motion_path / clear_motion_path / list_motion_path_presets | preset paths | presetId, scale 0.05..1.2, offsetX/Y canvas %, rotateDeg, orientToPath, autoReverse, phase 0..1 | | set_camera | project camera | panXTrack, panYTrack, panZTrack, zoomTrack, rotationTrack as { keyframes: [{ t: microseconds, v }] }, parallaxStrength 0..1, clear | | update_layer | camera opt-in | patch: { cameraEnabled: true, z: -1..1 } | | create_mask_animation | mask reveal in one call | shape, pattern, curve, or custom keyframes | | set_layer_mask | static mask plus cinematic content motion | effect: { type: "kenBurns", intensity, speed, curve } | | set_layer_transition / clear_layer_transition | entrance and exit on an existing layer | in/out: { id, durationSec, easing, intensity, blur, preset, shaderParams } | | set_layer_anchor, set_layer_scale_xy, set_aspect_lock | pivot and flips before animating | anchorX/Y 0..1; scaleX -1 mirrors | | set_layer_fade | plain fade envelope | fadeInMs, fadeOutMs (0 clears) | ## Motion paths A motion path replaces position keyframes for the layer's whole duration: the layer travels the preset once from start to end (twice if the layer's `motionPath.speedMultiplier` is set through update_layer). The 25 preset ids: Lines line-right, line-up, line-down, diagonal-up, diagonal-down; Arcs arc-right, arc-left, arc-up, arc-down; Curves wave, s-curve, loop, spiral, figure-8; Shapes circle, square, triangle, diamond, star, heart; Complex bounce, zigzag, spring, stairs, swing. set_layer_motion_path { layerId: "shape0", presetId: "arc-right", scale: 0.6, offsetY: -10, orientToPath: true } - The path is centred on the layer's `position` plus offset (canvas %); `scale` is the path's bounding box as a fraction of the canvas (default 0.5). `rotateDeg` turns the whole path, so line-right at 45 is a diagonal. `phase` staggers copies of the same preset, `autoReverse` yo-yos. - `orientToPath` makes the layer face the tangent; at export it replaces any rotation track. - While set, the path overrides `transform.x`/`transform.y` keyframes; clear_motion_path brings them back untouched. Export bakes the path into position keyframes at the export fps (at most 240 samples), so canvas and MP4 match. - Calling set_layer_motion_path again rewrites the config: a custom drawn path, snake mode or speedMultiplier set in the app are dropped. Read get_layer first if the user drew one. ## Virtual camera and parallax The camera is project-level and only moves layers that opt in (the After Effects 3D switch). Canvas centre is 50, 50. panX/panY are canvas percent (positive panX pans the camera right, so content slides left), zoom 1 is neutral (2 = push in), panZ is a dolly multiplier (0 neutral), rotation is degrees. Track keyframe `t` is project-relative microseconds. Per-layer depth: update_layer { id: "image0", patch: { cameraEnabled: true, z: -0.6 } }. The parallax factor is clamp(1 + z * parallaxStrength, 0.05, 2): z 0 moves 1:1 with the camera, z +1 moves twice as much (near), z -1 barely moves (far background). set_camera replaces the whole camera each call (omitted tracks disappear); pass every track you want to keep. set_camera { clear: true } removes it. Camera keyframes sent through MCP are linear (the handler keeps only `t` and `v`), so add intermediate keyframes when you want an eased feel. The camera runs last, after keyframes and motion paths, and exports on both platforms. ## Mask animation in one call create_mask_animation writes ordinary mask/opacity/rotation/filter keyframes and returns a receipt (pattern, curve, keyframe count, tracks written). - shape: one of rectangle, roundedRect, ellipse, triangle, star, heart, cross, xShape, linear, mirror, radial, angular, diamond, path, text (exact ids). - pattern: core ids reveal, hide, pulse, sweep, bounce, fast, soft, overshoot, elastic, flash, slideLeft, slideRight, wideOpen, tallOpen, spinIn, spinOut; composite ids that also animate opacity, rotation and filter: cinematicReveal, focusBloom, spinColor, noirWipe, softDrift, revealPop, flashCut, vintageTurn, cleanSlide, elasticColor; shape-scoped samples of the form `<shape>:sample:<key>` (for example mirror:sample:reveal), valid only when `shape` matches. Unknown or mismatched patterns throw. - curve: hold, linear, ease, easeIn, easeOut, easeInOut, flow, jumper, discer, bounce, elastic, spring, or `{ type: "bezier", x1, y1, x2, y2 }`. - Pattern keyframes are written from timeline 0 (offsets 0..1400 ms). For a layer that starts later, pass custom `keyframes` with absolute timeMs instead: `[{ timeMs: 2000, mask: { rect: { x: 0, y: 0, width: 0, height: 1 } } }, { timeMs: 2700, mask: { rect: { x: 0, y: 0, width: 1, height: 1 } }, interp: "easeOut" }]`. Each node may also carry `opacity`, `layerRotation`, `filterId`, `filterIntensity`. - rect, rotation, bandWidth, feather, expansion, gradientSoftness, invert, mirrorAxis on the call set the mask's resting state; replaceExisting: false keeps other mask tracks. For motion inside a static mask (no keyframes), use the mask's Effect tab: set_layer_mask { layerId: "image1", shape: "rectangle", effect: { type: "kenBurns", intensity: 0.4, speed: 1, curve: "easeInOut" } }. Types: zoomIn, zoomOut, pulse, breathe, panLeft, panRight, panUp, panDown, kenBurns, spin, sway, shake; type "none" clears. This is the simplest export-safe Ken Burns for a full-canvas photo or clip. ## Transitions on a layer set_layer_transition { layerId: "text0", in: { id: "slideUp", durationSec: 0.6, easing: "easeOut" }, out: { id: "fadeOut", durationSec: 0.4 } } - ids come from list_transitions: classic ids (fadeIn, slideUp, zoom, spin, iris, dipToBlack, lightCinematic and the other light* looks) or Shader ids (liquidwipe, slicewipe, tileflip, glassshatter, whippan, clocksweep, ...). Per role the two kinds are exclusive: setting one clears the other. `{ id: null }` clears a role. easing is linear, easeIn, easeOut or easeInOut (default easeOut); durations are seconds. - Shader customisation: get_effect_schema { id: "slicewipe" } lists params and preset names; then set_layer_transition { layerId: "image0", out: { id: "slicewipe", durationSec: 0.8, preset: "Cyan Shards", shaderParams: { bands: 14, transparentBg: true } } }. Typical shaderParams: fillColors (two hex stops), angle (radians, 0..6.28), transparentBg, revealOnly, useTexture. Wrong preset names return the list of valid ones. - clear_layer_transition { layerId: "text0", role: "in" } (role in, out or both). - Transitions on the cut between two clips are junctions: hand off to expocut-social-speed-edit. ## Recipes ### 1. Logo pop-in with overshoot (image0 starts at 0 s) set_layer_anchor { layerId: "image0", anchorX: 0.5, anchorY: 0.5 } keyframe_add { layerId: "image0", property: "transform.scale", timeMs: 0, value: 0.2, interp: { type: "preset", name: "easeOut" } } keyframe_add { layerId: "image0", property: "transform.scale", timeMs: 380, value: 1.12, interp: { type: "preset", name: "easeInOut" } } keyframe_add { layerId: "image0", property: "transform.scale", timeMs: 520, value: 1 } keyframe_add { layerId: "image0", property: "opacity", timeMs: 0, value: 0, interp: { type: "preset", name: "easeOut" } } keyframe_add { layerId: "image0", property: "opacity", timeMs: 220, value: 1 } preview_filmstrip { fromSec: 0, toSec: 0.8, frames: 6 } Scale reads as the authored scale from get_layer; if it was 0.6, end on 0.6 and overshoot to 0.67. Add a short `fx.blur` 6 to 0 over the first 250 ms for fake motion blur. For a spring feel replace the middle two keyframes with one at 520 ms and `interp: { type: "preset", name: "spring" }` on the first. ### 2. Ken Burns on a photo Full-canvas photo (image1, 0 to 6 s): one call, exports identically. set_layer_mask { layerId: "image1", shape: "rectangle", effect: { type: "kenBurns", intensity: 0.35, speed: 0.75, curve: "easeInOut" } } Framed photo placed as a box (get_layer says position 10, 20 and scale 0.8): keyframe_add { layerId: "image1", property: "transform.scale", timeMs: 0, value: 0.8, interp: { type: "preset", name: "easeInOut" } } keyframe_add { layerId: "image1", property: "transform.scale", timeMs: 6000, value: 0.92 } keyframe_add { layerId: "image1", property: "transform.x", timeMs: 0, value: 10, interp: { type: "preset", name: "easeInOut" } } keyframe_add { layerId: "image1", property: "transform.x", timeMs: 6000, value: 7 } keyframe_add { layerId: "image1", property: "transform.y", timeMs: 0, value: 20, interp: { type: "preset", name: "easeInOut" } } keyframe_add { layerId: "image1", property: "transform.y", timeMs: 6000, value: 18 } describe_canvas { timeSec: 6 } to confirm the box is still inside the frame. ### 3. Parallax camera push (background image0, subject image1, title text0; 4 s) update_layer { id: "image0", patch: { cameraEnabled: true, z: -0.6 } } update_layer { id: "image1", patch: { cameraEnabled: true, z: 0.2 } } update_layer { id: "text0", patch: { cameraEnabled: true, z: 0.8 } } set_camera { zoomTrack: { keyframes: [ { t: 0, v: 1 }, { t: 4000000, v: 1.25 } ] }, panXTrack: { keyframes: [ { t: 0, v: 0 }, { t: 4000000, v: 5 } ] }, parallaxStrength: 0.8 } preview_filmstrip { fromSec: 0, toSec: 4, frames: 6 } The title (z 0.8) drifts most, the background (z -0.6) least, which reads as depth. Keep zoom under 1.3 on 1080p sources or the near layers soften. ### 4. Mask wipe reveal on a clip that starts at 0 create_mask_animation { layerId: "video1", shape: "mirror", pattern:"mirror:sample:reveal", curve: "easeInOut", bandWidth: 0.5 } Then set_layer_transition { layerId: "video1", out: { id: "liquidwipe", durationSec: 0.7 } } for the exit. ## Pitfalls - Milliseconds in, microseconds out: keyframe_add and keyframe_remove_at take `timeMs`; keyframe_list and keyframe_set_animation use `t` in microseconds. A `timeMs: 1.5` lands at 1.5 ms, and `t: 1500` in keyframe_set_animation lands at 1.5 ms too. - Times are timeline-absolute on canvas and in export. Keyframes placed before a layer's `startTime` simply hold their last value once the layer appears. - keyframe_set_interp currently treats `timeMs` as a keyframe index (the handler converts ms to microseconds, the helper expects an index), so it silently does nothing for any timeMs above 0 and edits the first keyframe when timeMs is 0. Change easing by re-adding the keyframe with keyframe_add at the same timeMs and value. - keyframe_remove_at deletes that time column on every track of the layer, not one property. To drop one property's keyframe, re-send the plan through keyframe_set_animation. - keyframe_snapshot_at captures only x, y, scale, rotation and opacity, not colour. - The scalar `filter.id` track stores a numeric filter index; to switch filters over time use create_mask_animation's `filterId` (a string id) or set_layer_filter without keyframes. `lut.id` is listed as a discrete property but is rejected for every layer type today. - Shorthand property names (`scale`, `x`) are accepted by keyframe_add; write full dot paths anyway so keyframe_clear { layerId: "image0", property: "transform.scale" } matches. - Layers created with stretchToCanvas or a fit mode keep their box; scale keyframes on them rarely look right. Use the mask effect (recipe 2) or the camera for full-frame media. - Text presets from set_text_animation and your keyframes both apply; pick one per layer. - keyframe_clear with no property erases the whole plan: confirm with the user first. - preview_filmstrip and capture_canvas need the editor mounted; export_project takes seconds to minutes and the phone must stay awake. ## Reference - references/keyframe-properties.md: all 58 keyframeable properties with kind, units and layer types. Read it when a request goes beyond the table above. - https://expocut.com/mcp.html for the full tool list. - Siblings: expocut-kinetic-captions (text animation), expocut-compositing (mattes, parenting, time remap), expocut-social-speed-edit (junctions, speed), expocut-editor-ops (inspection, undo, export).
expocut-color-grading
The cinematic look — round-trippable.
Grade footage in ExpoCut through its MCP server with the levers that exist - hue/saturation/brightness (set_layer_color_adjust), ASC CDL slope/offset/power (set_layer_cdl, the real contrast, lift and gamma control), 3D LUTs (set_layer_lut with the six built-ins or any custom .cube), 134 preset filters (set_layer_filter), one-call look passes over a time range (apply_global_color_grade), local Light/LUT zones (add_light_region), the CDL to .cube to lut_register_custom round trip, chroma-key basics and export quality. Raw file: /skills/expocut-color-grading/SKILL.md · install: npx skills add https://expocut.com/skills/expocut-color-grading
--- name: expocut-color-grading description: "Grade footage in ExpoCut through its MCP server with the levers that exist - hue/saturation/brightness (set_layer_color_adjust), ASC CDL slope/offset/power (set_layer_cdl, the real contrast, lift and gamma control), 3D LUTs (set_layer_lut with the six built-ins or any custom .cube), 134 preset filters (set_layer_filter), one-call look passes over a time range (apply_global_color_grade), local Light/LUT zones (add_light_region), the CDL to .cube to lut_register_custom round trip, chroma-key basics and export quality. Use for \"colour grade\", \"color correct\", \"make it cinematic\", \"teal and orange\", \"warm it up\", \"cool it down\", \"more contrast\", \"lift the shadows\", \"black and white\", \"match these clips\", \"apply my LUT / .cube\", \"fix this dark clip\", \"green screen\". Do not use for halation, grain, CRT, cartoon or glow (expocut-fx-looks), colour keyframes (expocut-motion-graphics), blend modes and mattes (expocut-compositing), or custom-asset quotas and ids (expocut-asset-authoring)." license: Free to use and redistribute with attribution to expocut.com. compatibility: Works standalone as guidance; becomes hands-on when paired with the ExpoCut in-app MCP server (private/loopback network only). metadata: author: ExpoCut (expotechin.com) version: "3.0.0" homepage: https://expocut.com/skill.html --- # Colour grading with ExpoCut You are the colorist. Every tool below writes a field on a layer (`colorAdjust`, `ascCdl`, `lut`, `filterId`, `lutRegions`, `chromaKey`); nothing re-encodes the source and everything is undoable in the app. Grade with the levers the encoder really reads, and verify with a capture rather than assuming. ## When to use / hand off - Colour correction, looks, LUTs, CDL, local light zones, a reel-wide grade pass, chroma-key setup, export quality for colour: this skill. - Film grain, halation, vignette, CRT, cartoon, glow, light leaks: `expocut-fx-looks` (they are shader filters, a different pipeline). - Colour that changes over time (`color.saturation`, `color.brightness`, `lut.id` keyframes): `expocut-motion-graphics`. - Blend modes, adjustment layers, track mattes, masks after the key: `expocut-compositing`. - Registering many custom LUTs, the authoring tier, quotas: `expocut-asset-authoring`. ## Before you start 1. `get_active_project {}` then `list_layers {}` to get the real layer ids (`video0`, `image1`, ...) and their timing. Only image and video layers take a grade; shapes accept `set_layer_color_adjust` only. 2. Ask one question if you cannot see the answer: is there a reference look, and is the footage skin-heavy? Skin breaks first under saturation and strong LUTs. 3. `save_history_checkpoint { label: "before grade" }` so one `undo {}` reverts the whole pass. 4. `capture_canvas { timeSec: 2 }` for a baseline. `describe_canvas` is cheaper but reports geometry only, never colour. 5. Find LUT ids before you use one: built-ins are `warm`, `cool`, `cinematic`, `vintage`, `bw`, `noir`; everything else comes from `lut_list_custom { author: "any" }` (user-imported `.cube` files, CDN downloads with `cdn-` ids, agent-registered `ai.` ids). There is no bundled LUT list tool. ## The levers (what each tool really changes) | Tool | Writes | Parameters and units | | --- | --- | --- | | `set_layer_color_adjust` | `colorAdjust` | `hue` 0..360 degrees of rotation; `saturation` -1 (grey) .. +1 (double); `brightness` -1..+1; `intensity` 0..1 blend. All are offsets where 0 is neutral. Unspecified fields keep their previous value. | | `set_layer_cdl` | `ascCdl` | `slope` [r,g,b] multiplier (contrast and highlight tint), `offset` [r,g,b] additive (shadow lift and shadow tint), `power` [r,g,b] gamma (midtones), `saturation` 1 = neutral. Partial calls merge; identity is [1,1,1] / [0,0,0] / [1,1,1] / 1. This is the only contrast, lift and gamma control on a layer. | | `set_layer_lut` | `lut` | `id` LUT id, `intensity` 0..1. `id: null` clears. Unknown ids are accepted and render nothing. | | `set_layer_filter` | `filterId` | One preset filter from the 134 in `references/filters-and-luts.md` (`cin-teal-orange`, `port-soft-skin`, `bw-classic` ...), `intensity` 0..1. Not validated; a typo is a silent no-op. `filterId: null` clears. | | `apply_global_color_grade` | `lut` + `colorAdjust` on every video/image layer overlapping [`startSec`, `endSec`] | `lutId`, `lutIntensity` 0..1, `hue`, `saturation`, `brightness`, `intensity` (same offset scale as `set_layer_color_adjust`), `includeImages`, `includeVideos`. Returns `patchedIds`. It does not carry CDL, contrast or temperature, and there is no `scope` or `params` object. | | `add_light_region` | appends to `lutRegions` | `shape` area/gradient/radial/object/trident, `rect` {x,y,width,height} in 0..1 layer space, `feather` px at 1080p, `invert`, `rotation` degrees, `expansion` px, `intensity` 0..1, `label`, `tone` {exposure, contrast, brightness, highlights, shadows, whites, blacks, vibrance, saturation}, `filterId` + `filterIntensity` 0..1. Ranges in the reference file. | | `list_light_regions` / `clear_light_regions` | reads / removes `lutRegions` | `clear_light_regions { layerId }` wipes the stack; pass `regionId` (from the list) to remove one. | | `set_layer_chroma_key` | `chromaKey` | `keyColor` hex (default `#00FF00`), `similarity` 0..1 (default 0.4), `smoothness` 0..1 (default 0.1), `spill` 0..1 (default 0.5), `enabled`. Video layers. | | `bake_cdl_to_cube` | nothing (returns text) | `cdl` {slope, offset, power, saturation}, `size` 17/33/65 (default 33), `title`, `clipOutput`, `allowNegative`. Returns `{ cube, size }`. | | `write_cdl` | nothing (returns XML) | `entries: [{ cdl, id? }]`, `kind` cdl/ccc/cdl-list. For the user's Resolve/Premiere hand-off. | | `parse_cube_lut` | nothing | `text` of a `.cube`; returns size, domain, title, dataLength. Validate a user file before registering it. | | `lut_register_custom` | custom LUT store | `id` must match `ai.<slug>` (lowercase, digits, hyphens, max 48), `name`, `cubeText` (max 1 MB, grid size 33 or smaller), `persist`. Needs the Settings tier Standard or Full for `cubeText`. | | `set_working_color_space` | `exportSettings.workingColorSpace` | `space` sRGB/Rec.709/Rec.2020/linear. Accepted by the data model but non-sRGB values downgrade at encode time, so it changes nothing you can see today. | | `set_export_settings` | `exportSettings` | `resolution` 480p/720p/1080p/2K/4K or "1920x1080", `quality` low/medium/high/ultra, `format` mp4/mov, `fps`. There is no bitrate or codec parameter. | ## Core workflow 1. Checkpoint and baseline (above). 2. Normalise each clip with `set_layer_cdl` so all clips meet at the same exposure and contrast before any look. Skip clips that already match. 3. Put the look on. Per layer: `set_layer_color_adjust` and/or `set_layer_lut`. Across a reel: one `apply_global_color_grade` call. Save the returned `patchedIds` - they tell you which layers now carry the LUT. 4. Shape locally with `add_light_region` (a face lift, a sky grad, a darkened edge). 5. Verify: `capture_canvas { timeSec }` at two or three moments; for the encoder's exact result use `capture_export_frame { timeSec: 3 }` (needs the editor mounted, slower) or `verify_export_parity { timesSec: [1, 5] }`, which renders the whole project. 6. `save_project {}`. For export, set quality then hand the render to `expocut-editor-ops`: `set_export_settings { resolution: "1080p", quality: "high" }`. Describe the grade to the user in words ("cool shadows, warm highlights, LUT at 50%"), not as slider numbers. ## Looks as recipes (built only from the real levers) Start from these, then tune by eye. All CDL triplets are [r, g, b]. - Teal and orange: `set_layer_cdl { layerId: "video0", slope: [1.05, 1.0, 0.95], offset: [-0.01, 0, 0.02], power: [1, 1, 1.02], saturation: 1.1 }` then optionally `set_layer_lut { layerId: "video0", id: "cinematic", intensity: 0.5 }`. Red slope above 1 warms highlights; blue offset above 0 cools shadows. - Warm golden hour: `set_layer_cdl { layerId: "video0", slope: [1.06, 1.0, 0.92], offset: [0.01, 0, -0.01], power: [0.97, 0.98, 1.0], saturation: 1.05 }`. - Cool moody night: `set_layer_cdl { layerId: "video0", slope: [0.92, 0.98, 1.08], offset: [-0.02, -0.01, 0.02], power: [1.08, 1.08, 1.05], saturation: 0.8 }` plus `set_layer_color_adjust { layerId: "video0", brightness: -0.05 }`. - Faded matte / lifted blacks: `set_layer_cdl { layerId: "video0", slope: [0.94, 0.94, 0.94], offset: [0.06, 0.06, 0.06], power: [0.95, 0.95, 0.95], saturation: 0.75 }`. - Punchy contrast: `set_layer_cdl { layerId: "video0", slope: [1.15, 1.15, 1.15], offset: [-0.06, -0.06, -0.06], power: [1.05, 1.05, 1.05] }`. - Black and white: `set_layer_lut { layerId: "video0", id: "bw", intensity: 1 }`, or `set_layer_cdl { layerId: "video0", saturation: 0 }` when you also want the CDL contrast. - One-tap preset instead of hand-rolling: `set_layer_filter { layerId: "video0", filterId: "cin-teal-orange", intensity: 0.6 }`; skin-safe portrait: `filterId: "port-soft-skin"`. - Skin-heavy footage: keep CDL `saturation` at or below 1.15 and LUT `intensity` at or below 0.6; wide landscapes tolerate 0.7 to 0.85. ## Recipes Recipe A - reel-wide cinematic pass (three clips, 0 to 24 s). ``` save_history_checkpoint { label: "pre-grade" } list_layers {} // video0, video1, video2 set_layer_cdl { layerId: "video0", slope: [1.05, 1.0, 0.95], offset: [-0.01, 0, 0.02], power: [1, 1, 1.02], saturation: 1.1 } set_layer_cdl { layerId: "video1", slope: [1.05, 1.0, 0.95], offset: [-0.01, 0, 0.02], power: [1, 1, 1.02], saturation: 1.1 } set_layer_cdl { layerId: "video2", slope: [1.05, 1.0, 0.95], offset: [-0.01, 0, 0.02], power: [1, 1, 1.02], saturation: 1.1 } apply_global_color_grade { startSec: 0, endSec: 24, lutId: "cinematic", lutIntensity: 0.5, saturation: 0.05, includeImages: false } capture_canvas { timeSec: 3 } capture_canvas { timeSec: 15 } ``` `set_layer_cdl` is per layer because the global tool does not carry a CDL; loop over the ids from `list_layers`. Recipe B - black-and-white flashback from 8 s to 12 s inside a longer clip. ``` split_layer { layerId: "video0", atSec: 8 } // second half gets a new id, e.g. video3 split_layer { layerId: "video3", atSec: 4 } // atSec is relative to the layer start apply_global_color_grade { startSec: 8, endSec: 12, lutId: "bw", lutIntensity: 1 } ``` The global tool patches every layer that overlaps the range as a whole, so cut the clip first; otherwise the entire clip turns monochrome. Recipe C - a custom LUT from a CDL (brand look) and the file for the user's desktop tools. ``` bake_cdl_to_cube { cdl: { slope: [1.05, 1.0, 0.95], offset: [-0.01, 0, 0.02], power: [1, 1, 1.02], saturation: 1.1 }, size: 33, title: "Brand Teal" } lut_register_custom { id: "ai.brand-teal", name: "Brand Teal", cubeText: "<the cube text returned above>", persist: true } set_layer_lut { layerId: "video0", id: "ai.brand-teal", intensity: 0.7 } write_cdl { entries: [{ id: "brand-teal", cdl: { slope: [1.05, 1.0, 0.95], offset: [-0.01, 0, 0.02], power: [1, 1, 1.02], saturation: 1.1 } }], kind: "cdl" } ``` To use a `.cube` the user already has: `parse_cube_lut { text }` to confirm it parses (3D, size 33 or smaller), then `lut_register_custom` with the same text. Ask before `persist: true`; it writes to the phone's Documents folder. Recipe D - rescue an underexposed clip and lift a face. ``` set_layer_cdl { layerId: "video1", slope: [1.12, 1.12, 1.12], offset: [0.03, 0.03, 0.03], power: [0.9, 0.9, 0.9] } add_light_region { layerId: "video1", shape: "radial", label: "Face", rect: { x: 0.3, y: 0.15, width: 0.4, height: 0.5 }, invert: false, feather: 60, tone: { exposure: 0.25, shadows: 0.2 }, intensity: 0.8 } list_light_regions { layerId: "video1" } capture_canvas { timeSec: 2 } ``` `radial` is seeded with `invert: true` (the surroundings are graded, which makes a subject pop); pass `invert: false` when the region itself should brighten. Recipe E - green screen, then hand off. ``` set_layer_chroma_key { layerId: "video2", keyColor: "#00FF00", similarity: 0.35, smoothness: 0.15, spill: 0.6 } capture_canvas { timeSec: 1 } ``` Raise `similarity` in steps of 0.05 until the backdrop disappears, then `smoothness` for the edge, then `spill` for green fringes. Placement, masks and blend modes continue in `expocut-compositing`. ## Pitfalls - Offsets, not multipliers. `saturation: 1.06` on `set_layer_color_adjust` or `apply_global_color_grade` is +106%, and `brightness: 1` clips the layer to white because the encoder adds brightness. Neutral is 0. - There is no `exposure`, `temperature`, `tint`, `contrast`, `shadows` or `highlights` parameter on the colour-adjust tools. Contrast and lift live in `set_layer_cdl`; a warm/cool shift is a red-versus-blue slope; local exposure and shadows live in `add_light_region { tone }`. - `layerId: "all"` does not exist. Use `apply_global_color_grade` for a range or loop over `list_layers`. - `apply_global_color_grade` replaces the LUT on every overlapping layer when `lutId` is given, keeps existing LUTs when it is omitted, and merges colour fields one by one. It never touches `ascCdl`, `filterId` or light regions. - `set_layer_lut` and `set_layer_filter` accept unknown ids silently. Read ids back from the built-in list, `lut_list_custom`, or the reference file. - A static `set_layer_lut` call deletes any `lut.id` keyframe track on that layer (by design, to keep canvas and export identical). Re-add keyframes afterwards if you need them. - `lut_register_custom { grade }` (white balance + tone curve) is accepted at the Safe tier but no render path reads `grade` records; only `cubeText` LUTs draw. Bake a CDL to a cube instead. - Authoring tools answer `ok: false` with `PermissionDenied` when the phone's Settings tier ("AI may author assets") is Off; they never throw. Check `ok` before using the id. - Light-region `filterIntensity` is 0..1 on input and stored as 0..100; `list_light_regions` reports the stored form. - `set_working_color_space` changes metadata only; do not promise Rec.2020 output. - Quality for gradients (sunsets, smoke): `set_export_settings { quality: "ultra" }` and a 1080p or higher resolution; there is no bitrate field. - Confirm before `clear_light_regions` without `regionId` or before resetting a grade the user built by hand; both are undoable but only through the app's history. ## Reference - `references/filters-and-luts.md` - the 134 filter ids by category, where LUT ids come from, light-zone tone ranges and seed geometry. - https://expocut.com/mcp.html - the full tool list. - Siblings: `expocut-fx-looks` (shader filters, grain, glow), `expocut-motion-graphics` (colour keyframes), `expocut-compositing` (mattes, blend, masks), `expocut-asset-authoring` (custom LUT quotas and ids), `expocut-editor-ops` (export and render status).
expocut-voice-narration
Write the script. Speak it. Caption it.
Writes and places AI voiceover and transcribes speech in ExpoCut through its in-app MCP server - scripts written for the ear, the 14 on-device Kokoro voices with quality grades, multi-voice scripts with pause markers, tts_validate_script pre-flight, tts_add_audio_layer placement with speed, volume and fades, on-device Whisper transcription of any audio or video file by uri, one-call transcript (caption) layers from that uri, ducking music under the narration, and loudness targets. Raw file: /skills/expocut-voice-narration/SKILL.md · install: npx skills add https://expocut.com/skills/expocut-voice-narration
--- name: expocut-voice-narration description: "Writes and places AI voiceover and transcribes speech in ExpoCut through its in-app MCP server - scripts written for the ear, the 14 on-device Kokoro voices with quality grades, multi-voice scripts with pause markers, tts_validate_script pre-flight, tts_add_audio_layer placement with speed, volume and fades, on-device Whisper transcription of any audio or video file by uri, one-call transcript (caption) layers from that uri, ducking music under the narration, and loudness targets. Use when the user says voiceover, VO, narration, narrate this, text to speech, TTS, AI voice, read this script, British or American voice, transcribe, transcript, subtitles from the audio, captions from my recording, or wants a script written for a video. Do not use for caption styling and word-by-word animation (expocut-kinetic-captions), music and stem mixing beyond ducking (expocut-audio-post), or adding a voice file the user already has (expocut-video-creating)." license: Free to use and redistribute with attribution to expocut.com. compatibility: Works standalone as guidance; becomes hands-on when paired with the ExpoCut in-app MCP server (private/loopback network only). metadata: author: ExpoCut (expotechin.com) version: "3.0.0" homepage: https://expocut.com/skill.html --- # Voiceover and transcription You write the script, pick the voice, generate the narration as an audio layer, and turn speech into time-stamped text. Generation writes a new WAV and adds a layer; transcription reads a file and returns segments. Neither modifies existing media. Read every script back to the user before generating - synthesis is slow and the user should not hear a first draft. ## When to use / hand off | Need | Go to | | --- | --- | | Style the transcript layer, karaoke highlight, lower thirds | expocut-kinetic-captions | | Stems, music selection, full mix, loudness beyond a gain | expocut-audio-post | | Placing cuts to match the narration | expocut-social-speed-edit | | Projects, undo, playback, export | expocut-editor-ops | ## Before you start 1. `get_active_project {}` and `list_layers {}` - a project must be open; note the video's `startTime`/`duration` (seconds) so the narration fits. 2. `tts_list_voices {}` - 14 voices with `id`, `name`, `gender`, `accent`, `grade`. Ids encode accent and gender: `af_` American female, `am_` American male, `bf_` British female, `bm_` British male. Grades A to C are Kokoro's own quality ratings; af_heart (A) and af_bella (A-) are the safe defaults. `references/voices.md` has descriptions and best uses. 3. Models. Kokoro must be downloaded in the app's TTS modal; without it `tts_add_audio_layer` silently falls back to the system voice, which ignores `[VOICE:]` switches and voice ids. Whisper `tiny` or `base` must be downloaded in the app's Transcribe modal; `transcribe_audio` refuses to start a download and errors with "not downloaded" - ask the user to open that panel. 4. Getting a `uri`. The transcription tools take a file uri, never a layer id. Sources: `get_layer { layerId }` returns the full layer and its `content` field is the media path (video files work too - the audio is decoded to 16 kHz mono first); `tts_add_audio_layer`, `add_stock_music_layer` and `separate_audio` (per stem) return `uri` directly. For a video whose speech sits under music, `separate_audio` first and transcribe the vocal stem's uri. 5. Both engines run on the phone and block the call for many seconds; keep the phone awake and the client timeout generous. ## Writing for the ear - Kokoro reads about 150 words per minute at speed 1.0: 30 s of Reel is 70-80 words, 60 s is 140-160. Trim before generating rather than speeding up. - Short sentences, one idea each, the interesting word first. No lists longer than three items. Spell tricky brands phonetically ("chat gee pee tee"). - Pace with markers inside `text`: `...c` comma beat (160 ms), `...s` sentence pause (420 ms), `...p` paragraph pause (850 ms); a blank line also becomes a paragraph pause. `[VOICE: am_michael]` switches voice for the text that follows; repeat as needed. Pauses attach to the segment before them. ## Core workflow 1. Draft the script and read it back to the user. 2. `tts_validate_script { text, defaultVoice: "af_heart" }` - returns `segments` (voiceId, preview, pauseAfterMs), `perVoice` character counts and `totalPauseMs` without synthesising. Estimate duration as about 15 characters per second plus `totalPauseMs`. 3. `tts_add_audio_layer { text, voiceId, speed, startTime, volume, fadeInMs, fadeOutMs }` - returns `id`, `uri`, `durationMs`. Pass `voiceId` every time: the default differs between tools. 4. Fit the picture: if `durationMs` overshoots, regenerate at `speed: 1.1` or shorten the script; extend the video instead of clipping words. 5. Duck the music (recipe 1): `set_layer_volume_keyframes` from the known narration window, or `separate_audio` on the narration layer followed by `auto_duck` for speech-shaped ducking. 6. Captions: `add_caption_layer_from_audio { uri, language, startTime, y }` on the narration's `uri`, then hand styling to expocut-kinetic-captions. 7. Loudness: aim the narration at -14 LUFS for social; `gain_to_loudness_target` gives the dB, applied through `set_track_volume` (details in expocut-audio-post). 8. Verify: `seek { timeSec }`, `play {}`, `playback_status {}`; export through expocut-editor-ops and listen to the file. ## Tools you will use | Tool | What for | Key params | | --- | --- | --- | | `tts_list_voices` | voice catalog | none | | `tts_validate_script` | parse without synthesis | `text`, `defaultVoice` (defaults to am_michael) | | `tts_add_audio_layer` | generate and place | `text` (with markers), `voiceId` (defaults to af_bella), `speed` 0.5..2, `pitch` semitones (fallback engine only), `sampleRate` 16000/22050/44100, `startTime` seconds, `duration` seconds (override), `volume` 0..1, `fadeInMs`, `fadeOutMs` | | `transcribe_audio` | segments from a file | `uri`, `language` (e.g. "en", "ur"; omit = auto), `modelSize` tiny/base; returns `segments[] { text, startMs, endMs }` | | `add_caption_layer_from_audio` | transcript layer from a file | `uri`, `language`, `modelSize`, `startTime` seconds (anchor for the segments), `x`, `y` top-left percent (default 50/80), `scale` | | `get_layer` | read `content` (uri), `transcriptSegments` | `layerId` | | `update_layer` | fix a mis-heard word | `id`, `patch: { transcriptSegments }` | | `set_layer_volume_keyframes` | duck music by hand | `layerId`, `keyframes[] { timeMs, volume }` | | `separate_audio` / `auto_duck` | speech-shaped ducking | see expocut-audio-post | | `set_layer_fade` / `set_layer_audio_offset` | tails and sync | `fadeInMs`, `fadeOutMs` / `offsetMs` | | `mute_video_audio` | silence the original speech under a re-voice | `layerId`, `muted` | ## Recipes ### 1. Two-voice product spot with ducked music ``` tts_validate_script { text: "[VOICE: af_heart] Meet the kettle that boils in ninety seconds. ...s [VOICE: am_fenrir] Ninety. ...c Seconds. ...p [VOICE: af_heart] Order today.", defaultVoice: "af_heart" } tts_add_audio_layer { text: "[VOICE: af_heart] Meet the kettle that boils in ninety seconds. ...s [VOICE: am_fenrir] Ninety. ...c Seconds. ...p [VOICE: af_heart] Order today.", voiceId: "af_heart", speed: 1.0, startTime: 0.5, volume: 1, fadeInMs: 100, fadeOutMs: 250 } ``` The result gives `durationMs` (say 7400). Duck the music layer (example id "audio0") over that window: keep 1.0 until 0.35 s, sit at 0.25 while the voice talks, come back 0.4 s after it ends. ``` set_layer_volume_keyframes { layerId: "audio0", keyframes: [ { timeMs: 0, volume: 1 }, { timeMs: 350, volume: 1 }, { timeMs: 500, volume: 0.25 }, { timeMs: 7900, volume: 0.25 }, { timeMs: 8300, volume: 1 } ] } ``` For ducking that follows every phrase instead of one block, separate the narration layer with its stems muted (they only feed detection; unmuted stems would double the voice), then duck: ``` separate_audio { layerId: "audio1", tier: "fast", mutedStems: ["vocals", "drums", "bass", "other"] } auto_duck { musicLayerId: "audio0", vocalSourceLayerId: "audio1", duckDb: -15 } ``` ### 2. Caption a video's own speech ``` get_layer { layerId: "video0" } // content is the media path - pass it as-is (a file:// uri or an absolute path) transcribe_audio { uri: "/path/to/clip.mp4", language: "en", modelSize: "base" } // read the segments first; check names and numbers add_caption_layer_from_audio { uri: "/path/to/clip.mp4", language: "en", modelSize: "base", startTime: 0, y: 78 } ``` Segment times are relative to the file; `startTime` must equal the video layer's `startTime` (seconds) so the captions line up, and a trimmed in-point (`mediaOffset`) shifts them - transcribe the vocal stem or a trimmed export when the offset is large. The layer type is `transcript`; its duration ends at the last segment. Style it in expocut-kinetic-captions. ### 3. Fix a mis-heard word ``` get_layer { layerId: "transcript0" } // transcriptSegments: [{ text, startMs, endMs }, …] update_layer { id: "transcript0", patch: { transcriptSegments: [ { text: "Meet the kettle", startMs: 0, endMs: 1200 }, { text: "that boils in ninety seconds", startMs: 1200, endMs: 3100 } ] } } ``` Send the full array back - the patch replaces it. Re-transcribing with `modelSize: "base"` or a `language` hint usually fixes more than hand edits. ### 4. Re-voice a talking head ``` mute_video_audio { layerId: "video0", muted: true } tts_add_audio_layer { text: "…the new script…", voiceId: "bm_george", startTime: 0 } set_layer_audio_offset { layerId: "audio2", offsetMs: -120 } // nudge earlier to land on the lip movement ``` ## Pitfalls - Defaults disagree: `tts_add_audio_layer` uses af_bella when `voiceId` is omitted, `tts_validate_script` assumes am_michael. Always pass both. - Voice ids in `[VOICE: …]` markers must be `tts_list_voices` ids in lowercase; an unknown id falls through to the call's `voiceId` (or Kokoro speaker 10) without an error - `tts_validate_script` shows what each segment resolved to. - `pitch` only affects the system-voice fallback; Kokoro ignores it. `speed` is the lever, and going past 1.2 costs intelligibility. - `duration` on `tts_add_audio_layer` overrides the layer length, not the audio: shorter cuts words, longer leaves silence. Leave it out. - The generated file lives in the app cache (`tts_<timestamp>.wav`); the layer keeps pointing at it. Do not clear the app cache before exporting. - `transcribe_audio` returns segments, not words (`tokenTimestamps` is off), so word-by-word highlights are the caption skill's job from segment timing. - Whisper errors on a missing model rather than downloading; an empty result on `add_caption_layer_from_audio` throws "no segments" (silent or unrecognised audio - separate the vocal stem or pass `language`). - There is no `stylePreset`, `layerId` or `trackId` on the transcription tools; the transcript layer is positioned with `x`/`y` only and styled afterwards. - Long text means long waits: generation and transcription each block the MCP call while the phone works. Split scripts over 90 seconds into several layers. - New audio layers land at trackIndex 0; harmless for audio, but check `list_layers` if you later `reorder_layer` visual layers. ## Reference - `references/voices.md` - the 14 voices with grade, accent, style and best use, plus the script marker table. - https://expocut.com/mcp.html - full tool reference. - Siblings: expocut-kinetic-captions (styling), expocut-audio-post (ducking detail, loudness, stems), expocut-editor-ops (undo, playback, export).
expocut-template-import
Bring After Effects, Lottie and FCPXML projects in.
Bring outside project files into ExpoCut and send ExpoCut templates back out. Raw file: /skills/expocut-template-import/SKILL.md · install: npx skills add https://expocut.com/skills/expocut-template-import
--- name: expocut-template-import description: "Bring outside project files into ExpoCut and send ExpoCut templates back out. Use when the user has a Lottie / Bodymovin JSON, a Final Cut Pro FCPXML (or FCP7 / Premiere xmeml), an Adobe .mogrt, an ASC CDL .cdl/.ccc, an Adobe .cube LUT or an ExpoCut .ectpl file and says \"import this\", \"open my After Effects / Final Cut export on the phone\", \"convert this template\", \"which fonts will it swap\", \"will HEVC / ProRes / alpha work on that Android phone\", or wants \"export to Lottie / FCPXML\". Also covers detect_format, migrate_template for old layer JSON, font fallback (resolve_font_fallback, audit_font_coverage, list_font_aliases) and codec / device-class queries. Do not use for hand-authoring a reel JSON (expocut-reel-templates), applying templates, brand kits or Community submissions (expocut-templates-brand), LUT / CDL grading in the editor (expocut-color-grading), or loudness (expocut-audio-post). CapCut .draft, .aep, .prproj, .drp and OTIO are detected but cannot be imported." license: Free to use and redistribute with attribution to expocut.com. compatibility: Works standalone as guidance; becomes hands-on when paired with the ExpoCut in-app MCP server (private/loopback network only). metadata: author: ExpoCut (expotechin.com) version: "3.0.0" homepage: https://expocut.com/skill.html --- # Template import and interchange with ExpoCut You are the interchange specialist: you turn a foreign project file into an ExpoCut Template JSON, load it onto the timeline, read out what the importer could not reproduce, fix the rest by hand, and round-trip finished templates back out to Lottie or FCPXML. The importer tools are pure functions — nothing on the phone changes until you call `import_template_json`, and every edit after that is undoable in the app. ## When to use / hand off - A file from another tool: this skill. Formats that import today: Lottie / Bodymovin `.json` (plain JSON, not the dotLottie zip), FCPXML `.fcpxml`, FCP7 / Premiere legacy XML (`<xmeml>` root), Adobe `.mogrt`, ASC CDL `.cdl` / `.ccc`, Adobe `.cube`, ExpoCut `.ectpl` as JSON text. - Detected but refused (the reason comes back from `detect_format`): `.otio` (recognised, parser not wired), `.aep`, `.prproj`, `.drp`, `.fcpbundle`, CapCut `.draft`, VN `.vn`. Tell the user which export to make instead: Bodymovin / Lottie from After Effects, FCPXML from Premiere, Resolve or Final Cut. - Hand-authoring or debugging an `.ectpl` reel JSON → expocut-reel-templates. - Applying the bundled template, brand profiles, saving or submitting templates → expocut-templates-brand (`import_template_json` lives there; you call it in step 5 below). - Registering a `.cube` and applying LUTs / CDL grades on layers → expocut-color-grading. - `measure_loudness`, `gain_to_loudness_target`, `build_loudness_plan`, `list_loudness_targets` are registered in this family but belong to the audio workflow → expocut-audio-post. - Fonts and text styling after import → expocut-kinetic-captions. Inspection and export → expocut-editor-ops. ## Before you start 1. Ask where the file is. On the phone (Files app, the app's document directory) → `import_from_file_system { path }`. Pasted or attached in the conversation → `import_foreign_file { filename, text }` for JSON / XML / `.cube` / `.cdl`, or `{ filename, bytesBase64 }` for `.mogrt`. Inputs over 16 MB are refused before parsing. 2. If the extension is missing or suspicious, sniff first: `detect_format { filename: "clip.xml", textHead: "<first 4 KB of the file>" }`. For a zip pass `isZipContainer: true`. 3. Know what comes back: a Template document (`schemaVersion`, `id`, `name`, `category`, `aspectRatio`, `durationMs`, `slots`, `tracks`, `layers`) plus `warnings[]` with `severity: "info" | "warn"` and a `message`. Media never travels with Lottie / FCPXML / `.mogrt` imports — every clip, photo and audio file becomes an empty slot the user fills. 4. No importer tool needs an open project. `import_template_json` creates and opens one; `describe_canvas`, `capture_canvas`, `update_layer` then act on it. ## Core workflow 1. Detect (optional): `detect_format { filename: "title.mogrt", isZipContainer: true }` → `{ format: "mogrt", confidence: 1, reason }`. A refused type returns `format: "unknown"` with a reason such as `rejected: draft — CapCut draft — no schema, not importable`. Stop there and explain; do not try the importer. 2. Import — one of: `import_from_file_system { path: "file:///…/Documents/anim.json" }` `import_foreign_file { filename: "anim.json", text: "<file contents>" }` `import_foreign_file { filename: "title.mogrt", bytesBase64: "<base64 of the file>" }` Success: `{ ok: true, format, template, extraTemplates, warnings }`. Failure: `{ ok: false, format, reason, warnings }` — `reason` is readable ("Expected zip container for .mogrt, got png", "FCPXML contained zero sequences", "OpenTimelineIO import is not yet supported"). 3. Read every warning to the user before touching anything: they list each dropped or approximated feature (per-format list in `references/formats.md`). An FCPXML with several `<sequence>` elements returns the first as `template` and the rest in `extraTemplates` — ask which one to open. 4. Fonts. Collect the `fontFamily` values from `template.layers`, then `audit_font_coverage { fontNames: ["Bebas Neue", "Proxima Nova", "Georgia"] }` → `{ totalRequested, exact, aliased, fuzzy, histogram }`. For each fuzzy name confirm with `resolve_font_fallback { name: "Proxima Nova", weight: 700, category: "sans-serif" }` → `{ font: { id, label, … }, confidence, reason, exactMatch, requestedWeight }`. Confidence 1 is an exact bundled match, 0.95 the alias table, 0.5–0.85 a category guess, 0.25 the system font. Say the substitution in words ("Proxima Nova → Avenir Next") before applying it. 5. Load it: `import_template_json { template: <result.template>, name: "Imported title" }`. It validates the document, inflates it, saves a new project and navigates the editor there; returns `{ projectId, layerCount, unfilledRequiredSlots, validationWarnings }`. Add `bindings` to pre-fill slots (recipe 2). Validation failures come back as one error listing every violation — fix the JSON and call again. 6. Review cheaply, then look: `describe_canvas { timeSec: 0 }` (text only: visible layers with bounding boxes in canvas %), then `capture_canvas { timeSec: 0, maxWidth: 512 }`, then a mid-timeline frame with `capture_canvas { timeSec: 2.5, maxWidth: 512, xray: true, grid: true }` for labelled boxes over a 10 % grid. `preview_filmstrip { fromSec: 0, toSec: 5, frames: 6 }` shows motion and pacing in one call. 7. Fix up: `list_layers` for ids, then `update_layer { id: "text0", patch: { fontSize: 42, textColor: "#FFFFFF" } }`, `set_text_font { layerId: "text0", fontFamily: "din-alternate" }`, `remove_layer { id: "shape3" }` for placeholder boxes (ask first), `reorder_layer { layerId: "video0", position: 5 }` when z-order came in wrong. Finish with `save_project`. 8. Out again: get a document with `save_project_as_template { id: "my-title-v1", name: "My title", mode: "snapshot" }` and pass it to `export_template_to_lottie { template: <template>, fr: 30 }` or `export_template_to_fcpxml { template: <template>, fps: 30, wrapInLibrary: true }`. Both return text (`{ lottie, warnings }` / `{ xml, warnings }`); you write the file. ## Tools you will use | Tool | What for | Key params (units, enums) | | --- | --- | --- | | `detect_format` | Classify before importing | `filename*`, `textHead` (first ~4 KB), `isZipContainer` → `{ format, confidence 0..1, reason }` | | `import_foreign_file` | Import inline payload | `filename*`, `text` (JSON / XML / cube / cdl) or `bytesBase64` (.mogrt) | | `import_from_file_system` | Import from the phone | `path*` (`file://` URI or path under the document dir), `filename` override | | `migrate_template` | Back-fill old layer JSON | `layers*` array → `{ layers, report: { alreadyMigrated, fills, perLayer } }` | | `export_template_to_lottie` | Template → Bodymovin v5 | `template*`, `fr` (default 30), `width`, `height` | | `export_template_to_fcpxml` | Template → FCPXML 1.10 | `template*`, `fps`, `width`, `height`, `wrapInLibrary` (default true) | | `resolve_font_fallback` | One font → bundled id | `name*`, `weight` (`ultralight…black` or 100..900), `italic`, `category` (`sans-serif|serif|display|handwriting|monospace|rounded|condensed|arabic|urdu`) | | `audit_font_coverage` | Many fonts at once | `fontNames*` array | | `list_font_aliases` | The 62 curated aliases | none → `{ aliases: [{ from, to }], count }` | | `query_codec_support` | Can this device encode it | `kind*` `video|audio`, `codec*`, `platform*` `ios|android`, `deviceClass` | | `resolve_codec_fallback` | Degrade-and-warn profile | `videoCodec*`, `audioCodec*`, `container*` `mp4|mov|webm|mxf|mkv`, `alpha*`, `bitDepth*` `8|10`, `platform*`, `deviceClass` | | `default_device_class` | Conservative class | `platform*` → `apple-a10-plus` or `android-baseline` | | `parse_cube_lut` | Inspect a `.cube` | `text*` → `{ kind "3d"/"1d", size, dataLength, domainMin, domainMax, title }` (data not returned) | | `bake_cdl_to_cube` | CDL → `.cube` text | `cdl*` `{ slope, offset, power, saturation }`, `size` `17|33|65` (default 33), `title`, `allowNegative`, `clipOutput` | | `write_cdl` | CDL → XML | `entries*` array, `kind` `cdl|ccc|cdl-list` | Codec ids the capability tables know: video `h264`, `hevc-8bit`, `hevc-10bit`, `hevc-422`, `hevc-alpha`, `prores-422`, `prores-422-hq`, `prores-4444`, `dnxhr`, `dnxhd`, `vp9`, `av1`; audio `aac-lc`, `opus`, `flac`, `alac`, `pcm-24` (only `aac-lc` encodes on either platform). Device classes: `apple-a10-plus`, `apple-a12-plus`, `apple-a13-plus`, `android-baseline`, `android-mid`, `android-high`, `android-pixel-6-plus`. ## Recipes ### 1. Lottie title from the phone, verified on the canvas ``` import_from_file_system { path: "file:///…/Documents/lower-third.json" } → { ok: true, format: "lottie", template, warnings: [ { severity: "warn", message: "Shape layer … repeater dropped" } ] } audit_font_coverage { fontNames: ["Montserrat", "Roboto"] } → aliased: ["Montserrat", "Roboto"] // Montserrat → avenir-next, Roboto → helvetica-neue import_template_json { template: <template>, name: "Lower third (Lottie)" } → { projectId: "proj_…", layerCount: 4, unfilledRequiredSlots: [] } describe_canvas { timeSec: 0.5 } capture_canvas { timeSec: 0.5, maxWidth: 512, xray: true } list_layers update_layer { id: "text0", patch: { content: "Sabine Klauke", fontSize: 30 } } // example ids save_project ``` Lottie position / scale / rotation / opacity keyframes come through as keyframe tracks; shape paths, repeaters, trim paths, gradient fills and expressions are dropped with a warning each. Precomps are flattened. ### 2. A `.mogrt` title with the user's own background clip ``` detect_format { filename: "Title_04.mogrt", isZipContainer: true } import_foreign_file { filename: "Title_04.mogrt", bytesBase64: "<base64>" } → warnings include "Font \"Objektiv Mk2\" isn't embedded … substituted with \"Avenir Next\" (closest match, 85% confidence)" and "Any background footage in the Premiere preview is demo media … Add your own clip behind the title." import_template_json { template: <template>, name: "Bold title" } add_video_layer { uri: "file:///…/beach.mp4", startTime: 0, duration: 4.7, stretchToCanvas: true } list_layers // the new video landed on top; move it behind the text reorder_layer { layerId: "video0", position: 8 } // example: last index = back preview_filmstrip { fromSec: 0, toSec: 4.7, frames: 6 } ``` `startTime` / `duration` on `add_video_layer` are seconds. What a `.mogrt` import can and cannot recover is listed in `references/formats.md` (fonts, 3D camera, per-character animators, exact keyframe timing are the known gaps; each is reported as a warning, never silently wrong). ### 3. FCPXML round trip with a CDL grade ``` import_from_file_system { path: "file:///…/Documents/cut.fcpxml" } → template.slots: [ { id: "…-slot-0-video", kind: "video|image" }, { id: "…-slot-1-text", kind: "text", default: "OPENING" } ] import_template_json { template: <template>, name: "Cut v2", bindings: { "<video slot id>": { kind: "media", uri: "file:///…/clip.mp4", durationMs: 6000, width: 1080, height: 1920 } } } set_layer_cdl { layerId: "video0", slope: [1.05, 1, 0.95], offset: [0, 0, 0.02], power: [1, 1, 1], saturation: 0.9 } save_project_as_template { id: "cut-v2", name: "Cut v2", mode: "snapshot" } export_template_to_fcpxml { template: <template>, fps: 30 } → xml with the grade embedded as <note>cdl:{…}</note> on the clip (Final Cut ignores it; ExpoCut re-import restores it) ``` FCPXML asset paths are captured only as slot labels — the user supplies the media. `<filter>`, `<adjust-color>`, compound clips, alternate-lane audio and speed ramps are dropped with warnings. `write_cdl { entries: [ { id: "shot1", cdl: { slope: [1.05, 1, 0.95], offset: [0, 0, 0.02], power: [1, 1, 1], saturation: 0.9 } } ], kind: "cdl" }` produces a standalone `.cdl` for Resolve. ### 4. "Will this play on their phone?" before promising an export ``` default_device_class { platform: "android" } → { deviceClass: "android-baseline" } query_codec_support { kind: "video", codec: "hevc-alpha", platform: "android" } → { supported: false, reason: "…" } resolve_codec_fallback { videoCodec: "prores-4444", audioCodec: "opus", container: "mov", alpha: true, bitDepth: 10, platform: "android" } → { effectiveProfile: { videoCodec: "h264", audioCodec: "aac-lc", container: "mp4", alpha: false, bitDepth: 8 }, warnings: [ { severity: "lossy", dimension: "video-codec", message } ], rejected: false, maxSeverity: "lossy" } ``` These answer capability questions; they do not change the project. The actual encoder settings are `set_export_settings` (expocut-editor-ops). ## Pitfalls - The importers return a document, not a project. Nothing is on the timeline until `import_template_json`. Conversely `import_template_json` creates a new project every time — it never merges into the open one. - `.cube` through the importer: the template's video layer carries `lut: { id, intensity: 1 }` with an id derived from the file's `TITLE` (or the filename), but the LUT data is not registered by the MCP path. Register it under that same id first: `lut_register_custom { id: "<template.layers[0].lut.id>", name: "Film look", cubeText: "<the .cube text>" }`, then use `set_layer_lut` on real layers (expocut-color-grading). For a plain "apply this .cube" request skip the importer entirely and go straight to `lut_register_custom`. - `.ectpl` zips (Community bundles with `assets/`) rewrite media to `bundle://…` URIs; the MCP wrapper does not extract the assets, so import the zip through the app's Templates → Import button instead. Plain `template.json` text works through `import_foreign_file`. - `.lottie` (dotLottie) is a zip; the router needs the inner JSON as `text`. Unzip first. - `import_template_json` rejects `file://`, `http://` and `blob:` in URL fields of the document (`uri`, `remoteUrl`, `thumbnail`, `previewVideoUri`, `src`, …) — only `https:`, `asset:`, `bundled:` and `bundle:` pass. Local media goes in `bindings` (per slot), which are not scanned. - A Lottie made only of shapes / solids has zero slots; a template-mode document with no slots fails validation ("Template must declare at least one slot"). Set `mode: "snapshot"` on the returned document before importing — snapshot mode skips the slot requirement. - `migrate_template` takes a bare `layers` array (not a template) and back-fills `parentId`, `position`, `scale`, `rotation`, `trackIndex`, `startTime`, `duration`, `blendMode`, `opacity` and drops dangling track-matte references. Use it on hand-written or pre-1.0 layer JSON; the importer synths already emit migrated layers. - Units differ by surface: the Template JSON stores `startTime` / `duration` / `durationMs` in milliseconds and keyframe `t` in microseconds; the MCP layer tools take seconds; keyframe tools take `timeMs`. Passing `6000` to `add_video_layer` makes a 6000-second layer. - `list_font_aliases` keys are normalised (lowercase, punctuation stripped): "Bebas Neue" is looked up as `bebasneue`. 44 fonts are bundled; anything else resolves to the nearest one, so expect metric drift — re-check line breaks with `capture_canvas` after `set_text_font`. - `export_template_to_lottie` drops video layers (Lottie cannot embed timeline video); `export_template_to_fcpxml` drops audio and widget layers. Both say so in `warnings`; both embed a layer's CDL as opaque metadata that only ExpoCut reads back. - `export_project` is slow (seconds to minutes) and needs the phone awake and the editor mounted; `capture_canvas` and `preview_filmstrip` need the editor mounted too — `import_template_json` and `open_project` do that for you. ## Reference - `references/formats.md` — per-format matrix (what maps, what degrades, the warning texts), the full 62-entry font alias table, codec and device-class tables. Read it when a warning needs explaining or when the user asks "what will I lose". - https://expocut.com/mcp.html — the live tool reference. - Siblings: expocut-templates-brand (apply / save / submit / theme), expocut-reel-templates (author the JSON), expocut-color-grading (LUT / CDL on layers), expocut-audio-post (loudness), expocut-kinetic-captions (fonts, text), expocut-editor-ops (inspect, export).
expocut-templates-brand
One-tap brand kits, on repeat.
Start and finish branded ExpoCut projects from templates. Raw file: /skills/expocut-templates-brand/SKILL.md · install: npx skills add https://expocut.com/skills/expocut-templates-brand
--- name: expocut-templates-brand description: "Start and finish branded ExpoCut projects from templates. Use for \"use a template\", \"start from the speaker card\", \"drop this template JSON into the editor\", \"apply my brand kit / logo / slogan / socials\", \"change the theme colours\", \"swap the palette\", \"save this edit as a template\", \"publish / share this to the Community\", \"delete my upload\", reels and themes. Owns list_templates, apply_template (slot bindings + themeId), import_template_json, set_template_theme (@role palette tokens), save_project_as_template (template vs snapshot), submit_template / list_my_submissions / delete_my_submission, list_reels / apply_reel / swap_reel_theme, and the brand profile tools (create / update / delete / get / list, apply_brand_profile_to_project). Do not use for authoring or debugging the reel JSON itself (expocut-reel-templates), importing Lottie / FCPXML / .mogrt / .cube files (expocut-template-import), or ordinary editing once the project exists (expocut-video-creating)." license: Free to use and redistribute with attribution to expocut.com. compatibility: Works standalone as guidance; becomes hands-on when paired with the ExpoCut in-app MCP server (private/loopback network only). metadata: author: ExpoCut (expotechin.com) version: "3.0.0" homepage: https://expocut.com/skill.html --- # Templates, reels and brand kits with ExpoCut You are the templating and branding hand: you start projects from templates, keep the user's brand consistent across them, capture a finished edit as a template, and — only with explicit consent — publish it. Applying or importing a template always creates a new project (nothing is overwritten); theme swaps, brand application and saves act on the open project and are undoable in the app; a Community upload leaves the phone and is public the moment it lands, so confirm before calling it. ## When to use / hand off - Starting from a template, filling slots, theming, brand text/logo, saving, sharing: this skill. - Writing or fixing the Template / reel JSON (layers, slots, trackIndex, SVG, keyframes) → expocut-reel-templates. It documents the document format that `import_template_json` accepts. - Lottie / FCPXML / `.mogrt` / `.cube` / `.cdl` from other tools → expocut-template-import (its output is fed to `import_template_json` from this skill). - Placing extra media, cuts, text and export after the template is open → expocut-video-creating and expocut-editor-ops. Colour work beyond palettes → expocut-color-grading. ## Before you start 1. `list_templates` — what is actually bundled. In current builds the only bundled template is `speaker-card-v1` (Events, 4:5, 16 s, 20 slots). Every other gallery template is fetched from the CDN by the app's Templates screen and is not reachable through `apply_template`. For those, ask the user to open it in the app, or obtain its JSON and use `import_template_json`. 2. `list_reels` — returns an empty list in current builds (reels ship from the CDN; see expocut-reel-templates). `apply_reel` and `swap_reel_theme` therefore have nothing to act on; do not promise them. 3. `list_brand_profiles` — whether a default brand exists. A default brand's typography and colours are applied automatically by `apply_template` and `import_template_json` (those two fields are set in the app's Settings; the MCP tools edit name / logo / slogan / contact / socials only). 4. `get_active_project` and `list_layers` before any save, submit, theme or brand call — they need an open project and act on it as it is right now. ## Core workflow 1. Apply the bundled template: `apply_template { templateId: "speaker-card-v1", name: "Design Summit speakers", themeId: "sunset" }` → `{ projectId, templateId, themeId, layerCount, unfilledRequiredSlots }`. The editor opens on the new project. Themes for this template: `original`, `cyan-wave`, `sunset`, `forest`, `crimson`, `mono`. Slots (all `text` unless noted): `logoPrefix`, `logoText`, `eventLine1`, `eventLine2`, `dateLine`, `tagline`, `url`, `speaker1FirstName`, `speaker1LastName`, `speaker1Role`, `speaker1Company` (same four for `speaker2…` / `speaker3…`), `music` (audio). Pre-fill with `bindings`, a map of slot id → typed value: `{ kind: "text", value: "…" }`, `{ kind: "media", uri: "file:///…", durationMs, width, height }`, `{ kind: "color", hex: "#RRGGBB" }`, `{ kind: "number", value }`. No tool lists a template's slot ids (`list_templates` only reports `slotCount`), so use the list above or read `template.slots` from a JSON you already have. 2. Or load a JSON document (hand-authored, exported by `save_project_as_template`, or produced by expocut-template-import): `import_template_json { template: <doc>, name: "My reel", themeId: "…" }` → `{ projectId, layerCount, unfilledRequiredSlots, validationWarnings }`. The document is treated as untrusted: `schemaVersion` must be `"1.0"` or `"1.1"`; `id`, `name`, `category`, `aspectRatio`, `durationMs` required; `slots` non-empty unless `mode: "snapshot"`; `tracks` and `layers` non-empty; every layer needs `id`, `type`, `startTime`, `duration`, `trackIndex` (ms); one layer per `trackIndex` (except clips joined by an entry in `transitions[]`); URL fields (`uri`, `remoteUrl`, `thumbnail`, `previewVideoUri`, `src`, …) accept only `https:`, `asset:`, `bundled:`, `bundle:`; caps of 200 layers, 64 slots, 300 tracks, 4096-character strings, 30-minute duration. Every violation is listed in the single error message. 3. Pull the brand in: `list_brand_profiles` → `apply_brand_profile_to_project { profileId: "<id>" }` → `{ textUpdates, logoUpdates }`. It matches layers by `name` (case-insensitive): text layers named `Brand Name`, `Brand` or `Name` get the profile name; `Slogan` / `Tagline` the slogan; `Website` the handle of the social with `platform: "website"`; `Phone`, `Email`, `Address` the obvious fields; image layers named `Logo` or `Brand Logo` get the logo file URI. Empty profile fields are skipped, so `textUpdates: 0` usually means the layers are not named that way — check `list_layers` and rename with `update_layer { id: "text2", patch: { name: "Slogan" } }`. 4. Create or maintain the brand (each call is a full profile write, saved immediately): `create_brand_profile { name: "Acme Coffee", slogan: "Wake up different", logo: "file:///…/logo.png", email: "[email protected]", phone: "+1 555 0100", address: "12 Bean St", socials: [{ platform: "instagram", handle: "@acme" }, { platform: "website", handle: "acme.com" }], isDefault: true }` → `{ id, isDefault }`. `platform` must be one of `tiktok`, `instagram`, `youtube`, `twitter`, `facebook`, `linkedin`, `snapchat`, `pinterest`, `threads`, `website`; `handle` is a non-empty string. The first profile becomes the default automatically. `update_brand_profile { id, … }` merges a partial patch (`isDefault: true` clears the flag on others); `get_brand_profile { id }` returns everything, including `colors` and `typography` when set in-app; `delete_brand_profile { id }` promotes the first remaining profile to default — confirm first. 5. Re-theme a palette-based project: `set_template_theme { themeId: "emerald" }` → `{ themeId, fromThemeId, layerCount }`. Works only on projects whose template shipped `palettes` (colours written as `@role` tokens — see `references/theming.md`); a wrong id returns an error that lists the available palette ids; `my-brand` appears when the default brand has colours. `speaker-card-v1` uses legacy per-layer themes, so on that project the call fails — change theme by applying the template again with another `themeId`. 6. Save the open project as a template: `save_project_as_template { id: "acme-intro-v1", name: "Acme intro", description: "5 s brand opener", category: "My Templates", mode: "template" }` → `{ template }`. `mode: "template"` (default) turns text and media layers into slots and bakes everything else; it rejects two layers on one `trackIndex` ("one layer = one object") — move the layer with `reorder_layer` or use `mode: "snapshot"`, which copies every property verbatim with no slots. From MCP the JSON is only returned, not written to the My Templates rail: keep it (write it to a file) and reload it later with `import_template_json`. Check the layout with `capture_canvas { xray: true, grid: true }` before saving. 7. Share to the Community — explicit consent first: `submit_template { id: "acme-intro-v1", name: "Acme intro", description: "5 s brand opener", category: "Promo", retention: "7d", thumbnailUri: "file:///…/thumb.jpg", previewVideoUri: "file:///…/demo.mp4", stripPrivateMedia: true, metadata: { authorName: "Acme", tags: ["intro"] } }` It snapshots the open project, zips local assets into an `.ectpl`, uploads, and publishes at once (no moderation queue). Defaults: `retention: "1d"`, `stripPrivateMedia: true` (video / image / audio URIs and `remoteSource` are cleared; text, masks, effects, keyframes stay). Limits: bundle ≤ 100 MB, thumbnail ≤ 512 KB jpg / png / webp, `retention` one of `once`, `1d`, `7d`, `21d`, `30d` (server cap 60 days). `metadata` is forwarded verbatim as JSON (the client knows `authorName`, `authorEmail`, `licenseAgreement`, `categoryHint`, `tags`, `notes`). The app UI gates this behind the Elite plan; the MCP call does not check, so treat it as an Elite feature. Returns `{ ok, binaryId, publicUrl, thumbnailPublicUrl?, retention, expiresAtMs, … }` and stores the owner credential locally. 8. Manage uploads: `list_my_submissions` → rows with `binaryId`, `publicUrl`, `retention`, `expiresAtMs`, `timeLeft`, `deleted`; `delete_my_submission { binaryId: "<from the list>" }` → `{ ok, reason? }`. Only the device that uploaded can delete; if the local registry was cleared the upload simply expires with its retention. ## Tools you will use | Tool | What for | Key params | | --- | --- | --- | | `list_templates` | Bundled templates | `category`, `aspectRatio` → id, name, category, aspectRatio, durationMs, slotCount | | `apply_template` | New project from a bundled template | `templateId*`, `name`, `bindings` `{ slotId: value }`, `themeId` | | `import_template_json` | New project from a Template document | `template*`, `name`, `bindings`, `themeId` | | `save_project_as_template` | Open project → Template JSON | `id*`, `name*`, `description`, `category`, `mode` `template|snapshot` | | `set_template_theme` | Swap palette on a palette project | `themeId*` (palette id or `my-brand`) | | `submit_template` | Publish to the Community | `id*`, `name*`, `description`, `category`, `retention` `once|1d|7d|21d|30d`, `thumbnailUri`, `previewVideoUri`, `stripPrivateMedia`, `metadata` | | `list_my_submissions` | This device's uploads | none | | `delete_my_submission` | Retract an upload | `binaryId*` | | `list_reels` / `apply_reel` / `swap_reel_theme` | Bundled reels (currently none) | `aspectRatio` / `reelId*`, `themeId`, `name`, `bindings` / `themeId*` | | `list_brand_profiles` | Summary of saved brands | none → id, name, isDefault, socialCount, updatedAt | | `get_brand_profile` | Full profile | `id*` | | `create_brand_profile` | New brand | `name*`, `logo` (file URI), `slogan`, `address`, `phone`, `email`, `socials` `[{ platform, handle }]`, `isDefault` | | `update_brand_profile` | Patch a brand | `id*` + any of the above | | `delete_brand_profile` | Remove a brand | `id*` | | `apply_brand_profile_to_project` | Substitute brand fields by layer name | `profileId*` | Categories the Template type knows: `For You`, `Real Estate`, `Promo`, `Birthday`, `Travel`, `Food`, `Events`, `My Templates`. ## Recipes ### 1. Speaker card for a conference, branded ``` list_brand_profiles → default: "Design Summit" apply_template { templateId: "speaker-card-v1", name: "Keynote speakers", themeId: "cyan-wave", bindings: { eventLine1: { kind: "text", value: "Design Summit" }, eventLine2: { kind: "text", value: "Lisbon" }, dateLine: { kind: "text", value: "3–5 Oct 2026" }, speaker1FirstName: { kind: "text", value: "Sabine" }, speaker1LastName: { kind: "text", value: "Klauke" }, music: { kind: "media", uri: "file:///…/bed.mp3", durationMs: 16000 } } } describe_canvas { timeSec: 4 } // text only — is the speaker beat laid out? capture_canvas { timeSec: 4, maxWidth: 512 } apply_brand_profile_to_project { profileId: "<default id>" } // fills any layer named Website / Email … save_project ``` Unbound text slots keep their defaults ("WeAreDevelopers", "World Congress"); fix leftovers with `update_layer { id: "text3", patch: { content: "…" } }` after `list_layers`. ### 2. Turn the open edit into a reusable, brand-aware template ``` list_layers // find the title and the logo image update_layer { id: "text0", patch: { name: "Brand Name" } } update_layer { id: "text1", patch: { name: "Slogan" } } update_layer { id: "image0", patch: { name: "Logo" } } capture_canvas { timeSec: 1, maxWidth: 512, xray: true, grid: true } save_project_as_template { id: "acme-opener-v1", name: "Acme opener", category: "My Templates", mode: "template" } → { template } // write this JSON to disk; it is not stored on the phone ``` Later, on any device with the JSON: `import_template_json { template: <doc>, name: "Acme opener – May" }` then `apply_brand_profile_to_project { profileId: "<id>" }` — the renamed layers pick up the current brand's name, slogan and logo. ### 3. A palette template, imported and re-themed Author the colours as tokens and ship two palettes (ids are free-form; these two are examples) (full grammar in `references/theming.md`): ```json { "schemaVersion": "1.1", "id": "promo-card-v1", "name": "Promo card", "category": "Promo", "aspectRatio": "9:16", "durationMs": 6000, "palettes": [ { "id": "original", "label": "Original", "roles": { "bg": "#0B1020", "accent": "#5EE7FF", "textPrimary": "#FFFFFF" }, "pairs": { "textPrimary": "bg" } }, { "id": "sunset", "label": "Sunset", "roles": { "bg": "#F4E9D8", "accent": "#C2410C", "textPrimary": "#1F1300" }, "pairs": { "textPrimary": "bg" } } ], "slots": [ { "id": "headline", "label": "Headline", "kind": "text", "default": "SUMMER SALE" } ], "tracks": [ { "id": "t0", "type": "overlay", "name": "Title", "trackIndex": 0 }, { "id": "t1", "type": "image", "name": "Background", "trackIndex": 1 } ], "layers": [ { "id": "title", "type": "text", "name": "Headline", "slotRef": "headline", "trackIndex": 0, "startTime": 0, "duration": 6000, "position": { "x": 0, "y": 42 }, "textFullWidth": true, "textAlign": "center", "fontSize": 36, "fontFamily": "din-alternate", "textColor": "@textPrimary" }, { "id": "bg", "type": "shape", "name": "Background", "trackIndex": 1, "startTime": 0, "duration": 6000, "stretchToCanvas": true, "shapeConfig": { "type": "rectangle", "fillColor": "@bg", "strokeColor": "@accent/60", "strokeWidth": 12 } } ] } ``` ``` import_template_json { template: <doc>, name: "Promo card", themeId: "sunset" } capture_canvas { timeSec: 1, maxWidth: 512 } set_template_theme { themeId: "original" } → { themeId: "original", fromThemeId: "sunset" } set_template_theme { themeId: "my-brand" } // only when the default brand has colours ``` ### 4. Publish, check, retract ``` get_active_project capture_canvas { timeSec: 2, maxWidth: 512 } // what will be shared // ask: "Publish 'Acme opener' to the Community for 7 days with your media stripped?" submit_template { id: "acme-opener-v1", name: "Acme opener", category: "Promo", retention: "7d", stripPrivateMedia: true } list_my_submissions → [{ binaryId, publicUrl, timeLeft: "6d 23h", deleted: false }] delete_my_submission { binaryId: "<from the list>" } → { ok: true } ``` ## Pitfalls - `apply_template` only knows bundled templates (`speaker-card-v1`); CDN gallery templates and all reels are out of reach of `apply_template` / `apply_reel`. Say so instead of guessing an id. - `set_template_theme` needs `templatePalettes` on the project record — only projects created from a template with `palettes` have it. The error text tells you which ids exist; if it says the project has no palettes, the template is hex-authored and must be recoloured with `update_layer` or expocut-color-grading tools. - `apply_brand_profile_to_project` writes only into layers with the exact names listed in step 3. It never invents a handle, phone or address; if the profile lacks a field the layer is left as is — never fill brand facts from memory. - The brand `colors` / `typography` that drive `my-brand` theming and automatic typography are not settable through MCP; direct the user to Settings → Brand for those. - `save_project_as_template` in template mode throws on two layers sharing a `trackIndex`, which happens after tools such as `add_light_leak_overlay` that emit several layers on one track. Re-number with `reorder_layer` or save a `snapshot`. - `save_project_as_template` does not persist to the phone from MCP even though the JSON is registered when the same action runs in the app UI. Always capture the returned `template`. - `submit_template` is irreversible in the sense that the file is public immediately; the only undo is `delete_my_submission` from the same device. Keep `stripPrivateMedia: true` unless the user explicitly says the footage may be public, and never set `retention` longer than asked. - `category` on `save_project_as_template` / `submit_template` is a free string to the validator but the gallery expects one of the eight categories above; use `My Templates` for private saves. - Time units: template JSON is milliseconds (`durationMs`, layer `startTime` / `duration`, `preferredDurationMs`, binding `durationMs`); layer tools you use afterwards (`add_*_layer`, `update_layer` patches) take seconds. - `delete_brand_profile` and `delete_my_submission` are destructive outside the editor's undo stack — confirm with the user. ## Reference - `references/theming.md` — the `@role` token grammar, `pairs` contrast rule, how `my-brand` is derived from brand colours, legacy per-layer themes, what the project stores. Read it when authoring palettes or when a theme swap leaves a colour unchanged. - https://expocut.com/mcp.html — live tool reference. - Siblings: expocut-reel-templates (the document format, slots, SVG, keyframes), expocut-template-import (foreign files in / Lottie / FCPXML out), expocut-video-creating, expocut-editor-ops, expocut-color-grading.
expocut-fx-looks
One hundred looks, seven engines, one call.
Apply ExpoCut's stylised looks through its MCP server - 68 image and video shader filters including the seven parametric engines and their named presets via set_layer_shader_filter, the map-driven pair compoundblur and displacementmap plus the assisted tools (apply_subject_blur, apply_liquify, apply_face_blur, set_layer_shutter_angle), the 77 per-layer video effects, animated shaders (add_generative_bg_layer), layer animations (set_layer_effect), CDN light-leak overlays, borders and neon glow. Raw file: /skills/expocut-fx-looks/SKILL.md · install: npx skills add https://expocut.com/skills/expocut-fx-looks
--- name: expocut-fx-looks description: "Apply ExpoCut's stylised looks through its MCP server - 68 image and video shader filters including the seven parametric engines and their named presets via set_layer_shader_filter, the map-driven pair compoundblur and displacementmap plus the assisted tools (apply_subject_blur, apply_liquify, apply_face_blur, set_layer_shutter_angle), the 77 per-layer video effects, animated shaders (add_generative_bg_layer), layer animations (set_layer_effect), CDN light-leak overlays, borders and neon glow. Use for \"film look\", \"film grain\", \"halation\", \"VHS\", \"glitch\", \"cartoon\", \"lens flare\", \"light leak\", \"neon glow\", \"vignette\", \"animated background\", \"blur the background\", \"bokeh\", \"liquify\", \"blur faces\", \"motion blur\", \"stack effects on this clip\". Do not use for colour correction and LUTs (expocut-color-grading), clip transitions (expocut-social-speed-edit), keyframed motion (expocut-motion-graphics), Portrait Blur and background removal (expocut-compositing), or new effect specs (expocut-asset-authoring)." license: Free to use and redistribute with attribution to expocut.com. compatibility: Works standalone as guidance; becomes hands-on when paired with the ExpoCut in-app MCP server (private/loopback network only). metadata: author: ExpoCut (expotechin.com) version: "3.0.0" homepage: https://expocut.com/skill.html --- # FX and looks with ExpoCut You are the finishing artist. Every look below is stored as fields on a layer (`shaderFilters`, `videoEffects`, `effectId` + `fxParams`, `border`, an overlay layer); nothing is baked into the media and everything is undoable. Prefer a named preset, tune one or two parameters, and look at the frame before adding more. ## When to use / hand off - Filters, engines, video effects, animated shader backgrounds, light leaks, borders and glow: this skill. - Subject Blur, Liquify and Face Blur: this skill too - they drive the same shader-filter chain. So does shutter-angle motion blur, which sets a field on the base clip. - Portrait Blur (`apply_portrait_blur`), which splits a layer into a sharp cutout in front of a blurred copy, and background removal: `expocut-compositing`. - Hue, saturation, brightness, CDL, LUTs, one-tap photo filters like `cin-teal-orange`: `expocut-color-grading`. - Cuts and the transition on a cut (`set_junction_transition`), or a layer's own in/out transition: `expocut-social-speed-edit`. - Position, scale, opacity, mask keyframes, motion paths: `expocut-motion-graphics`. - Text glow and text effects (`set_text_effect`): `expocut-kinetic-captions`. Shape fills, textures, mesh gradients: `expocut-shapes-layouts`. - A look that does not exist yet (an FxSpec, a custom border preset): `expocut-asset-authoring`. ## Before you start 1. `list_layers {}` for real ids. Shader filters and video effects need an image or video layer; `set_layer_effect` also accepts text and shapes; `set_shape_glow` needs a shape. 2. `get_effect_schema { id: "cinematicgrade" }` before writing `fxParams`: it returns every param's min/max/default, the advanced params, and the preset names with their values. Integer `mode` params (warp, retrodisplay, edgesketch, lightfx, cartoon, lightflicker) are only documented through those preset values, so start from a preset name rather than guessing a mode number. 3. `save_history_checkpoint { label: "before fx" }` and `capture_canvas { timeSec: 2 }` as the baseline. 4. Light leaks come from the CDN (`list_light_leaks`); the first use of a preset needs the phone online. 5. The assisted tools are gated: `subject_blur_status` before `apply_subject_blur` (a segmentation model has to be on the device), `face_blur_status` before `apply_face_blur` (detection is iOS only). Both apply calls are long-running. ## The five surfaces | Surface | Tools | Notes | | --- | --- | --- | | Shader filter chain on a layer | `set_layer_shader_filter { layerId, effectId, preset, fxParams, append, mapUri }`, `remove_layer_shader_filter { layerId, effectId }` or `{ layerId, index }`, `clear_layer_shader_filters { layerId }` | `effectId` must have scope `filter`: `list_effects { scope: "filter" }` returns 71 ids, 68 of them optical filters for image and video (the other three - `shinyText`, `glitchText`, `gradientText` - are text-only and belong to `expocut-kinetic-captions`). Rendered on canvas and by both export encoders. Default call replaces the chain; `append: true` stacks up to 4 stages. `mapUri` binds a control image to `compoundblur` or `displacementmap`. | | Video effect chain | `set_layer_video_effects { layerId, effects: [{ effectId, intensity }] }`, `list_video_effects {}` | 77 ids in Film / Color / Mood / Stylize / Light / Blur & Focus / Distort. `intensity` is 0..100. The array replaces the chain; `[]` clears. Unknown ids throw. | | Layer animation effect | `set_layer_effect { layerId, effectId, params }`, `list_effects { category }` | Scope `layer` ids: entrance (`fadeIn`, `popIn`, `bounceIn`), exit (`fadeOut`, `zoomOut`), motion (`pulse`, `breathe`, `slowZoom`, `orbit`), glitch (`glitch`, `rgbSplit`, `flicker`), style (`neonGlow`, `twinkle`), text and sticker. `params` are usually `speed` and `intensity` (0..100). One effect per layer; `effectId: null` removes it. | | Shader layers | `add_generative_bg_layer { effectId, preset, fxParams, sourceMediaUri, sourceMediaKind, startTime, duration, opacity }`, `list_shaders { category }`, `set_shader_source { layerId, uri, kind }`, `clear_shader_source { layerId }`, `add_procedural_filter_layer { effectId, preset, fxParams }` | Scope `background` ids - the generative backgrounds plus the shader transitions and `lightleak`; `list_shaders {}` returns the current set with its thematic buckets. A new shader layer stretches to the canvas and lands at trackIndex 0 (front); send it back with `reorder_layer { layerId, position: "back" }`. Every background shader has a `transparentBg` param for overlaying footage. | | Overlays, borders, glow, tint | `add_light_leak_overlay { presetId, startTime, duration, intensity }`, `set_layer_border { layerId, presetId, width, color, pattern, cornerRadius, position, sides }`, `list_border_presets {}`, `set_layer_border_glow { layerId, mode, color, radius, intensity, speed, layers, colorStops }`, `set_shape_glow { layerId, mode, color, radius, intensity, placement }`, `set_layer_overlay_color { layerId, color }` | Border `width` and glow `radius` are px at 1080p (radius 0..40); glow `intensity` is 0..100; `layers` 1..3 stacks halos; glow `mode` static/pulse/rainbow/chase/breathe/gradient. | ## The seven engines (scope filter, one Look per engine) | effectId | Params | Preset names (exact spelling, case-insensitive) | | --- | --- | --- | | `cinematicgrade` | temperature, exposure, contrast, saturation, vibrance, shadowColor, highlightColor, splitStrength, skinProtect, halation, grain, intensity | Teal & Orange, Muted Orange-Teal, Cyberpunk Neon, Cross-Process, Retro Miami, Technicolor Classic, Film Print, Dream Halation, Golden Skin, Emerald Noir, Infrared Bloom, Night Neon Rain | | `warp` | mode 0..10, amount -1..1, speed 0..4, intensity; advanced freq, segments, center | Swirl, Vortex Spin, Bulge, Pinch, Fisheye, Ripple Rings, Heat Wave, Kaleido Six, Kaleido Twelve, Mirror Split, Tiny Planet, Slant Shear, Shockwave, Frosted Glass | | `retrodisplay` | mode 0..6, amount, speed, intensity | CRT Monitor, Deep CRT, Scanline TV, Interlace Cam, LED Billboard, Dot Matrix, Handheld Mono, Amber Terminal, Datamosh Blocks, Signal Loss, Ghost Broadcast | | `edgesketch` | mode 0..6, amount, intensity | Pencil Sketch, Fine Liner, Neon Edge, Neon Magenta, Toon Shade, Comic Ink, Emboss Relief, Crisp Sharpen, Charcoal, Ink Outline | | `lightfx` | mode 0..6, amount, speed, intensity | Lens Flare, Golden Flare, Star Cross, Star Six, Warm Leak, Rose Leak, Sparkle Dust, Stage Spotlight, Sun Rays, Anamorphic Blue, Anamorphic Gold | | `cartoon` | mode 0..5, amount, intensity | Cartoon Classic, Bold Cartoon, Comic Pop, Anime Cel, Watercolor, Wet Watercolor, Oil Paint, Thick Oil, Pop Art Pink, Pop Art Blue | | `lightflicker` | mode 0..6, amount, speed, intensity | Film Projector, Silent Film, Club Strobe, Soft Pulse, Neon Sign, Broken Neon, Candlelight, Fireside, Lightning Storm, Party Hue, Disco Wash | Other scope-filter ids worth knowing: `colorgrade` (24 presets: Warm Pop, Cool Cinematic, Bleach Bypass, Day for Night, Golden Hour, Blue Hour ...), `halation` (Classic Halation, Dreamy Red ...), `vignette` (Classic, Heavy, Subtle, Cool Edge, Warm Glow), `oldfilm`, `gateweave`, `noir`, `duotone`, `bloomglow`, `glitchrgb`, `vcrdistortion`, `halftone`, `pixelate`, `chromaticaberration`, `noisefilter` (Film Grain, Vintage VHS, 8mm ...), `phonemockup`. Three more are the raw material the assisted tools below drive: `compoundblur` (per-pixel blur from a control image), `displacementmap` (2D warp from a control image) and `censorblur` (one pixelated or blurred box, uv-placed). Three work on the ALPHA channel, so they need a layer that has one - a PNG or sticker, or a clip after `set_layer_chroma_key` / `set_layer_background_remover` - and do nothing on opaque full-frame video: `morphology` (Choke & Spread - `morphOp` 0 erodes the matte inward, 1 dilates it outward, for killing a green fringe or closing a hole the key left), `dropshadow` (offset, blurred shadow cast from the alpha; it clips at the layer's own bounds, so keep the subject off the edge) and `invert` (`amount` 0..1 partial negative, or `lumaOnly: 1` to invert brightness and keep hue - the all-or-nothing negative in `set_layer_video_effects` is a separate, older entry with the same id). The full list with param keys is in `references/effects-catalog.md`. ## Stacking rules 1. A plain `set_layer_shader_filter` call replaces the whole chain on that layer. 2. `append: true` stacks a different engine. The canvas and both encoders compose at most 4 stages; a fifth throws and tells you to remove one. 3. Appending an engine already in the chain swaps that instance in place, so switching "Teal & Orange" to "Film Print" is just another call. 4. Order is render order: grade first (`cinematicgrade` or `colorgrade`), distortion or display next, light and flicker last. 5. `fxParams` override the preset's values key by key; keys not in `get_effect_schema` are ignored. ## Assisted blur and warp Subject Blur, Liquify and Face Blur are not a sixth surface. Each bakes an asset once - a subject matte, a displacement field, a tracking keyframe track - and leaves an ordinary entry behind in the same `shaderFilters` chain as the engines above, so playback and both export encoders need nothing extra afterwards. That means the stacking rules apply to them too: each holds one of the four stages, and a plain `set_layer_shader_filter` without `append: true` wipes it along with the rest of the chain. They do not all compose the same way, though: Subject Blur APPENDS its `compoundblur`, Liquify swaps its own `displacementmap` entry in place, but Face Blur REPLACES the whole chain with its single `censorblur` - so grade first and censor second, and the grade is gone with no error. Censor first, then grade. Shutter-angle motion blur is the odd one out - it sets a field on the base clip, writes no shader filter at all, and only the export renders it. ### Subject blur - background out of focus, still one layer `apply_subject_blur { layerId, radius, gamma, threshold }` cuts the subject out with an on-device segmentation model, then drives `compoundblur` from the cutout's alpha: subject sharp, surround softly out of focus. The layer count never changes and the subject's own pixels are never replaced, so a soft or imperfect matte degrades into a slightly soft edge rather than a visibly damaged subject. The model is never fetched silently. It is a three-step handshake: ``` subject_blur_status { layerId: "video0" } // { applied, modelId, modelDownloaded, sourceKind } subject_blur_download_model { layerId: "video0" } // only when modelDownloaded is false apply_subject_blur { layerId: "video0", radius: 34, gamma: 1.6 } ``` Skipping the check throws `subjectBlur.MODEL_NOT_DOWNLOADED`. Which model a layer needs depends on its `sourceKind` (a still and a video want different ones), so read `subject_blur_status` per layer instead of assuming one download covers the project, and tell the user a download is pending rather than starting it behind their back. | Param | Range | Default | What it does | | --- | --- | --- | --- | | `radius` | 0..64 | 30 | maximum background blur | | `gamma` | 0.25..3 | 1.4 | falloff tightness; higher keeps more of the near-subject sharp | | `threshold` | 0..0.9 | 0 | lifts the floor so near-subject pixels stay fully sharp | Out-of-range values are clamped, not rejected. `apply_subject_blur` is long-running (seconds for a still, longer for video) and refuses a second application with `subjectBlur.ALREADY_APPLIED`; `remove_subject_blur { layerId }` takes it off and leaves the rest of the chain alone. The baked matte file stays on disk, but nothing reads it back: `apply_subject_blur` writes a fresh timestamped matte and re-runs the model every time, so re-applying costs the same as the first bake. Reach for `apply_portrait_blur` (in `expocut-compositing`) instead when the user wants the background as its OWN editable layer - it splits the layer into a sharp cutout in front of a blurred copy and exposes bokeh controls (`blurRadius`, `blades`, `highlightBoost`). The two do not mix: Subject Blur refuses a layer that already has Portrait Blur with `subjectBlur.PORTRAIT_BLUR_ACTIVE`. ### Liquify - push, bloat and pucker as data `apply_liquify { layerId, strokes }` bakes brush strokes into a displacement field and binds it to `displacementmap`, so the warp needs no new shader and renders identically on canvas and in export on both platforms. | Stroke field | Meaning | | --- | --- | | `tool` | `push`, `bloat` or `pucker` | | `x`, `y` | brush centre in normalized layer uv, 0..1 | | `dx`, `dy` | drag vector for `push`, also in uv (0.05 moves content 5% of the frame); ignored by bloat and pucker, and optional (0) | | `radius` | brush radius as a fraction of the layer's SHORTER axis — a circle on screen, and the same proportion of the frame on any aspect | | `strength` | 0..1; for bloat and pucker it is the push distance, scaled by `radius` | `apply_liquify` REPLACES the whole stroke list on every call - it never appends. To add a stroke, read the existing ones back and resend them with the new one: ``` liquify_status { layerId: "image0" } // { applied, strokes, maxShift } apply_liquify { layerId: "image0", strokes: [ /* the strokes it returned */, { tool: "bloat", x: 0.5, y: 0.38, radius: 0.18, strength: 0.5 } ] } ``` An EMPTY `strokes` array removes the warp, same as `remove_liquify { layerId }`. Accumulated displacement is clamped to +/-0.125 uv (`maxShift` in the status payload) rather than wrapped, so very long drags stop moving instead of tearing. ### Face blur - privacy, with an iOS-only detector `apply_face_blur { layerId, mode, padding, cellSize, blurRadius, minScore, samplePeriodMs }` detects faces across the clip and writes keyframed `censorblur` boxes that FOLLOW the faces, so moving subjects stay covered. Up to **four** faces are covered at once — `censorblur` carries four independent regions, and each person keeps the same region from keyframe to keyframe so the boxes do not swap places mid-shot. Beyond four the reply carries `uncoveredKeyframes` and a `warning`, and `maxSimultaneousFaces` tells you how many were there; add manual regions with `set_layer_shader_filter { effectId: "censorblur" }` for the rest, and tell the user rather than reporting a clean success. `mode` is `pixelate` (default) or `blur`; `padding` 0.35 grows the box over hair and chin; `cellSize` 26 is the mosaic cell, `blurRadius` 26 the softening when `mode: "blur"`; `minScore` 0.3 is the confidence floor and `samplePeriodMs` 250 the detection interval (lower tracks faster motion at more cost). It is long-running. Detection is iOS only. `face_blur_status { layerId }` reports `detectionSupported`; on Android `apply_face_blur` throws `faceBlur.UNSUPPORTED_PLATFORM` rather than silently doing nothing. The Android fallback is the same filter placed by hand - a manual censor region, keyframed yourself if the subject moves: ``` face_blur_status { layerId: "video0" } // detectionSupported: false on Android set_layer_shader_filter { layerId: "video0", effectId: "censorblur", preset: "Face Pixels", fxParams: { centerX: 0.5, centerY: 0.34, width: 0.26, height: 0.3 } } ``` `censorblur` is placed in normalized layer uv: `centerX` / `centerY` 0..1, `width` / `height` 0.02..1, `feather` 0..0.3, `cellSize` 2..80, `blurRadius` 0..60, `pixelate` 0 or 1. Two more failures are deliberately distinct, because for a privacy tool "no faces present" and "detection never ran" must not look the same: `faceBlur.NO_FACE_FOUND` (nothing scored above `minScore` - lower it, or censor manually) and `faceBlur.DETECTION_UNAVAILABLE` (the detector produced nothing at all). It never censors a guessed region. There is no remove tool. Undo is the clean route; otherwise clear BOTH halves, because the tracking lives in a discrete keyframe track and the static chain is only its first frame: ``` remove_layer_shader_filter { layerId: "video0", effectId: "censorblur" } keyframe_clear { layerId: "video0", property: "shader.filters" } ``` ### Shutter-angle motion blur `set_layer_shutter_angle { layerId, angleDeg }` is an export-time temporal blend of each frame with the previous few, which smooths the strobing that speed ramps and stepped slow-mo produce. `angleDeg` 0 = off, 180 = the film convention, up to 720 for long trails; anything outside 0..720 is clamped. Two caveats to pass on to the user: it affects the BASE video clip only, not overlay layers, and the canvas preview does NOT simulate it - the editor shows a sharp frame, so judge it from `export_project`, never from `capture_canvas`. ### Map-driven filters (`compoundblur`, `displacementmap`) `set_layer_shader_filter` takes a top-level `mapUri`: a local `file://` control image bound alongside the frame at texture slot 1. Only `compoundblur` and `displacementmap` accept one - any other `effectId` with a `mapUri` throws - and `mapUri: null` clears it. Canvas and both encoders read it. - `compoundblur` reads the map as a per-pixel blur radius. That is exactly what Subject Blur binds a cutout to. - `displacementmap` reads it as a 2D warp field. That is what Liquify binds its baked field to. - `fxParams.mapChannel` picks what is read: `0` = luminance (default), `1` = alpha, `2` = red. A cutout PNG carries its matte in alpha, so a cutout wants `mapChannel: 1`. - `fxParams.mapZoom` (0.25..4) scales the map. The map is STRETCHED onto the layer, never cover-cropped, so a control image with a different aspect will not line up on its own. - Direction, measured byte-identical on iOS and Android: `off = (map/255 - 0.5) * amount`, and the map says where to sample FROM. So red above 0.5 moves content LEFT and green above 0.5 moves it UP. To push the other way, set `displacementmap`'s `scaleX` / `scaleY` (-2..2) negative, or `compoundblur`'s `invert` to 1. - With NO map bound both still do something sensible rather than nothing: `compoundblur` blurs evenly at full radius, and `displacementmap` displaces the layer by its own pixels (its red and green, or its luminance with `lumaMode: 1`). Never write `fxParams.hasMap` yourself. It and `mapUri` are two halves of one fact and the tool keeps them in step; setting either alone leaves the shader reading the wrong branch. ## Core workflow 1. Baseline capture and checkpoint. 2. Pick the surface: a look on one clip is a shader filter; a subtle photo-style treatment is a video effect; motion on a title or sticker is `set_layer_effect`; a backdrop is a generative shader layer. 3. Apply the preset by name. Read `get_effect_schema` first when you plan to pass `fxParams`. 4. `get_layer { layerId }` and check `shaderFilters` (or `videoEffects`, `effectId`) lists what you expect, in order. 5. `capture_canvas { timeSec }` and compare with the baseline. Time-driven engines (`lightflicker`, `warp` with speed, `glitchrgb`) look static on a still; judge them with `preview_filmstrip { fromSec: 0, toSec: 4, frames: 6 }`. 6. `save_project {}`. Before a final export of a heavily filtered project, `verify_export_parity { timesSec: [1, 4] }` shows canvas-versus-encoder pairs. ## Recipes Film print finish on a clip. ``` set_layer_shader_filter { layerId: "video0", effectId: "cinematicgrade", preset: "Film Print", fxParams: { skinProtect: 0.8, grain: 0.25 } } set_layer_shader_filter { layerId: "video0", effectId: "lightfx", preset: "Anamorphic Gold", append: true } set_layer_shader_filter { layerId: "video0", effectId: "lightflicker", preset: "Film Projector", fxParams: { amount: 0.3 }, append: true } get_layer { layerId: "video0" } // shaderFilters: [cinematicgrade, lightfx, lightflicker] preview_filmstrip { fromSec: 0, toSec: 3, frames: 6 } ``` Retro broadcast with a video-effect base. ``` set_layer_video_effects { layerId: "video0", effects: [{ effectId: "super8", intensity: 55 }, { effectId: "vignette", intensity: 40 }] } set_layer_shader_filter { layerId: "video0", effectId: "retrodisplay", preset: "CRT Monitor", fxParams: { amount: 0.45 } } capture_canvas { timeSec: 2 } ``` Aurora backdrop behind a title, then a glow on the title. ``` add_generative_bg_layer { effectId: "aurora", preset: "Northern Lights", startTime: 0, duration: 8 } reorder_layer { layerId: "generativeBg0", position: "back" } // example id; use the id the add call returned set_layer_border_glow { layerId: "text0", mode: "breathe", color: "#7DF9FF", radius: 24, intensity: 85, layers: 2 } capture_canvas { timeSec: 1 } ``` Light leak overlay and a Polaroid frame on a photo. ``` list_light_leaks { category: "warm-sunset" } add_light_leak_overlay { presetId: "warm-sunset-01", startTime: 0, duration: 5, intensity: 0.7 } set_layer_border { layerId: "image0", presetId: "polaroid", cornerRadius: 6 } describe_canvas { timeSec: 1 } // the leak adds a new "light" track on top ``` Pulse a sticker or logo without keyframes. ``` set_layer_effect { layerId: "image1", effectId: "pulse", params: { speed: 1.2, intensity: 40 } } ``` Drop the background out of focus behind a talking head, then grade the shot. ``` subject_blur_status { layerId: "video0" } // modelDownloaded: false subject_blur_download_model { layerId: "video0" } apply_subject_blur { layerId: "video0", radius: 34, gamma: 1.6, threshold: 0.1 } set_layer_shader_filter { layerId: "video0", effectId: "cinematicgrade", preset: "Golden Skin", append: true } // append, or the grade replaces the blur capture_canvas { timeSec: 2 } ``` Censor a face before a repost. ``` face_blur_status { layerId: "video0" } // detectionSupported: true only on iOS apply_face_blur { layerId: "video0", mode: "pixelate", padding: 0.45, samplePeriodMs: 150 } preview_filmstrip { fromSec: 0, toSec: 4, frames: 6 } // confirm the box FOLLOWS, not just frame 0 ``` ## Pitfalls - A plain `set_layer_shader_filter` replaces the chain, and that deletes a Subject Blur, Liquify or Face Blur entry along with everything else. On a layer that already carries one of them, pass `append: true` - and count them against the 4-stage cap. - `apply_subject_blur` and `apply_face_blur` bake before they return: seconds for a still, much longer for video. Do not fire a second call because the first looks slow. - `mapUri` takes a local `file://` control image and only `compoundblur` and `displacementmap` accept one; the map is stretched onto the layer, so a different aspect needs `mapZoom` or a re-cropped map rather than luck. - `set_layer_shutter_angle` is invisible on canvas by design - check `get_layer { layerId }` for `shutterAngle` and judge the look from an export, not from a capture. - Intensity scales differ by surface: `set_layer_video_effects` uses 0..100; shader `fxParams.intensity` and `set_layer_filter` use 0..1; glow `intensity` is 0..100. Read the schema, do not guess. - `preset` names must exist on that engine; the error lists the valid names. `fxParams` keys must come from `get_effect_schema`. - `add_procedural_filter_layer` creates a standalone `proceduralFilter` layer that does not composite the layers beneath it and currently renders nothing on canvas or export. Put filters on the media layer with `set_layer_shader_filter` instead. - `set_layer_effect` takes one effect per layer and replaces the previous one; it is not a chain. It validates ids against the effects registry, so custom `ai.` FxSpec ids are rejected. - Shader filters need a real image or video layer. A shape or text layer accepts the call but the filter has nothing to shade. - Generative backgrounds land in front. Reorder them to the back before adding text, or the title disappears behind the shader. - `add_light_leak_overlay` inserts a new track at the front and shifts every other track index by one; re-read `list_layers` before further z-order work. Duration defaults to the loop length (3 s or more) and tiles the clip to fill `duration`. - `set_shape_glow` throws on non-shape layers; use `set_layer_border_glow` for images, video and text. - Light leaks, CDN thumbnails and first-time shader compiles need time; a capture straight after the call can still show the previous frame. Capture again if the result looks unchanged. - Combining a layer mask with shader filters on Android has produced exports without the filters; run `verify_export_parity` on that platform before delivering. ## Reference - `references/effects-catalog.md` - effect ids grouped by category with param keys, plus the 77 video effects, the popular light leaks and the border presets. It is a snapshot: `list_effects {}` is the live answer. - `references/shaders.md` - the shaders split into generative backgrounds and shader transitions, with bucket, preset count and acceptsSource. Also a snapshot; `list_shaders {}` is live. - https://expocut.com/mcp.html - the full tool list. - Siblings: `expocut-color-grading`, `expocut-motion-graphics`, `expocut-social-speed-edit`, `expocut-kinetic-captions`, `expocut-shapes-layouts`, `expocut-asset-authoring`.
expocut-audio-post
Stems, ducking, beats — the mix engineer.
Mixes and post-produces audio in ExpoCut through its in-app MCP server - on-device stem separation (vocals, drums, bass, other), karaoke and vocal isolation, per-stem mixes, auto-ducking music under a vocal stem, beat times from the drum stem, audio-reactive zoom, flash and wobble, AI dialogue denoise, silence removal, loudness targets and gain maths, 37 per-layer audio effects, 25 audio in/out transitions, volume automation, fades, sync offsets, and royalty-free music from Freesound. Raw file: /skills/expocut-audio-post/SKILL.md · install: npx skills add https://expocut.com/skills/expocut-audio-post
--- name: expocut-audio-post description: "Mixes and post-produces audio in ExpoCut through its in-app MCP server - on-device stem separation (vocals, drums, bass, other), karaoke and vocal isolation, per-stem mixes, auto-ducking music under a vocal stem, beat times from the drum stem, audio-reactive zoom, flash and wobble, AI dialogue denoise, silence removal, loudness targets and gain maths, 37 per-layer audio effects, 25 audio in/out transitions, volume automation, fades, sync offsets, and royalty-free music from Freesound. Use when the user mentions stems, remove vocals, instrumental, karaoke, isolate the voice, duck the music, mix voice and music, beat sync, cut on the beat, pulse with the bass, clean up audio, denoise, hiss, hum, silence, LUFS, loudness, too quiet, too loud, compressor, EQ, reverb, fade the music, crossfade, lip-sync offset, or background music. Do not use for generating a voiceover or transcribing speech (expocut-voice-narration) or for caption styling (expocut-kinetic-captions)." license: Free to use and redistribute with attribution to expocut.com. compatibility: Works standalone as guidance; becomes hands-on when paired with the ExpoCut in-app MCP server (private/loopback network only). metadata: author: ExpoCut (expotechin.com) version: "3.0.0" homepage: https://expocut.com/skill.html --- # Audio post-production You are the mix engineer. Everything here is non-destructive: stems are new audio layers, ducking is a volume curve on the music layer, effects and transitions are fields on the layer, and the source files are never rewritten. `clean_audio` is the one tool that writes a new file - and it keeps the original layer's media. ## When to use / hand off | Need | Go to | | --- | --- | | Generate narration, transcribe speech, captions from a file | expocut-voice-narration | | Caption styling, karaoke text | expocut-kinetic-captions | | Placing the video cuts on the beat times you found | expocut-social-speed-edit | | Layer opacity/scale keyframes not driven by audio | expocut-motion-graphics | | Projects, undo, playback, export status | expocut-editor-ops | ## Before you start 1. `get_active_project {}` then `list_layers {}` - find the audio and video ids, their `startTime`/`duration` (seconds) and which layer carries the voice. `list_tracks {}` gives track ids for `set_track_volume` / `set_track_mute`. 2. Know what needs a model. `separate_audio` tier `fast` runs a model-free DSP path on the device (no download); tier `studio` downloads HT-Demucs from the CDN on first use (hundreds of MB, needs network and the phone awake) and falls back to fast on low-memory devices (`fellBack: true` in the result). `clean_audio` needs a build with the native denoise engine and downloads the DeepFilterNet3 model on first use. A build without the native separation module returns silent placeholder stems, and in the iOS simulator the fast tier has been seen failing while studio works - judge results on a device. 3. Stem layers are deterministic: `<sourceLayerId>__stem_vocals`, `__stem_drums`, `__stem_bass`, `__stem_other`. They inherit the source's start, in-point and speed and sit on rows behind it. `separate_audio` also returns each stem's `layerId` and file `uri`. 4. Model-backed calls block for many seconds. Set a generous client timeout and `save_history_checkpoint { label }` before a call that adds layers. ## Core workflow 1. Identify sources: music layer, voice layer (or a video whose embedded audio is the voice - `unlink_video_audio { videoLayerId }` gives it its own audio layer). 2. Separate when you need components: `separate_audio`, or the one-call wrappers `remove_vocals` (instrumental) and `isolate_voice` (dialogue boost). 3. Balance: `set_stem_mix` for stems, `update_layer { id, patch: { volume } }` (0..1) for a whole layer, `set_track_volume` (0..2.5) when you need a boost. 4. Duck the music under speech: `auto_duck` (recipe 1). 5. Clean dialogue: `clean_audio`, then an effect chain with `set_layer_audio_effects`. 6. Rhythm: `beat_cut_from_drums` for cut times, `set_audio_reactive` for a layer that pulses with a stem. 7. Polish: `set_layer_fade`, `set_layer_audio_transitions`, `set_layer_audio_offset`. 8. Loudness: pick a target with `list_loudness_targets`, compute gain with `gain_to_loudness_target`, apply it (recipe 5). 9. Verify: `seek { timeSec }`, `play {}`, `playback_status {}`, `pause {}`; then export through expocut-editor-ops and listen to the file. ## Tools you will use | Tool | What for | Key params | | --- | --- | --- | | `separate_audio` | 4 stems as new layers | `layerId`, `tier` fast/studio, `stems[]`, `mutedStems[]` (vocals/drums/bass/other/noise) | | `remove_vocals` | instrumental / karaoke | `layerId`, `tier` | | `isolate_voice` | vocal up, rest down | `layerId`, `boostDb` (default 6; others ducked -12 dB), `tier` | | `set_stem_mix` | stem faders | `sourceLayerId`, `mix[]` of `{ stem, volume 0..1, muted }` | | `beat_cut_from_drums` | onset times | `sourceLayerId` (must be separated), `sensitivity` (1.5; higher = fewer), `maxCuts`; returns `cutTimesMs` | | `set_audio_reactive` | property follows a stem | `targetLayerId`, `stemLayerId`, `stem`, `presetId` bass-zoom-pulse / kick-zoom-soft / vocal-flash-opacity / drum-wobble-rotate, `intensity` 0..1 | | `auto_duck` | music under voice | `musicLayerId`, `vocalSourceLayerId` (a layer that has been separated), `duckDb` negative, default -12 | | `swap_music` | strip the music stems | `sourceLayerId`, `includeDrums` | | `clean_audio` | AI denoise / speech enhance | `layerId`, `strength` 0..1 (0.85) | | `auto_remove_silence` | ripple-delete gaps | `layerId`, `thresholdDb` (-40), `minSilenceMs` (500), `paddingMs` (80) - returns `newLayerIds`, the source id is gone | | `set_layer_audio_effects` | effect chain | `layerId`, `effects[]` of `{ effectId, enabled?, params? }` - replaces the whole chain; `[]` clears | | `list_audio_effects` | 37 ids with params/ranges | none | | `set_layer_audio_transitions` | audio in/out | `layerId`, `in`/`out`: `{ transitionId, durationMs (500), params }` or `null` | | `list_audio_transitions` | 25 ids with canIn/canOut | none | | `set_layer_volume_keyframes` | volume automation | `layerId`, `keyframes[]` of `{ timeMs, volume 0..1 }`; `[]` clears | | `set_layer_fade` | fades | `layerId`, `fadeInMs`, `fadeOutMs` (0 clears) | | `set_layer_audio_offset` | sync nudge | `layerId`, `offsetMs` (positive = later) | | `mute_video_audio` / `unlink_video_audio` / `relink_video_audio` | embedded audio | `layerId`, `muted` / `videoLayerId` / `audioLayerId` | | `stock_search_music` / `stock_trending_music` | Freesound | `query`, `category` all/music/sfx/ambient/vocals/nature/electronic/cinematic (default music), `sort`, `minDuration`/`maxDuration` seconds, `perPage` | | `add_stock_music_layer` | add a Freesound clip | `soundId` (or `url`), `startTime`, `duration` (seconds, default full clip), `volume`, `fadeInMs`, `fadeOutMs` | | `add_audio_layer` | any local file | `uri`, `duration` required (seconds), `startTime`, `mediaOffsetSec`, `volume` | | `list_loudness_targets` / `gain_to_loudness_target` / `build_loudness_plan` / `measure_loudness` | loudness | see recipe 5 | ## Recipes ### 1. Voice over music, ducked ``` stock_search_music { query: "upbeat acoustic", category: "music", minDuration: 30, maxDuration: 120 } add_stock_music_layer { soundId: 123456, startTime: 0, duration: 45, volume: 0.8, fadeInMs: 500, fadeOutMs: 1500 } // example id -> returns { id: "audio…" } separate_audio { layerId: "audio0", tier: "fast", mutedStems: ["vocals", "drums", "bass", "other"] } // the VOICE layer (example id): stems muted, they only feed detection auto_duck { musicLayerId: "audio1", vocalSourceLayerId: "audio0", duckDb: -15 } ``` `auto_duck` detects speech from the vocal stem's RMS (50 ms windows, gaps under 250 ms merged), writes `volumeKeyframes` on the music layer every 100 ms with a 150 ms attack and 400 ms release, and replaces any curve already there. -12 dB suits light beds, -15 to -18 dense music, -22 when the voice is quiet. Inspect with `get_layer { layerId: "audio1" }` (`volumeKeyframes`), hand-edit with `set_layer_volume_keyframes`. The muted stems still work for `auto_duck` because it reads the stem file, not the layer's volume. ### 2. Karaoke, then the silence drop ``` remove_vocals { layerId: "audio0", tier: "studio" } // vocal stem muted, three stems audible set_stem_mix { sourceLayerId: "audio0", mix: [ { stem: "vocals", volume: 0.2, muted: false }, { stem: "drums", volume: 1 } ] } set_layer_volume_keyframes { layerId: "audio0__stem_bass", keyframes: [ { timeMs: 0, volume: 1 }, { timeMs: 14000, volume: 1 }, { timeMs: 14050, volume: 0 }, { timeMs: 16000, volume: 0 }, { timeMs: 16050, volume: 1 } ] } ``` Repeat the last call for `audio0__stem_other` to drop everything but drums for one bar before the payoff. The original layer `audio0` still plays the full mix: mute it with `update_layer { id: "audio0", patch: { audioMuted: true } }` (or `mute_video_audio` when the source is a video layer) - through MCP, separation leaves the source audible. ### 3. Beat times and an audio-reactive title ``` separate_audio { layerId: "audio0", tier: "fast" } beat_cut_from_drums { sourceLayerId: "audio0", sensitivity: 1.8, maxCuts: 12 } // -> cutTimesMs from the start of the music FILE set_audio_reactive { targetLayerId: "text0", stemLayerId: "audioBass", stem: "bass", presetId: "bass-zoom-pulse", intensity: 0.8 } // stemLayerId = the bass stem's layerId from separate_audio, i.e. audio0__stem_bass ``` Timeline time = music `startTime` + (cut - `mediaOffset`) - both in ms on `get_layer`. Hand the times to expocut-social-speed-edit for the cuts. Presets: bass-zoom-pulse scales 1 to 1.15, kick-zoom-soft 1 to 1.06, vocal-flash-opacity 0.7 to 1, drum-wobble-rotate 0 to 4 degrees; `intensity` scales the swing. The binding is stored on the target layer and baked into keyframes at export. ### 4. Dialogue cleanup chain ``` clean_audio { layerId: "video0", strength: 0.8 } // video: source muted + new cleaned audio layer (cleanedAudioLayerId); audio layer: replaced in place set_layer_audio_effects { layerId: "audio2", effects: [ { effectId: "high_pass", params: { cutoff: 90 } }, { effectId: "compressor", params: { threshold: -18, ratio: 3, attack: 10, release: 120, makeupGain: 3 } }, { effectId: "vocal_enhance", params: { presence: 4, deEss: 35 } } ] } ``` Params are numbers in the ranges `list_audio_effects` reports (see `references/audio-effects.md`); omitted params use the defaults. For a mild touch use `de_noise` in the chain instead of `clean_audio`. ### 5. Loudness pass ``` list_loudness_targets {} // tiktok -14, spotify -14, youtube -14, appleMusic -16, broadcastEbu -23, broadcastUs -24 LUFS gain_to_loudness_target { currentLufs: -20.5, target: "youtube", maxBoostDb: 12 } // -> gainDb 6.5 set_track_volume { trackId: "track-audio-1", volume: 2.1 } // 10^(6.5/20) = 2.11; track id from list_tracks ``` `measure_loudness { samplesBase64, sampleRate }` needs base64 Float32 mono PCM; no MCP tool exports a layer's samples, so it is only practical when the client itself holds the audio file (desktop). On the phone, work from a known or estimated LUFS. `build_loudness_plan { rows: [ { clipId, name, measurement: { integratedLufs, maxShortTermLufs, truePeakDbtp, gatedBlocks } } ], target: "tiktok", mode: "absolute" }` returns per-clip `gainDb` entries; nothing in the export path consumes the plan, so apply each gain yourself (dB to linear gain is 10^(dB/20): -6 dB = 0.5, +6 dB = 2). Layer `volume` is a 0..1 fader, so boosts go through `set_track_volume` or a `compressor` `makeupGain` / `normalize` effect. ## Pitfalls - Through MCP, `separate_audio`, `remove_vocals` and `isolate_voice` leave the source layer audible, so source and stems play together (the in-app panel mutes the source; the MCP path does not). Mute the source: `mute_video_audio { layerId, muted: true }` for video, `update_layer { id, patch: { audioMuted: true } }` for audio - or pass `mutedStems` when the stems are only for analysis. - `auto_duck` and `beat_cut_from_drums` fail with "No vocal stem" / "No drum stem" until `separate_audio` (or a wrapper) has run on that source layer. - `set_stem_mix` throws when the source has no stems; each entry needs `stem`. The `noise` stem is in the enum but the shipped 4-stem models never produce it. - `separate_audio` adds four audio layers every time you call it; call it once per source. Studio on a long track can take minutes. - `swap_music` only mutes stems (bass + other, plus drums with `includeDrums`); add the replacement with `add_stock_music_layer` yourself. - Freesound results are preview MP3s; the app build must carry Freesound credentials (a 401 means it does not). Check each result's `license` before commercial use. - `add_audio_layer` requires `duration` in seconds - the file length is not read. - `set_layer_audio_effects` rejects unknown `effectId`s and overwrites the whole chain. `set_layer_audio_transitions` rejects unknown ids but does not check `canIn`/`canOut`, so respect the flags yourself (filter_sweep_in is in-only, reverb_tail out-only - the wrong role is stored but meaningless). - `set_layer_fade`, `fadeInMs`, `durationMs`, `offsetMs`, `timeMs` are milliseconds; `startTime`, `duration`, `minDuration` are seconds. - `auto_remove_silence` removes the source layer and returns new ids; it decodes the whole clip on the phone. Video and audio layers only. - `set_audio_reactive` on a text layer needs the preset's property to apply (scale/opacity/rotation apply to image, text, shape and video layers). - Confirm before `remove_layer`; use `update_layer { id, patch: { audioMuted: true } }` or `set_track_mute` to audition instead. ## Reference - `references/audio-effects.md` - all 37 audio effects with categories and param ranges, and the 25 audio transitions with in/out capability. - https://expocut.com/mcp.html - full tool reference. - Siblings: expocut-voice-narration, expocut-social-speed-edit, expocut-kinetic-captions, expocut-editor-ops.
expocut-kinetic-captions
Karaoke captions built for muted feeds.
Everything text in ExpoCut through the in-app MCP server: titles and captions (add_text_layer, update_text), 44 fonts including Urdu/Arabic RTL faces, 99 text styles, 85 text effects, 18 text shades, 300 text animations (entrance/exit/loop, the typewriter family, the 101-preset Particles family, per-character ranges), text on an arc/circle/wave, per-range style runs, wrap box, video-in-text, 180 broadcast lower thirds, and captions transcribed from an audio file with karaoke word highlighting. Raw file: /skills/expocut-kinetic-captions/SKILL.md · install: npx skills add https://expocut.com/skills/expocut-kinetic-captions
--- name: expocut-kinetic-captions description: "Everything text in ExpoCut through the in-app MCP server: titles and captions (add_text_layer, update_text), 44 fonts including Urdu/Arabic RTL faces, 99 text styles, 85 text effects, 18 text shades, 300 text animations (entrance/exit/loop, the typewriter family, the 101-preset Particles family, per-character ranges), text on an arc/circle/wave, per-range style runs, wrap box, video-in-text, 180 broadcast lower thirds, and captions transcribed from an audio file with karaoke word highlighting. Use when the user says title, headline, caption, subtitles, auto captions, karaoke text, word-by-word, kinetic typography, animated text, typewriter, lower third, name plate, RTL, Urdu, Arabic, curved text, or wants speech readable with the sound off. Do not use to generate the voiceover audio or to transcribe a file into plain text (expocut-voice-narration), for text position/scale keyframes or motion paths (expocut-motion-graphics), or for caption-box, ticker and quote-card widgets (expocut-data-widgets)." license: Free to use and redistribute with attribution to expocut.com. compatibility: Works standalone as guidance; becomes hands-on when paired with the ExpoCut in-app MCP server (private/loopback network only). metadata: author: ExpoCut (expotechin.com) version: "3.0.0" homepage: https://expocut.com/skill.html --- # Kinetic captions, titles and lower thirds You are the typographer for an ExpoCut project. Every call below edits the user's open project live on their phone. Edits are undoable in the app, but ask before removing a layer or replacing a transcript the user has already corrected by hand. ## When to use / hand off - This skill: any text layer — titles, captions, karaoke transcripts, lower thirds, curved text, RTL text, video-in-text. - Voiceover generation and plain file transcription: expocut-voice-narration (tts_add_audio_layer, transcribe_audio). - Moving or scaling text over time (keyframes, motion paths): expocut-motion-graphics. - Layer-level transitions and effects on a text layer (set_layer_transition, set_layer_effect): expocut-fx-looks. - Plates, pills and bars behind text: expocut-shapes-layouts. Caption-box, ticker and quote-card widgets: expocut-data-widgets. - Opening projects, undo, capture and export: expocut-editor-ops. ## Before you start 1. A project must be open (open_project). Every text tool throws "No project is open" otherwise. 2. Call get_canvas_info for the aspect ratio, pixel size and duration, and list_layers for existing ids. Ids look like text_m0c3k1x9_7a2b4c1d (type prefix, timestamp, hex); always use the id returned by the add call, never invent one. 3. Discover before you set: list_fonts { category, source }, list_text_styles { category }, list_text_effects { category }, list_text_animations, list_lower_thirds. Setters reject unknown ids with a hint back to the right list tool. 4. Captions need the Whisper model downloaded inside the app (Transcribe panel). MCP never downloads it; ask the user to do that once before add_caption_layer_from_audio. 5. Font sizes are display points, not pixels: divide a 1080-px design size by about 2.7. On a 9:16 canvas a long title wraps fast, so give single-line titles textAutoFit instead of a raw fontSize. ## Core workflow 1. Add the layer. add_text_layer { text: "STOP SCROLLING", fontFamily: "din-alternate", textTransform: "uppercase", textAutoFit: { maxSize: 80, minSize: 24, maxLines: 1 }, verticalAnchor: "center", startTime: 0.5, duration: 3 }. Defaults: fullWidth true, textAlign center, duration 3 s. verticalAnchor top/center/bottom picks y (about 12 / centred / 86 percent); pass fullWidth: false with x and y for a hand-placed block. 2. Pick the look. set_text_style { layerId: "text_…", styleId: "title-impact" } then set_text_effect { layerId: "text_…", effectId: "glow-neon-pink" }. Refine with update_text { layerId: "text_…", fontSize: 44, letterSpacing: 2, textColor: "#FFFFFF" } and set_text_font { layerId: "text_…", fontFamily: "futura" }. A style or effect is baked into concrete fields (textColor, textStrokeColor, textShadow*, textBg*, textGradient*) at apply time, so apply the preset first and override after. 3. Optional backdrop silhouette. set_text_shade { layerId: "text_…", shapeId: "highlighter", opacity: 0.9 }. Valid shapeId values (there is no list tool): rect, brush, rough, swipe, tape, banner, angled, cloud, speech, starburst, ticket, arrowRight, arrowLeft, pennant, highlighter, wave, stamp, cornerCut. shapeId: null clears. 4. Animate. set_text_animation { layerId: "text_…", inId: "in-fade-up", inDurationSec: 0.4, outId: "out-fade", outDurationSec: 0.3, loopId: "loop-breathe", loopSpeed: 1 }. Each slot is optional; null clears it. Durations are seconds (stored as ms internally). Per-character presets obey set_text_animation_range { layerId: "text_…", start: 0.5, end: 1 } (fractions of the glyph count). 5. Layout extras. set_text_wrap_box { layerId: "text_…", width: 0.8, offsetX: 0.1 } (fractions of canvas width, width 0.05..1). set_text_path { layerId: "text_…", path: { kind: "arc", amplitude: 40 } }. set_text_style_runs for one accent word (below). 6. Check cheaply. describe_canvas { timeSec: 1.2 } lists every visible layer with its box in canvas percent; capture_canvas { timeSec: 1.2 } when you need pixels. Text animations and keyframes evaluate at timeSec, no seek needed. ## Tools you will use | Tool | What for | Key params (units, enums) | | --- | --- | --- | | add_text_layer | create a text layer | text*; fontSize (pt); fontFamily (list_fonts id); fontWeight "400"/"700"/"bold"; textAlign left/center/right/justify; fullWidth; verticalAnchor top/center/bottom; x, y (top-left %); startTime, duration (s); letterSpacing (pt); textTransform none/uppercase/lowercase/capitalize; lineHeight (multiplier); textStrokeColor, textStrokeWidth; textShadowColor, textShadowBlur; textAutoFit { maxSize, minSize, maxLines }; transitionIn/transitionOut (list_transitions id) with transitionInDuration/transitionOutDuration (s); fadeInMs/fadeOutMs | | update_text | edit an existing text or transcript layer | layerId*; text; fontSize; lineHeight; fontWeight; fontItalic; textTransform; textAlign; letterSpacing; textColor; opacity 0..1. Only passed fields change | | set_text_font / set_text_style / set_text_effect | Fonts tab, Text Styles row, Effects row | layerId*; fontFamily / styleId / effectId. styleId or effectId null clears. set_text_effect also accepts a category id (glow, outline, 3d, shadow, distort, retro, nature, metallic, comic, tech) and resolves it to that category's first preset | | set_text_shade | SVG silhouette behind the text | layerId*; shapeId (18 ids above or null); opacity 0..1 | | set_text_animation | entrance / exit / loop | layerId*; inId, outId, loopId (list_text_animations ids or null); inDurationSec, outDurationSec (s); loopSpeed (multiplier); inParams, outParams, loopParams (typewriter family tuning, see references/text-animations.md) | | set_typewriter | simple glyph-by-glyph reveal | layerId*; charDelayMs (30-80 feels human, 0 clears); startDelayMs | | set_text_animation_range | limit a per-character animation to part of the text | layerId*; start, end (0..1 of glyph count); randomize 0..1; seed; invert | | set_text_style_runs / clear_text_style_runs | per-range bold/italic/colour | layerId*; runs: [{ start, end, bold, italic, color }] as [start, end) character indexes into the content, sorted, non-overlapping; [] clears. Text layers only | | set_text_wrap_box | tighten the wrap box without resizing the font | layerId*; width (0.05..1 of canvas width); offsetX (fraction) | | set_text_path / clear_text_path | glyphs along a curve | layerId*; path: { kind: "arc", amplitude, flip } or { kind: "circle", radius, startAngleDeg, flip } or { kind: "wave", amplitude, frequency } or { kind: "none" }. amplitude/radius in the same points as fontSize | | set_text_writing_direction | force bidi direction | layerId*; direction auto/ltr/rtl | | set_text_font_features / set_text_font_variations | OpenType toggles, variable axes | layerId*; features { liga, smcp, tnum, ss01 … } / axes { wght, wdth, slnt … }; {} clears. Text layers only | | set_text_media_fill / clear_text_media_fill | video or image inside the glyphs | layerId*; uri* (local file://); type* video/image; offsetSec (s into a video). Text layers only | | add_lower_third_layer | broadcast name plate from a preset | presetId* (list_lower_thirds); lines (strings, one per preset line); startTime, duration (s, default 5); x, y (top-left %, default 50/75) | | get_widget_config / update_widget_config | read and patch lowerThirdConfig on a lower-third layer | layerId*; config (shallow merge, see Recipe 2) | | add_caption_layer_from_audio | transcribe a file and create a transcript layer | uri* (local audio file); language ("en", "ur"; omit = auto); modelSize tiny/base; startTime (s); x, y (top-left %); scale | | transcribe_audio | segments only, no layer | uri*; language; modelSize | | get_layer / update_layer | read the full layer, patch raw fields | layerId* / id*, patch* (startTime/duration in the patch are seconds) | ## Text animation quick picks Full grouped catalog with per-character and popular flags: references/text-animations.md. Keep in-* ids in inId, out-* in outId, loop-* in loopId; the tool only checks that an id exists. - Clean titles: in-fade-up, in-fade-zoom, in-mask-reveal, in-apple-title / out-apple-title, out-cinema-fade. - Punchy social: in-pop-chars (per-character), in-pink-bounce, in-elastic, in-wow / out-wow, out-scatter (per-character). - Typewriter family: in-tw-classic, in-tw-cursor, in-tw-word (reveals by word), in-tw-sparkle / in-tw-sparkle-word (particle burst from each letter / word as it lands — tune with inParams.sparkle), in-tw-scramble (decode), in-tw-highlighter (marker overlay), in-tw-terminal. Tune with inParams, e.g. set_text_animation { layerId: "text_…", inId: "in-tw-classic", inDurationSec: 1.2, inParams: { speedMs: 40, cursor: true, cursorColor: "#FFD93D" } }. - Glitch and tech: in-glitch-reveal, out-glitch-exit, loop-neon-flicker, loop-glitch-cycle. - Ambient loops: loop-pulse, loop-breathe, loop-float, loop-subtle-zoom, loop-counter-tick. - One motion family per video (pop, or slide, or type). Entrances 0.3-0.5 s; exits shorter than entrances. ## Captions from audio (the transcript layer) add_caption_layer_from_audio { uri: "/…/voice.m4a", language: "en", modelSize: "base", startTime: 2, x: 10, y: 75 } (uri is a file:// or absolute path to an m4a/mp3/wav/aac file) transcribes on-device and creates a transcript layer whose duration runs to the last segment. Segment times (startMs/endMs) are relative to the layer's startTime, so pass the same startTime you gave the narration audio layer. What the tool does not do: the canvas and the export only draw a transcript layer that also has textAnimationStyleId (a transcript style) and textAnimationWords (word timings). The app's Transcript panel fills those in when the user taps Apply; through MCP you set them yourself: 1. get_layer { layerId: "transcript_…" } and read transcriptSegments: [{ text, startMs, endMs }]. 2. Build words: split each segment's text on whitespace and spread the words evenly across [startMs, endMs] — exactly what the app does (Whisper gives segment timing, not word timing). 3. update_layer { id: "transcript_…", patch: { textAnimationStyleId: "karaoke-classic", textAnimationWords: [{ word: "Stop", t0: 0, t1: 320 }, { word: "scrolling", t0: 320, t1: 640 }], transcriptFontSize: 30, textAlign: "center", textFullWidth: true, position: { x: 0, y: 70 } } }. Transcript style ids (no list tool; colours, weight and the highlight animation come from the style): karaoke-classic, karaoke-neon, karaoke-fire, highlight-yellow, highlight-blue, highlight-green, highlight-red, highlight-gradient, bounce-pop, bounce-scale, bounce-wave, typewriter-classic, typewriter-mono, typewriter-subtitle, cinematic-bold, cinematic-slide, cinematic-minimal, cinematic-impact. Layer-level overrides the transcript renderer reads: transcriptFontSize (pt), transcriptFontFamily (a list_fonts id), textAlign, textFullWidth, position, scale, opacity. Per segment you may set enterAnim/exitAnim (none, fade, slide-left, slide-right, slide-up, slide-down, scale, bounce), rtl, fontFamily, by patching the whole transcriptSegments array back. update_text only helps here for textAlign; its fontSize and textColor, and set_text_font / set_text_style / set_text_effect, write fields this renderer ignores (it reads transcriptFontSize, transcriptFontFamily and the style colours). Caption spec that performs: 1-3 word segments held 600-900 ms, heavy sans in caps, white on a dark style, one highlight colour, placed 60-70 percent down the frame inside the safe zone. Fix mis-heard words in the segments before styling; spelling errors at caption size are fatal. There is no translation tool: caption in the spoken language. ## RTL: Urdu, Arabic, Hebrew - RTL faces from list_fonts { source: "rtl" }: geeza, al-nile, damascus, noto-naskh, nadeem (Arabic); noto-nastaliq, jameel-noori (Urdu). Direction auto-detects from the text; call set_text_writing_direction { layerId: "text_…", direction: "rtl" } only when mixed content is mis-detected. - Keep letterSpacing at 0 for Arabic-script text; tracking breaks letter joining. - Curved text on connected scripts renders as one shaped run bent along the path (Latin renders glyph by glyph). Hebrew works glyph by glyph on paths; skip niqqud. - RTL lower thirds: the five urdu-* presets (trend) and the twelve fx-urdu-* / fx-arabic-* presets; pass Urdu/Arabic strings in lines. ## Recipes ### 1. Hook title with one accent word (kinetic typography) ``` add_text_layer { text: "STOP SCROLLING NOW", fontFamily: "din-alternate", textTransform: "uppercase", textAutoFit: { maxSize: 84, minSize: 28, maxLines: 1 }, verticalAnchor: "center", startTime: 0, duration: 2.5 } // returns { id: "text_…" } set_text_style { layerId: "text_…", styleId: "title-impact" } set_text_effect { layerId: "text_…", effectId: "fx-outline-thick" } set_text_style_runs { layerId: "text_…", runs: [{ start: 15, end: 18, color: "#FFD93D", bold: true }] } // "NOW" set_text_animation { layerId: "text_…", inId: "in-pop-chars", inDurationSec: 0.6, outId: "out-fade", outDurationSec: 0.25 } describe_canvas { timeSec: 1 } ``` Run indexes count characters of the stored content ("STOP SCROLLING NOW": N at 15, W at 17, end is exclusive). Runs live on text layers only. ### 2. Branded lower third ``` list_lower_thirds add_lower_third_layer { presetId: "accent-bar", lines: ["Maya Chen", "Head of Design"], startTime: 1, duration: 5, x: 6, y: 72 } // returns { id: "lowerthird_…" } get_widget_config { layerId: "lowerthird_…" } update_widget_config { layerId: "lowerthird_…", config: { accentColor: "#FFD93D", textAlign: "left", transitionIn: { type: "slide-left", "duration": 400, easing: "easeOut" }, transitionOut: { type: "fade", "duration": 300, easing: "easeIn" } } } // transition durations are ms ``` lowerThirdConfig keys you can patch: accentColor, accentElement, textAlign, hasBackground, backgroundColor, backgroundOpacity, backgroundRadius, padding { top, bottom, left, right }, lineGap, transitionIn/transitionOut { type, duration (ms), easing }, lines[]. Transition types: none, fade, slide-left, slide-right, slide-up, slide-down, scale, scale-up, scale-down, wipe-left, wipe-right, wipe-up, wipe-down, flip-x, flip-y, bounce, elastic, blur, zoom-rotate, typewriter. update_widget_config is a shallow merge, so to recolour one line read lines from get_widget_config, edit the entry ({ text, color, fontScale, fontWeight, uppercase, letterSpacing }) and send the whole array back. Hold a lower third at least 3 s; slide in 300-500 ms. ### 3. Captions for a muted feed ``` add_caption_layer_from_audio { uri: "/…/narration.m4a", language: "en", modelSize: "base", startTime: 0, x: 10, y: 75 } // returns { id: "transcript_…", segmentCount } get_layer { layerId: "transcript_…" } // transcriptSegments → build [{ word, t0, t1 }] per segment, evenly spread update_layer { id: "transcript_…", patch: { textAnimationStyleId: "highlight-yellow", textAnimationWords: [ /* built above */ ], transcriptFontSize: 30, transcriptFontFamily: "din-condensed", textAlign: "center", textFullWidth: true, position: { x: 0, y: 68 } } } capture_canvas { timeSec: 1.4, maxWidth: 540 } // mid-sentence: is the active word highlighted and legible on this footage? ``` Whisper tiny is fast and rough, base is slower and more accurate; both take seconds to minutes with the phone awake. If the response says the model is not downloaded, stop and ask the user to open the Transcribe panel. ### 4. Video inside display type ``` add_text_layer { text: "SUMMER", fontFamily: "impact", textAutoFit: { maxSize: 120, minSize: 40, maxLines: 1 }, verticalAnchor: "center", startTime: 0, duration: 4 } set_text_media_fill { layerId: "text_…", uri: "/…/waves.mp4", type: "video", offsetSec: 3 } set_text_animation { layerId: "text_…", inId: "in-scale", inDurationSec: 0.5 } ``` Whole-text entrances and exits bake into the export; per-character presets on media-filled text render static in the export, so keep those for solid text. ## Pitfalls - Units: startTime, duration, inDurationSec, outDurationSec, transitionInDuration, offsetSec are seconds; charDelayMs, startDelayMs, fadeInMs, lower-third transition duration, and transcript t0/t1/startMs/endMs are milliseconds. A patch startTime of 3000 makes a 3000-second layer and wedges the export. - textStyleId and textEffectId are bookkeeping; only the baked fields render. Applying a style after update_text overwrites the overlapping fields (colour, weight, case, tracking), so preset first, overrides second. - set_text_font accepts any string (treated as a Google font, flagged bundled: false in the response). Prefer list_fonts ids: a runtime font that fails to load falls back to the system face in the export. - New layers always land on top (trackIndex 0). Put a plate under a title with reorder_layer { layerId: "shape_…", position: "back" } or add the plate first. - The transcript layer default position is x 50 / y 80 with an 80-percent-wide box, so half of it starts off the right edge. Pass x: 10 or set textFullWidth: true with position x 0. - Transcript layers render nothing until textAnimationStyleId and textAnimationWords are set (see above). transcript ids look like transcript_…. - set_text_style_runs, set_text_shade, set_text_path, set_text_media_fill, font features and variations refuse non-text layers (transcript and lowerthird included). - Lower-third text is drawn from lowerThirdConfig; set_text_font/style/effect accept the layer but do not change what it draws. Use update_widget_config. - Media-filled text needs a local file:// uri; remote URLs and data URIs do not export. - set_text_path still describes native export as in progress. Arc, circle and wave text has exported on both platforms in practice, but compare capture_canvas with capture_export_frame at the same timeSec before promising it, especially with a media fill or a cursive script. - delete or remove_layer only after the user confirms; there is no undo through MCP except undo / undo_to_checkpoint (expocut-editor-ops). ## Reference - references/text-animations.md — all 199 animation ids grouped by type and category, per-character and popular flags, typewriter params. - references/lower-thirds.md — all 180 lower-third presets by category with line counts. - references/fonts-styles-effects.md — 44 fonts (RTL flagged), 99 text styles with their key props, 85 text effects. - https://expocut.com/mcp.html — tool reference. Siblings: expocut-voice-narration, expocut-motion-graphics, expocut-fx-looks, expocut-shapes-layouts, expocut-data-widgets, expocut-editor-ops, expocut-retention-playbook.
expocut-data-widgets
Bar chart races people actually finish.
Builds data and overlay widgets in ExpoCut through the in-app MCP server: bar chart races from CSV or arrays, countdowns, stopwatches and live clocks, scoreboards and stat bars, polls, follower counters and like bursts, comment bubbles, news tickers, breaking banners and news alerts, QR codes, weather cards, quote cards, caption boxes, search-bar typing, confetti and fire meters, plus phone, tablet and browser device mockups for app demos from a recording, or a LIVE website rendered straight into the mockup (add_html_layer + set_html_mockup + set_html_camera). Raw file: /skills/expocut-data-widgets/SKILL.md · install: npx skills add https://expocut.com/skills/expocut-data-widgets
--- name: expocut-data-widgets description: "Builds data and overlay widgets in ExpoCut through the in-app MCP server: bar chart races from CSV or arrays, countdowns, stopwatches and live clocks, scoreboards and stat bars, polls, follower counters and like bursts, comment bubbles, news tickers, breaking banners and news alerts, QR codes, weather cards, quote cards, caption boxes, search-bar typing, confetti and fire meters, plus phone, tablet and browser device mockups for app demos from a recording, or a LIVE website rendered straight into the mockup (add_html_layer + set_html_mockup + set_html_camera). Use when the user asks for a stats video, chart race, data visualisation, timer, countdown, score bug, live ticker, subscriber counter, poll sticker, QR code, \"show my app in a phone frame\", \"put this website in a phone mockup\", \"app store preview video\", or animated numbers. Do not use for plain titles, captions or lower thirds (expocut-kinetic-captions), for shape grids and screen layouts (expocut-shapes-layouts), or for importing Lottie files (expocut-template-import)." license: Free to use and redistribute with attribution to expocut.com. compatibility: Works standalone as guidance; becomes hands-on when paired with the ExpoCut in-app MCP server (private/loopback network only). metadata: author: ExpoCut (expotechin.com) version: "3.1.0" homepage: https://expocut.com/skill.html --- # Data videos, widgets and device mockups You are the data storyteller. Numbers perform when they move and when one number owns each beat. Every call edits the user's open project live; edits are undoable in the app, but confirm before removing layers. ## When to use / hand off - This skill: the 20 widget types add_widget_layer can create, bar_chart_race.set_data, get_widget_config / update_widget_config, add_device_mockup, add_html_layer / set_html_mockup / set_html_camera / set_html_interactions. - Three entries of list_widgets are not created by add_widget_layer: lower-third-simple → add_lower_third_layer (expocut-kinetic-captions); shape-widget → add_shape_widget (expocut-shapes-layouts); lottie-animation → add_lottie_layer { uri or presetId, loop, playbackSpeed, startTime, duration, x, y, scale } (file import guidance in expocut-template-import). - Titles and captions: expocut-kinetic-captions. Sound effects on number pops and beat sync: expocut-audio-post. Keyframing a widget's position or opacity: expocut-motion-graphics. Capture, export, undo: expocut-editor-ops. ## Before you start 1. A project must be open. get_canvas_info tells you the canvas size and duration; list_layers lists ids (widget ids look like clock_m0c3k1x9_7a2b4c1d, scoreboard_…, bar-chart-race_…). 2. list_widgets returns id, name, category and layerType. The widgetId goes to add_widget_layer; the layerType is what get_layer reports. 3. Every widget config has a default factory; add_widget_layer deep-merges your partial config into it, so pass only the keys you change. Read the real keys in references/widget-configs.md before guessing. 4. Widgets are auto-sized: x/y place the top-left corner in canvas percent (default 50/50, which is off-centre). Use describe_canvas after adding to read the box and re-place with update_layer. ## Core workflow 1. Add. add_widget_layer { widgetId: "countdown", config: { countdownFromMs: 10000, format: "MM:SS", fontSize: 64, showLabel: true, labelText: "STARTS IN" }, x: 25, y: 40, startTime: 0, duration: 10 }. Default duration is 5 s (2 s for confetti and like-burst). Returns { id, widgetId, layerType, trackIndex }. 2. Inspect. get_widget_config { layerId: "clock_…" } returns { field, config } with the full merged config. 3. Patch. update_widget_config { layerId: "clock_…", config: { textColor: "#FFD93D", glowEnabled: true, glowColor: "#FFD93D" } }. This is a shallow merge: nested objects and arrays (scoreboard teamA, ticker items, poll options, bar-chart-race series) are replaced wholesale, so send the complete object you want. 4. Place and time. describe_canvas { timeSec: 1 } shows the widget's box in canvas percent; update_layer { id: "clock_…", patch: { position: { x: 30, y: 8 }, scale: 1.2, startTime: 2, duration: 6 } } (seconds in the patch). 5. Stage changes over time by adding a second layer with a different config and startTime; widget configs are static per layer, and clocks, countdowns, stopwatches and animated bars run from the layer's startTime. 6. Verify. capture_canvas { timeSec } for a look; capture_export_frame { timeSec } when the render must match, since widgets are composited natively on export. ## Widgets and the config keys that matter Shared keys on almost every widget: transition (none, fade, slideUp, slideDown, slideLeft, slideRight, zoom, bounce) with transitionDuration (ms); glowEnabled/glowColor/glowRadius; borderEnabled/borderColor/borderWidth; shadowEnabled/shadowColor/shadowOffsetX/shadowOffsetY; fontSize (pt); bgColor/bgOpacity. Full lists with defaults: references/widget-configs.md. | widgetId | layer type | config keys you will actually set | | --- | --- | --- | | live-clock / countdown / stopwatch | clock | mode is set by the widgetId; format HH:MM:SS, HH:MM, MM:SS, MM:SS.ms, H:MM:SS, D:HH:MM:SS, "Dd HHh MMm SSs"; countdownFromMs (ms); fontSize, fontWeight regular…heavy, textColor, digitSpacing; bgEnabled/bgColor/bgRadius; showLabel/labelText/labelBgColor | | scoreboard | scoreboard | teamA / teamB { name, fullName, score, color, secondaryColor }; sport; period; showPeriod; layout compact/standard/broadcast/minimal; scoreFontSize; useTeamColors | | stat-bar | statbar | label, subLabel, value (0-100), unit; style standard/segmented/gradient/neon; barColor, barColorStops; animateOnPlay, animationDuration (ms); segmentCount | | follower-counter | follower | count, label; platform youtube/instagram/tiktok/twitter/twitch/custom; customPlatformName, platformIcon; style card/pill/badge/minimal/broadcast; animated; showGrowthBadge, growthText | | like-burst | likeburst | emojiType heart/thumbs/fire/star/clap/custom, customEmoji; particleCount, size, speed, spread; showCount, countValue | | comment-bubble | comment | username, message; platform; style modern/chat/broadcast/minimal; avatarInitial, avatarColor; showVerified; showTimestamp, timestampText; maxWidthPct | | news-ticker | ticker | items [{ id, text, iconType, enabled }] (iconType none/breaking/live/alert/info/sports/weather/finance/politics/tech/custom); label { enabled, text, bgColor, textColor }; direction left/right; speed (px/s); bgHeightPct (0..1); borderColor; divider { enabled, symbol, color } | | breaking-banner | banner | text, subtext, showSubtext; badgeText, showBadge; position top/bottom; animation slide/fade/flash/none; style breaking/alert/info/sports/custom; bgColor, accentColor; uppercase | | news-alert | newsalert | headline, subtext, icon; style card/pill/minimal/broadcast; animation slideIn/fadeIn/bounceIn/none; badgeText, showBadge; maxWidthPct | | qr-code | qrcode | url; label, showLabel; style standard/rounded/dots/branded; fgColor, bgColor; size (pt); showLogo, logoEmoji | | weather-card | weather | location, temperature, feelsLike, humidity, windSpeed (static values, nothing is fetched); condition sunny/cloudy/rainy/stormy/snowy/windy/foggy/partly-cloudy; unit C/F; style card/minimal/broadcast/glassmorphism; showDate, dateText | | quote-card | quote | quote, attribution; style minimal/bold/cinematic/editorial/card/gradient; alignment; quoteFontSize; showQuoteMarks; accentColor; animateIn | | caption-box | caption | text; style standard/broadcast/minimal/pill/gradient; position top/center/bottom; fontSize; maxWidthPct; uppercase; scrollEnabled, scrollSpeed | | searchbar | searchbar | query, placeholder; typeSpeedMs, startDelayMs; showCursor, cursorColor; fontSize; barColor; minWidthFrac (0..1); focusGlow | | poll | poll | question; options [{ id, label, votes, color }] (2-4); showPercentages, showVoteCounts; animateOnPlay, animationDuration (ms); headerText, showHeader | | confetti | confetti | style classic/emoji/stars/hearts/streamers; emoji; colors []; particleCount; direction down/up/burst; loop; gravity; speed | | fire-meter | firemeter | level (0-100); label; style bar/circular/thermometer/emoji-stack; theme fire/electric/ice/neon/custom; animateFromZero; width, height (pt) | | bar-chart-race | bar-chart-race | see below | ## Bar chart race 1. add_widget_layer { widgetId: "bar-chart-race", config: { title: "Streaming subscribers", valueSuffix: "M", decimals: 0, topN: 8, secondsPerFrame: 1.5, matchHostDuration: true }, x: 5, y: 15, startTime: 0, duration: 30 } then update_layer { id: "bar-chart-race_…", patch: { canvasRelativeWidth: 90, canvasRelativeHeight: 55 } } so the plot gets a real box (the default box is far too small for axis, labels and timeline). 2. Load data with the dedicated tool, never with update_widget_config: bar_chart_race.set_data { layerId: "bar-chart-race_…", data: { frames: ["2019", "2020", "2021"], series: [{ id: "netflix", label: "Netflix", color: "#E50914", values: [167, 204, 222] }, { id: "disney", label: "Disney+", color: "#113CCF", values: [null, 87, 130] }] } }. It validates that every series.values.length equals frames.length (null = missing, interpolated through) and records provenance; a shallow merge would corrupt the parallel arrays. 3. CSV alternative: bar_chart_race.set_data { layerId: "bar-chart-race_…", csv: ",2019,2020,2021\nNetflix,#E50914,167,204,222\nDisney+,#113CCF,,87,130", source: "csv-paste" }. Row 0 is the frame labels (leading empty cells skipped); column 0 is the series label; an optional hex/rgb cell becomes the colour and an optional single-emoji cell becomes the icon; the rest are values (empty = null). Pass exactly one of data or csv. 4. Pace it: the race lasts frames × secondsPerFrame unless matchHostDuration is true (then it fits the layer duration). rankEaseMs / axisEaseMs (ms) soften overtakes; valueInterp linear/step/smoothstep. Content keys: title, subtitle, source, axisLabel, valueLabel, valuePrefix, valueSuffix, decimals, sortDirection desc/asc; layout keys: topN, barHeight, barGap, showIcon, iconShape circle/square/none, labelPlacement auto/insideRight/outsideRight; showTimeline, showPlayhead, showFrameLabel; theme { background, titleFont, valueFont, frameFont, titleColor, subtitleColor, axisColor, gridColor, sourceColor }. 5. Craft: 8-12 bars, one protagonist bar in a distinct colour, a big frame label as the anchor, let the race resolve by 80 percent of the runtime and hold the end state. Check with preview_filmstrip { fromSec: 0, toSec: 30, frames: 8 }. ## Device mockups add_device_mockup { device: "phone", color: "#1A1A1F", widthFrac: 40, startTime: 0, duration: 8 } adds an SVG image layer (id image_…) with a transparent screen and returns screenRect { x, y, widthFrac, heightFrac } in canvas percent. device: phone (9:19.5), tablet (4:3), browser (16:10). Defaults centre the frame; x/y are top-left percent. The screen is a hole: the content must sit behind the frame (higher trackIndex). New layers land on top, so either add the content first and the mockup second, or reorder_layer { layerId: "video_…", position: "back" } (or a numeric position larger than the frame's trackIndex). Size the content to the hole from screenRect. The phone bezel is 4.5 percent of the frame width on every side (tablet 5 percent; the browser is full width below a title bar 8.5 percent of the frame height), so: screenW = widthFrac × 0.91; screenX = x + widthFrac × 0.045; inset = widthFrac × 0.045 × canvasAspect (canvas width / height, 0.5625 on 9:16) because the vertical inset must be expressed in canvas-height percent; screenH = heightFrac − 2 × inset; screenY = y + inset. On a 9:16 canvas a phone at widthFrac 40 returns heightFrac 48.75 (widthFrac × 0.5625 / 0.4615), so the hole is update_layer { id: "video_…", patch: { canvasRelativeWidth: 36.4, canvasRelativeHeight: 46.7, position: { x: 31.8, y: 26.6 } } }. Add the video (uri is a file:// or absolute local path) with stretchToCanvas: false so it is not stretched to the canvas first, then confirm the boxes with describe_canvas. ### Live web pages in a mockup — no recording needed When the source is a real website rather than a screen recording, skip add_device_mockup + the hole-sizing math entirely: add_html_layer { sourceType: "url", url: "https://…", viewportMode: "phone" } renders the page itself, live, on the canvas — then set_html_mockup { layerId, frame: "phone-punch", glow: { mode: "auto" } } frames it (phone, phone-punch, tablet, laptop, display, browser, or none), and set_html_camera { layerId, recipe: "showcase" } bakes an entrance + camera move + exit into keyframes (recipes: showcase, launch, hero, pop, tabletop, cinematic). transparent: true on add_html_layer clears the page's own background instead of framing it. To act out a flow on screen (scroll a landing page, tap a button, fill a field) use set_html_interactions with tap / type / scroll / swipe / hover / key steps — it plays on the canvas and bakes into the export. This whole path — url in, framed and camera-moved out — is one recipe below. ## Recipes ### 1. Countdown reveal with confetti ``` add_widget_layer { widgetId: "countdown", config: { countdownFromMs: 5000, format: "MM:SS", fontSize: 72, fontWeight: "heavy", textColor: "#FFFFFF", bgEnabled: false, glowEnabled: true, glowColor: "#FF2D55", glowRadius: 14 }, x: 32, y: 40, startTime: 0, duration: 5 } add_widget_layer { widgetId: "confetti", config: { style: "classic", direction: "burst", particleCount: 40, loop: false, transition: "fade", transitionDuration: 200 }, x: 20, y: 20, startTime: 5, duration: 2.5 } add_text_layer { text: "WE ARE LIVE", verticalAnchor: "center", textAutoFit: { maxSize: 90, minSize: 30, maxLines: 1 }, startTime: 5, duration: 3 } describe_canvas { timeSec: 2.5 } ``` ### 2. Score bug that changes between beats ``` add_widget_layer { widgetId: "scoreboard", config: { teamA: { name: "LAL", fullName: "Lakers", score: 98, color: "#552583", secondaryColor: "#FDB927" }, teamB: { name: "BOS", fullName: "Celtics", score: 101, color: "#007A33", secondaryColor: "#FFFFFF" }, sport: "BASKETBALL", period: "Q4", layout: "broadcast" }, x: 20, y: 4, startTime: 0, duration: 6 } add_widget_layer { widgetId: "scoreboard", config: { teamA: { name: "LAL", fullName: "Lakers", score: 101, color: "#552583", secondaryColor: "#FDB927" }, teamB: { name: "BOS", fullName: "Celtics", score: 101, color: "#007A33", secondaryColor: "#FFFFFF" }, sport: "BASKETBALL", period: "Q4", layout: "broadcast", transition: "bounce", transitionDuration: 400 }, x: 20, y: 4, startTime: 6, duration: 6 } ``` Two layers back to back read as one bug updating; the second one's transition sells the change. Pair the cut with a pop SFX (expocut-audio-post). ### 3. App demo in a phone frame ``` add_video_layer { uri: "/…/screen-recording.mp4", stretchToCanvas: false, startTime: 0, duration: 8 } // content first → video_… add_device_mockup { device: "phone", widthFrac: 42, startTime: 0, duration: 8 } // 9:16 canvas → screenRect { x: 29, y: 24.4, widthFrac: 42, heightFrac: 51.2 } update_layer { id: "video_…", patch: { canvasRelativeWidth: 38.2, canvasRelativeHeight: 49.1, position: { x: 30.9, y: 25.5 } } } // hole = frame inset by 4.5% of its width describe_canvas { timeSec: 1 } // the video box must sit inside the frame box, frame listed first (on top) ``` ### 4. Live website in a phone mockup ``` add_html_layer { sourceType: "url", url: "https://example.com", viewportMode: "phone" } // → html_… set_html_mockup { layerId: "html_…", frame: "phone-punch", glow: { mode: "color", color: "#8b5cf6" } } set_html_camera { layerId: "html_…", recipe: "showcase" } // entrance + move + exit baked into keyframes describe_canvas { timeSec: 1 } ``` No screen recording, no hole-sizing math — the page renders live and the mockup + camera do the framing. Swap viewportMode to "tablet" or "desktop" for a wider layout, or frame: "none" to show the bare page with just the camera move. ### 5. Ticker plus QR call to action ``` add_widget_layer { widgetId: "news-ticker", config: { label: { enabled: true, text: "TONIGHT", bgColor: "#FFD93D", textColor: "#000000", fontWeight: "800", paddingH: 10, paddingV: 5, borderRadius: 2, pulse: false }, items: [{ id: "i1", text: "Doors open 7pm", iconType: "live", enabled: true }, { id: "i2", text: "Tickets at the link", iconType: "info", enabled: true }], speed: 320, bgHeightPct: 0.06 }, x: 0, y: 92, startTime: 0, duration: 15 } add_widget_layer { widgetId: "qr-code", config: { url: "https://example.com/tickets", label: "Scan for tickets", style: "rounded", size: 120 }, x: 70, y: 70, startTime: 3, duration: 12 } ``` ## Pitfalls - update_widget_config is shallow: patching { teamA: { score: 5 } } drops name/colour; patching items or options replaces the whole array. get_widget_config first, edit, send back complete objects. Never patch frames or series this way on a bar chart race. - update_widget_config and get_widget_config work on widget layers, lower thirds, collages and plain shape layers (shapeConfig); they throw "has no widget config" for text, media, SVG and shape-widget layers. Patch a shape widget's shapeWidgetConfig with update_layer. - Units: startTime/duration in add and patch calls are seconds; countdownFromMs, transitionDuration, animationDuration, typeSpeedMs are milliseconds; fontSize and size are display points (about 1080-px design size / 2.7). - Countdowns and stopwatches start at the layer's startTime, not at the project start. A countdown shorter than the layer duration sits at 00:00 for the remainder. - Weather, follower and comment widgets show the values you type; nothing is fetched live. The ticker's remoteFeed and schedule blocks are for the in-app live mode and do not export. - Widgets are auto-sized; scale them with the layer scale (update_layer patch scale) rather than expecting canvasRelativeWidth to apply. The bar chart race is the exception and needs canvasRelativeWidth/Height. - Keep widgets inside the safe zone (about the central 900 × 1400 of a 1080 × 1920 canvas): capture_canvas { timeSec: 1, grid: true } draws the guides. - Export composites widgets natively; if capture_canvas and capture_export_frame disagree, trust the export frame and simplify the config (fewer particles, no glow) before blaming the data. - Confirm before remove_layer; there is no MCP-side undo other than undo / undo_to_checkpoint (expocut-editor-ops). ## Reference - references/widget-configs.md — every widget config key with its default and enum values, the ticker and bar-chart-race shapes, CSV contract. - https://expocut.com/mcp.html — tool reference. Siblings: expocut-kinetic-captions (lower thirds, captions), expocut-shapes-layouts (shape widget, plates), expocut-motion-graphics (keyframes), expocut-audio-post (pops and beat sync), expocut-template-import (Lottie files), expocut-editor-ops (capture, export, undo).
expocut-retention-playbook
Hooks, cadence, safe zones, loops.
Retention editing for TikTok, Instagram Reels and YouTube Shorts inside ExpoCut through the in-app MCP server: the first-second hook (open mid-action, a four to seven word text hook, an audio spike), a visible change every three to five seconds, snap punch-ins as hold keyframes at real timeline milliseconds, duration targets per platform, text kept inside platform safe zones checked with describe_canvas and the capture_canvas grid, seamless loop endings, a cover frame, and a preview_filmstrip verify loop. Raw file: /skills/expocut-retention-playbook/SKILL.md · install: npx skills add https://expocut.com/skills/expocut-retention-playbook
--- name: expocut-retention-playbook description: "Retention editing for TikTok, Instagram Reels and YouTube Shorts inside ExpoCut through the in-app MCP server: the first-second hook (open mid-action, a four to seven word text hook, an audio spike), a visible change every three to five seconds, snap punch-ins as hold keyframes at real timeline milliseconds, duration targets per platform, text kept inside platform safe zones checked with describe_canvas and the capture_canvas grid, seamless loop endings, a cover frame, and a preview_filmstrip verify loop. Use when the user wants a video to perform, go viral, hold attention, asks about hooks, retention, watch time, completion rate, the best length for a platform, where text is safe from the app UI, why viewers swipe away, or wants a cover frame. It decides what to change and when, then calls the executing skills (expocut-video-creating, expocut-social-speed-edit, expocut-audio-post, expocut-kinetic-captions). Do not use for long-form YouTube pacing beyond the basics." license: Free to use and redistribute with attribution to expocut.com. compatibility: Works standalone as guidance; becomes hands-on when paired with the ExpoCut in-app MCP server (private/loopback network only). metadata: author: ExpoCut (expotechin.com) version: "3.0.0" homepage: https://expocut.com/skill.html --- # The retention playbook You are the retention editor. The scroll decision happens in about 1.5 seconds, so everything below serves the first second first. You change the live project on the user's phone; keep a checkpoint before a pass, verify with cheap text readouts before spending images, and let the user decide on removals. ## When to use / hand off Use this skill to decide what to change and when: hook, cadence, punch-ins, duration, safe zones, loop, cover frame. It executes the simple calls itself and hands the rest off: - cuts, trims, titles, stock, music, export: expocut-video-creating and expocut-editor-ops - speed ramps, cut-level transitions between two clips, zoom punches at scale: expocut-social-speed-edit - beat detection, silence removal, ducking, loudness: expocut-audio-post - captions and word-by-word text (most viewers watch muted): expocut-kinetic-captions - keyframe easing beyond a snap, motion paths: expocut-motion-graphics ## Before you start - Ask which platform and whether the piece is entertainment or educational; that sets the target length (table below) and the safe zone. - `get_active_project`, then `get_canvas_info` for `totalDurationSec` and the aspect, and `list_layers` for ids and timings (seconds). Never guess an id. - `save_history_checkpoint { label: "before retention pass" }` so the whole pass can be reverted with `undo_to_checkpoint { id }`. - Units: cut tools take seconds; `keyframe_add` takes `timeMs` in milliseconds on the project timeline (a punch-in at 4 s is `timeMs: 4000`, and `timeMs: 4` lands at 4 ms). ## Core workflow ### 1. The hook window - 0 to 1 s: a pattern interrupt (hard cut mid-action, whip pan, snap zoom), a four to seven word text hook, and an audio spike. Start mid-motion, never on an intro card. - 0 to 3 s: the hook has to work with sound off. Text, not narration, carries it. - 3 to 5 s: state the explicit promise ("3 edits that double watch time"). - Hook formats that keep working: bold or contrarian claim, open question, result first (show the payoff frame at t=0, then the process), numbered promise, direct callout ("if you edit on your phone"). Execution: ``` trim_layer { layerId: "video_…", mediaOffsetSec: 3.2, startTimeSec: 0 } // skip the wind-up, open mid-action add_text_layer { text: "3 edits that double watch time", fontSize: 64, fontWeight: "800", textColor: "#FFFFFF", fontFamily: "din-condensed", textAlign: "center", y: 18, startTime: 0, duration: 2.5, transitionIn: "slideUp", transitionInDuration: 0.25, textStrokeColor: "#000000", textStrokeWidth: 3, textAutoFit: { maxSize: 64, minSize: 40, maxLines: 2 } } stock_search_music { query: "whoosh", category: "sfx", maxDuration: 2 } add_stock_music_layer { soundId: 123456, startTime: 0, duration: 1, volume: 0.9 } // example id set_layer_transition { layerId: "video_…", in: { id: "whippan", durationSec: 0.3 } } ``` If the strongest moment sits later in the clip, `split_layer { layerId, atSec: 6 }` and move the second half to the front with `trim_layer { layerId: "video_…second", startTimeSec: 0 }`, or let expocut-social-speed-edit rebuild the order. ### 2. Cadence: change something every 3 to 5 seconds - Baseline for short-form: a visible change (cut, B-roll, text pop, zoom, angle) at least every 3 to 5 s. Trend and entertainment cuts run 0.5 to 1 s; educational content can hold 4 to 5 s. - Talking head: cut at sentence boundaries. Run `auto_remove_silence { layerId: "video_…", thresholdDb: -40, minSilenceMs: 500, paddingMs: 80 }` first (it rebuilds the layer into trimmed segments; take the checkpoint before it), then punch in on every second or third cut. - Music-led: cut on beats; expocut-audio-post owns `beat_cut_from_drums`. A snap punch-in is two hold keyframes, not a ramp; `interp` describes the curve leaving the keyframe, so a hold on the 1.0 key keeps the frame still until it jumps: ``` keyframe_add { layerId: "video_…", property: "transform.scale", timeMs: 0, value: 1.0, interp: { type: "hold" } } keyframe_add { layerId: "video_…", property: "transform.scale", timeMs: 4000, value: 1.1, interp: { type: "hold" } } keyframe_add { layerId: "video_…", property: "transform.scale", timeMs: 8000, value: 1.0, interp: { type: "hold" } } ``` Alternate 1.0 and 1.1 at each chosen cut. For a settle instead of a snap, ease back: `keyframe_add { layerId: "video_…", property: "transform.scale", timeMs: 4000, value: 1.1, interp: { type: "preset", name: "easeOut" } }` followed by a 1.0 key 400 ms later. Times are project-timeline milliseconds, so a clip that starts at 12 s gets keys at 12000 and up; a text layer cannot host scale keys for a video. ### 3. Durations that fit distribution | platform | sweet spot | note | | --- | --- | --- | | TikTok | 21 to 34 s entertainment; 60 to 90 s educational | completion is the first signal; over 50 percent completion earns pushes | | Shorts | 15 to 30 s | roughly 65 percent retention needed under 30 s; loops count as watch time | | Reels | 7 to 15 s punchy; 30 to 60 s value | watch time and sends outrank likes | Under 15 s, build a seamless loop: make the last shot match the opening shot and end mid-motion. `trim_layer { layerId: "video_…last", durationSec: 2.6 }` so the final frame resembles the first, and never add a "thanks for watching" outro to short-form. ### 4. Safe zones Platform UI covers the edges. In canvas percent (measured on 1080 x 1920, valid for any 9:16 size): - TikTok: top 10, bottom 18, right 12. Reels: top 13, bottom 21, right 14. Shorts: top 10, bottom 21, right 12. - Universal cross-platform zone: x from 9 to 91, y from 14 to 86. Practical rule: hook text at y 15 to 22; captions and CTAs no lower than y 78; nothing important in the right 14 percent. Check numerically before spending an image: `describe_canvas { timeSec: 0.5 }` returns each visible layer's box as `{ x, y, w, h }` in percent; text is safe when `y >= 14`, `y + h <= 79` and `x + w <= 86`. Then confirm visually with `capture_canvas { timeSec: 0.5, grid: true }` (10 percent grid) and fix with `update_layer { id: "text_…", patch: { position: { x: 0, y: 18 } } }` or a smaller `fontSize` through `update_text`. ### 5. Signals to optimise 1. Completion and watch time, everywhere. 2. Sends per reach (DM shares) on Instagram: frame content as "send this to someone who". 3. Likes are the weakest signal; never trade the first two for them. Plan the cover: one readable three to five word frame. `render_still { timeSec: 0.4, format: "png" }` writes it to a file for the Shorts thumbnail; Reels covers must survive a centred 1:1 crop, so keep that frame's text inside x 22 to 78. ### 6. Verify ``` preview_filmstrip { fromSec: 0, toSec: 3, frames: 6 } // would you stop scrolling? is the hook readable in frame 0? preview_filmstrip { fromSec: 0, toSec: 30, frames: 12 } // any 5 s window with nothing changing is a swipe risk describe_canvas { timeSec: 0.5 } // safe-zone numbers capture_canvas { timeSec: 0.5, grid: true } // safe-zone eyes ``` Both filmstrip calls need the editor on screen and cost one image per frame; use the `describe_canvas` readout first when you only need placement. ## Tools you will use | tool | what for | key params (units) | | --- | --- | --- | | trim_layer | open mid-action, shorten, move a clip | layerId*, mediaOffsetSec, durationSec, startTimeSec (s) | | split_layer | cut a clip at a moment | layerId*, atSec (s, relative to the layer start) | | add_text_layer | the text hook | text*, fontSize, fontWeight, y (percent), startTime, duration (s), transitionIn (list_transitions id), textStrokeColor/Width, textAutoFit | | update_text / update_layer | resize or move the hook | layerId + text fields; id + patch { position } | | set_layer_transition | pattern interrupt on one clip | layerId*, in { id: "whippan" or "flash" or "zoomBlur", durationSec } | | keyframe_add | snap punch-in | layerId*, property "transform.scale", timeMs (ms, timeline), value, interp { type: "hold" } | | keyframe_clear | remove a punch-in track | layerId*, property | | auto_remove_silence | sentence-boundary cuts on a talking head | layerId*, thresholdDb, minSilenceMs, paddingMs | | stock_search_music / add_stock_music_layer | whoosh or riser at t=0 | query, category "sfx", maxDuration; soundId, startTime, duration, volume | | describe_canvas / capture_canvas | safe-zone numbers / grid picture | timeSec; grid: true | | preview_filmstrip | hook and cadence review | fromSec, toSec, frames 1..12 | | render_still | cover frame file | timeSec, format png/jpg | | get_canvas_info / list_layers | length, aspect, ids | none | | save_history_checkpoint / undo_to_checkpoint | revert the whole pass | label; id | ## Recipes ### 1. Talking-head retention pass (Shorts, 25 s target) ``` save_history_checkpoint { label: "before retention pass" } list_layers // video_… is the talking head auto_remove_silence { layerId: "video_…", thresholdDb: -40, minSilenceMs: 450, paddingMs: 80 } list_layers // re-read: segments are new ids with new startTimes add_text_layer { text: "Stop exporting at 480p", fontSize: 60, fontWeight: "800", textColor: "#FFFFFF", fontFamily: "din-condensed", y: 17, startTime: 0, duration: 2.2, transitionIn: "slideUp", transitionInDuration: 0.25, textStrokeColor: "#000000", textStrokeWidth: 3 } keyframe_add { layerId: "video_…seg2", property: "transform.scale", timeMs: 5200, value: 1.1, interp: { type: "hold" } } keyframe_add { layerId: "video_…seg3", property: "transform.scale", timeMs: 10800, value: 1.0, interp: { type: "hold" } } describe_canvas { timeSec: 0.4 } preview_filmstrip { fromSec: 0, toSec: 25, frames: 10 } ``` Captions for the muted majority come next from expocut-kinetic-captions. Keyframe times are timeline milliseconds, so use each segment's own startTime (from `list_layers`, in seconds, times 1000). ### 2. Twelve-second loop reel (Reels) ``` get_canvas_info trim_layer { layerId: "video_…open", mediaOffsetSec: 1.4, durationSec: 4 } // start on the action trim_layer { layerId: "video_…close", durationSec: 3.2 } // end on a frame that matches the opener set_layer_transition { layerId: "video_…mid", in: { id: "whippan", durationSec: 0.3 } } add_text_layer { text: "Send this to your co-founder", fontSize: 56, fontWeight: "700", textColor: "#FFFFFF", y: 20, startTime: 0, duration: 3, transitionIn: "fadeIn", transitionInDuration: 0.2 } render_still { timeSec: 0.3, format: "png" } // cover preview_filmstrip { fromSec: 0, toSec: 12, frames: 8 } ``` End mid-motion; do not fade to black. The last frame and the first frame should read as one shot. ## Pitfalls - `keyframe_add` takes milliseconds on the project timeline. Seconds in `timeMs` place every key inside the first few milliseconds and the punch-in never shows. - `interp` is the curve leaving that keyframe. A hold on the 1.1 key keeps the punch until the next key; a linear (default) 1.1 key ramps into whatever follows. - `auto_remove_silence` replaces the layer with trimmed segments; re-run `list_layers` before touching ids, and take a checkpoint first. - `add_text_layer` ignores `x` while `fullWidth` is true (the default). Use `y` for the safe zone and `textAlign` for horizontal placement; `verticalAnchor` and `y` are alternatives, not a pair. - `preview_filmstrip` and `capture_canvas` need the editor on screen; `describe_canvas` does not. - Safe-zone percentages assume a 9:16 canvas; for 1:1 or 16:9 keep text inside the middle 80 percent and re-check with `describe_canvas`. - Cut-level transitions between two clips use `set_junction_transition`, whose ids differ from `list_transitions`; leave those to expocut-social-speed-edit. `set_layer_transition` on the incoming layer is the executable shortcut used above. ## Reference - Executing skills: expocut-video-creating, expocut-social-speed-edit, expocut-audio-post, expocut-kinetic-captions, expocut-motion-graphics, expocut-editor-ops. - Full tool list and conventions: https://expocut.com/mcp.html
expocut-reel-templates
Author the .ectpl reel template JSON.
Author, fix or review the .ectpl reel / template JSON that ExpoCut loads through import_template_json (schemaVersion 1.1, template and snapshot modes): the canvas and aspect model, percent placement, font sizing, inline SVG vector graphics with unique gradient ids, slots and slotRef, text animation ids, microsecond keyframes, junction transitions, trackIndex z-order, and the export-safe rules that make the MP4 match the canvas on any device. Raw file: /skills/expocut-reel-templates/SKILL.md · install: npx skills add https://expocut.com/skills/expocut-reel-templates
--- name: expocut-reel-templates description: "Author, fix or review the .ectpl reel / template JSON that ExpoCut loads through import_template_json (schemaVersion 1.1, template and snapshot modes): the canvas and aspect model, percent placement, font sizing, inline SVG vector graphics with unique gradient ids, slots and slotRef, text animation ids, microsecond keyframes, junction transitions, trackIndex z-order, and the export-safe rules that make the MP4 match the canvas on any device. Use for \"write a reel template\", \"turn this design / CapCut / Figma / poster into an ExpoCut template JSON\", \"why is the slot empty, the logo missing, the gradient gone or the text off-centre in the export\", \"validate / lint my reel JSON\". Do not use for applying bundled templates, brand profiles, palette theming or Community submissions (expocut-templates-brand), for importing Lottie / FCPXML / .mogrt files (expocut-template-import), or for animating layers on a live project (expocut-motion-graphics)." license: Free to use and redistribute with attribution to expocut.com. compatibility: Works standalone as guidance for hand-authoring the JSON; becomes hands-on when paired with the ExpoCut in-app MCP server (build in the editor, then export the reel). metadata: author: ExpoCut (expotechin.com) version: "3.0.0" homepage: https://expocut.com/skill.html --- # Perfect reel templates with ExpoCut A **reel template** is a single JSON file (served from the CDN) that the app turns into an editable project. This skill is the field guide to authoring one that (a) looks exactly like the design, (b) **renders correctly in the exported MP4**, and (c) **reuses on any device** without empty slots. Everything here is the distilled result of building real templates. Follow the rules — most were learned by watching a template break. ## When to use / hand off - You are writing or debugging the JSON document itself. To load it into the editor call `import_template_json { template: <doc>, name: "Test import" }` — it validates (strict one layer per `trackIndex`, https-only URLs, size caps), creates a new project and opens it. To get a starting document from a project built in the editor call `save_project_as_template { id: "my-reel-v1", name: "My reel", mode: "snapshot" }` — the JSON is returned, not stored, so keep it. - Applying the bundled template, brand profiles, `set_template_theme` palettes, Community `submit_template` / `delete_my_submission` → **expocut-templates-brand**. - Lottie / FCPXML / `.mogrt` / `.cube` / `.cdl` files from other tools → **expocut-template-import** (it produces this same document shape, which you then fix with the rules below). - Keyframes on a live project (`keyframe_add`, motion paths) → **expocut-motion-graphics**; junction transitions on a live cut (`set_junction_transition`) → **expocut-social-speed-edit**; looking at the result (`describe_canvas`, `capture_canvas`, `preview_filmstrip`, `export_project`) → **expocut-editor-ops**. --- ## 1. The two modes — decide first Every file declares `"schemaVersion": "1.1"` and a `mode`: | mode | What it is | Media layers | When to use | |------|-----------|--------------|-------------| | **`template`** | A blank frame with **slots** to fill. | `content: ""`, filled from `slots[].defaultBinding`. | The reusable, gallery-facing product. User drops in their clips/photos. | | **`snapshot`** | A **saved project** with everything embedded. | Real URLs / inline SVG baked in. `slotRef` still exposes the editable ones. | A finished, branded piece (e.g. an agency's own reel) that others tweak text/photos on. | Rule of thumb: a montage users re-shoot (Travel Cinematic, Velocity) → **template**. A branded, opinionated piece (Agent Intro) → **snapshot** with slots on the text and photos. --- ## 2. Mental model - **Canvas** — fixed by `aspectRatio` + `durationMs`. Everything is placed in **percent of canvas** (0–100), never pixels. So the same numbers work at any export resolution. - **Layers** — the actual content (text, image/SVG, video, audio, generativeBg). Each has a `trackIndex`, `startTime`, `duration`, `position`, `scale`. - **Tracks** — one lane per layer; `trackIndex` sets **z-order and timeline row**. Templates carry a light `tracks[]` (id/type/name/trackIndex); snapshots carry richer tracks with a `layers:[ids]` list. - **Slots** — the editable holes. A `slots[]` entry defines the hole + its default; a layer opts in with `slotRef: "<slotId>"`. Many layers can share one slot. ``` aspectRatio + durationMs → the stage slots[] → what the user can change tracks[] (trackIndex) → z-order + timeline rows layers[] → the content itself ``` ### Canvas presets | aspectRatio | Export px | Use | |---|---|---| | `9:16` | 1080×1920 | TikTok / Reels / Shorts | | `4:5` | 1080×1350 | Feed video (max feed real estate) | | `1:1` | 1080×1080 | Square | | `16:9` | 1920×1080 | YouTube / landscape | --- ## 3. Coordinates & placement — the rules that bite Positions are **percent**. `{x:50, y:50}` is dead center. But *how* x behaves depends on the layer: **Text — two placement modes:** - `textFullWidth: true` → the text box spans the **whole canvas width**; `textAlign` (`center`/`left`/`right`) places the glyphs. **Set `position.x: 0`** — x is effectively ignored; only `y` matters. This is how you center a headline. (Every centered line in these templates has `x≈0, textFullWidth:true, textAlign:"center"`.) - `textFullWidth: false` → a **free box** anchored by `x` (its left-ish edge). Use for left-aligned column text or a label that sits beside something (e.g. an agent's "YOUR AGENT" at `x:43.7, textAlign:"left"`). **Images / SVG — center-scale drift:** a layer renders **scaled about its own center**, so at `scale ≠ 1` the visual creeps toward (scale<1) or away from (scale>1) the canvas center. That's why logos sit at **negative y** (e.g. `y:-3`) — you push the anchor up to counter the downward drift. Don't fight the math in your head: > **Place → screenshot → measure → correct.** Nudge `position`/`scale` until the element lands where the design says. (If driving the editor via MCP: `capture_canvas`, read the frame, adjust, repeat. A closed measure-and-correct loop is how every element here was aligned — never eyeballed.) **Matching a source design (poster, Figma, screenshot):** convert its pixels directly. - `x% = px_x / designWidth * 100` · `y% = px_y / designHeight * 100` - `fontSize = px_fontsize × 0.37` (see §4) This maps a 1080-wide comp onto the canvas almost 1:1. --- ## 4. Font sizing `fontSize` is in **canvas points**, not pixels — the numbers look small (8–42). Convert from a pixel design on a 1080-wide reference: > **fontSize ≈ design_px × 0.366** (≈ px ÷ 2.73) Worked examples (all real): 106px hero → **38.8** · 74px CTA → **27** · 60px name → **22** · 42px → **15.4** · 22px badge → **8** · 20px eyebrow → **7.3**. Other type fields: `fontFamily` (`arial`, `georgia`, `din-alternate`, `snell-roundhand`, …), `fontWeight` (`"400"`–`"800"`), `fontItalic`, `textColor` (`#RRGGBB`), `letterSpacing` (pt; 2–3 for luxe caps labels), `lineHeight`, `textTransform`, and shadow (`textShadowColor/Blur/OffsetX/OffsetY` — a 12px black blur keeps captions legible over video). **Two-font discipline:** pick one display face + one utility face and stay on them. Mirror the source system exactly (e.g. Georgia serif for headlines/quote, Arial for every label). --- ## 5. SVG vector graphics — build shapes here, not as images Any non-photo graphic (bars, pills, plates, bordered boxes, dividers, diamonds, icon grids, badges) is an **`image` layer with inline `svgContent`** plus `naturalWidth`/`naturalHeight` = the viewBox size. Pure vector = crisp at any res, self-contained (no external asset to go missing), and it renders reliably in export. ```json { "type":"image", "name":"cta pill", "svgContent":"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 92'><defs><linearGradient id='pillG2' x1='0' y1='0' x2='0' y2='1'><stop offset='0%' stop-color='#F8D374'/><stop offset='50%' stop-color='#F2B739'/><stop offset='100%' stop-color='#D99A28'/></linearGradient></defs><rect x='2' y='2' width='596' height='88' rx='9' fill='url(#pillG2)'/></svg>", "naturalWidth":600, "naturalHeight":92, "scale":1.55, "fitMode":"contain" } ``` Four rules, each learned the hard way: 1. **Every `<linearGradient>` id must be UNIQUE across the whole file.** The native export renderer treats gradient ids **globally** — two shapes both using `id="g"` collide and one silently renders with **no fill** (it vanishes in the MP4 while looking fine on the editor canvas). Name them `pillG2`, `plateG`, `box1G`, `box2G`, … 2. **Use SVG for wide shapes; raster only for ~square media.** A wide raster (a gradient PNG bar) gets forced into a near-square render box and stretches tall. SVG honors its `viewBox` aspect, so bars/plates/boxes stay correct. 3. **Solid fills always render; gradients need rule #1.** If a gradient shape keeps dropping out, either give it a unique id or fall back to a solid `fill="#F2B739"`. 4. **`<text>` inside SVG renders** (bare `font-family="Arial"`), good for baked labels — but text baked into SVG is **not editable and can't be a slot**. If the user needs to change it, make it a real `text` layer instead. (Breaking a baked block into a plate SVG + separate text layers is the editable pattern.) --- ## 6. Layers & z-order `trackIndex` controls **stacking**: **lower index = ON TOP (front)**. So the base/background sits at the *highest* index and hero text at a low one. When you place a plate behind its label, the plate needs a **higher** trackIndex than the text. Common fields on every layer: `id`, `type`, `name`, `startTime` (ms), `duration` (ms), `position{x,y}`, `scale`, `rotation`, `trackIndex`, `fitMode`. Layer types & their signature fields: | type | Key fields | |---|---| | **text** | `content`, `textAlign`, `textFullWidth`, `fontFamily/Weight/Size`, `textColor`, `letterSpacing`, `textAnimInId/OutId/LoopId`, `textAnim*Duration`, shadow, `slotRef` | | **image** | `content` (URL) **or** `svgContent`, `naturalWidth/Height`, `scale`/`scaleX`/`scaleY`, `fitMode`, `stretchToCanvas`, `fadeInMs/OutMs`, `cropRect`, `border{}`, `remoteSource{}`, `slotRef` | | **video** | `content:""`, `mediaOffset` (trim into source, ms), `fitMode:"fill"`, `stretchToCanvas:true`, `canvasRelativeWidth/Height:100`, `filterId`, `filterIntensity`, `transitionIn`, `transitionInDuration`, `videoEffects[]`, `keyframes`, `colorGradeDelta`, `slotRef` | | **audio** | `content`, `volume` (0–1), `fadeInMs/OutMs`, `remoteSource{url,kind:"freesound",sourceId}`, `slotRef` | | **generativeBg** | `effectId` (`lightrays`, `bokehdrift`, …), `fxParams{}`, `canvasRelativeWidth/Height`, `opacity` — full-canvas atmosphere (light bursts, drifting bokeh) | **One clip, many looks:** a video slot can appear on several segments with different `mediaOffset` + `filterId` per segment (the Velocity reel plays one portrait across 5 beat-synced grades). Great for "single-clip" trending templates. --- ## 7. Animation & timing - **Text:** `textAnimInId` / `textAnimOutId` / `textAnimLoopId` + matching `*Duration` (ms). Ids come from `list_text_animations` (199 presets) and must match exactly. Vocabulary you'll reach for: in — `in-fade-up`, `in-mask-reveal`, `in-fade-zoom`, `in-slide-left-fade`, `in-apple-title`, `in-wow`, `in-tw-classic` (typewriter); out — `out-fade`, `out-fade-up`, `out-cinema-fade`; loop — `loop-float`, `loop-breathe`. (These carry most reels.) The blur-named presets (`in-blur-slide`, `out-fade-blur`, `out-blur-slide`, `in-tw-blur`) still work but do NOT blur — that keyframe channel rendered on no surface and was removed in 5.7.0, so reach for them only for their motion. - **Video transitions:** dotted namespaces on `transitionIn` — `dissolve.cross`, `cut.flash`, `light.warm-sunset`, `light.cool-window`, `slide.zoom`, `dissolve.dip-to-black` — plus `transitionInDuration`. - **Keyframes** (`keyframes.tracks[{property, keyframes:[{t, v, interp}]}]`): animate `transform.scale`, `fx.blur`, `transform.x/y`, `opacity`, etc. **`t` is in MICROSECONDS** — `12000000` = 12,000 ms = 12 s. A subtle `scale 1.12 → 1.0` over 0.7 s is the classic Ken-Burns push; blur `20 → 0` is a motion-blur reveal. `interp`: `{type:"preset",name:"easeOut"}` or `{type:"linear"}`. **Sequencing:** overlap `startTime`/`duration` so each element fades out as the next fades in — no hard gaps. Give the whole reel a rhythm (hook → build → payoff → CTA). --- ## 8. Slots — make it reusable A slot is a labeled, typed hole with a default. A layer joins a slot via `slotRef`. ```json "slots": [ { "id":"headline", "label":"Headline", "kind":"text", "default":"CINEMATIC" }, { "id":"clip1", "label":"Clip 1", "kind":"video", "required":true, "preferredDurationMs":1500, "defaultBinding": { "kind":"media", "uri":"https://…/clip.mp4", "remoteUrl":"https://…/clip.mp4", "width":360, "height":640, "durationMs":6000 } }, { "id":"music", "label":"Music", "kind":"audio", "defaultBinding": { "kind":"media", "uri":"https://…/song.mp3", "remoteUrl":"https://…/song.mp3" } } ] ``` - `kind`: `text` | `image` | `video` | `audio`. Text slots use `default`; media slots use `defaultBinding` (always give `width`/`height` and, for video, `durationMs`). - `required:true` forces the user to supply media; `preferredDurationMs` hints the ideal trim. - **Reuse a slot across layers:** the three "BUY. SELL. INVEST." lines in the Agent Intro all point to one `s_headline` — edit once, updates everywhere. - Keep `slotCounts` (tallies per kind) in sync with the actual slots. --- ## 9. Assets & the "empty slots / missing logo" trap This is the #1 way a template fails on someone else's device: - **Every media asset must live at a stable, public URL the app can fetch** — host on the ExpoCut CDN (`https://expocut.b-cdn.net/reels/assets/<template-id>/…`) or a reliable source (Pexels/Freesound previews). Set `remoteSource {url, kind, sourceId}` on video/audio so it re-resolves. - **Never reference a local device/simulator file path** (`file:///…`). It exists only on the machine that built it; everywhere else the slot is **empty** ("Required slots are empty: …"). If you built in the editor with a local image, re-import it through the app's picker or upload to the CDN before exporting the template. - **Remote raster in the export:** a not-yet-cached remote image can render missing or glitched in the MP4 (a download race). Host it on the trusted CDN and it renders. SVG graphics have no such issue (they're inline). - Ship a **`previewVideoUri`** and **`thumbnail`** — the gallery needs both. --- ## 10. Export-safe checklist (paste this before you publish) - [ ] `schemaVersion:"1.1"`, correct `mode`, unique `id`, `name`, `description`, `category`, `aspectRatio`, `durationMs`. - [ ] `slotCounts` matches `slots[]`; every `slotRef` points to a real slot. - [ ] **Every gradient `id` is unique** across the whole file (no two `id="g"`). - [ ] All shapes are **SVG** (not stretched raster); raster reserved for photos/logos. - [ ] Every media asset is a **public CDN/URL** — zero `file://` paths. - [ ] Centered text: `textFullWidth:true`, `position.x:0`. Column text: `textFullWidth:false` with a real `x`. - [ ] `fontSize` set via `design_px × 0.37`; two-font system held. - [ ] Plates/pills sit **behind** their text (higher `trackIndex`). - [ ] Keyframe `t` values are in **microseconds**. - [ ] `previewVideoUri` + `thumbnail` present. - [ ] `tracks[]` is **non-empty** and every layer has a **unique `trackIndex`**. - [ ] No `colorAdjust` / `videoEffects` / `keyframes` / `scale≠1` on a **media-fill shape** (§12). - [ ] Ran `node reference/validate-reel.mjs my-reel.json` — zero ERRORs. - [ ] Rendered the MP4 and eyeballed **every scene** — logos present, gradients filled, nothing clipped, text on-plate and centered. --- ## 12. The four rules that cost the most (read before authoring) Each was found by exporting an MP4 and finding the layer wrong while the editor canvas looked perfect. Full detail + copy-paste blocks in `reference/recipes.md`; machine-readable in `reference/layer-capability-matrix.json`; enforced offline by `reference/validate-reel.mjs`. 1. **Positioned photos must be media-fill SHAPES sized by `canvasRelativeWidth/Height`.** A positioned `image` layer sized with `scale` + `fitMode:"contain"` matches on canvas and comes out huge/blurry in the export (the encoder sizes it from `baseWidth × scale`). Full-screen photos are fine as `image` + `stretchToCanvas`. 2. **Never put `colorAdjust`, `videoEffects`, `keyframes` or `scale≠1` on a media-fill shape** — the fill exports as a **blank rectangle**. Grade shape photos with a light-leak overlay on top instead. `image`/`video` layers *do* take the full stack. 3. **Three time units.** MCP `add_*_layer` takes **seconds**; the JSON uses **ms**; keyframe `t` is **microseconds**. Passing `9000` "ms" to MCP builds a 9000-*second* project — the export then renders for hours and there is no cancel tool. 4. **Assign `trackIndex` by role, not authoring order** (lower = front): `text · flashes · light leaks · stickers · photo cards · full-canvas media · background · audio`. Re-numbering by sort order silently buried an on-screen hook behind the video. Bonus: use **real-footage light-leak overlays** (`blendMode:"screen"`, CDN clips) for "lights". `generativeBg` shaders diverge canvas↔export — `light-leak` has no canvas shader at all and prints a `No SkSL preview` watermark while rendering fog in the MP4. --- ## 11. Build workflow 1. **Design first** — pick aspect, duration, the scene beats, palette, two fonts. If matching a comp, note each element's px → convert (§3/§4). 2. **Lay the base** — background/video, then work forward; remember lower `trackIndex` = front. 3. **Place & measure** — position each layer, screenshot, correct. Don't eyeball. 4. **Vectorize graphics** — every shape as unique-id SVG. 5. **Animate & sequence** — anim ids + keyframes; stagger timings for smooth hand-offs. 6. **Slot it** — add `slots[]`, wire `slotRef`, host defaults on the CDN. 7. **Preview render** — export the MP4, walk every scene against the checklist. 8. **Publish** — attach `previewVideoUri` + `thumbnail`, drop the JSON on the CDN. ### Driving the loop through MCP ``` import_template_json { template: <doc>, name: "Reel test" } // validate + create + open describe_canvas { timeSec: 1.2 } // cheap text check of what is visible capture_canvas { timeSec: 1.2, maxWidth: 512, xray: true, grid: true } preview_filmstrip { fromSec: 0, toSec: 9, frames: 6 } // pacing and hand-offs export_project // the MP4 is the ground truth (40–60 s, phone awake) ``` A validation failure lists every violation in one error — fix the JSON, call again. Layer edits made in the editor afterwards (`update_layer` patches take **seconds**) can be captured back with `save_project_as_template { id: "my-reel-v1", name: "My reel", mode: "snapshot" }`. ### Reference files | File | Use it for | |---|---| | `reference/recipes.md` | **Start here.** Export-verified copy-paste blocks: grid photo card, full-screen Ken-Burns photo, beat-synced video segment, light-leak overlay, typewriter text, SVG background, the z-stack helper, units cheat-sheet, and the sim test loop. | | `reference/layer-capability-matrix.json` | Which fields are safe on which layer type (`ok` / `canvasOnly` / `breaks`), plus units, validator rules, tool quirks and Wizard behaviour. Machine-readable. | | `reference/validate-reel.mjs` | Offline linter — `node validate-reel.mjs my-reel.json`. Catches unit mix-ups, duplicate/empty tracks, orphan slotRefs, `file://` paths, gradient-id collisions, illegal field/type combos and text hidden behind full-canvas media, in <1s instead of a 60s export round-trip. | | `reference/schema.md` | Compact field reference. | | `reference/starter-4x5.json` | Minimal valid skeleton to copy. | | `reference/examples/` | Two complete worked templates + their generator. | ## Style of advice Be concrete and measured. When a template looks wrong, name the rule it broke (gradient-id collision, local-file slot, fullWidth vs free-box text, microsecond keyframes, trackIndex z-order). Verify by rendering and looking, not by assuming.
expocut-editor-ops
Projects, tracks, playback, undo, inspection, export.
Operate the ExpoCut editor session through the in-app MCP server: projects (create, open, save, rename, duplicate, delete with confirmation), reading state without an image (get_canvas_info, describe_canvas, list_layers, get_layer, get_layer_schema, get_authoring_capabilities), layer edits (update_layer, remove_layer, reorder_layer, visibility), playback, selection, session checkpoints with undo, redo and undo_to_checkpoint, previews (capture_canvas, preview_filmstrip, capture_export_frame), export settings, export_project, get_render_status, verify_export_parity and the resize review queue. Raw file: /skills/expocut-editor-ops/SKILL.md · install: npx skills add https://expocut.com/skills/expocut-editor-ops
--- name: expocut-editor-ops description: "Operate the ExpoCut editor session through the in-app MCP server: projects (create, open, save, rename, duplicate, delete with confirmation), reading state without an image (get_canvas_info, describe_canvas, list_layers, get_layer, get_layer_schema, get_authoring_capabilities), layer edits (update_layer, remove_layer, reorder_layer, visibility), playback, selection, session checkpoints with undo, redo and undo_to_checkpoint, previews (capture_canvas, preview_filmstrip, capture_export_frame), export settings, export_project, get_render_status, verify_export_parity and the resize review queue. Use when the user says open, rename, duplicate or delete the project, what does the frame look like, screenshot it, undo that, checkpoint, hide or mute the track, export settings, 1080p, 4K, 60 fps, is the export done, am I allowed to register a custom LUT or effect, which fields can I patch, or seconds versus milliseconds. Not for adding media or text (expocut-video-creating) or keyframes (expocut-motion-graphics)." license: Free to use and redistribute with attribution to expocut.com. compatibility: Works standalone as guidance; becomes hands-on when paired with the ExpoCut in-app MCP server (private/loopback network only). metadata: author: ExpoCut (expotechin.com) version: "3.0.0" homepage: https://expocut.com/skill.html --- # ExpoCut editor operations You are the operator of the editor session: you find and open projects, read what is on the canvas, patch layers, keep a checkpoint before every risky pass, and run the export. Nothing here is destructive unless it says so; the two destructive calls (`delete_project`, `remove_layer`) always get an explicit yes from the user first. ## When to use / hand off Own: project lifecycle, inspection, generic `update_layer` patches, playback, selection, tracks, checkpoints, previews, export settings, rendering, parity checks, resize review. Hand off adding media, stock and titles to expocut-video-creating; keyframes and `set_camera` to expocut-motion-graphics; fit/anchor/crop/blend to expocut-compositing; effect schemas (`get_effect_schema`) to expocut-fx-looks; registering custom effects, LUTs, borders and mask shapes to expocut-asset-authoring — but run `get_authoring_capabilities` yourself first, it is read-only and tells you whether that hand-off can succeed at all. ## Before you start - `get_active_project` is the cheapest state probe and never throws: `projectId` is null when nothing is open; otherwise it returns layerCount, trackCount, isDirty and exportSettings. - Almost every other tool throws `No project is open. Call open_project first.` until a project is loaded. `open_project { id }` loads it from disk and navigates the phone to the editor. - `get_authoring_capabilities {}` is safe to call cold: no arguments, no open project, no quota, and it is deliberately not gated by the setting it reports on. (The project guard is per tool, not global — `ping`, `echo` and the `list_*` catalogs need no open project either.) Call it once before planning any custom effect, LUT, border or mask registration — a locked surface means the user must raise a setting, not that the app lacks the feature. Recipe 5 reads the reply. - Mount matters. These reach into the live editor screen and fail (after a 2 to 6 s grace period) if it is not on screen: `capture_canvas`, `preview_filmstrip`, `render_still`, `capture_export_frame`, `export_project`, `verify_export_parity`. Everything else reads or writes the store and works with the editor in the background. - Units. Tool arguments named `timeSec`, `startTime`, `duration`, `*Sec` are seconds. Stored layer fields and everything named `*Ms` are milliseconds. The precise rule is in the section "Seconds versus milliseconds" below; read it before your first `update_layer`. ## Core workflow 1. Find or make the project: `list_projects` (id, name, aspectRatio, updatedAt) or `create_project { name: "Launch teaser", aspectRatio: "16:9", fps: 30 }`, then `open_project { id }`. 2. Read the state you need, cheapest first: `get_canvas_info` (size, fps, duration, counts, playhead), `list_layers` (compact, seconds), `describe_canvas { timeSec: 2 }` (visible layers with boxes), `get_layer { layerId }` (full record, milliseconds), `get_project` only when you really need tracks and clips too. 3. Checkpoint before a pass that touches many layers: `save_history_checkpoint { label: "before grade pass" }` returns `{ id }`. Keep that id. 4. Edit: `update_layer { id, patch }` for fields without a typed setter (check `get_layer_schema { category: "text" }` for valid names), `reorder_layer` for z-order, `set_layer_visibility` for A/B toggles, `remove_layer` after confirmation, track tools for whole rows. 5. Look: `describe_canvas` again; `capture_canvas { timeSec, grid: true }` when geometry matters; `preview_filmstrip` for motion. 6. Not happy: `undo_to_checkpoint { id }` restores the checkpoint you took in step 3. 7. Output: `set_export_settings { resolution: "1080x1920", quality: "high", format: "mp4", fps: 30 }`, `save_project`, `export_project`, and `get_render_status` from a second connection while it runs. ## Tools you will use Projects | tool | what for | notes | | --- | --- | --- | | list_projects | ids and names | reads projects.json | | create_project | new empty project | name*, aspectRatio (default 9:16), fps; does not open it; stores no resolution | | open_project | load + navigate | id*; reloads from disk, discarding unsaved session edits | | get_active_project | what is open | never throws | | save_project | write the session to disk now | the editor also autosaves every 30 s while mounted and on backgrounding | | rename_project | name only | id*, name* | | duplicate_project | clone from disk to a new id | id*, name (default "<name> (copy)"); save first or unsaved edits are missing; not opened automatically | | delete_project | permanent, no undo | id*; confirm first; clears the editor if that project was open | Session, playback, selection | tool | what for | notes | | --- | --- | --- | | play / pause / playback_status | store playback state | status returns isPlaying, playheadMs, totalDurationMs | | seek { timeSec } / seek_seconds { timeSec } | move the playhead (seconds) | identical; capture tools take their own timeSec | | ping {} / echo { message } | is the server alive; round-trip a payload | the two builtins; use them to confirm the phone is reachable before a long pass, they need no open project | | select_layer { layerId } / get_selection | in-app selection | layerId may be null to clear; highlighted in capture_canvas | Capability discovery | tool | what for | notes | | --- | --- | --- | | get_authoring_capabilities | may I register custom assets right now | no arguments, no open project, no quota; returns tier, featureEnabled, settingsPath, tierOrder and one row per authoring surface with allowed, requiredTier and the tool names it gates; see recipe 5 | Tracks (one track per MCP-added layer; ids look like `track-video-…`) | tool | what for | notes | | --- | --- | --- | | list_tracks | id, type, name, layerCount, isVisible, isMuted, volume | | | set_track_visibility { trackId, visible } | hide a row from canvas and export | | | set_track_mute { trackId, muted } | silence a row | honoured by the exporter | | set_track_volume { trackId, volume } | 0..2.5 multiplier | throws outside the range | | reorder_track { trackId, toIndex } | move a row in the tracks array | export stacking only; see pitfalls, prefer reorder_layer | Layers | tool | what for | notes | | --- | --- | --- | | list_layers | compact summary, seconds | id, type, name, startTime, duration, trackIndex, position, scale, opacity, text fields | | get_layer { layerId } | every stored field, milliseconds | diff before patching | | get_layer_schema { category } | valid field names and units | categories: core, text, animation, transition, text_animation, audio, media, color, visual_effects, compositing, masks, shape, widget_configs, lottie, transcript | | update_layer { id, patch } | merge a partial patch | no validation of names; startTime/duration in seconds, everything else raw | | remove_layer { id } | delete a layer and its track and clip | confirm first; no undo unless you checkpointed | | reorder_layer { layerId, position } | "front", "back" or a number | renumbers layers, clips and tracks together | | set_layer_visibility { layerId, hidden, locked } | hide from canvas and export, or lock in the UI | | | get_project | everything: layers, tracks, timelineClips, exportSettings, selection, playhead, camera | heavy | Previews and rendering | tool | what for | notes | | --- | --- | --- | | get_canvas_info | aspect, pixel size, fps, duration, frames, counts, playhead | width/height are null unless resolution is "WxH" | | describe_canvas { timeSec } | visible layers top-most first with box x/y/w/h in percent, hidden and out-of-time counts | free, no mount | | capture_canvas { timeSec, maxWidth, format, quality, xray, outlinesOnly, grid } | one frame as an image | maxWidth default 512, cap 1024; needs mount | | preview_filmstrip { fromSec, toSec, frames, maxWidth, format } | 1..12 labelled frames | default 6 frames at 320 px jpg; needs mount | | render_still { timeSec, maxWidth, format, quality } | write the frame to a file:// png/jpg | maxWidth defaults to canvas width, cap 2160; needs mount | | capture_export_frame { timeSec, format } | one export-accurate frame with every overlay | needs mount; the true preview equals export still | | compose_single_frame / capture_project_base_frame / render_composited_frame | native compositor diagnostics | base video only, iOS native path; not a full composite | | set_export_settings | aspectRatio, resolution, quality, format, fps | only passed fields change | | export_project | render and encode | no params, blocks, 10-minute internal timeout, returns { uri } | | get_render_status | isExporting, lastExportUri, exportSettings, estimatedFrames, totalDurationSec | poll from a second connection | | verify_export_parity { timesSec, maxWidth, existingExportPath } | canvas versus export frame pairs | runs a full export unless existingExportPath is given | | get_resize_review_queue / clear_resize_review | layers flagged after an aspect change | see recipe 3 | Checkpoints (session-scoped, in memory, ring of 50, cleared when the app restarts) | tool | what for | notes | | --- | --- | --- | | save_history_checkpoint { label } | snapshot layers, tracks, clips, exportSettings | returns { id, depth }; taking one while mid-undo drops the redo tail | | undo | move one checkpoint back | no-op with fewer than two checkpoints | | redo | move one checkpoint forward | | | undo_to_checkpoint { id } | jump to a specific checkpoint | the reliable way to revert a pass | | list_checkpoints | ids, labels, timestamps, current cursor | | ## Seconds versus milliseconds Verified in `layers.ts` and `introspection.ts`: - `update_layer` multiplies `patch.startTime` and `patch.duration` by 1000 before merging, so they are seconds, like every `add_*` call. Every other key in the patch is written exactly as given. Fields that the layer stores in milliseconds therefore stay milliseconds inside a patch: `mediaOffset`, `transitionInDuration`, `transitionOutDuration`, `fadeInMs`, `fadeOutMs`, `audioOffset`, `textAnimInDuration`, `textAnimOutDuration`, `volumeKeyframes[].timeMs`. - `get_layer` and `get_project` return the stored record, so `startTime`, `duration` and `mediaOffset` come back in milliseconds. `list_layers` divides startTime and duration by 1000. `describe_canvas`, `get_canvas_info` and `playback_status` report both (`atMs`/`atSec`, `playheadMs`/`playheadSec`, `totalDurationMs`/`totalDurationSec`). - Consequence: a `get_layer` startTime of 4000 pasted into `update_layer` becomes 4,000,000 ms. Divide by 1000 first, or use `trim_layer { layerId, startTimeSec: 4 }`, which takes seconds. - Keyframe `timeMs` (expocut-motion-graphics) is milliseconds on the project timeline. ## Recipes ### 1. A safe automated pass ``` save_history_checkpoint { label: "before opacity pass" } // → { id: "ckpt_…" } list_layers update_layer { id: "text_…", patch: { opacity: 0.85, startTime: 0.2 } } // seconds update_layer { id: "image_…", patch: { opacity: 0.6 } } describe_canvas { timeSec: 1 } capture_canvas { timeSec: 1, maxWidth: 640 } undo_to_checkpoint { id: "ckpt_…" } // only if the user dislikes it save_project ``` `undo` alone would not revert this: it steps to the checkpoint before the current cursor, and with a single checkpoint there is none. Either jump by id, or take a second checkpoint after the pass so `undo`/`redo` flip between "before" and "after". ### 2. Diagnose a blank or wrong frame without an image ``` get_canvas_info // totalDurationSec, playhead, counts describe_canvas { timeSec: 6 } // visibleCount 0? check scheduledElsewhereCount list_layers // find the layer whose startTime+duration misses 6 s trim_layer { layerId: "video_…", startTimeSec: 4, durationSec: 5 } reorder_layer { layerId: "shape_…", position: "back" } // background covering everything describe_canvas { timeSec: 6 } ``` Boxes come back as `{ x, y, w, h }` in canvas percent, top-left anchored, `approx: true` when the size is estimated (text without measured metrics). Lower `trackIndex` paints on top. ### 3. Change the aspect ratio and clean up ``` save_history_checkpoint { label: "before 1:1" } set_export_settings { aspectRatio: "1:1", resolution: "1080x1080" } get_resize_review_queue // → { layerIds, count } describe_canvas { timeSec: 1 } // inspect the flagged ids update_layer { id: "text_…", patch: { fontSize: 56 } } clear_resize_review capture_canvas { timeSec: 1, grid: true } ``` Changing `aspectRatio` runs the store's re-anchor pass and fills the review queue with layers whose placement needed a guess; the queue is the same list the in-app review modal shows. ### 4. Export and poll ``` set_export_settings { resolution: "1080x1920", quality: "high", format: "mp4", fps: 30 } save_project export_project // blocks; → { uri: "file://…/export.mp4" } get_render_status // from another connection: isExporting, lastExportUri ``` For a proof frame instead of a full render: `capture_export_frame { timeSec: 3 }`. For a poster file: `render_still { timeSec: 3, format: "png" }`. ### 5. Can I author that asset, or is it a setting? ``` get_authoring_capabilities // no args, no project, no quota ``` One call answers every "am I allowed to register this?" question, so you never probe a family with a `*_list_custom` call or, worse, attempt a registration just to read the denial: ``` { ok: true, tier: "safe", // off | safe | standard | full featureEnabled: true, // build-level master switch settingsPath: 'Settings → AI Integration → "AI may author assets"', tierOrder: ["off", "safe", "standard", "full"], // weakest → strongest surfaces: [ { surface: "fx-declarative", allowed: true, requiredTier: "safe", tools: ["fx_register_spec", "fx_compose_from_template", ...] }, { surface: "fx-shader", allowed: false, requiredTier: "full", tools: [...] }, ... ] } ``` `surfaces` carries one row per authoring surface, in this order: `fx-declarative`, `fx-shader`, `lut-cube`, `lut-grade`, `border`, `mask-simple`, `mask-path`. Read it like this: - `allowed: false` with a `requiredTier` means the feature ships and the user's setting is too low. Quote `settingsPath` and name that tier; it is a phone setting no MCP tool can change. - `requiredTier` is the cheapest tier that unlocks the surface, not the strongest — ask for `safe` when `safe` is enough. `null` would mean no tier grants it, which is a bug, not a setting. - `featureEnabled: false` means authoring is compiled out of that build and no tier helps. Do not send the user to Settings; apply the same values inline (`set_layer_border`, `set_layer_cdl`, `set_layer_mask`) instead. Then hand the registration itself to expocut-asset-authoring. ## Pitfalls - `update_layer` and `remove_layer` take `id`; almost every other setter takes `layerId`. - `update_layer` does not validate field names. A typo becomes a harmless junk field and the change silently does nothing; confirm names with `get_layer_schema` and re-read with `get_layer`. - `reorder_track` only moves the tracks array, which is what the native exporter uses for stacking; the canvas stacks by `layer.trackIndex`. Moving a track alone makes the preview and the export disagree. `reorder_layer` updates both. - Checkpoints are not per project. Restoring a checkpoint taken in another project overwrites the current project's layers. `list_checkpoints` before jumping. Checkpoints also do not capture cut transitions or the camera, and they are separate from the in-app undo button. - `open_project` reloads from disk. Unsaved session edits vanish if you reopen the same project. `duplicate_project` clones the disk copy too, so `save_project` before either. - `set_export_settings` accepts `resolution` as a preset (480p, 720p, 1080p, 2K, 4K) or "WxH" between 64 and 7680, `fps` 1..120, `quality` low/medium/high, `format` mp4 or mov (anything else is written as .mov). `create_project` stores no resolution and the editor's own default is 480p low, so set these explicitly before `export_project`. - `get_canvas_info` parses only "WxH"; with a preset the width/height fields are null even though the canvas is sized correctly. - `export_project` blocks the connection for the whole render and needs the phone unlocked with the editor on screen; a backgrounded app stalls it. Do not fire it twice. - `verify_export_parity` runs an entire export unless `existingExportPath` is given. Its `meanAbsDiff`/`maxAbsDiff` are always -1; the verdict compares encoded PNG sizes, so look at the returned image pairs yourself or pass them to `analyze_pixels` for real numbers. - `compose_single_frame` and `capture_project_base_frame` render the first video layer only and need the native iOS compositor; use `capture_export_frame` for a frame with overlays. - `capture_project_base_frame` picks the first video layer in array order, which is not always the lowest track. ## Reference - `references/layer-fields.md`: every `get_layer_schema` category with field names, units and ranges; read it when composing an `update_layer` patch. - Full tool list and conventions: https://expocut.com/mcp.html - Siblings: expocut-video-creating (adding media and text), expocut-motion-graphics (keyframes, camera), expocut-compositing (fit, anchor, crop, blend), expocut-fx-looks (effect schemas), expocut-asset-authoring (registering custom fx, LUTs, borders and mask shapes).
expocut-compositing
Blend modes, mattes, parenting, time remap, chroma key.
Control how layers composite in the ExpoCut mobile editor through its MCP server - 14 blend modes, alpha/luma track mattes, adjustment layers, parent and null rigs, time remapping (freeze frame, reverse, ramps), stacked-alpha overlay packs, working colour space, 15 mask shapes including Video-in-Text, chroma key, Portrait Blur (subject cut out in front of its own blurred background), fit mode, anchor, crop, pan, blur fill, timed fades, fade-on-edge masks, and video-audio unlink, relink and sync nudges. Raw file: /skills/expocut-compositing/SKILL.md · install: npx skills add https://expocut.com/skills/expocut-compositing
--- name: expocut-compositing description: "Control how layers composite in the ExpoCut mobile editor through its MCP server - 14 blend modes, alpha/luma track mattes, adjustment layers, parent and null rigs, time remapping (freeze frame, reverse, ramps), stacked-alpha overlay packs, working colour space, 15 mask shapes including Video-in-Text, chroma key, Portrait Blur (subject cut out in front of its own blurred background), fit mode, anchor, crop, pan, blur fill, timed fades, fade-on-edge masks, and video-audio unlink, relink and sync nudges. Use when the user says blend mode, multiply, screen, overlay, matte, cut out, green screen, remove background, portrait blur, blur the background behind a person, bokeh, adjustment layer, parent, null, freeze frame, reverse clip, crop, fit, letterbox, blur background, fade the edges, detach audio, lip sync, or overlay pack. Not for keyframes or animated mask reveals (expocut-motion-graphics), LUTs and grades (expocut-color-grading), or speed presets and junction transitions (expocut-social-speed-edit)." license: Free to use and redistribute with attribution to expocut.com. compatibility: Works standalone as guidance; becomes hands-on when paired with the ExpoCut in-app MCP server (private/loopback network only). metadata: author: ExpoCut (expotechin.com) version: "3.0.0" homepage: https://expocut.com/skill.html --- # Compositing with ExpoCut You are the compositor. You decide how layers stack, blend, clip and time-shift inside the user's open project. Every setter is a merge-patch on one layer: it changes nothing else, it is undoable in the app, and most of it is invisible until you look at the right frame. Inspect first, checkpoint, then write. ## When to use / hand off - Animating anything over time (keyframes, motion paths, camera, animated mask reveals): expocut-motion-graphics. This skill sets the static mask; that skill moves it. - LUTs, CDL grades, colour adjust passes: expocut-color-grading (set_layer_cdl lives in this family but the grading decisions belong there). - Constant-speed slow motion or fast forward (set_layer_speed) and the transition on a cut between two clips: expocut-social-speed-edit. - Ducking, stems, volume automation after you unlink audio: expocut-audio-post. - Effects, shader looks, light leaks as footage overlays: expocut-fx-looks (add_light_leak_overlay already picks a blend for you). apply_subject_blur lives there too: it softens the surround inside one layer. Portrait Blur below is the version that splits the frame into two layers, which is why it is here. - Custom mask shapes (mask_register_shape), fade-mask and border presets: expocut-asset-authoring. ## Before you start 1. get_active_project, then list_layers for real ids and types; the tools below reject the wrong type (chroma key and alpha packing are video only, unlink is video only). 2. describe_canvas { timeSec: 1 } to see paint order: lower `trackIndex` paints on top, `trackIndex` 0 is the front. reorder_layer { layerId: "video1", position: "front" } fixes order before any matte or adjustment work, because both depend on what is beneath. 3. get_layer { layerId: "video0" } to read the current compositing fields; get_layer_schema { category: "compositing" } and get_layer_schema { category: "masks" } list every field update_layer can patch when no typed setter exists. 4. save_history_checkpoint { label: "before compositing" }. 5. Know how you will verify: describe_canvas and capture_canvas show the editor canvas, which approximates blend modes and does not composite track mattes or adjustment layers the way the encoder does. capture_export_frame { timeSec: 1 } renders one frame through the real export builder (editor must be mounted); export_project is the final word and takes seconds to minutes. ## The compositing model (from the app code) Paint order and blend. The renderer sorts by `trackIndex` descending and paints the highest first, so the lowest ends up on top. Each layer then blends with everything already painted using `blendMode` (default normal). The 14 modes: normal, multiply, screen, overlay, darken, lighten, color-dodge, color-burn, hard-light, soft-light, difference, exclusion, add, subtract. Formulas run per channel in sRGB with source-over alpha. The encoder applies the exact mode; the canvas preview maps add to screen and subtract to difference, so judge those two on an export frame. Track matte. set_track_matte stores `{ sourceLayerId, mode, keepSourceVisible }` on the target layer. At render time the source layer (any type: text, shape, image, video) is drawn to an offscreen buffer and its alpha (mode alpha) or luminance (mode luma) clips the target; the inverted modes swap inside and outside. By After Effects convention the source is hidden from the composite while any consumer uses it, unless one passes keepSourceVisible: true. The source only exists while it is on the timeline, so give it the same startTime and duration as the target. A layer cannot matte itself, the source must exist, and importers drop dangling references. Both encoders implement mattes (Android also on the CPU fallback path). Adjustment layer. set_layer_is_adjustment turns a layer into one with no pixels of its own; what it carries is applied to the accumulated composite of every layer beneath it (higher trackIndex) that overlaps its time range, and nothing above it. Today the encoder applies the adjustment layer's LUT (set_layer_lut) and CDL (set_layer_cdl); effects, filters and colorAdjust on an adjustment layer do not propagate. `disabled: true` keeps the layer but suspends it. Any full-canvas shape makes a good adjustment layer (recipe 4). Parenting and null objects. set_layer_parent makes the child inherit the parent's position, scale and rotation (parent first, then the child's offset, pivoting on the parent's anchor); cycles are rejected. set_layer_is_null marks a rig layer that renders nothing. These fields are stored and exported to Lottie and FCPXML, but the editor canvas and the video encoders do not read `parentId` or `isNull` yet: a parented child does not follow its parent in the preview or the MP4. Use them for interchange, and move groups with the virtual camera or matching keyframes (expocut-motion-graphics) when the result has to render. Time remap. set_time_remap maps editor timeline time to source media time with keyframes `{ playheadMs, sourceMs }` (both milliseconds; playheadMs on the global timeline, sorted ascending, at least one). Between keyframes the mapping is linear: equal slopes play at normal speed, a steeper source slope is faster, a flat segment (same sourceMs twice) is a freeze frame, a decreasing segment plays in reverse. Outside the range the value clamps (extrapolation clamp or hold behave the same today). A remap does not lengthen the layer: extend it with trim_layer { layerId: "video0", durationSec: 9.5 } when you add a hold. Preview, iOS and Android all evaluate it; the explicit remap wins over any speed setting. clear_time_remap removes it. Alpha. set_layer_alpha_mode records whether the source is straight or premultiplied; the value is stored for interchange only and no renderer reads it yet, so it will not fix dark fringes by itself. set_layer_alpha_packing { packing: "stacked" } is for video layers whose file carries transparency as a packed frame: the frame is twice the visual height with the colour image in one half and a luma matte (white = opaque) in the other; the encoders unpack it into real alpha, the canvas preview still shows the double frame. Any non-video layer ignores it. Working colour space. set_working_color_space accepts sRGB (default), Rec.709, Rec.2020, linear and stores it with the export settings; non-sRGB values downgrade at encode time, so treat it as intent metadata. ## Masks, keying and edges set_layer_mask { layerId: "image0", shape: "ellipse", rect: { x: 0.1, y: 0.1, width: 0.8, height: 0.8 }, feather: 24 } - shape ids are exact: rectangle, roundedRect, ellipse, triangle, star, heart, cross, xShape, linear, mirror, radial, angular, diamond, path, text. Defaults on first apply: rectangle, rect { x: 0, y: 0, width: 1, height: 1 } (identity, layer-local 0..1), feather 0, invert false. Later calls merge with the existing mask, so you can change one field. - rotation is degrees about the rect centre; bandWidth is the mirror band half-width or the roundedRect corner radius (0..0.5); invert shows the outside; feather is px at 1080p. - shape "text" is Video-in-Text: textData { content, fontFamily, fontWeight, italic, align } is required and content must be non-empty. shape "path" needs a registered custom shape (mask_register_shape) or a `layerMask.pathData` patch through update_layer. - effect (cinematic motion inside the mask) and morph (material between mask shape keyframes) belong to expocut-motion-graphics; enabled: false removes the mask. - Masks are designed for image and video layers. On Android an enabled mask forces the CPU export path, which drops shader filters on that layer (iOS is unaffected). Chroma key (video layers): set_layer_chroma_key { layerId: "video1", keyColor: "#00FF00", similarity: 0.4, smoothness: 0.15, spill: 0.6 }. Defaults are #00FF00, similarity 0.4, smoothness 0.1, spill 0.5. Raise similarity in 0.05 steps until the backdrop clears, add smoothness for hair edges, raise spill for green fringes; enabled: false disables and keeps the settings. Read the real backdrop colour from capture_canvas before keying a blue or uneven screen. Background remover: set_layer_background_remover { layerId: "video1", quality: "balanced", featherPx: 2 } stores the ML segmentation config (quality fast, balanced, accurate) and shows the badge in the editor, but no encoder module implements it yet, so the export is unchanged. Prefer a chroma key or a shape mask when the result must render, and tell the user. Portrait Blur (below) drives the same segmentation but bakes the cutout to a file, so that one does render. Spatial fade (fade on edge): set_layer_fade_mask { layerId: "video1", mode: "linear", angle: 270, position: 0.5, softness: 0.4 }. Modes: linear (angle 0 fades from the left edge, 90 top, 180 right, 270 bottom), radial (vignette outward from the centre), inset (all four edges). position, softness and floor are 0..1 (floor is the minimum opacity), invert flips, curve is linear, easeIn, easeOut or easeInOut. Defaults: linear, angle 180, position 0.5, softness 0.4, floor 0. This is spatial; set_layer_fade { layerId: "video1", fadeInMs: 300, fadeOutMs: 300 } is the timed envelope (works on every layer type; 0 clears). clear_layer_fade_mask removes the spatial one. ## Portrait Blur: one layer becomes two apply_portrait_blur is a compositing operation, not a filter. It cuts the subject out of an image or video layer with an on-device segmentation model, bakes that cutout to a file once, and then splits the layer into a pair: - SUBJECT — the layer you passed in. Same id, same geometry, same timing, same keyframes, same filter chain; only its source is swapped for the cutout, so selection and anything already animated on it survive. - BACKGROUND — a new layer holding the untouched original source, on a new row directly behind the subject (`trackIndex` + 1), named "<layer> — Blur". It is a copy of the subject with its `shaderFilters` array REPLACED by a single `lensblur` (stacking the subject's own chain on top would double-apply it). Everything else about the layer is copied, so a LUT, colour adjust, preset filter, video-effect chain, border or mask applied BEFORE the call is now on both halves — usually what you want, since the whole frame was graded, but it means a later grade must be applied twice, or to a shared adjustment layer, to match. Because the cutout is baked once, playback and export need no segmentation afterwards: the pair is two ordinary layers and renders the same on iOS and Android. The call returns { subjectLayerId, backgroundLayerId, cutoutUri }, where subjectLayerId is the id you passed in. Inserting the row pushes every layer behind the subject back — by one row, or by two when the source is a video whose audio is still linked, because the pair moves together — so re-read list_layers before any matte, adjustment or reorder work that follows. The model handshake, which is never silent: portrait_blur_status { layerId: "image0" } → { applied, modelId, downloaded, sourceKind }. sourceKind is "video" for a video layer and "image" for anything else, and the two kinds load different models — a device that has already blurred a still can still owe a download for a clip. When downloaded is false: portrait_blur_download_model { layerId: "image0" } apply_portrait_blur { layerId: "image0", blurRadius: 34, blades: 6, highlightBoost: 4 } Applying without the model throws portraitBlur.MODEL_NOT_DOWNLOADED instead of fetching in the background; these are large downloads the user opts into. The other refusals are portraitBlur.ALREADY_APPLIED (remove it first — a second call would stack a second background) and portraitBlur.NO_SOURCE (the layer has no media). apply_portrait_blur is long-running: the bake takes seconds for a still and longer for video, so say so before you fire it. These three optional numbers are the whole surface; they are written to the background's Lens Blur filter. | param | range | default | what it does | | --- | --- | --- | --- | | blurRadius | 0..80 | 34 | how far the background blur reaches; larger reads as a longer lens | | blades | 0..9, whole numbers | 6 | aperture shape: below 3 is a round disc, 3..9 an iris with that many sides | | highlightBoost | 0..12 | 4 | how hard bright pixels bloom into bokeh balls | The rest of Lens Blur is fixed by the bake (uniform focus, full intensity) because the sharp cutout in front already supplies the in-focus area. To reach the others (bladeRotation, aspect, highlightThreshold) call set_layer_shader_filter { layerId: "<backgroundLayerId>", effectId: "lensblur", fxParams: { ... } } — that re-seeds from Lens Blur's own defaults, so restate blurRadius as `radius`, plus blades and highlightBoost, in the same call or they snap back. Everything else you can do to a layer works on the background too: grade it, mask it, keyframe it. remove_portrait_blur { layerId: "<subjectLayerId>" } takes the SUBJECT id, restores its original source and deletes the background layer and its clip. Choosing between the two: apply_subject_blur (expocut-fx-looks) bakes a matte and drives a per-pixel blur inside ONE layer — the composition never changes, nothing new appears in the timeline, and a soft matte degrades into a soft edge rather than a visibly cut-out subject. Portrait Blur is the one to reach for when the background has to be its own editable layer, with its own grade, bokeh and moves. Both need the same download handshake. ## Geometry inside the frame | tool | what it does | notes | | --- | --- | --- | | set_layer_fit_mode | contain, cover, fill, scale-down, none | fill also sets the legacy stretchToCanvas true; the others set it false | | set_blur_fill | true / false / null | blurred copy of the media behind a contain-fit image or video; null = auto, which fires only on portrait and square canvases; preview only until the encoders read it | | set_stretch_pan | x, y in canvas percent | pans a fill-mode layer inside the frame; preview only today | | set_layer_anchor | anchorX, anchorY 0..1 | pivot for scale and rotation, default centre; exported | | set_aspect_lock | locked | only affects the user's edge-handle gestures | | set_layer_scale_xy | scaleX, scaleY | -1 mirrors or flips; multiplies with the uniform scale; exported | | set_layer_crop | x, y, width, height 0..1 | source sub-rectangle, top-left origin; identity is 0, 0, 1, 1; encoders clamp invalid rects to identity | | set_layer_overlay_color | "#RRGGBB" or null | multiplicative tint over the rendered layer | | reorder_layer | front, back or a number | z-order; other layers renumber densely | ## Video and audio linking - mute_video_audio { layerId: "video0", muted: true } silences the embedded track without creating a layer (video layers only). - unlink_video_audio { videoLayerId: "video0" } creates an audio layer (returned as audioLayerId) with the same start, duration, media offset and volume, then mutes the video. Do this before ducking, stems or audio effects on the clip's own sound. - relink_video_audio { audioLayerId: "<audioLayerId returned by unlink_video_audio>" } removes that audio layer and unmutes the video; it only works on layers created by unlink. - set_layer_audio_offset { layerId: "audio1", offsetMs: 120 } nudges playback later (negative = earlier); the exporter folds it into the layer's timing. Lip-sync fixes live within about 200 ms. ## Recipes ### 1. Video revealed through a title (track matte) add_text_layer { text: "SUMMER", fontSize: 140, fontWeight: "900", verticalAnchor: "center", startTime: 0, duration: 5 } set_track_matte { layerId: "video0", sourceLayerId: "text0", mode: "alpha" } capture_export_frame { timeSec: 1 } The clip video0 (full canvas, 0 to 5 s) now shows only inside the glyphs and the text hides itself. mode "luma" keys on brightness instead, useful with a gradient shape as the source. If the user wants to see it live on the canvas, the alternative is Video-in-Text: set_layer_mask { layerId: "video0", shape: "text", textData: { content: "SUMMER", fontWeight: "900" } }. ### 2. Green-screen presenter over B-roll reorder_layer { layerId: "video1", position: "front" } set_layer_chroma_key { layerId: "video1", keyColor: "#00FF00", similarity: 0.4, smoothness: 0.15, spill: 0.6 } set_layer_fit_mode { layerId: "video1", fitMode: "contain" } set_layer_fade_mask { layerId: "video1", mode: "inset", softness: 0.15 } capture_canvas { timeSec: 2 } The presenter (video1) sits in front of the B-roll (video0); the inset fade hides the hard frame edge of the keyed plate. Increase similarity if green remains, decrease it if skin starts to disappear. ### 3. Freeze frame then reverse (video0 runs 0 to 8 s) set_time_remap { layerId: "video0", keyframes: [ { playheadMs: 0, sourceMs: 0 }, { playheadMs: 3000, sourceMs: 3000 }, { playheadMs: 4500, sourceMs: 3000 }, { playheadMs: 8000, sourceMs: 0 } ], extrapolation: "clamp" } preview_filmstrip { fromSec: 2.5, toSec: 8, frames: 8 } Normal speed for 3 s, a 1.5 s hold on the frame at 3 s, then the clip runs backwards to its first frame by 8 s. To keep the original ending instead of eating it, extend the layer first: trim_layer { layerId: "video0", durationSec: 9.5 }. clear_time_remap restores 1:1. ### 4. Adjustment band with a stacked-alpha overlay on top add_shape_layer { shape: "rectangle", fillColor: "#000000", stretchToCanvas: true, startTime: 4, duration: 3 } set_layer_is_adjustment { layerId: "shape1", isAdjustment: true } set_layer_cdl { layerId: "shape1", slope: [1.05, 1, 0.9], offset: [0, 0, 0], power: [1, 1, 1.05], saturation: 0.85 } add_video_layer { uri:"file:///path/to/wipe-stacked.mp4", stretchToCanvas: true, startTime: 4, duration: 1.5 } set_layer_alpha_packing { layerId: "video2", packing: "stacked" } reorder_layer { layerId: "video2", position: "front" } Between 4 and 7 s everything beneath shape1 gets a warm, slightly desaturated grade; the packed overlay (video2) composites with real transparency in the export while the canvas shows its double frame. For a light leak or particle clip without a matte, use set_layer_blend_mode { layerId: "video2", mode: "screen" } instead of alpha packing. ## Pitfalls - Anything set on a layer that is not visible at the frame you check looks like a no-op: confirm startTime and duration with get_layer first. - Track mattes and adjustment layers depend on `trackIndex`; adding a layer puts it at 0 (front) and shifts the rest, so reorder after adding, not before. - set_track_matte hides the source layer in the export; keepSourceVisible: true draws it twice (source and matte). A source that ends before the target un-mattes the remainder. - Adjustment layers propagate only LUT and CDL. An effect on an adjustment layer affects nothing. - parentId, isNull and alphaMode are data-model fields today: no canvas or encoder result. Say so rather than promising a rigged move. - set_layer_alpha_packing on an image or shape is ignored (video only); the tool description and the type comments disagree about which half holds colour, so test one overlay from a pack before batch-applying. - Time remap keyframes must be sorted by playheadMs; a decreasing segment reverses, a flat one freezes, and the layer's duration does not change on its own. - set_layer_fit_mode writes fitMode and stretchToCanvas together; do not patch one of them alone through update_layer. - blur fill and stretch pan render in the editor only; verify with capture_export_frame or a real export before promising them in the MP4. - Background removal is stored but not rendered by the encoders; chroma key is. - apply_portrait_blur renumbers rows: every layer behind the subject moves back one row (two for a video with linked audio), so a matte or adjustment you lined up by trackIndex needs re-checking. remove_portrait_blur deletes the background layer and its clip but leaves that row behind as an empty track; undo or undo_to_checkpoint is the tidier reversal. - On a video whose audio has been merged into it, the duplicate step that mints the background clones the linked audio onto its own row as well, and only the video half becomes the background. Check list_layers afterwards and remove the stray audio row, or the clip's sound plays twice. - describe_canvas ignores blend modes, mattes and adjustments; capture_export_frame and export_project need the editor mounted and the phone awake. - remove_layer and clear_track_matte, clear_time_remap, clear_layer_fade_mask are undoable but silent: state what you are removing before you call them. ## Reference - https://expocut.com/mcp.html for the full tool list and parameter shapes. - Siblings: expocut-motion-graphics (keyframes, camera, mask animation), expocut-color-grading (LUT, CDL, colour adjust), expocut-social-speed-edit (speed presets, junctions), expocut-audio-post (after unlinking audio), expocut-asset-authoring (custom mask shapes, fade-mask presets), expocut-editor-ops (undo, checkpoints, render status).
expocut-shapes-layouts
Shapes, brushes, mesh gradients, layouts, SVG.
Draws and styles vector graphics in ExpoCut through the in-app MCP server: 91 shape presets, fills (solid, gradient, 16 pattern textures, 130 mesh gradients, photo or video), outlines, glow, 9 one-tap shape styles, canvas-relative sizing for bars and plates, 12 screen layouts that tile the canvas into panels, 134 brush strokes (arrows, circles, underlines, sparkles, neon, comic, splatter, ink, marker, paint, spray, scribble) as tintable vector layers, raw SVG logos and icons, animated shape-widget grids, and borders and glow on any layer. Raw file: /skills/expocut-shapes-layouts/SKILL.md · install: npx skills add https://expocut.com/skills/expocut-shapes-layouts
--- name: expocut-shapes-layouts description: "Draws and styles vector graphics in ExpoCut through the in-app MCP server: 91 shape presets, fills (solid, gradient, 16 pattern textures, 130 mesh gradients, photo or video), outlines, glow, 9 one-tap shape styles, canvas-relative sizing for bars and plates, 12 screen layouts that tile the canvas into panels, 134 brush strokes (arrows, circles, underlines, sparkles, neon, comic, splatter, ink, marker, paint, spray, scribble) as tintable vector layers, raw SVG logos and icons, animated shape-widget grids, and borders and glow on any layer. Use when the user asks for a background plate, colour block, gradient or mesh backdrop, pill, bar, strip, divider, frame, badge, arrow, doodle, underline, scribble, sticker, SVG logo, split screen, collage grid, slanted panels, neon outline or glowing border. Do not use for text (expocut-kinetic-captions), counters and charts (expocut-data-widgets), masks and mattes (expocut-compositing), or keyframe animation (expocut-motion-graphics)." license: Free to use and redistribute with attribution to expocut.com. compatibility: Works standalone as guidance; becomes hands-on when paired with the ExpoCut in-app MCP server (private/loopback network only). metadata: author: ExpoCut (expotechin.com) version: "3.0.0" homepage: https://expocut.com/skill.html --- # Shapes, layouts, brushes and SVG You are the graphic designer for an ExpoCut project: plates under titles, split-screen panels, hand-drawn annotations, logos. Every call edits the user's open project live; edits are undoable in the app, but confirm before removing layers. ## When to use / hand off - This skill: add_shape_layer and the shape style family, add_layout, add_brush_layer, add_svg_layer, add_shape_widget, set_layer_border, set_layer_border_glow, set_shape_glow, list_border_presets. - Text on the plate: expocut-kinetic-captions. Device mockups and data widgets: expocut-data-widgets. Registering your own border presets and mask shapes: expocut-asset-authoring. Clipping a layer to a shape (set_layer_mask) or using a shape as a matte: expocut-compositing. Moving, scaling or fading shapes over time: expocut-motion-graphics. Capture, export, undo: expocut-editor-ops. ## Before you start 1. A project must be open. get_canvas_info gives the aspect ratio (layout tiles and canvas-relative sizes follow it); list_layers gives ids. Shape ids look like shape_m0c3k1x9_7a2b4c1d; SVG and mockup layers are image_…. 2. Discover ids before setting them: list_shapes { category } (basic, arrows, stars, objects, lines, rulers), list_layouts, list_brushes { category }, list_shape_styles, list_shape_textures, list_mesh_gradients { category }, list_border_presets. Unknown ids throw with a hint back to the list tool. 3. Know the sizing model. A shape without canvas-relative sizing renders in a base box (about a third of the canvas width) multiplied by scale, and scale is applied about the box centre. Passing both canvasRelativeWidth and canvasRelativeHeight (0-100 percent of the canvas) makes the box a fixed canvas fraction anchored at position (top-left percent), which is the predictable way to build bars, plates and tiles. 4. Colours are #RRGGBB; stroke and glow radii are px at 1080p; opacity is 0..1. ## Core workflow 1. Add. add_shape_layer { shape: "rounded_rect", fillColor: "#111111", opacity: 0.85, cornerRadius: 24, canvasRelativeWidth: 90, canvasRelativeHeight: 18, x: 5, y: 64, startTime: 0, duration: 6 }. Default shape is rectangle, duration 5 s, position 50/50. Gradient at creation: gradientColors: ["#FF6B6B", "#4ECDC4"], gradientDirection horizontal/vertical/diagonal/radial. Full-canvas background: stretchToCanvas: true. 2. Restyle. set_shape_fill { layerId: "shape_…", style: "mesh", meshPresetId: "aurora-violet" }; set_shape_outline { layerId: "shape_…", color: "#FFFFFF", width: 3, align: "inside" }; set_shape_glow { layerId: "shape_…", mode: "breathe", color: "#7C3AED", radius: 18, intensity: 80, speed: 1, placement: "outer" }; or one tap: set_shape_style { layerId: "shape_…", styleId: "neon-pink" }. set_shape { layerId: "shape_…", shape: "hexagon" } swaps the silhouette and keeps fill, outline and glow. 3. Size and place. set_shape_canvas_relative_size { layerId: "shape_…", widthPct: 100, heightPct: 8 } then update_layer { id: "shape_…", patch: { position: { x: 0, y: 92 }, rotation: 0 } }. 4. Stack. New layers land on top (trackIndex 0). Add backgrounds first, or reorder_layer { layerId: "shape_…", position: "back" } (also "front" or a numeric trackIndex; lower renders on top). 5. Check. describe_canvas { timeSec: 1 } lists boxes in canvas percent (cheap, no image); capture_canvas { timeSec: 1 } to see it. ## Tools you will use | Tool | What for | Key params | | --- | --- | --- | | add_shape_layer | new shape | shape (list_shapes id); fillColor; gradientColors [2+], gradientDirection; opacity; stretchToCanvas; canvasRelativeWidth + canvasRelativeHeight (%); strokeColor, strokeWidth (px); cornerRadius; rotation (deg); x, y (%); scale; startTime, duration (s); fadeInMs, fadeOutMs | | set_shape | change silhouette | layerId*; shape* | | set_shape_fill | Fill tab | layerId*; style* solid/transparent/gradient/texture/mesh/image/video; color (solid); gradientColors + gradientDirection (gradient); textureId (list_shape_textures); meshPresetId (list_mesh_gradients); mediaUri (local file://) + mediaFit cover/contain/fill/none (image/video); opacity. Switching style clears the other fill sources | | set_shape_outline | Outline tab | layerId*; color; width (px, 0 removes); align inside/center/outside; opacity | | set_shape_glow | halo on a shape (shapeConfig.glow) | layerId*; enabled; mode static/pulse/rainbow/chase/breathe/gradient; color; radius 0..40; intensity 0..100; speed 0.25..4; colorStops (gradient mode); layers 1/2/3; placement outer/inner/both | | set_shape_style | one-tap look | layerId*; styleId* clean, neon-pink, rainbow, cyan-breathe, sunset, ghost, chase-yellow, pulse-violet, hot-flame | | set_shape_canvas_relative_size | fixed canvas fraction | layerId*; widthPct, heightPct (0..100). Shape layers only | | add_layout | tile the canvas with rectangles | layoutId* (list_layouts); startTime, duration (s). Returns layerIds in reading order | | add_brush_layer | decorative vector stroke | brush* (list_brushes id); fillColor or gradientColors + gradientDirection; strokeColor, strokeWidth; opacity; canvasRelativeWidth + canvasRelativeHeight; rotation; x, y; scale; startTime, duration; fadeInMs, fadeOutMs | | add_svg_layer | your own vector artwork | svgContent* (raw svg markup, 256 KB cap); name; x, y; scale; rotation; opacity; startTime, duration; stretchToCanvas | | add_shape_widget | animated grid of shapes | rows [{ shape, count (1-8), fillColor }]; gap 0..0.4; animation none/scroll-up/scroll-down/scroll-left/scroll-right/continue/parallel/juggling/swapping/zoom-in/zoom-shapes; animationSpeed 0.25..4; backgroundColor; x, y; scale; rotation; opacity; startTime, duration | | set_layer_border | stroke border on any layer | layerId*; presetId (list_border_presets); enabled; width (px); color; pattern solid/dashed/dotted/double; cornerRadius; position inside/outside/center; sides { top/right/bottom/left: { visible, width } } | | set_layer_border_glow | halo on any layer (border.glow) | layerId*; enabled; mode; color; radius; intensity; speed; position inside/outside; colorStops; layers 1/2/3 | | list_border_presets | 12 built-in border looks | polaroid, film-strip, neon-pink, rainbow-glow, breath-cyan, chase-yellow, dashed-outline, dotted-outline, ocean-sunset, aurora, vapor-wave, mint-neon | | update_layer / reorder_layer / describe_canvas | place, stack, verify | id + patch (position, scale, rotation, opacity, startTime/duration in s) / layerId + position / timeSec | ## Layouts add_layout { layoutId: "split3", startTime: 0, duration: 6 } drops one rectangle shape layer per tile, sized in canvas percent so the split follows the project aspect. Ids: full (1 tile, fills the canvas), half, split2, split3 (bands: top-to-bottom on portrait, left-to-right on landscape), split4 (2 × 2), bars5, bars10, bars5_bw, bars10_bw (vertical bars), slant3, slant4, slant5 (parallelograms rotated -24 degrees, 210 percent tall so only the slanted edges show). Each tile gets a distinct palette colour and is an ordinary shape layer: recolour with set_shape_fill, drop a photo into it with set_shape_fill { layerId: "shape_…", style: "image", mediaUri: "/…/photo.jpg", mediaFit: "cover" }, or turn tiles transparent to use them as guides. The last tile added is on top. ## Brushes add_brush_layer { brush: "brush-circles-02", fillColor: "#FFD93D", canvasRelativeWidth: 60, canvasRelativeHeight: 34, x: 20, y: 30, rotation: -4, startTime: 1, duration: 3 }. A brush is a shape layer of type custom_path, so every shape tool applies afterwards: set_shape_fill (gradient, texture, mesh), set_shape_outline, set_shape_glow for the neon one-liners, set_shape_style. Do not call set_shape on a brush; it replaces the stroke with a preset silhouette. list_brushes returns aspect (width/height of the stroke): keep canvasRelativeHeight = canvasRelativeWidth × canvasAspect / aspect so the stroke is not squashed (on 9:16 canvasAspect is 0.5625). Categories: arrows, circles, underline, sparkle, neon, comic, energy, splatter, ink, marker, paint, drybrush, spray, scribble. Catalog: references/brushes.md. ## SVG layers add_svg_layer { svgContent: "<svg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'><circle cx='200' cy='200' r='180' fill='#FFD93D'/></svg>", x: 35, y: 40, scale: 1, startTime: 0, duration: 5 } stores sanitized markup on an image layer and rasterises it for export. Rules learned the hard way: - The layer renders in an aspect-fitted 150-point box (about a third of a phone-width canvas) multiplied by scale, and scale is applied about the box centre. The visual top-left is position + baseBox × (1 - scale) / 2 per axis, so compensate: for a target visual top-left (vx, vy) pass position (vx - uw × (1 - scale) / 2, vy - uh × (1 - scale) / 2) where uw/uh are the unscaled box size in canvas percent (read them from describe_canvas once at scale 1). Position keyframes carry the same constant offset. - Sanitizer strips scripts, event handlers, remote references and embedded raster (<image href="data:…"> comes back empty). Use add_image_layer for bitmaps. No <filter>; fake glows with radialGradient fills. - Give every gradient a unique id across all SVGs in the project (plateG, pillG …). Shared ids collide in the native export and the fill drops out. - <tspan> children each centre independently at the parent x; use separate <text> elements with absolute x for multi-colour lines. - Full-bleed artwork: match the viewBox to the canvas aspect and leave stretchToCanvas false, or size explicitly (see Pitfalls). ## Shape widget add_shape_widget { rows: [{ shape: "circle", count: 4, fillColor: "#FF6B6B" }, { shape: "square", count: 3, fillColor: "#4ECDC4" }], animation: "scroll-left", animationSpeed: 1, gap: 0.08, backgroundColor: null, x: 0, y: 30, scale: 2, startTime: 0, duration: 8 } creates a shapeWidget layer (default: three rows of one rectangle). Per-shape styling lives in shapeWidgetConfig.rows[].shapes[] (each a full shape config); patch it with update_layer { id: "shapeWidget_…", patch: { shapeWidgetConfig: { … } } } after reading it with get_layer, because update_widget_config does not route shapeWidget layers. Vertical scrolls use a slower base period than the other modes. ## Recipes ### 1. Glowing mesh card behind a title ``` add_shape_layer { shape: "rounded_rect", cornerRadius: 28, canvasRelativeWidth: 84, canvasRelativeHeight: 22, x: 8, y: 39, startTime: 0, duration: 6, fadeInMs: 300 } set_shape_fill { layerId: "shape_…", style: "mesh", meshPresetId: "galaxy-nebula", opacity: 0.95 } set_shape_outline { layerId: "shape_…", color: "#FFFFFF", width: 2, align: "inside" } set_shape_glow { layerId: "shape_…", mode: "breathe", color: "#A78BFA", radius: 22, intensity: 70, speed: 0.8, placement: "outer" } add_text_layer { text: "NEW DROP", verticalAnchor: "center", textAutoFit: { maxSize: 72, minSize: 24, maxLines: 1 }, startTime: 0, duration: 6 } // lands on top of the card describe_canvas { timeSec: 1 } ``` ### 2. Three-panel slant collage ``` add_layout { layoutId: "slant3", startTime: 0, duration: 8 } // returns layerIds [a, b, c] set_shape_fill { layerId: "shape_a", style: "image", mediaUri: "/…/left.jpg", mediaFit: "cover" } set_shape_fill { layerId: "shape_b", style: "video", mediaUri: "/…/middle.mp4", mediaFit: "cover" } set_shape_fill { layerId: "shape_c", style: "image", mediaUri: "/…/right.jpg", mediaFit: "cover" } set_shape_outline { layerId: "shape_b", color: "#FFFFFF", width: 6, align: "center" } capture_canvas { timeSec: 2, maxWidth: 540 } ``` Media fills need local paths (file:// or absolute; stock downloads and camera-roll copies qualify); remote URLs do not export. ### 3. Hand-drawn annotation with a neon arrow ``` add_brush_layer { brush: "brush-circles-01", fillColor: "#FFD93D", canvasRelativeWidth: 56, canvasRelativeHeight: 32, x: 22, y: 34, startTime: 2, duration: 3, fadeInMs: 150 } add_brush_layer { brush: "brush-arrows-04", gradientColors: ["#FF3CAC", "#784BA0"], gradientDirection: "horizontal", canvasRelativeWidth: 40, canvasRelativeHeight: 10, x: 8, y: 70, rotation: -20, startTime: 2.3, duration: 2.7 } set_shape_glow { layerId: "shape_…", mode: "static", color: "#FF3CAC", radius: 16, intensity: 90 } ``` For "draw-on" motion, hand the brush id to expocut-motion-graphics for a mask reveal or opacity keyframes. ### 4. Bottom strip bar that survives aspect changes ``` add_shape_layer { shape: "rectangle", fillColor: "#0B0B0F", opacity: 0.8, canvasRelativeWidth: 100, canvasRelativeHeight: 9, x: 0, y: 91, startTime: 0, duration: 10 } set_layer_border { layerId: "shape_…", enabled: true, width: 2, color: "#FFD93D", pattern: "solid", position: "inside", sides: { top: { visible: true }, right: { visible: false }, bottom: { visible: false }, left: { visible: false } } } ``` ## Pitfalls - Pass both canvasRelativeWidth and canvasRelativeHeight; one alone is ignored and the shape falls back to the 150-point box. - stretchToCanvas draws full-bleed on the canvas, but some native encoders have rendered such layers at their unstretched size (black background, offset block). If an exported background comes out wrong, size it explicitly instead: canvasRelativeWidth 104, canvasRelativeHeight 103, position x -2, y -1.5, so the edges bleed off-frame. Verify with capture_export_frame { timeSec }. - set_shape_fill switches the whole fill: choosing "solid" drops a mesh, choosing "mesh" drops a media fill. gradient needs 2 or more colours; texture and mesh ids are validated; image/video needs mediaUri. - opacity in set_shape_fill and set_shape_outline is the shape's own opacity (shapeConfig.opacity), separate from the layer opacity in update_layer. - set_shape_glow, set_shape_canvas_relative_size, set_shape, set_shape_fill, set_shape_outline and set_shape_style refuse layers that are not shapes (SVG and mockup layers are images). Use set_layer_border and set_layer_border_glow on those. - A border glow made with set_layer_border_glow lives inside the layer's border; set_layer_border enabled: false keeps the glow, and a preset via presetId replaces the base border but keeps its glow. - Layout tiles and shapes with a rotation (slant panels) report an axis-aligned box in describe_canvas; the visible parallelogram is narrower. - add_layout uses the project aspect at call time; change the aspect first (set_export_settings) or the bands run the wrong way. - The Add Layer tray can pop open on the phone when MCP adds layers; harmless. - Units: startTime and duration are seconds everywhere here (a duration of 5000 is 83 minutes); fadeInMs/fadeOutMs are milliseconds; widths and radii are px at 1080p. - Confirm before remove_layer; recover with undo (expocut-editor-ops). ## Reference - references/shapes.md — all 91 shape ids by category, the 12 layouts with tile counts and orientation rules, 9 shape styles, 16 textures, 12 border presets. - references/mesh-gradients.md — all 130 mesh gradient ids in 16 categories. - references/brushes.md — all 134 brush ids by category with their aspect ratios. - https://expocut.com/mcp.html — tool reference. Siblings: expocut-kinetic-captions, expocut-data-widgets, expocut-compositing, expocut-motion-graphics, expocut-fx-looks, expocut-asset-authoring, expocut-editor-ops.
expocut-asset-authoring
Register custom FX, LUTs, borders, masks, light leaks.
Register custom assets in ExpoCut through the MCP asset-authoring tools - declarative FxSpec effects (fx_dry_run, fx_register_spec, fx_compose_from_template), custom 3D LUTs from .cube text (lut_register_custom, lut_list_custom, lut_delete_custom), reusable border presets (border_register_preset), custom mask shapes (mask_register_shape), plus the light-leak overlay and fade-on-edge setters in the same families. Raw file: /skills/expocut-asset-authoring/SKILL.md · install: npx skills add https://expocut.com/skills/expocut-asset-authoring
--- name: expocut-asset-authoring description: "Register custom assets in ExpoCut through the MCP asset-authoring tools - declarative FxSpec effects (fx_dry_run, fx_register_spec, fx_compose_from_template), custom 3D LUTs from .cube text (lut_register_custom, lut_list_custom, lut_delete_custom), reusable border presets (border_register_preset), custom mask shapes (mask_register_shape), plus the light-leak overlay and fade-on-edge setters in the same families. Covers the guard rails: the Settings tier (Off / Safe / Standard / Full), quotas and rate limits, the ai.<slug> id rule, persist versus in-memory, the audit log, and which registered assets render today. Use for \"register this LUT\", \"save this .cube\", \"reusable border\", \"custom mask shape\", \"author a new effect\", \"dry-run this FxSpec\", \"why does authoring say PermissionDenied\", \"clean up the assets you made\". Do not use for applying looks (expocut-fx-looks), grading (expocut-color-grading), or mask animation (expocut-compositing, expocut-motion-graphics)." license: Free to use and redistribute with attribution to expocut.com. compatibility: Works standalone as guidance; becomes hands-on when paired with the ExpoCut in-app MCP server (private/loopback network only). metadata: author: ExpoCut (expotechin.com) version: "3.0.0" homepage: https://expocut.com/skill.html --- # Custom asset authoring with ExpoCut You are the asset registrar. These tools never touch the timeline; they add named assets (an effect spec, a LUT, a border preset, a mask shape) to the user's phone so other tools can reference them. Registration is cheap but rate-limited and quota'd, `persist: true` writes files to the phone, and every call is logged to an audit trail the user can read. Register only what the catalog cannot give you, and delete what you no longer need. ## When to author versus use the catalog Use the catalog first; it is larger than most requests need: - Effects: `list_effects {}` has 224 ids and every engine has named presets (`expocut-fx-looks`). - LUTs: six built-ins (`warm`, `cool`, `cinematic`, `vintage`, `bw`, `noir`), the user's imported and CDN-downloaded LUTs via `lut_list_custom { author: "any" }`, and 134 preset filters (`expocut-color-grading`). - Borders: `list_border_presets {}` has 12, and `set_layer_border` takes any config inline. - Masks: `set_layer_mask` ships 14 shapes including `path`. Author when: the user hands you a `.cube` file or wants a brand look baked from a CDL; you will reuse one border config on many layers across sessions; a silhouette is not among the 14 mask shapes; or you are prototyping a declarative animation curve and want the runtime's samples back. Ask before `persist: true`; in-memory registrations vanish on the next app launch, which is often what a one-off session wants. ## Guard rails (read before the first call) Every register call runs the same ladder: feature flag, Settings tier, validator, quota and rate limit, then admit and audit. Results are objects, not exceptions: check `ok`, then `error` and `message`. Tier matrix (Settings, "AI may author assets"; default Off): | Surface | Off | Safe | Standard | Full | | --- | --- | --- | --- | --- | | FX, `renderer: "declarative"` (`fx-declarative`) | no | yes | yes | yes | | FX, `renderer: "shader"` (`fx-shader`) | no | no | no | yes | | LUT from `cubeText` (`lut-cube`) | no | no | yes | yes | | LUT from `grade` (`lut-grade`) | no | yes | yes | yes | | Border preset (`border`) | no | yes | yes | yes | | Mask `rect` / `ellipse` (`mask-simple`) | no | yes | yes | yes | | Mask `polygon` / `path` (`mask-path`) | no | no | yes | yes | Quotas: 50 register calls per app session, 10 per rolling minute, and per install at most 256 FX, 64 LUTs, 128 borders, 32 masks. Listing and `fx_dry_run` do not consume quota. Ids: every asset id must match `^(ai|user)\.[a-z0-9-]{1,48}$`; use `ai.` for ids you mint (`ai.brand-teal`, `ai.soft-pulse`). Uppercase, spaces, underscores and dots after the prefix are rejected with `InvalidId`. Error codes you will see in `error`: `Disabled` (compile flag), `PermissionDenied` (tier too low), `InvalidId`, `InvalidSpec`, `AlreadyExists` (FX only; pass `overwriteIfExists: true`), `NotFound`, `EvalFailed` (dry-run expression), `ProbeFailed` (shader compile, NaN LUT, empty mask), `PersistFailed`, `DeleteFailed`, `OverQuota`, `RateLimited`. Persistence: `persist: false` (default) keeps the asset in memory for this app run; `persist: true` writes a JSON file under the app's Documents folder (`customFx/`, `customLuts/`, and the border and mask stores). Delete tools remove the in-memory copy; add `alsoFromDisk: true` to remove the file as well. Audit: each admit, reject, delete and disabled call appends to an in-memory audit log (last 1024 rows) that the app's AI Operations screen shows, newest first. Tell the user what you registered so the log makes sense to them. ## Before you start 1. Probe the tier without spending quota: `lut_list_custom { author: "any" }` and `fx_list_custom { author: "ai" }`. Both return `ok: false` with an empty list when their surface is off, and `ok: true` with the assets already registered when it is on. Reuse an existing id rather than registering a duplicate. 2. If a register call returns `PermissionDenied`, stop and tell the user which tier the surface needs (table above); the tier is a phone setting you cannot change from MCP. If their build has no such setting, authoring is unavailable there; fall back to applying the same values inline (`set_layer_border`, `set_layer_cdl`, `set_layer_mask`). 3. Have the consumer call ready before registering (which layer, which setter), so you can verify the asset does something and delete it if it does not. ## Families and exact shapes ### FX specs (fx_*) An FxSpec is a JSON object: `FXVSN: "1.0"`, `id`, `name`, `category` (Animation, Distortion, Stylize, Glitch, Light, Color, Transition, Generator, Custom), `kind` (filter, layer-fx, transition, generator), `renderer` (declarative or shader), optional `author`, `description`, `tags`, `INPUTS`. A declarative spec needs `timeline: { duration: "auto" | seconds, tracks: [...] }`; each track drives one `param` from translateX, translateY (percent of frame), scale, scaleX, scaleY, rotation (degrees), opacity (0..1), hueShift (degrees), saturation, brightness, contrast (multipliers), blur (px), using either `keyframes: [[t, value], ...]` with `t` in 0..1 and an `easing`, or an `expr` string (max 1024 chars). A shader spec needs `shader: { fragment }` (SkSL, max 32 KB) and the Full tier. Full field and expression reference: `references/fxspec.md`. Minimal declarative example, dry-run first: ``` fx_dry_run { spec: { FXVSN: "1.0", id: "ai.soft-pulse", name: "Soft Pulse", category: "Animation", kind: "layer-fx", renderer: "declarative", timeline: { duration: "auto", tracks: [ { param: "scale", expr: "1 + 0.04 * sin(t * TAU * 2) * intensity", loop: true, loopPeriodSec: 2 }, { param: "opacity", keyframes: [[0, 0], [0.15, 1], [1, 1]], easing: "easeOut" } ] } }, sampleTimes: [0, 0.25, 0.5, 0.75, 1] } ``` The result carries `samples: [{ t, params }]` where `params` is the evaluated result (translateX, translateY, scale, scaleX, scaleY, rotation, opacity, hueShift, saturation, brightness, contrast, blur). Check the curve shape, then register the identical object: ``` fx_register_spec { spec: { ...same spec... }, persist: false } ``` `fx_compose_from_template { templateId, name, id, params, durationMs, persist }` builds a transition spec from a curated family without any shader text. `templateId` is `transition.distortion.<warp|ripple|melt|inkBleed|liquid|twirl|pinch|displace>` or `transition.lightLeak.<warm-sunset|cool-window|prism-rainbow|vintage-orange|neon-magenta|soft-anamorphic>`; `params` are clamped to the family's ranges and each clamp is reported in `warnings`; `durationMs` is 100..8000; `id` is auto-generated (`ai.distortion-warp-xxxxxx`) when omitted. `fx_list_custom { author, kind, category }` returns up to 50 non-builtin specs. `fx_delete_custom { id, alsoFromDisk }` refuses built-ins. Status you must tell the user: as of this build, a registered FxSpec is stored, listed and audited, but no timeline setter accepts an `ai.` effect id. `set_layer_effect` validates against the built-in effects registry, and the canvas and encoders render from that registry, so passing an `ai.` id such as `ai.soft-pulse` as the `effectId` of `set_layer_effect` returns Unknown effectId. Treat FX authoring as a staging and validation surface (the dry-run samples are still useful for designing a curve you then express with `keyframe_add`), and prefer catalog effects for anything that has to render today. ### LUTs (lut_*) `lut_register_custom { id, name, cubeText, persist }` accepts raw Adobe `.cube` text (max 1 MB; `LUT_3D_SIZE` 33 or smaller; 1D LUTs are accepted with a warning and render as a per-channel curve; any NaN rejects with `ProbeFailed`). Needs Standard or Full. The alternative `grade` form (`{ whiteBalance: { temperature, tint }, toneCurve: [[x, y], ...], lift: { lift, gamma, gain } }`) is accepted at Safe, but no canvas or encoder path reads `grade` records, so it will not draw; use `cubeText`. Apply with `set_layer_lut { layerId, id, intensity }` or `apply_global_color_grade { lutId }`; both canvas and export resolve `ai.` cube LUTs. `lut_list_custom { author }` returns `{ id, name, author, kind }`; user-imported files and CDN downloads appear as `author: "user"`. `lut_delete_custom { id, alsoFromDisk }`. ### Border presets (border_*) `border_register_preset { id, name, config, persist }` stores a full BorderConfig. Required: `enabled` boolean, `width` 0..200 (px at 1080p), `color` `#RGB`, `#RRGGBB`, `#RRGGBBAA` or `rgba()`, `pattern` solid/dashed/dotted/double, `cornerRadius` 0..1000, `sides: { top, right, bottom, left }` each `{ visible: boolean, width?: number }`. Optional `position` inside/outside/center and `glow: { enabled, mode, color, radius, intensity 0..100, speed, layers, colorStops }`. `border_list_presets { author }` and `border_delete_preset { id, alsoFromDisk }` manage them. Note that `set_layer_border { presetId }` only knows the 12 built-in presets, so to apply a registered preset you replay its fields: `set_layer_border { layerId, width, color, pattern, cornerRadius, position, sides }` followed by `set_layer_border_glow` for the glow block. ### Mask shapes (mask_*) `mask_register_shape { id, name, geometry, feather 0..100, invert, persist }` with `geometry` one of `{ kind: "rect" }`, `{ kind: "ellipse", rx, ry }` (each in (0, 1]), `{ kind: "polygon", points: [[x, y], ...] }` (3..256 points in 0..1), or `{ kind: "path", d }` (SVG path data using only M, L, Q, C and Z, max 2048 chars; arcs are rejected). A 64 by 64 bake probe rejects geometry with empty coverage or, for anything but `rect`, fully opaque coverage. `mask_list_custom { author }` and `mask_delete_custom { id, alsoFromDisk }`. `set_layer_mask` and `create_mask_animation` take only the 14 built-in shape names, so a registered shape is not yet applicable from MCP; for a one-off silhouette use `set_layer_mask { layerId, shape: "path" }` with the same path data through `update_layer` if the built-in path shape covers it, or hand off to `expocut-compositing`. ### Light-leak overlays and fade masks (same families, no registration) `list_light_leaks { category }` and `add_light_leak_overlay { presetId, startTime, duration, intensity }` place CDN footage on a new track (details in `expocut-fx-looks`). `set_layer_fade_mask { layerId, mode, angle, position, softness, floor, invert, curve }` is the spatial fade-on-edge (`linear` with `angle` 0 = left, 90 = top, 180 = right, 270 = bottom; `radial`; `inset`), and `clear_layer_fade_mask { layerId }` removes it; both are covered in `expocut-compositing`. ## Recipes Brand LUT from a CDL, applied and verified. ``` bake_cdl_to_cube { cdl: { slope: [1.05, 1.0, 0.95], offset: [-0.01, 0, 0.02], power: [1, 1, 1.02], saturation: 1.1 }, size: 33, title: "Brand Teal" } lut_register_custom { id: "ai.brand-teal", name: "Brand Teal", cubeText: "<cube text from the previous result>", persist: true } set_layer_lut { layerId: "video0", id: "ai.brand-teal", intensity: 0.7 } capture_canvas { timeSec: 2 } ``` Register a user's `.cube` after validating it. ``` parse_cube_lut { text: "<file contents>" } // expect kind 3d, size 33 or smaller lut_register_custom { id: "ai.client-kodak-look", name: "Client Kodak look", cubeText: "<file contents>", persist: true } lut_list_custom { author: "ai" } ``` Reusable border preset, applied by replaying its fields. ``` border_register_preset { id: "ai.thin-white-frame", name: "Thin white frame", config: { enabled: true, width: 6, color: "#FFFFFF", pattern: "solid", cornerRadius: 12, sides: { top: { visible: true }, right: { visible: true }, bottom: { visible: true }, left: { visible: true } } }, persist: true } set_layer_border { layerId: "image0", width: 6, color: "#FFFFFF", pattern: "solid", cornerRadius: 12 } ``` Clean up at the end of a session. ``` fx_list_custom { author: "ai" } fx_delete_custom { id: "ai.soft-pulse", alsoFromDisk: true } lut_delete_custom { id: "ai.brand-teal", alsoFromDisk: true } // only if the user does not want to keep it ``` ## Pitfalls - Results are `{ ok, id, error, message, warnings }`; a failed register does not throw, so a later `set_layer_lut` with the intended id renders nothing. Always branch on `ok`. - Id format is strict; the message echoes the regex. `ai.` is for you, `user.` is for ids the user typed. - `fx_register_spec` without `overwriteIfExists: true` fails with `AlreadyExists` for an id you registered earlier in the session; delete or overwrite deliberately. - `fx_dry_run` only evaluates declarative specs; a shader spec returns `ok: true` with a warning and is compiled only at register time (64 by 64 probe, 250 ms budget). - `fx_compose_from_template` needs only the Safe tier but its output is a transition spec; nothing on the timeline accepts it yet (see the FX status above). - The LUT `grade` form and the FX and mask registries are ahead of the render path; only `cubeText` LUTs are consumed by canvas and export today. Say so instead of promising a result. - `persist: true` writes to the phone; ask first, and use `alsoFromDisk: true` when the user asks you to remove what you made. - Rate limit is 10 register calls per minute across all families; batch validation with the list and dry-run tools, which are free. ## Reference - `references/fxspec.md` - FxSpec fields, categories, kinds, track params, easings, expression variables and functions, INPUT types, template ids and their families. - https://expocut.com/mcp.html - the full tool list. - Siblings: `expocut-color-grading` (CDL and LUT usage), `expocut-fx-looks` (catalog effects and presets), `expocut-compositing` (masks, fade masks), `expocut-motion-graphics` (keyframes when a custom curve must render today).
"I have 8 clips from Bali. Make a 30s Reel that pops, with a voiceover."
An assistant loaded with the Primer + Core + Social + Voice + Color Skills would respond like this. Notice how it branches between Skills without losing context.
Quick checks first: 1) Platform & length → Reel, 30s, 9:16 ✓ 2) Vibe → upbeat travel 3) Music → "got a song, or should I pull from Freesound?" 4) Voiceover → "any lines in mind, or should I draft 6 short ones?" # Proposed structure (Core Skill) Hook (0–2.5s): strongest beach shot + spoken hook line. Middle (2.5–26s): 6 cuts on beat — temple, food, scooter, sunset, waterfall, friend. Payoff (26–30s): wide drone with location title + CTA line. # In ExpoCut 1. create_project { name:"Bali Reel", aspectRatio:"9:16", resolution:"1080p", fps:30 } 2. add_video_layer ×8 (Gallery) 3. stock_search_music { query:"upbeat travel 118 BPM" } 4. add_stock_music_layer (chosen track) # Voiceover (Voice Skill) 5. Draft 6 lines, ~12 words each: "Day 2 in Bali — the morning starts before sunrise..." ... 6. tts_validate_script { script, voice:"warm-female-en-us" } → OK, est. 28.4s 7. tts_add_audio_layer { script, voice, start:0.4 } # Beat-synced cuts (Social Skill) 8. beatMs = 60000/118 = ~508 ms. 9. split_layer at each beat mark; trim each clip to its strongest 1.5s. 10. set_layer_audio_transitions whip-pan at clips 1↔2 and 4↔5; hard cut elsewhere. # Look (Color Skill) 11. apply_global_color_grade { exposure:+0.15, saturation:+0.20, temp:-8 } 12. set_layer_lut { layerId:"all", lut:"cinematic-teal-orange", intensity:0.6 } # Punch (Social Skill) 13. Scale-punch the drop clip at the chorus mark via 4-keyframe transform.scale. # Captions (Voice Skill) 14. transcribe_audio { layerId:"voice" } 15. add_caption_layer_from_audio { stylePreset:"bold-pop", position:{x:0.5,y:0.82} } # Audio mix 16. set_layer_audio_effects { trackId:"music", duck:true, volume:0.3 } # Export 17. set_export_settings { resolution:"1080p", codec:"h264", fps:30, bitrate:14 } 18. export_project {} Want me to swap a clip order, or are we good to render?
Where to read more.
MCP Reference →
The full 252-typed-tool capability surface across 18 families — including the new Templates / Brand / Advanced Compositing / Session Control groups — plus creative recipes and runtime-callable schema lookup. Pairs with every Skill above.
.ectpl Format Spec →
The full reference for ExpoCut's video template format — container, schema, slots, layers, keyframes, asset bundles, security policy, caps and the performance model — plus an operating doctrine for agents. Machine-readable at spec.json.
llms.txt →
A short, machine-readable summary of ExpoCut for AI crawlers and agents. llms-full.txt has every tool's typed schema.
Make something good.
Pick the Skill that matches the moment — then create the video in ExpoCut.
Join the beta Read the MCP reference