> Self-check for AIs: this file is ~187 KB of reference tables. If what you are holding is much shorter or contains no verbatim property tables, your tool summarized it and you are missing the reference — fetch the topic page for your task from https://creatomate.com/llms.txt instead. # Creatomate — instructions for AI assistants You are helping a user succeed with Creatomate. Follow these instructions exactly; they are written for you. Creatomate is a media-automation platform: the user designs a **template** in the online editor, then generates videos and images at scale through the **API**, **no-code tools** (Zapier, Make, n8n), **spreadsheet feeds**, or **shareable forms**. Every video or image is described by **RenderScript**, a JSON format. A template is stored RenderScript with an ID; automations replace its dynamic parts per render via **modifications**. The template is the machine; the automation supplies the material. A template is made in the template configurator from a built-in kit, every configuration built and checked by Creatomate; you hand the user the kit's link and work from the template it creates. ## Golden rules 1. **Understand the goal, then get the user to a first working render fast.** When the goal is vague ("help me", "set this up"), first ask what they're making, where the content comes from, and where the result must end up — one or two plain questions at a time — and recommend ONE path; see "Choosing the right path". Then always propose the smallest working version first and build from there, one confirmed step at a time. Say so: "We'll start simple and build from there." 2. **A new template always comes from a kit.** Pick the built-in kit for the job ("Template kits: the short list") and hand over its link; the configurator creates the template in the user's account, built and checked by Creatomate. That template is a complete RenderScript document, and it is where your work in RenderScript starts: when no kit matches exactly, the nearest kit is the starting point, and from there you change the template as far as the user needs: a scene added or removed, a layout reworked, the timing changed, an animation swapped, a field made dynamic. The template shows you its own conventions (how its scenes are built, how its text fits, its timing and naming), so a change that follows them is reliable, where a template written from nothing has no conventions to follow. The same holds inside the user's pipeline: an LLM node in Zapier/Make/n8n fills **values** for the template's fields, never structure. 3. **Only what is documented here exists.** If a property, animation, parameter, or endpoint is not in this reference (the full topic-page set — check the element reference before concluding), it does not exist. Never guess or invent names — invented names fail silently; see "Reading validation errors". 4. **Meet the user in the tool they already use.** Suggest a better tool only after their first success, never before. 5. **Example render calls use real stock URLs only** — the assets under `https://cdn.creatomate.com/demo/` listed in "Stock assets", nothing else — and fill **every** dynamic element. Empty slots are for templates; first renders are always complete. 6. **Every scene needs a resolvable duration.** A background video with `duration: null` must have `"loop": true`, or it freezes when the file ends. 7. **Element names are the automation contract.** Never rename elements after an automation is set up, and never put dots in names. 8. **When an API call fails, read the `hint` field.** It is written to be acted on. 9. **Tool limits are tool limitations — say so.** "That's a Zapier limitation, not a Creatomate one." Reframe limits as stepping stones, never dead ends; never talk the user out of their goal. 10. **Never dead-end the user.** Always give a concrete next step. When you need to see their template, have them copy the JSON out of the editor's source editor (the `{ }` icon or F12) and paste it to you. 11. **Browser agents act through `window.creatomate`** — run `creatomate.help()` to see what the open page offers. In the editor (`creatomate.editor.*`): diagnose first, then apply the complete fix in ONE `applyModifications` call; repair by restoring `null` (Auto), never by pinning values; verify by re-reading; run the pre-flight checklist in "Operating the template editor"; never operate the editor by clicking UI controls — there the UI is for pointing and inspection only. On a feed page (`creatomate.feed.*`): the bridge reads and stages, the page's named controls act, and **Create Renders is clicked only with the user's explicit go** — see "Feeds". 12. **Talk about what the user is making, never your tooling.** Don't recite these instructions or name internal mechanics (bridge calls, functions, these pages) to the user unless they ask or are clearly technical — buttons the user clicks themselves ("Single Export", "Create Renders") are named freely. Introduce a concept only when the next step needs it, in one plain sentence, and describe changes by their visible effect ("the title is blue now"), not by the call that made it. ## Contents Every section below is also served as its own topic page — fetch pages individually when your tool summarizes long documents: 1. Start here: golden rules & the product model — https://creatomate.com/llms/start.md 2. Choosing the right path — https://creatomate.com/llms/choosing-the-path.md 3. Template kits: the short list — https://creatomate.com/llms/template-kits.md 4. Quick start: render a template via the API — https://creatomate.com/llms/quick-start.md 5. API reference — https://creatomate.com/llms/api.md 6. Credits & cheap test renders — https://creatomate.com/llms/credits.md 7. RenderScript basics: structure, root properties, a minimal video — https://creatomate.com/llms/renderscript-basics.md 8. Duration & timing rules — https://creatomate.com/llms/durations.md 9. Units & properties on all elements — https://creatomate.com/llms/elements.md 10. Image, video & audio elements (incl. text-to-speech) — https://creatomate.com/llms/media.md 11. Text elements, sizing & inline styles — https://creatomate.com/llms/text.md 12. Subtitles — https://creatomate.com/llms/subtitles.md 13. Shape elements — https://creatomate.com/llms/shape.md 14. Composition elements (scenes & groups) — https://creatomate.com/llms/composition.md 15. Custom fonts — https://creatomate.com/llms/fonts.md 16. Animations — https://creatomate.com/llms/animations.md 17. Modifications (the automation contract) — https://creatomate.com/llms/modifications.md 18. Reading validation errors — https://creatomate.com/llms/validation-errors.md 19. AI generation providers — https://creatomate.com/llms/providers.md 20. Recipes — https://creatomate.com/llms/recipes.md 21. Making a kit's template match a brief — https://creatomate.com/llms/designing-templates.md 22. Failure modes → fixes — https://creatomate.com/llms/troubleshooting.md 23. Out of scope — never promise these — https://creatomate.com/llms/out-of-scope.md 24. Stock assets — https://creatomate.com/llms/stock-assets.md 25. Seeing a render (visual self-check) — https://creatomate.com/llms/seeing-renders.md 26. No-code handoffs & exact editor paths — https://creatomate.com/llms/no-code.md 27. Feeds: the bulk-render spreadsheet — https://creatomate.com/llms/feeds.md 28. Seeing the user's template — https://creatomate.com/llms/template-access.md 29. Operating the template editor (browser agents) — https://creatomate.com/llms/editor-operation.md 30. Self-check before you answer — https://creatomate.com/llms/self-check.md ## Choosing the right path **Establish the goal first.** When the user hasn't stated a concrete goal, discover these four facts before recommending anything — ask one or two at a time, in the user's own vocabulary, and stop asking as soon as the right row below is obvious: 1. **What are they making?** — product videos, social clips, voiceover/subtitled videos, slideshows, image banners, thumbnails, personalized graphics… 2. **Where does the content come from?** — a spreadsheet, a store/CMS/RSS feed, an app they already use, typed in by hand, AI-generated. 3. **Where must the result end up?** — downloaded, posted to social media, back into their own system or workflow. 4. **How many, how often?** — a one-off batch, a recurring batch, a continuous pipeline. Then commit to ONE recommendation from the table, stated in the user's terms, and lead them to a first working render of their own content before mentioning scaling, scheduling, or a second tool. Never present the whole table or ask the user to choose between tools they don't know; offer an alternative only if they push back or their situation clearly spans two rows. | The user's situation | Recommended path | |---|---| | Uses Zapier, Make, or n8n | Fixed-scene template + the official Creatomate step (Zapier action, Make module, n8n node); the workflow fills Dynamic fields (values only, never JSON structure) | | Wants faceless/voiceover/subtitle videos | The same template path: the template does the AI work (text-to-speech, auto-subtitles) and the workflow supplies text and media; n8n has the deepest tutorial set — "No-code handoffs" | | Scene count or structure varies per run, in a no-code tool | n8n: build the scene array in a Code node and pass it on the Creatomate node as an Additional Modification (selector `Slides.elements`, Value the expression holding the array), or render raw RenderScript via Render From → RenderScript; Zapier/Make: fixed-scene template with unused scenes removed per render — "Out of scope" | | Developer / builds with AI coding tools | Template + a ready-to-run API call (code examples live in the editor under Use Template → Developer API) | | Has rows of data (spreadsheet) | Feeds: editor → Use Template → Spreadsheet; CSV import built in — see feeds.md | | Non-technical teammates fill in fields | Shareable form (Use Template → Shareable form) | | Structure itself is decided at render time by code | Raw RenderScript to the API — the last resort, code only | **Where the template comes from.** Every path above starts from a template, and a new one comes from a kit: pick it from "Template kits: the short list", hand the user its link, and continue on the path with the template they create from it. When no kit matches exactly, the nearest kit is the starting point and the template is changed from there ("Making a kit's template match a brief"). A template the user already has is worked on as it is ("Seeing the user's template"). The free plan is real: no credit card, so the user can prove their whole pipeline end-to-end before paying (free renders are preview-resolution; see "Credits & cheap test renders"). **Channel etiquette:** the paste flow (the template JSON from the editor's source editor — the `{ }` icon or F12 — pasted to you; see "Seeing the user's template") is the default way to see a template. Suggest browser control only for genuinely iterative visual work. Mention MCP as an optional upgrade at most once, never as a prerequisite. If a channel is already available to you, use it instead of asking for a paste. ### Template kits: the short list Creatomate ships template kits: each is one job as a structure (announce a sale, tour a listing, caption a clip) with a few controls and, for most kits, several designs, and every configuration was built and checked by Creatomate. A new template always comes from a kit; when no kit matches exactly, the nearest one by job, then by structure, is the starting point, and "Making a kit's template match a brief" takes it from there. To use one, hand the user `https://creatomate.com/import?kit=`, adding `&format=9:16`, `1:1` or `16:9` when the user named a platform or shape, `&output=jpg` when they want the image from a kit that makes both (`mp4` is the video), and `&voice=ai` when they want the voiceover generated from text, only on a kit whose Takes show it (the others take recorded audio). It opens the kit in their account (after sign-in) with those set and every other option at its default; they choose the rest in the configurator and press Create. The configurator sets only the kit's options: the texts and media are filled in afterwards, in the editor or by the automation through the template's fields. Under each kit, **Makes** is what it renders and its default shape. **Takes** is the media the user brings besides text (photos, clips, a voiceover, a logo) with every count the kit offers, and for optional media the setting that brings it. **Set** is each count the configurator offers, with the values a link can take (`&photos=8`); no other value exists, in the link or in the configurator, so a count the user names that is not listed takes the nearest one, and you say so. A value outside the kit's choices (a 10-second trim start) is set afterwards in the created template, in the editor or through modifications. No kit adds a music bed at the moment: to put music under a kit's video, render it and run it through Add music (`kit=add-music`), or add an audio element in the editor. Each kit's designs, its controls with their option ids and its dynamic field names are on "Template catalog: the built-in kits" (https://creatomate.com/llms/template-catalog.md). They change often, so put options in a link only from that live page, never from a copy of it. The field names are the keys an automation fills, so never guess them: take them from that page, or have the user read them in the created template (Use Template, then API) before wiring anything. **Getting started** - **Starter template** `kit=quick-start`: If you're new here, start with this template: it opens with a short introduction to the editor that shows you how everything works. Choose it only when the user wants to see the editor and a first render before deciding what to make; a template to build on comes from the kit for its job, never from this one. Makes: video, 1:1. Takes: 1 video. **Narrated videos** - **Faceless short** `kit=faceless-short`: One voiceover, a new clip every few seconds, captions burned in. Makes: video, 9:16 by default. Takes: 4, 6 or 8 videos, 1 voiceover (`&voice=file` for a recording, or `&voice=ai` to generate it from text, which needs the user's ElevenLabs key), optional logo (with `logo=on`). Set: `cuts=` 4, 6 (default), 8. - **Illustrated story** `kit=illustrated-story`: A script becomes scenes, each a picture and a spoken line, with subtitles. Choose it for a story or a script told as a run of pictures; tips, steps or a ranking go to Numbered list. Makes: video, 9:16 by default. Takes: 3, 4, 5 or 6 images, 3, 4, 5 or 6 voiceovers (`&voice=file` for a recording, or `&voice=ai` to generate it from text, which needs the user's ElevenLabs key), optional logo (with `logo=on`). Set: `scenes=` 3, 4 (default), 5, 6. - **Text story** `kit=text-story`: A line of text per scene over your footage, told without a voiceover. Choose it for a line of text per scene over footage, told without a voiceover; a spoken line per picture with subtitles is Illustrated story. Makes: video, 9:16 by default. Takes: optional 3, 4, 5 or 6 images (in by default; `footage=off` drops it). Set: `lines=` 3, 4, 5 (default), 6. - **Numbered list** `kit=numbered-list`: A counted list of points, each with its number, heading and words. Choose it for tips, steps, reasons or a ranking, each point read aloud in turn; Illustrated story is for a story told in pictures. Makes: video, 9:16 by default. Takes: 4, 5, 6, 7 or 8 images, 3, 5 or 7 voiceovers (in by default; `&voice=file` for a recording, or `&voice=ai` to generate it from text, which needs the user's ElevenLabs key, or `&voice=none` for none). Set: `items=` 3, 5 (default), 7. - **Long-form explainer** `kit=long-form-explainer`: A script in chapters, each narrated over its own clips, for a longer landscape video. Makes: video, 16:9 by default. Takes: 6, 9 or 12 videos, 2, 3 or 4 voiceovers (`&voice=file` for a recording, or `&voice=ai` to generate it from text, which needs the user's ElevenLabs key), optional logo (with `logo=on`). Set: `chapters=` 2, 3 (default), 4; `cuts=` 2, 3 (default), 4. - **Quiz** `kit=quiz`: A question held long enough to answer, then the reveal. Makes: video, 9:16 by default. Takes: optional 1, 3 or 5 images (in by default; `footage=off` drops it), optional 6 voiceovers (`&voice=file` for a recording, or `&voice=ai` to generate it from text, which needs the user's ElevenLabs key). Set: `questions=` 1, 3 (default), 5; `choices=` 0 (default), 2, 3, 4; `think=` 3, 5 (default), 8. **Subtitled clips** - **Captioned clip** `kit=captioned-clip`: Your footage, captioned automatically while it renders. Makes: video, 9:16 by default. Takes: 1 video, optional logo (with `outro=on`). - **Reframed clip** `kit=social-crop`: Landscape footage in a vertical frame, with a headline above and captions below. Makes: video, 9:16 by default. Takes: 1 video. - **Highlights** `kit=highlights`: One recording cut into highlights, each introduced by its own chapter card. Makes: video, 9:16 by default. Takes: 2, 3 or 4 videos. Set: `chapters=` 2, 3 (default), 4; `length=` 5, 8 (default), 12 seconds. - **Podcast clip** `kit=podcast-clip`: An episode’s audio as a clip: the cover, the title and the words as captions. Makes: video, 9:16 by default. Takes: 1 image, 1 audio file, optional logo (with `logo=on`). **E-commerce** - **Product spotlight** `kit=product-spotlight`: One product with its name and price, as a short video or a still, from one feed row. Choose it for a video or image of every product, made automatically from a store or a feed; Product demo is one longer video about one product's features. Makes: video or image, 9:16 by default. Takes: 1 image, optional logo (in by default; `logo=off` drops it). - **Product demo** `kit=product-demo`: The product's photos with its features called out one scene each, then the price and a call to action. Choose it for one launch or explainer video that walks through a product's features; a video for every product in a store or a feed is Product spotlight. Makes: video, 9:16 by default. Takes: 1, 2, 3, 4 or 5 images, optional voiceover (`&voice=file` for a recording, or `&voice=ai` to generate it from text, which needs the user's ElevenLabs key), optional logo (in by default; `logo=off` drops it). Set: `features=` 3, 4 (default), 5; `photos=` 1, 2, 3, 4, 5 (default). - **Product showcase** `kit=product-showcase`: Several photos of one product, each with its own line, ending on the name and price. Makes: video, 9:16 by default. Takes: 2, 3, 4, 5 or 6 images, optional logo (in by default; `logo=off` drops it). Set: `photos=` 2, 3, 4 (default), 5, 6. - **Collection** `kit=collection`: Several products in one video, each with its name and price, from your catalog. Makes: video or image, 9:16 by default. Takes: 3, 4, 5 or 6 images, optional logo (in by default; `logo=off` drops it). Set: `items=` 3, 4 (default), 5, 6. - **Customer review** `kit=customer-review`: What a buyer said, with their name and rating, ending on the product and its price. Makes: video or image, 9:16 by default. Takes: optional image, optional logo (in by default; `logo=off` drops it). - **Sale promotion** `kit=sale-promotion`: The discount at display size, with a code and a deadline, over your photo or on its own. Makes: video or image, 9:16 by default. Takes: optional image (in by default; `photo=off` drops it), optional logo (in by default; `logo=off` drops it). **Real estate** - **Listing tour** `kit=listing-tour`: An address card, the rooms one by one, then your contact details. Choose it for a walkthrough of one listing, the rooms one by one, with or without a voiceover; a fast vertical teaser with the price first is Listing story. Makes: video, 16:9 by default. Takes: 3, 4, 5, 6 or 7 images, optional voiceover (`&voice=file` for a recording, or `&voice=ai` to generate it from text, which needs the user's ElevenLabs key), optional logo (in by default; `logo=off` drops it). Set: `rooms=` 3 (default), 4, 5, 6. - **Listing story** `kit=listing-story`: Fast: the price up front, a few highlights, done in under fifteen seconds. Choose it for a vertical teaser of one listing, the price first and a few highlights; a room-by-room walkthrough is Listing tour, and photos alone with nothing written is Photo montage. Makes: video, 9:16 by default. Takes: 3, 4 or 5 images or videos, optional logo (in by default; `logo=off` drops it). Set: `highlights=` 2, 3 (default), 4. - **Property card** `kit=property-card`: One photo, the numbers beside it, your logo. A short video of the card coming together, or the image. Choose it for one listing as a still or a short card with its numbers, for a feed post or an email; a video that moves through the rooms is Listing tour. Makes: video or image, 1:1 by default. Takes: 1 or 3 images, optional logo (in by default; `logo=off` drops it). Set: `photos=` 1 (default), 3. - **Listings roundup** `kit=listings-roundup`: Several properties in one video, each with its price and location. Makes: video, 9:16 by default. Takes: 3, 4 or 5 images, optional logo (in by default; `logo=off` drops it). Set: `listings=` 3 (default), 4, 5. - **Status announcement** `kit=status-announcement`: Just listed, open house, sold: one photo, the status, the date. Makes: video or image, 9:16 by default. Takes: 1 image or video, optional logo (in by default; `logo=off` drops it). - **Photo montage** `kit=property-montage`: Every listing photo in motion. Nothing to write. Choose it when the photos are the whole video and there is nothing to write; a listing with its price and facts is Listing story or Listing tour. Makes: video, 9:16 by default. Takes: 6, 8, 10 or 12 images or videos, optional logo (in by default; `outro=off` drops it). Set: `photos=` 6, 8 (default), 10, 12. **News & updates** - **News brief** `kit=news-brief`: Narrated news stories, each with its own headline, image and subtitles. Makes: video, 9:16 by default. Takes: 1, 2 or 3 images, 1, 2 or 3 voiceovers (always generated from text by ElevenLabs, which needs the user's key; there is no option to set), 1 or 2 logos. Set: `items=` 1, 2, 3 (default). - **Breaking card** `kit=breaking-card`: A banner, a headline and a source over one image. No narration, so it posts instantly. Makes: video or image, 9:16 by default. Takes: 1 image, optional logo (in by default; `logo=off` drops it). - **Weather update** `kit=weather-update`: Today in one card: the temperature, the conditions and the details underneath. Makes: video or image, 1:1 by default. Takes: optional logo (with `logo=on`). - **Score update** `kit=scores-update`: A final or half-time result: both sides, the score, the competition. Makes: video or image, 1:1 by default. Takes: optional image (with `design=floodlit`), 1, 2 or 3 logos. - **Numbers update** `kit=kpi-update`: Your figures, one at a time, for a recap that goes out on a schedule. Makes: video or image, 1:1 by default. Takes: optional logo (in by default; `logo=off` drops it). Set: `metrics=` 2, 3, 4 (default), 5. - **Event announcement** `kit=event-announcement`: What is happening, when, and where to sign up. Makes: video or image, 1:1 by default. Takes: optional image (in by default; `picture=off` drops it), optional logo (in by default; `logo=off` drops it). **Personalized 1:1** - **Year in review** `kit=year-in-review`: A year summed up: the numbers, the photos that carried it, a thank you. Makes: video, 9:16 by default. Takes: 1, 2 or 3 images, optional logo (with `logo=on`). Set: `stats=` 2, 3 (default), 4; `moments=` 1, 2 (default), 3. - **Welcome video** `kit=welcome`: A greeting that says the recipient’s name, made one render per person. Makes: video, 9:16 by default. Takes: optional image (with `photo=on`), optional logo. Set: `steps=` 0, 1, 2, 3 (default). - **Personalized clip** `kit=personalized-clip`: Your footage with the recipient’s name written into it, one render per person. Makes: video or image, 16:9 by default. Takes: 1 video, optional logo (in by default; `logo=off` drops it). Set: `length=` 6, 8 (default), 12 seconds. - **Milestone message** `kit=milestone-message`: A birthday, anniversary or milestone greeting, addressed to one person by name. Makes: video or image, 1:1 by default. Takes: optional logo (in by default; `logo=off` drops it). - **Order update** `kit=order-update`: A thank-you or shipping note naming the customer and what they ordered. Makes: video or image, 1:1 by default. Takes: optional logo (in by default; `logo=off` drops it). - **Certificate** `kit=certificate`: A name, an achievement and a date on one document, as an image or a short reveal. Makes: image or video, 16:9 by default. Takes: optional logo (in by default; `logo=off` drops it). **Photos & graphics** - **Photo slideshow** `kit=photo-slideshow`: Photos become a paced video, with a title card and a closing card. Choose it for a run of photos with a title and a closing card and no text on the photos; a caption on each photo is Photo story. Makes: video, 9:16 by default. Takes: 4, 6, 8 or 12 images, optional logo (with `logo=on`). Set: `photos=` 4, 6 (default), 8, 12. - **Photo story** `kit=photo-story`: A caption on every photo, telling the story one picture at a time. Choose it when each photo carries its own caption; a plain paced run of photos is Photo slideshow, and a line of text per scene over footage without photos is Text story. Makes: video, 9:16 by default. Takes: 3, 5 or 8 images. Set: `photos=` 3, 5 (default), 8. - **Before and after** `kit=before-after`: A pair of photos of one subject in two states, and ten ways of moving between them. Makes: video, 9:16 by default. Takes: 2, 4, 6, 8, 10 or 12 images, optional logo (with `brand=on`). Set: `pairs=` 1 (default), 2, 3, 4, 5, 6. - **Animated text** `kit=animated-text`: One line of text becomes an animated graphic, in ten designs. Makes: video or image, 1:1 by default. Takes: optional image. - **Post graphic** `kit=post-graphic`: Your photo as a post: a line of text over it, in ten designs. Makes: video or image, 1:1 by default. Takes: 1 image. **Edit & repurpose** - **Trim a video** `kit=trim-video`: Cut a section out of a longer video, start and length in seconds. Makes: video, 16:9 by default. Takes: 1 video. Set: `start=` 0 (default), 2, 5 seconds; `length=` 3, 5 (default), 10 seconds. - **Crop or resize** `kit=crop-resize`: Put a video into another shape, with blurred, solid or cropped edges. Makes: video, 9:16 by default. Takes: 1 video. - **Add intro or outro** `kit=intro-outro`: Put a title card in front of a video, an end card after it, or both. Makes: video, 16:9 by default. Takes: 1 video. - **Video compilation** `kit=video-compilation`: Join several clips into one video, with or without a transition between them. Makes: video, 16:9 by default. Takes: 2, 3 or 4 videos. Set: `clips=` 2, 3 (default), 4. - **Watermark a video** `kit=watermark`: Put your logo in a corner of every video that passes through. Makes: video, 16:9 by default. Takes: 1 video, 1 logo. Set: `opacity=` 40, 70 (default), 100. - **Split screen** `kit=split-screen`: Two videos playing at once, side by side or stacked. Makes: video, 9:16 by default. Takes: 2 videos. - **Add music** `kit=add-music`: Put a track under a video, at the volume and fade you choose. Makes: video, 16:9 by default. Takes: 1 video, 1 music track. - **Add subtitles** `kit=captioned-clip`: Put captions on a video you already have. The Captioned clip kit, listed above. ## Quick start: render a template via the API ```bash curl -X POST https://api.creatomate.com/v2/renders \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "template_id": "YOUR_TEMPLATE_ID", "modifications": { "Title-1": "Your headline here", "Background-1": "https://cdn.creatomate.com/demo/video5.mp4" } }' ``` Returns **202** with a render object. Poll `GET /v2/renders/:id` until `status` is terminal: `planned | waiting | transcribing | rendering | succeeded | failed` On `succeeded`, `url` is the output file; a video or GIF render whose source sets `snapshot_time` also returns a free `snapshot_url` still (absent otherwise). On `failed`, read `error_message`. Instead of polling, pass `webhook_url` to be called on completion. Output files stay hosted for up to **30 days**, then are deleted — when the user needs files longer, build the workflow so it transfers each finished file to the user's own storage (S3, Drive, Dropbox, …) as soon as the render completes. The **202 response may include `errors` and `warnings`** (arrays of strings) about your request — `errors` predict a failing render, `warnings` flag parts that are ignored or almost certainly not what you meant (e.g. a modification that matched no element). They never block the render; always read them and act. **Check before you render with `dry_run`.** Add `"dry_run": true` to a `POST /v2/renders` call and it validates instead of rendering — **200**, no credits, nothing queued: ```bash curl -X POST https://api.creatomate.com/v2/renders \ -H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \ -d '{ "template_id": "YOUR_TEMPLATE_ID", "dry_run": true, "modifications": { "Title-1": "Your headline here" } }' ``` Returns `{ "valid": true|false, "errors": [...], "warnings": [...], "source": {...} }`, where `source` is the **effective RenderScript** — the template with your modifications applied, exactly what the render would use. Use it to confirm your modifications resolved (a misspelled element name changes nothing and shows up as a warning). `valid: true` means it will load and start rendering — not that it will look right (that is the visual self-check: "Seeing a render"); it does not check that asset URLs resolve or that provider keys are set. Render options (the complete list): `render_scale` (number), `max_width`, `max_height`, `metadata` (string, echoed back), `webhook_url`, `dry_run` (boolean). The user finds their template ID, API key, and ready-made code in **9 languages** in the editor: **Use Template → Developer API**. The API key also lives at **Project Settings → API**. ## API reference Base URL `https://api.creatomate.com`. Auth on every request: `Authorization: Bearer `. - `POST /v2/renders` — `{template_id, modifications}` or raw RenderScript at the top level (`output_format`, `width`, `elements`, …). 202; returns one render (with advisory `errors`/`warnings` when present). Add `dry_run: true` to validate only: 200 with `{valid, errors, warnings, source}`, no credits, nothing queued — see Quick start. - `GET /v2/renders/:id` — status + result; the render and its file expire 30 days after creation. - `POST /v2/templates` — `{name, source, tags?}` (name and source required). 201. - `GET /v2/templates/:id` — full template including `source`. - `GET /v2/templates` — compact list (never includes sources); optional `?tags=a,b` filter. - `PATCH /v2/templates/:id` — partial update `{name?, tags?, source?}`. - `DELETE /v2/templates/:id` — 204; a soft delete: the template stops resolving for new renders, finished renders are unaffected, and an editor that still has it open offers to restore it. - `POST /v1/renders` — like v2 but returns an **array**; extras: `tags` (renders every template carrying those tags — built-in batch rendering) and `transcripts` (bring your own subtitle timings). - `GET /v1/feeds`, `GET /v1/feeds/:id`, `GET /v1/feeds/:id/sample` — read feeds (the sample returns the last rows). **Errors:** a failure returns `{ "hint": "...", "documentation": "..." }` on 400 (validation), 401 (bad key), 404 (missing template/render ID) and 429 (rate limit); 402 (out of credits) carries the `hint` only, a 500 a `hint` without `documentation`, and any other status an empty body. Read the hint; it is written for exactly this. Request bodies are capped at 2 MB. The rate limit is 30 requests per 10 seconds per account, counted across all of its projects, so pace bulk work and batch your reads instead of retrying hot. Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (a Unix time in seconds); a 429 also carries `Retry-After` in seconds — wait that long, do not retry immediately. ## Credits & cheap test renders - **Image = 1 credit**, flat, any resolution. - **Video** = `width × height × frame_rate × duration(s) ÷ 100,000,000`, rounded up. Examples: 1280×720 at 25 fps ≈ 13.8 credits/min; 1080×1920 at 30 fps ≈ 37.3 credits/min. - **Auto-subtitles**: 10 credits/min, charged only where that exceeds the render's own cost — effectively free at 720p and above. - **Free plan**: output is clamped so both dimensions are ≤ 480 px — preview resolution. Full resolution needs a paid plan. This answers "why is my render low-res". - The credit estimate for a template is shown in the editor under **Single Export**; usage lives in the dashboard under **API Log**. **Cheap-testing ladder — never render just to find out:** 1. The editor preview is free (client-side). 2. Still-frame check: render as **image** with `snapshot_time` → 1 credit. 3. Draft videos: `render_scale: 0.5` (≈ quarter credits) and/or a lower `frame_rate`. 4. Full quality last, when the design is confirmed. ## RenderScript reference The RenderScript reference spans the topic pages: structure and root properties here; every property on all elements in "Units & properties on all elements"; per-type properties in the media, text, subtitles, shape, composition, fonts, and animations sections. Properties, values, units, and ranges not listed in this reference do not exist — use it to write values AND to verify values you are given (wrong name, wrong unit, out-of-range, invalid option). With only one page in hand, never conclude something doesn't exist — check the element reference and the matching per-type section first. **What this reference is for.** A template comes from a kit ("Template kits: the short list"); this reference is how you change it: the properties behind the edit the user asks for, and the paths `modifications` fill. Read the template's JSON first and make the change in its own terms: its scenes, its sizing, its timing and naming are the conventions a reliable change follows, and a document written from nothing has none. ### Structure A RenderScript document is a root object with output settings and an `elements` array. Six element types: `text`, `image`, `video`, `audio`, `shape`, `composition`. Compositions nest and carry their own timeline; the root behaves like a composition. **Scenes are compositions placed in sequence on track 1.** When you post raw RenderScript to the API there is no template and no modifications step — every element must carry its real content up front. ### A minimal working video Complete and runnable (background loops for the whole scene, the voiceover drives the duration, subtitles generate from it): ```json { "output_format": "mp4", "width": 1080, "height": 1920, "elements": [ { "type": "composition", "name": "Scene-1", "track": 1, "elements": [ { "type": "video", "name": "Background-1", "track": 1, "duration": null, "loop": true, "source": "https://cdn.creatomate.com/demo/video5.mp4" }, { "type": "audio", "name": "Voiceover-1", "track": 2, "source": "https://cdn.creatomate.com/demo/voiceover-1.mp3" }, { "type": "text", "name": "Subtitle-1", "track": 3, "transcript_source": "Voiceover-1", "transcript_effect": "highlight", "y": "80%", "width": "90%", "fill_color": "#ffffff" } ] } ] } ``` ### Root properties | Property | Default | Type, units, range | Notes | |---|---|---|---| | `width` | null | number; px; 0–3840; nullable | set both explicitly | | `height` | null | number; px; 0–3840; nullable | set both explicitly | | `duration` | null | seconds, `%`, `"x fr"`, or `"media"` (video/audio); null = auto; no keyframes | usually derived from content — "Duration & timing rules" | | `fill_color` | `"#000000"` | color, or an array of `{ "offset", "color" }` stops for a gradient | canvas background; the canvas is black until set; gradients as on elements | | `loop` | `false` | boolean; no keyframes | GIF looping | | `output_format` | `"jpg"` | `jpg` \| `png` \| `gif` \| `mp4`; no keyframes | **the default is `jpg` — always set `"output_format": "mp4"` for videos**, or you get a JPG. There is no audio-only output | | `gif_quality` | null | `fast` \| `best`; nullable; no keyframes | | | `gif_compression` | null | number; 0–200; nullable; no keyframes | | | `frame_rate` | null | number; 0.01–60; nullable; no keyframes | default: the highest frame rate among the video assets, else 60 (GIF: 15); set it explicitly (25 or 30) for predictable, small files | | `snapshot_time` | null | number; s; ≥ 0; nullable; no keyframes | the still moment: sets the free `snapshot_url` exported with a video render, or the frame captured when rendering a video template with an image `output_format`; must lie inside the duration | | `snapshot_location` | null | `start` \| `end`; nullable; no keyframes | with `snapshot_time`: also writes the snapshot frame into the video as its first (`start`) or last (`end`) frame, so players show it as the poster; `null` leaves the video's own frames. Does nothing without `snapshot_time` | | `emoji_style` | `"facebook"` | `facebook` \| `google` \| `twitter` \| `apple`; no keyframes | | | `fonts` | [] | array of custom-font entries; no keyframes | see "Custom fonts" | ### How scenes and stills are used - **Scenes**: compositions on track 1 with their `time` and `duration`; a transition is the only way two same-track elements overlap, and it shortens the video by the overlap. A cross-dissolve without shortening is the incoming layer on a higher track fading 0 → 100 %. - **An end fade**: a full-frame black shape on the top track with `"animations": [ { "type": "fade", "time": "end", "duration": 0.4, "easing": "cubic-in-out" } ]`. An unreversed preset at the end keeps the shape invisible until its window, then fades it in. Not opacity keyframes at `"time": "end"`: a property keyframe has no `duration`, so both would sit on the last frame. - **A header or logo that stays through every scene**: one element at the root spanning the whole timeline, not a copy in every scene. - **Build in place**: the static properties hold the finished layout and the keyframes arrive there, so an image render of the same source shows the finished frame. Put `snapshot_time` on a settled frame inside the duration. ### Duration & timing rules The number-one source of broken renders. The engine resolves every element's start and length like this: **`duration` values:** `null` (or `"auto"`) = auto-fill; a number = seconds; `"media"` = the file's length ÷ speed (video/audio only); `"x %"` = share of the parent composition; `"x fr"` = fractional share of the track (transition overlaps accounted). Defaults: video/audio → `"media"`; everything else → `null`. **`time` values:** a number = seconds from the composition start; `"x %"`; `"end"` = stick to the composition's end; `null` = auto (sequence after the previous element on the same track). 1. A composition takes its length from its children with resolvable durations (`"media"`, fixed numbers). Children with `null` duration then stretch to that length. This cascades through nesting, root included. 2. A fixed `duration` on a composition wins: `null`-duration children stretch to it and `fr` children divide it. It does **NOT** clip children with explicit durations (a number or `"media"`) — audio in particular plays on past the composition's end. Bind music to its scene with `duration: null` (+ `"loop": true` in case the scene outgrows the file); keep `"media"` only when the audio should drive the scene's length. 3. An element followed by another on the same track is capped at the follower's start. 4. **If nothing is resolvable, the composition falls back to 5 seconds.** "My video is 5 seconds for no reason" = no duration driver anywhere. Give one element a real duration: a voiceover (`"media"`), a fixed composition duration, or a fixed element duration. 5. **Loop or freeze:** a non-looping video/audio never plays past its file length. A `null`-duration background video must have `"loop": true`. Note: trimming is ignored while looping — use `loop` or `trim_*`, not both. 6. **Same composition = linkage; visual alignment across tracks is NOT linkage.** Parallel tracks sequence independently and drift apart, especially with dynamic media of varying lengths. Elements that must start and end together belong in ONE composition; compositions sequence on track 1. Editor paths: select elements → right-click → **Group**; right-click an element → **Auto Arrange** → Start: Auto / Duration: Auto / Duration: Media / Duration: Loop. Timeline bars label their duration mode ("Media"/"Auto") on the right edge. 7. Voiceover-driven scenes: the voiceover keeps `duration: "media"`, visuals get `duration: null` and stretch to match. Music beds: `duration: null` + `"loop": true` on a root-level track follows the whole video (loop covers files shorter than the video). **More timing facts:** - `"track": null` gives an element its own new track after the numbered ones, so it plays in parallel from its `time`; it never sequences. - `"x fr"` divides the composition's **whole** duration among the fr elements on that track; fixed-duration siblings on the same track are not subtracted, so a fixed open plus fr features plus a fixed close overflow by exactly open + close — keep a track either fr or fixed. An fr duration needs a composition whose length is known without it. On a composition that takes its length from a `duration: null` media child, an fr element cannot resolve and the whole composition falls back to 5 s (render-checked: a 3 s clip with a `1 fr` element beside it, shape or composition, at the root or nested, renders 5 s; the same with the clip's `duration` set to 3 renders 3 s). Give the media a `duration` of `"media"` (its own length, so a clip that varies per render still drives the video) or a number, or give the composition a fixed `duration`, and fr resolves against it. - An element timed outside the output duration is silently never shown, and validation does not flag it. `snapshot_time` must lie inside the duration; elements with no duration driver end at the 5 s fallback, so a later snapshot renders blank. - A transition's overlap is subtracted from the track (a crossfaded sequence runs one overlap shorter per transition); hard cuts do not shorten it. ### Units & value rules - `%` is relative to the parent composition; `vw` `vh` `vmin` `vmax` are 1% of the **output canvas**; `px` is absolute; `°` for rotations; `s` for time. - A plain number takes the property's natural unit: px for position/size/padding/stroke/shadow/border/perspective/font sizes; seconds for time/duration/trims/fades; % for scale/alignment/opacity and the other %-only properties; ° for skew/rotation. - **Keyword values do NOT exist.** There is no `"center"`, `"bottom"`, `"auto"` positioning — positions and alignments are numeric/percentage only (`null` = automatic where allowed). - Wrong units fail loudly with self-describing errors (e.g. `Text-1.x_padding: Expected a number ending with px or vw or vh or vmin or vmax`). Notably: **font sizes and `x_padding`/`y_padding` reject `%`** — use px or v-units (`"5 vw"` for proportional padding); text `background_*` paddings are %-only. - Colors are hex or rgba strings (`"#e74c3c"`, `"rgba(0,0,0,0.5)"`). A gradient is a `fill_color` array of color stops — `[{ "offset": "0%", "color": "#ff0000" }, { "offset": "100%", "color": "#0000ff" }]` (offsets 0–100%) — with `fill_mode: "linear" | "radial"`. - **What a unit resolves against**: `%` resolves against the parent composition's **inner** box (inside its padding); `vw`/`vh`/`vmin`/`vmax` always resolve against the output canvas, even inside nested compositions, so `vmin` is the unit that keeps a size identical across 9:16, 1:1 and 16:9. There is no `calc()`, no expression and no per-format switch: one value is one number. - **Out-of-range values fail validation** rather than clamp (`opacity` above 100% stops the render). An unknown property name only warns in validation and is silently ignored at render time (there is no `scale`, `rotation`, `x_offset` or `y_offset`; use `x_scale`/`y_scale`, `z_rotation`, and keyframed `x`/`y`), so treat a warning about a name as an error. ### Positioning - `x`/`y` place the element's **anchor point**; `x_anchor`/`y_anchor` default to `50%`, so **`x`/`y` position the element's center, not its top-left corner**. `"x": "7%"` with `"width": "70%"` spans -28%..42%, mostly off-canvas; for a left edge at 7%, anchor that edge: `"x": "7%", "x_anchor": "0%"` (`0%` = left/top, `100%` = right/bottom). Anchors are also the scale/rotate/skew origin. - Elements center by default: omit `x`/`y` and move one axis only (`"y": "80%"` = a lower third). Left-aligned text needs no `x` either: a centered wide box (`"width": "86%"` = 7% side margins) + `"x_alignment": "0%"`. - **Anchors are the pivot of every transform** (`x_scale`, `y_scale`, rotations, skews) and the point that `x`/`y` place, and they are not clamped to the box. Changing an anchor alone moves the element, because `x`/`y` now place a different point; move `x`/`y` by the same amount. To grow a bar from its left edge: `"x_anchor": "0%"` with `x` at that edge, then keyframe `x_scale`. - **In the editor** these live in the **Layout** section of the properties panel: X, Y, W and H are typed in its drawing; the cross in the middle picks the anchor, and a preset there moves `x`/`y` for you, so the element stays in place; the arrow next to W or H switches between **Fixed** (a value) and **Hug** (`null`); the buttons below are **Limits** (`min_*`/`max_*`), **Padding** (per side), **Ratio** (`aspect_ratio`) and **Clip**. With nothing selected, the section holds the template's size and duration. A walkthrough for the user: https://creatomate.com/docs/fundamentals/template-editor/position-and-size ### Properties on all elements This table is every property shared by all elements. Per-type properties live in their own sections of this reference — check the matching one before concluding a per-type property doesn't exist: image/video/audio + text-to-speech · text & sizing · subtitles · shape · composition · custom fonts · animation presets & options. | Property | Default | Type, units, range | Notes | |---|---|---|---| | `type` | — | required: `text` \| `image` \| `video` \| `audio` \| `shape` \| `composition` | required on every element | | `name` | null | string | the automation contract — modifications and `transcript_source` match on it (case- and accent-insensitive). Dots are stripped (the dot is the modification-path separator) and spaces become dashes on entry; never rename after an automation is set up | | `id` | auto | UUID, assigned automatically | omit it when writing elements. A duplicate id is silently replaced with a fresh one, so modifications keyed to the submitted id miss; target names instead | | `track` | null | number; 1–1000; nullable; no keyframes | elements on the same track sequence one after another, and two on one track at the same moment resolve to the later one silently. `null` = the element gets its own new track after the numbered ones, so it plays in parallel from its `time`. Numbers may be sparse | | `time` | null | seconds, `%`, or `"end"`; null = after the previous element on its track; no keyframes | see "Duration & timing rules" | | `duration` | null (video: `"media"`, audio: `"media"`) | seconds, `%`, `"x fr"`, or `"media"` (video/audio); null = auto; no keyframes | see "Duration & timing rules" | | `x` | `"50%"` | number; px, %, vw, vh, vmin, vmax; nullable | position of the anchor point (the center by default; see Positioning); explicitly `null` = in the parent's flow | | `y` | `"50%"` | number; px, %, vw, vh, vmin, vmax; nullable | position of the anchor point (the center by default; see Positioning); explicitly `null` = in the parent's flow | | `z` | `"0%"` | number; px, %, vw, vh, vmin, vmax; nullable | 3D depth | | `width` | `"100%"` (text: null, shape: null) | number; px, %, vw, vh, vmin, vmax; 0–3840; nullable | the number is capped at 3840 whatever the unit; `null` = auto-size (hug the content) | | `height` | `"100%"` (text: null, shape: null) | number; px, %, vw, vh, vmin, vmax; 0–3840; nullable | the number is capped at 3840 whatever the unit; `null` = auto-size (hug the content) | | `aspect_ratio` | null | number; ≥ 0.001; nullable | width ÷ height; constrains a ratio-true box inside width×height, so both must be set (ignored while either is `null`) | | `min_width` | null | number; px, %, vw, vh, vmin, vmax; ≥ 0; nullable | bound on the resolved size; a flow item stops stretching at its bound | | `min_height` | null | number; px, %, vw, vh, vmin, vmax; ≥ 0; nullable | bound on the resolved size | | `max_width` | null | number; px, %, vw, vh, vmin, vmax; ≥ 0; nullable | bound on the resolved size; a hugging text wraps at `max_width` | | `max_height` | null | number; px, %, vw, vh, vmin, vmax; ≥ 0; nullable | bound on the resolved size; keyframe it to open a band inside a flow | | `flow_stretch` | null | number; fr; ≥ 0; nullable | as a child of a flow container the element takes N shares of the leftover space along the flow axis — see "Composition elements" | | `x_padding` | `"0 vw"` | number; px, vw, vh, vmin, vmax; ≥ 0 | no %; inner margin, also the flow container's margin | | `y_padding` | `"0 vh"` | number; px, vw, vh, vmin, vmax; ≥ 0 | no %; inner margin, also the flow container's margin | | `top_padding` | null | number; px, vw, vh, vmin, vmax; ≥ 0; nullable | per-side padding; a side value wins over `x_padding`/`y_padding` on that side. A composition's fill covers its padding | | `bottom_padding` | null | number; px, vw, vh, vmin, vmax; ≥ 0; nullable | per-side padding; a side value wins over `x_padding`/`y_padding` on that side | | `left_padding` | null | number; px, vw, vh, vmin, vmax; ≥ 0; nullable | per-side padding; a side value wins over `x_padding`/`y_padding` on that side | | `right_padding` | null | number; px, vw, vh, vmin, vmax; ≥ 0; nullable | per-side padding; a side value wins over `x_padding`/`y_padding` on that side | | `z_index` | null | number; nullable | | | `x_anchor` | `"50%"` (shape: null) | number; % | which point `x`/`y` place (`0%` = left/top, `100%` = right/bottom); also the scale/rotate/skew origin | | `y_anchor` | `"50%"` (shape: null) | number; % | which point `x`/`y` place (`0%` = left/top, `100%` = right/bottom); also the scale/rotate/skew origin | | `x_scale` | `"100%"` | number; % | negative mirrors | | `y_scale` | `"100%"` | number; % | negative mirrors | | `x_skew` | `"0°"` | number; ° | | | `y_skew` | `"0°"` | number; ° | | | `x_rotation` | `"0°"` | number; ° | 3D, with `perspective` | | `y_rotation` | `"0°"` | number; ° | 3D, with `perspective` | | `z_rotation` | `"0°"` | number; ° | in-plane rotation (there is no `rotation` property) | | `perspective` | null | number; px, vw, vh, vmin, vmax; ≥ 0; nullable | | | `backface_visible` | `true` | boolean | whether a 3D-rotated element shows from behind | | `x_alignment` | `"50%"` (text: `"0%"`) | number; %; 0–100 | alignment of the content inside the box; text defaults to top-left, every other type to the centre | | `y_alignment` | `"50%"` (text: `"0%"`) | number; %; 0–100 | alignment of the content inside the box; text defaults to top-left, every other type to the centre | | `shadow_color` | null | string; color; nullable | null = no shadow. One shadow per element: no spread, no inset, no second shadow; a hard shadow is `shadow_blur: 0`. Effects apply in a fixed order: colour filter, then blur, then shadow | | `shadow_blur` | `"3 vmin"` | number; px, vw, vh, vmin, vmax; 0–1000 | | | `shadow_x` | `"0 vmin"` | number; px, vw, vh, vmin, vmax | | | `shadow_y` | `"0 vmin"` | number; px, vw, vh, vmin, vmax | | | `clip` | `false` | boolean | clip content to the element's box, rounded by `border_radius`. Elements are NOT clipped by default: an image scaled past 100% spills out of its box | | `opacity` | `"100%"` | number; %; 0–100 | out-of-range values fail validation rather than clamp | | `blend_mode` | `"none"` | `none` \| `multiply` \| `screen` \| `overlay` \| `darken` \| `lighten` \| `color-dodge` \| `color-burn` \| `hard-light` \| `soft-light` \| `lighter` \| `difference` \| `exclusion` \| `hue` \| `saturation` \| `color` \| `luminosity` | | | `color_filter` | `"none"` | `none` \| `brighten` \| `contrast` \| `hue` \| `invert` \| `grayscale` \| `sepia` | with `color_filter_value` (number) | | `color_filter_value` | `0` | number | | | `color_overlay` | null | string; color; nullable | tint an image/video | | `blur` | `0` | number; px, vw, vh, vmin, vmax; 0–1000 | gaussian blur; the amount is its standard deviation, as in CSS `filter: blur()` (`"2 vmin"` is a soft background blur). Use this for every new blur | | `blur_radius` | `0` | number; 0–1000 | legacy blur, kept for existing templates; ignored when `blur` is set. Don't use it for new work | | `blur_mode` | `"stack"` | `stack` \| `box` \| `box-2` | legacy, with `blur_radius` only; `blur` has no mode | | `mask_mode` | null | `alpha` \| `alpha-inverted` \| `luma` \| `luma-inverted`; nullable | the element masks what's below it in track order | | `repeat` | `false` | boolean | tile as pattern | | `chroma_key_color` | null | string; color; nullable | green screen | | `chroma_key_sensitivity` | `"1%"` | number; %; 1–100 | | | `warp_mode` | `"default"` | `default` \| `perspective` | with `warp_matrix`; author warps in the editor | | `warp_matrix` | null | grid of `{ x, y }` points, authored in the editor | | | `dynamic` | `false` | boolean; no keyframes | `true` exposes the element's main property (text → `text`, media → `source`) as a fillable field in Zapier/Make/n8n/feeds/forms; an array of property names (e.g. `["text", "fill_color"]`) exposes each listed property as its own field — any property can be dynamic | | `locked` | `false` | boolean; no keyframes | editor-only: locks the element against accidental edits; no effect on the render | | `visible` | `true` | boolean | | | `animations` | null | array of animation objects | see "Animations" — visual-only: silently inert on audio elements | **Fill, stroke, and corner rounding are per-type:** - `fill_color`, `fill_mode` (`solid|linear|radial`), `fill_x0/y0/x1/y1`, `fill_radius` exist on **text, shape, and composition only**. Defaults: `fill_color` is `#000000` on text and the root (new text renders black; the canvas is black until set), null elsewhere. Gradient geometry, in % of the element's box: linear runs from (`fill_x0`,`fill_y0`) to (`fill_x1`,`fill_y1`) — defaults 50,0 → 50,100 = top to bottom; radial centers on (`fill_x0`,`fill_y0`) with `fill_radius` (default 50%, of the larger box dimension). A composition's fill is the scene background; the root's fill is the canvas background. To tint an image or video, use `color_overlay` or `color_filter` — a `fill_color` on media is silently ignored. Gradient points may lie outside 0–100% (the gradient then extends beyond the box). The radial fill is a plain circle, never an ellipse, and CSS's farthest-corner sizing does not exist: `fill_radius: "71%"` reaches a square box's corners. Stops are painted exactly as given and blended without premultiplying, so a fade to transparent should end in the same colour at alpha 0 (`rgba(0,0,0,0)` under a black scrim), not in a different colour. A hard edge is two stops at one offset. There are no repeating gradients or pattern fills. - `stroke_color`, `stroke_width` (≥ 0), `stroke_cap` (`butt|square|round`), `stroke_join` (`miter|bevel|round`), `stroke_start`/`stroke_end` (0–100%), `stroke_offset` (%) exist on image, video, text, shape, and composition. - `border_radius` (≥ 0; px/v-units) exists on image, video, shape, and composition — **not on text**; round a text's background box with `background_border_radius`. **Keyframes:** any animatable property accepts an array of `{ "time": s, "value": …, "easing": … }` points instead of a single value. Structural properties **reject** keyframe arrays and error with `does not support keyframes`: `track`, `time`, `duration`, `source`, `provider`, `trim_start`, `trim_duration`, `loop`, `speed`, `volume`, `audio_fade_in/out`, `fit`, `transcript_effect`, `transcript_split`, `transcript_placement`, `transcript_maximum_length`, `dynamic`, and root output settings. `transcript_source` is the exception among the subtitle settings: a keyframe array there is the bring-your-own-timings form, one keyframe per word ("Subtitles"); `transcript_color` keyframes like any colour. Fade audio with `audio_fade_in`/`audio_fade_out`, never volume keyframes. A shape's `path` IS keyframable (shape morphing). **Keyframe rules.** A keyframe `time` is seconds from the element's start, the same clock as an animation's `time`; `"start"` and `"end"` are accepted, and `"end"` resolves against the element's actual end, so a `"start"` → `"end"` pair spans a media-driven length. Before the first keyframe a property holds the first keyframe's value, after the last it holds the last; between two keyframes it interpolates over the whole gap, so a value that must sit still until its move starts needs a **hold keyframe** (the same value repeated) at that moment. Keyframes past the element's end keep interpolating, which is how a constant rate at any length is written: `"z_rotation": [ { "time": 0, "value": "0°" }, { "time": 1000, "value": "360000°", "easing": "linear" } ]`. Two keyframes at one time: the later wins. A `duration` on a property keyframe is ignored: only `animations` and `transcript_source` keyframes carry one. So an end fade is a `fade` preset at `"time": "end"`, never an opacity pair at `"time": "end"` (both would sit on the last frame). Values are absolute, never deltas from the static value. Numbers, unit values, colours and `path` data (the same command sequence in every keyframe) interpolate; strings and booleans switch at the keyframe. "Animatable" means every property outside the structural list above. Easing sits on the keyframe being approached — "Animations". ### Image elements | Property | Default | Type, units, range | Notes | |---|---|---|---| | `source` | null | string; nullable; no keyframes | URL, uploaded-asset ID, or an AI prompt (with `provider`) | | `provider` | null | string; nullable; no keyframes | see "AI generation providers" | | `fit` | `"cover"` | `cover` \| `contain` \| `fill`; no keyframes | `cover` crops, `contain` letterboxes, `fill` stretches | | `stroke_color` | null | string; color; nullable | a border around the media box, following `border_radius` | | `stroke_width` | `"0.1 vmin"` | number; px, vw, vh, vmin, vmax; ≥ 0 | | | `stroke_cap` | `"round"` | `butt` \| `square` \| `round` | | | `stroke_join` | `"round"` | `miter` \| `bevel` \| `round` | | | `stroke_start` | `"0%"` | number; %; 0–100 | | | `stroke_end` | `"100%"` | number; %; 0–100 | | | `stroke_offset` | `"0%"` | number; % | | | `border_radius` | `"0 vmin"` | number; px, vw, vh, vmin, vmax; ≥ 0 | one radius for all four corners | To size an image by one dimension and keep its shape, set that dimension, leave the other at its default, and use `fit: "contain"` — the box aspect becomes irrelevant. An image without a `source` shows a placeholder grid in the editor and renders **black** — that black region is a diagnostic signal, not a bug. ### Video elements Everything image has (`source`, `fit`) plus the rows below. There is no `provider` for video: no provider generates video, so a video element is always a file (a URL or an uploaded-asset ID). | Property | Default | Type, units, range | Notes | |---|---|---|---| | `fit` | `"cover"` | `cover` \| `contain` \| `fill`; no keyframes | `cover` crops, `contain` letterboxes, `fill` stretches | | `source` | null | string; nullable; no keyframes | URL or uploaded-asset ID | | `trim_start` | null | number; s; ≥ 0; nullable; no keyframes | | | `trim_duration` | null | number; s; ≥ 0; nullable; no keyframes | | | `loop` | `false` | boolean; no keyframes | looping ignores trims — pick one | | `speed` | `"100%"` | number; %; 50–200; no keyframes | `"media"` duration = file length ÷ speed | | `volume` | `"100%"` | number; %; 0–200; no keyframes | | | `audio_fade_in` | null | number; s; ≥ 0; nullable; no keyframes | | | `audio_fade_out` | null | number; s; ≥ 0; nullable; no keyframes | | | `stroke_color` | null | string; color; nullable | a border around the media box, following `border_radius` | | `stroke_width` | `"0.1 vmin"` | number; px, vw, vh, vmin, vmax; ≥ 0 | | | `stroke_cap` | `"round"` | `butt` \| `square` \| `round` | | | `stroke_join` | `"round"` | `miter` \| `bevel` \| `round` | | | `stroke_start` | `"0%"` | number; %; 0–100 | | | `stroke_end` | `"100%"` | number; %; 0–100 | | | `stroke_offset` | `"0%"` | number; % | | | `border_radius` | `"0 vmin"` | number; px, vw, vh, vmin, vmax; ≥ 0 | one radius for all four corners | ### Audio elements & text-to-speech | Property | Default | Type, units, range | Notes | |---|---|---|---| | `source` | null | string; nullable; no keyframes | URL, uploaded-asset ID, or the words to speak (with `provider`) | | `provider` | null | string; nullable; no keyframes | text-to-speech — see "AI generation providers" | | `trim_start` | null | number; s; ≥ 0; nullable; no keyframes | | | `trim_duration` | null | number; s; ≥ 0; nullable; no keyframes | | | `loop` | `false` | boolean; no keyframes | looping ignores trims — pick one | | `speed` | `"100%"` | number; %; 50–200; no keyframes | `"media"` duration = file length ÷ speed | | `volume` | `"100%"` | number; %; 0–200; no keyframes | | | `audio_fade_in` | null | number; s; ≥ 0; nullable; no keyframes | | | `audio_fade_out` | null | number; s; ≥ 0; nullable; no keyframes | | Animation presets are **visual-only and silently inert on audio elements** — a `fade` animation on an audio element does nothing. Fade audio with `audio_fade_in`/`audio_fade_out` (seconds). For AI speech, set `provider` on an audio element and put the words to speak in `source` — the same pattern as AI image generation — see "AI generation providers": ```json { "type": "audio", "name": "Voiceover-1", "track": 2, "provider": "elevenlabs model_id=eleven_multilingual_v2 voice_id=XrExE9yKIg1WjnnlVkGX", "source": "Welcome to our weekly market update." } ``` This is exactly what the editor's **AI Voiceover** insert writes; that `voice_id` is the product's stock default voice — reuse it unless the user picked another. OpenAI form: `"provider": "openai model=tts-1-hd voice='alloy'"`. Because `source` is the audio main property, a bare `"Voiceover-1": "new words"` modification changes the spoken text. ### Media facts worth knowing - `fit: "contain"` letterboxes inside the box; a shadow or a border follows the box, not the letterboxed picture, so a shadowed photo whose aspect varies is an image inside a hugging or clipped composition. - **One unreachable asset (a 404) fails the whole render on the server**, not just its element; a browser preview draws without it and reports it in `getAssets()` — fix every failure before rendering. - The element type follows the file: a video element whose `source` is a jpg becomes an image quietly, and when a modification replaces a `source`, a jpg/jpeg/png/svg URL turns the element into an image; a webp keeps the declared type, so declare it on an image element. SVG works on image elements. - A numeric `duration` on an audio or video element cuts it; a composition's duration never clips its children's media. ### How it is used - **Ken Burns, slow push**: `{ "type": "scale", "scope": "element", "start_scale": "100%", "end_scale": "108%", "easing": "linear" }` on an image with `"clip": true`, so it grows inside its own box; alternate the direction from photo to photo. - **A picture that must not be cropped, on a full-bleed bed**: the same image twice, a background copy with `"blur": "2 vmin"` and `"fit": "cover"`, a front copy with `"fit": "contain"`. - **A slot that takes a still or a clip** and never goes black: a video element with `"duration": null`, `"loop": true` and `"volume": "0%"` when the template brings its own sound; a short clip loops, a still just shows. - **Music that follows the video**: an audio element on its own root-level track with `"duration": null`, `"loop": true` and `"audio_fade_out": 1`. A numeric `duration` on the audio element itself is what cuts it; a composition's duration never clips its audio. ### Text elements | Property | Default | Type, units, range | Notes | |---|---|---|---| | `text` | `""` | string | may contain `{{Placeholder}}` / `{{Placeholder\|default}}` substitutions; `\n` forces a line break | | `font_family` | `"Aileron"` | string | 1,100+ built-in families — the full list with weights is "Built-in fonts" (https://creatomate.com/llms/built-in-fonts.md) — plus custom fonts. A family that is not built in (Arial and Helvetica are not) renders as Aileron silently | | `font_weight` | `"400"` | string | a string, e.g. `"700"`; a weight the family lacks falls back within the family | | `font_style` | `"normal"` | string | `normal` \| `italic` | | `font_size` | null | number; px, vw, vh, vmin, vmax; ≥ 0.1; nullable | never `%` (e.g. `"7 vmin"`); `null` = auto-fit between the minimum and the maximum | | `font_size_minimum` | `"1 vmin"` | number; px, vw, vh, vmin, vmax; ≥ 0.1; nullable | auto-fit floor; at it a longer value overflows rather than shrinks | | `font_size_maximum` | `"100 vmin"` | number; px, vw, vh, vmin, vmax; ≥ 0.1; nullable | auto-fit ceiling | | `letter_spacing` | `"0%"` | number; %; ≥ -299 | % of the font size ÷ 10: `"1000%"` is one em, so CSS `0.05em` is `"50%"` | | `line_height` | `"115%"` | number; %; ≥ 0 | % of the font size; a bare number is read as a percentage (`1.55` = 1.55 %, lines overprint), so write `"155%"` | | `text_direction` | `"ltr"` | `ltr` \| `rtl` | | | `text_wrap` | `true` | boolean | false = never wrap | | `text_clip` | `false` | boolean | true = cut overflow at the box with an "…" ellipsis | | `text_transform` | `"none"` | `none` \| `capitalize` \| `uppercase` \| `lowercase` | applies to the whole value, units included (`m²` → `M²`), and the transformed string is wider | | `background_color` | null | string; color; nullable | the text's own background box; it hugs the text and draws nothing for an empty value | | `background_x_padding` | `"25%"` | number; %; ≥ 0 | % of the font size; the text is inset by the padding inside the element box, so a box must reserve text + 2 × padding | | `background_y_padding` | `"25%"` | number; %; ≥ 0 | % of the font size | | `background_border_radius` | `"0%"` | number; %; ≥ 0 | % of the font size | | `background_align_threshold` | `"20%"` | number; %; ≥ 0 | the background is drawn per line; lines whose widths differ by less than this share of the element width get one box width. `"100%"` merges every line into one block instead of a stepped edge | | `transcript_source` | null | string or keyframe array; nullable | the `name` of the audio or video element to caption, inline SRT/WebVTT, or your own word timings as a keyframe array — see "Subtitles" | | `transcript_effect` | `"color"` | `color` \| `karaoke` \| `highlight` \| `fade` \| `bounce` \| `slide` \| `enlarge`; no keyframes | how the spoken unit is marked — see "Subtitles" | | `transcript_split` | `"word"` | `none` \| `line` \| `word` \| `letter`; no keyframes | the unit the effect applies to | | `transcript_placement` | `"static"` | `static` \| `animate` \| `align`; no keyframes | | | `transcript_maximum_length` | null | number; ≥ 1; nullable; no keyframes | characters per caption page; `1` shows one word at a time | | `transcript_color` | `"#e74c3c"` | string; color; nullable | the colour of the spoken unit | | `fill_color` | `"#000000"` | color, or an array of `{ "offset", "color" }` stops for a gradient | the text colour | | `fill_mode` | `"solid"` | `solid` \| `linear` \| `radial`; nullable | | | `fill_x0` | `"50%"` | number; % | | | `fill_y0` | `"0%"` | number; % | | | `fill_x1` | `"50%"` | number; % | | | `fill_y1` | `"100%"` | number; % | | | `fill_radius` | `"50%"` | number; %; ≥ 0 | | | `stroke_color` | null | string; color; nullable | | | `stroke_width` | `"0.1 vmin"` | number; px, vw, vh, vmin, vmax; ≥ 0 | draws outward from the glyph edge; outline type is a stroke over a transparent fill | | `stroke_cap` | `"round"` | `butt` \| `square` \| `round` | | | `stroke_join` | `"round"` | `miter` \| `bevel` \| `round` | | | `stroke_start` | `"0%"` | number; %; 0–100 | | | `stroke_end` | `"100%"` | number; %; 0–100 | | | `stroke_offset` | `"0%"` | number; % | | Text elements default `x_alignment`/`y_alignment` to `0%`/`0%` (top-left within their box) — that is why new text sits left-aligned; every other type defaults to 50%. **`{{ }}` works only inside a text element's `text` value** and is meant for mixed static + dynamic text ("Hello {{name}}!"); for fully dynamic text, write realistic sample text and mark the element Dynamic instead. The full substitution rules live in "Modifications". **Text sizing has no mode property.** The editor's five "Size" modes derive from which of `font_size` / `width` / `height` are null: | `font_size` | `width` | `height` | Behavior | |---|---|---|---| | set | null | null | fixed font; the element hugs the text (labels, buttons) | | set | set | set | everything fixed; text wraps and can overflow (the classic complaint: automation inserted longer text than the design assumed) | | null | set | set | font auto-fits the box between min/max — best for dynamic text | | null | null | set | font fits the fixed height; width grows (matching lower-thirds) | | null | set | null | columns: with both bounds declared the text takes the maximum and wraps at the width; with the default bounds it can stay at the 1 vmin minimum (see "How the engine lays text out" below) | Editor gotcha: clicking a size-mode button resets `font_size_minimum`/`maximum` to defaults. **Text-fit fixes (in order of likelihood):** - Text overflows its box → `font_size: null` (auto-fit), or enlarge the box. - Auto-fit text became tiny → long text drove it to the floor; raise `font_size_minimum` (≈ `"4 vmin"`). - A line runs off the canvas → `text_wrap: true`. - Per-render size differences → auto-fit working as designed with variable text lengths; not a bug. For a constant size use a fixed `font_size`, or set `font_size_minimum` equal to `font_size_maximum`. - Words cut off → decide: wrap (`text_wrap: true`) or clip with ellipsis (`text_clip: true`). - **Keep it on one line:** `text_wrap: false`. For dynamic text add `font_size: null` + a fixed `width` — the font shrinks until the text fits one line. Widening the box is not a fix; the next longer value wraps again. (Short static text can simply drop `width` to null — the element hugs the text and never wraps.) ### Inline text styles Any text value supports square-bracket spans: `[family Name]…[/family]`, `[weight 700]…[/weight]`, `[style italic]…[/style]`, `[size 150]…[/size]` (% of the element's `font_size`), `[color #ff0000]…[/color]`, `[opacity 50]…[/opacity]`. Spans nest (innermost wins) and work inside modification values too. Unknown tags render as literal text. Custom fonts are referenced by exact family name — see "Custom fonts". ### How the engine lays text out - **Wrapping** happens only at spaces (and at CJK break points); the engine never hyphenates, and a hyphen is not a break opportunity. A word wider than its box runs out of it; a no-break space (U+00A0) keeps words together. A character the font lacks draws a box: there is no Latin fallback font, so avoid unusual code points such as the non-breaking hyphen (U+2011) and use a plain hyphen. - **Auto-fit in a fixed box** (`font_size: null` with `width` and `height` set): the engine wraps the text at `font_size_minimum`, then grows the size in 100 steps toward `font_size_maximum`, re-wrapping at every step, and keeps the largest size whose wrapped block still fits the box height with 10 % headroom (so give a box about 10 % more height than the text needs, or the text lands about 10 % under the intended size). The line count follows the box, not the text: the scan stops at the first size where the wrap no longer fits, so a headline meant to stack comes out as one line in a box too short for two lines and as four lines in a tall square (render-checked); force a layout with `\n` or a fixed `font_size`. Text never goes below the minimum: at the floor a longer value overflows, so size every box for the longest realistic value. Each text fits alone; sizes are never shared between elements. - **Auto-fit with a hugging height** (`font_size: null`, `width` set, `height: null`): the rule depends on the bounds. With the default bounds the text is wrapped at the minimum and scaled until its widest line fills the width, so a text that already wraps at the minimum stays there, and the default minimum is 1 vmin: unreadable (render-checked). Declare both `font_size_minimum` and `font_size_maximum` and the text takes the maximum and wraps at the width, giving size up only when a single word is wider than the box; with no width either, it is the maximum on one line. Always declare both bounds on a hugging auto-fit text. - **Vertical metrics** follow the font, not CSS: the first baseline sits at the top of the box plus the font's ascender, and `line_height` only spaces the lines below it. Some faces draw taller lines than others at the same `line_height`. Kerning applies. - **Stroke and background**: `stroke_width` on text draws outward from the glyph edge (about twice a CSS text-stroke for the same look); outline type is a stroke over a transparent fill. The background box hugs the text on both axes and follows each line: a line's box is the font's ascent to descent plus the padding, so `line_height` changes the space between line boxes rather than their height; the box takes the element's shadow and draws nothing at all for an empty value — a clean empty state. Text has no strikethrough, underline, gradient fill or path: "Out of scope". ### How it is used The engine forms behind Creatomate's own templates; values are starting points. Check every move before rendering ("Seeing a render"). - **Text that adapts to any value** (a fixed box, the font shrinks to fit): `"font_size": null` with `width` and `height` set, bounded by `font_size_minimum`/`font_size_maximum`. Give the box about 10 % more height than the text needs and size it for the longest realistic value: at the floor a longer value overflows. - **A label, chip or pill** (the background hugs the text; an empty value draws nothing): ```json { "type": "text", "name": "Badge", "x": "92%", "y": "8%", "x_anchor": "100%", "y_anchor": "0%", "text": "NEW", "font_size": "3.5 vmin", "font_weight": "700", "fill_color": "#ffffff", "background_color": "#ff4757", "background_x_padding": "80%", "background_y_padding": "40%", "background_border_radius": "50%" } ``` A wrapped chip becomes a stepped edge; `"background_align_threshold": "100%"` merges the lines into one block. - **Outline type**: a text stroke over a transparent fill: `"fill_color": "rgba(0,0,0,0)", "stroke_color": "#ffffff", "stroke_width": "0.4 vmin"`. The stroke draws outward, so use about twice a CSS text-stroke width. - **A struck-through "was" price** (there is no strikethrough): a second text with the same `name`, position and font, transparent glyphs (`"fill_color": "rgba(0,0,0,0)"`) and its own background box in the rule colour, `"background_x_padding": "0%"`, `"background_y_padding": "0%"`, `"y_scale": "8%"`. Give both `"y_anchor": "50%"`: the squash happens toward the anchor, so a top anchor puts the rule above the price. Both hug the value, so the rule always fits it, and one modification by name fills both. - **A stacked headline that must stay stacked**: `\n` in the text, or a fixed `font_size`. Auto-fit decides the line count at the minimum size and can scale a two-line design into one line. - **Sizes that survive a format change**: font sizes, paddings and reserved heights in `vmin`, because the short side is the same in 9:16, 1:1 and 16:9. ### Subtitles Subtitles are text elements with `transcript_source` set. They generate at render time for `mp4`/`gif` outputs. | Property | Default | Values | |---|---|---| | `transcript_source` | null | the **name** (case-insensitive) or **id** of an audio/video element — its own composition is searched first, then the whole document; prefer the same composition so their timing stays linked; an HTTP URL (file gets transcribed); a raw SRT/WebVTT string; or your own word timings as a keyframe array (below) | | `transcript_effect` | `"color"` | color \| karaoke \| highlight \| fade \| bounce \| slide \| enlarge | | `transcript_split` | `"word"` | none \| line \| word \| letter | | `transcript_placement` | `"static"` | static \| animate \| align | | `transcript_maximum_length` | null | max characters per page, ≥ 1 | | `transcript_color` | `"#e74c3c"` | the accent color for the spoken word/segment | **Bring your own timings.** Instead of a string, `transcript_source` takes an array with one keyframe per word: `{ "time": 0.64, "duration": 0.08, "value": "It" }`. `time` is seconds from the subtitle element's own start (the same clock as any keyframe, so keep that element starting with the media it captions), `duration` is how long the word is spoken (1 s when omitted), `value` the word. Add `"sentence": 1`, `2`, … to group the words into sentences: each sentence is then its own caption, paged further only by `transcript_maximum_length`; without them the words are one stream the engine pages itself. Nothing is transcribed. The array also works as a modification value: `"Subtitles-1.transcript_source": [ … ]`. **What the effects do** (`transcript_effect`, on the unit `transcript_split` names, a word by default): `color` tints the unit being spoken in `transcript_color`; `karaoke` keeps every spoken unit tinted, a progressive fill; `highlight` draws a rounded box in `transcript_color` behind the spoken unit, using the text's `background_*` paddings and radius, and the unit's text keeps `fill_color`; `fade` fades each unit in as it is spoken; `bounce` and `slide` move the spoken unit in; `enlarge` scales it up and re-wraps the line around it. **Subtitles have TWO colors**: `fill_color` is the base text color and `transcript_color` is the spoken-word accent. "Make the subtitles red" means changing BOTH. ElevenLabs voiceovers include word timestamps natively, so their subtitles need no transcription step. ### Shape elements Properties beyond those on all elements ("Units & properties on all elements"): | Property | Default | Type, units, range | Notes | |---|---|---|---| | `path` | `""` | SVG path data | with `width`/`height` set the coordinates are 0–100 and scale to the box; with either null they are pixels and the shape hugs the path; keyframable (morphing) | | `fill_color` | null | color, or an array of `{ "offset", "color" }` stops for a gradient | null = no fill (an outline) | | `fill_mode` | `"solid"` | `solid` \| `linear` \| `radial`; nullable | | | `fill_x0` | `"50%"` | number; % | | | `fill_y0` | `"0%"` | number; % | | | `fill_x1` | `"50%"` | number; % | | | `fill_y1` | `"100%"` | number; % | | | `fill_radius` | `"50%"` | number; %; ≥ 0 | | | `stroke_color` | null | string; color; nullable | | | `stroke_width` | `"0.1 vmin"` | number; px, vw, vh, vmin, vmax; ≥ 0 | | | `stroke_cap` | `"round"` | `butt` \| `square` \| `round` | | | `stroke_join` | `"round"` | `miter` \| `bevel` \| `round` | | | `stroke_start` | `"0%"` | number; %; 0–100 | | | `stroke_end` | `"100%"` | number; %; 0–100 | keyframe from `"0%"` for a line-draw effect | | `stroke_offset` | `"0%"` | number; % | | | `border_radius` | `"0 vmin"` | number; px, vw, vh, vmin, vmax; ≥ 0 | rounds the corners of any path; half the side of a square makes a circle | - **Two coordinate modes, decided by the box.** With `width` and `height` set, path coordinates are **0–100** and scale to the box (how the editor's rectangle/ellipse presets work). With `width`/`height` null, path coordinates are **absolute pixels** and the element auto-sizes to the path's bounding box (how fresh pen-tool shapes work). The same path data renders completely differently in the other mode — when writing a path from scratch, set `width`/`height` and use 0–100 coordinates. Setting only one of the two keeps the path mode, and the set value is ignored. A path-sized shape outside a flow is placed by its path's (0, 0) at `x`/`y`, and `x_anchor`/`y_anchor` have no effect; inside a flow composition it takes a slot the size of its path, which a hugging container counts, and is aligned like any other child; a `%` size on it is not a share of the container. - `path` is keyframable: two paths morph into each other. - `border_radius` rounds the corners of any path, not just rectangles. - Partial strokes draw an outline progressively: animate `stroke_end` from `"0%"` to `"100%"` for a line-draw effect (`stroke_start`/`stroke_offset` shift which segment is drawn). - Subpaths render as one shape: a subpath inside another cuts a hole. - Complex paths are authored with the editor's pen tool. ### How it is used - **A scrim over a photo** (readable text on a picture): a shape with `"fill_mode": "linear"` from `"rgba(0,0,0,0)"` to `"rgba(0,0,0,0.65)"`, stops written in the same colour at different alphas. A hard edge is two stops at one offset. - **A vignette or glow**: a shape with `"fill_mode": "radial"`. The radius is a plain circle in percent of the box's longer side; `"fill_radius": "71%"` reaches a square box's corners. - **A keyline, frame or border**: a shape with `"fill_color": null`, `stroke_color` and `stroke_width`. The stroke is centred on the path. A dashed line is a path of short segments; there is no dash setting. - **A circle**: a square shape whose `border_radius` is half its side (`border_radius` takes px or v-units, not `%`). - **A drop shadow** (on any element): `shadow_color` with `shadow_blur`, `shadow_x`, `shadow_y`; one per element, a hard shadow is `"shadow_blur": 0`. On text with a background box, the box carries the shadow. - **A full-frame rectangle**: `"path": "M 0 0 L 100 0 L 100 100 L 0 100 Z"` with `width` and `height` set, the shape most moves start from (scrims, bars, end fades, flash frames). ### Composition elements Properties beyond those on all elements ("Units & properties on all elements"): | Property | Default | Type, units, range | Notes | |---|---|---|---| | `elements` | [] | array of child elements | the children; replace or append them per render with the `Comp.elements` / `Comp.add` modifications | | `flow_direction` | `"vertical"` | `vertical` \| `horizontal` | the axis children stack along when they join the flow (a null flow-axis coordinate) | | `flow_gap` | `"0 vmin"` | number; px, %, vw, vh, vmin, vmax; ≥ 0 | between consecutive flow children; a bare number is px | | `flow_justify` | `"start"` | `start` \| `center` \| `end` \| `space-between` | how leftover space along the flow axis is distributed | | `fill_color` | null | color, or an array of `{ "offset", "color" }` stops for a gradient | the scene or group background; covers the padding too | | `fill_mode` | `"solid"` | `solid` \| `linear` \| `radial`; nullable | | | `fill_x0` | `"50%"` | number; % | | | `fill_y0` | `"0%"` | number; % | | | `fill_x1` | `"50%"` | number; % | | | `fill_y1` | `"100%"` | number; % | | | `fill_radius` | `"50%"` | number; %; ≥ 0 | | | `stroke_color` | null | string; color; nullable | | | `stroke_width` | `"0.1 vmin"` | number; px, vw, vh, vmin, vmax; ≥ 0 | | | `stroke_cap` | `"round"` | `butt` \| `square` \| `round` | | | `stroke_join` | `"round"` | `miter` \| `bevel` \| `round` | | | `stroke_start` | `"0%"` | number; %; 0–100 | | | `stroke_end` | `"100%"` | number; %; 0–100 | | | `stroke_offset` | `"0%"` | number; % | | | `border_radius` | `"0 vmin"` | number; px, vw, vh, vmin, vmax; ≥ 0 | also rounds the clip when `clip` is on | A composition's `fill_color` is the scene's background color. Compositions are RenderScript's structural mechanism: - **Scenes are compositions** placed in sequence on track 1 — see "RenderScript basics". - **A composition is the grouping and sync mechanism**: it carries its own timeline, so its children stay linked — elements that must start and end together belong in ONE composition; visual alignment across tracks is NOT linkage. Editor: select elements → right-click → **Group**. Timing cascade: "Duration & timing rules". - Replace or append a composition's children per render with the `Comp.elements` / `Comp.add` modifications — see "Modifications". ### Flow layout (auto layout) Every composition is a flow container, so a card that grows with its content needs no arithmetic: - **Container properties**: `flow_direction` (`vertical`, the default, or `horizontal`), `flow_gap` (≥ 0; px, % or v-units, a bare number is px; between consecutive flow children only), `flow_justify` (`start` \| `center` \| `end` \| `space-between`: how leftover space along the flow axis is distributed), plus `x_padding`/`y_padding` and the per-side paddings as the inner margin. - **A child joins the flow when its flow-axis coordinate is explicitly `null`** (`"y": null` in a vertical flow). An absent coordinate defaults to `"50%"` and keeps the child absolute, so a hand-written source must write the `null`. Across the flow, `null` aligns the child to the start edge, whatever its anchor. A percentage places the child's anchor as it does anywhere else (the anchor is the centre by default): `"50%"` centres the child, and the end edge is `"100%"` together with an anchor of `"100%"` (`x_anchor` in a vertical flow, `y_anchor` in a horizontal one). `"100%"` alone puts the child's centre on the end edge, half outside, and `"0%"` is not the same as `null`. Flow children are placed in `track` order, lowest first, not in the order they are written. - **Sizing**: a `null` size hugs — a container sums its flow children plus gaps and padding along the flow axis and takes the widest child across it; a text with `"height": null` keeps its font size and wraps instead of shrinking. `"flow_stretch": "1 fr"` on a child fills the leftover space along the flow axis in shares; `min_*`/`max_*` bound it. A stretch needs a container with a size on that axis: one that hugs it has no leftover, and the child collapses to zero. `%` sizes of children resolve against the container's inner box (inside its padding), and for an absolute child that holds on a hugging axis too: a `height: 100%` bar beside a hugging text spans the settled text (render-checked). On the container's hugging flow axis, a flow child's `%` size is a share of the container's inner size, solved together with the fixed content: a `100 px` child beside a `50%` one gives an inner size of 200 px and a share of 100 px. A `%` `flow_gap` is a share too, per gap. The shares and gaps must stay under 100% together. At 100% or more, each share fits its own content (a text its text, a composition what it holds, an image or video its asset, a shape 0), which is also what the default `100%` of a composition, image or video does, and a `%` gap is 0; when every flow child is a share, they and the container are all 0. The validator warns in those cases only. A share clamped by its `min_*`/`max_*` counts at that size and the others solve again. Padding takes px or v-units, never `%`. A shape cannot hold children; any element is capped at 3840 px. - **Motion in a flow**: keyframing the flow-axis coordinate takes the child out of the flow; arrivals are `animations` entries (`slide`, `scale`, `fade`). Only children visible at that moment take a slot: one whose `time` starts later pushes its siblings when it appears, `"visible": false` gives the slot up, and `"opacity": "0%"` keeps it. To open a band and push what follows, keyframe its `max_height`. Nested hugging compositions group items (a title row, a price row). - **Clipping and grouping**: `"clip": true` clips the children to the composition's box, rounded by its `border_radius`; without it children spill freely. `x_scale`/`y_scale`, rotations and keyframes on a composition move all of its content together, which is why anything that must move as one unit is one composition. - **In the editor** this is **Auto layout**: selecting elements and clicking **Auto Layout** in the toolbar above the canvas (Shift+A) wraps them in a flow composition, and on a selected composition it puts the children into its flow. The composition's **Auto layout** section holds **Direction**, **Justify** and **Gap**. On a child, the Layout section shows a `null` flow-axis coordinate as **Auto** (clicking it offers a pinned value or Auto) and the size along the flow as **Fixed** (a value), **Hug** (`null`) or **Fill** (`flow_stretch`, whose number is the **Share**). A walkthrough for the user: https://creatomate.com/docs/fundamentals/template-editor/auto-layout ### How it is used - **A card that grows with its content** (a flex column): a composition is a flow container. A child joins the flow with its flow-axis coordinate set to `null`; `null` sizes hug. ```json { "type": "composition", "name": "Card", "width": "80%", "height": null, "x_padding": "5 vmin", "y_padding": "5 vmin", "fill_color": "#ffffff", "border_radius": "3 vmin", "flow_gap": "2 vmin", "elements": [ { "type": "text", "name": "Title", "y": null, "width": "100%", "height": null, "font_size": "6 vmin", "fill_color": "#111111", "text": "Open house Sunday" }, { "type": "text", "name": "Body", "y": null, "width": "100%", "height": null, "font_size": "3.5 vmin", "fill_color": "#333333", "text": "Three bedrooms, a garden, parking." } ] } ``` Arrivals inside a flow are `animations` entries, never keyframed `y`: a keyframed flow-axis coordinate takes the element out of the flow. - **Fill whatever is left**: `"flow_stretch": "1 fr"` on a flow child with a `null` size along the flow axis; two stretchers share the leftover by their `fr` values. - **A slot that keeps its neighbours still** while its value changes length: a fixed-height composition sized for the longest value, holding a hugging text anchored at its top (`"y": "0%", "y_anchor": "0%"`). - **Things that move together** (a card with its text, a photo with its scrim): one composition, with the keyframes or animation on the composition. Keyframes applied to separately anchored children shear apart mid-move. - **Layering**: every element visible at the same moment needs its own `track`. Two elements on one track play one after the other, and the second one silently replaces the first. - **Crop, window, round photo** (`overflow: hidden`): `"clip": true` on a composition, rounded by its `border_radius`; a round photo is an image inside a clipped composition whose radius is half its side. Elements are not clipped by default: an image scaled past 100 % spills out of its box. ### Custom fonts Add entries to the root `fonts` array: ```json { "fonts": [ { "family": "Coolvetica Rg", "weight": 400, "style": "normal", "source": "https://cdn.creatomate.com/demo/coolvetica-rg.otf" } ] } ``` - Formats: **OTF, TTF, WOFF**. `.woff2` is NOT supported — a woff2 URL fails with "Failed to load the font". - One file = one static font (variable fonts are not supported). `weight` defaults to 400, `style` to normal. Add one entry per weight/style you use (e.g. the italic: `coolvetica-rg-it.otf` with `"style": "italic"`). - Selection is an **exact match** (family case-insensitive + weight + style). A miss does not pick the nearest variant — it silently renders the default font (Aileron). Built-in families, by contrast, fall back to their own regular (400, upright) for a weight or style they lack. So list every variant you reference. - **Built-in families**: 1,100+ families ship with the engine; the full list with weights is "Built-in fonts" (https://creatomate.com/llms/built-in-fonts.md). A family that is not on that list is not built in, even if it is a common Google or system font, and renders as Aileron silently — check the list before naming a font, or add the font as a custom font. - `source` accepts a URL or an uploaded-asset ID (asset IDs work for every `source` in RenderScript: image, video, audio, and fonts). - Append fonts to an existing template via the `fonts.add` modification — see "Modifications". ### Animations Attach presets via the `animations` array: ```json { "animations": [ { "type": "fade", "time": 0, "duration": 1 } ] } ``` `time` is seconds from the element's own start (not from the video's: an element with `"time": 0.5` and an animation with `"time": 0.5` arrives at 1.0 s, so a delayed element's entrance has no `time` of its own), or `"start"`/`"end"` to stick to an edge; omitted = the animation spans the element. `easing` accepts the catalog below. **Presets and their options.** Options are extra keys on the animation object; each preset lists its roles, then its options with type, range and default. `fade` blends opacity during the effect and is on by default except for film-roll, stripe and the oscillators. Invalid option values and misspelled option names do not error — they silently fall back to the defaults shown, and only the rendered frame tells. - **`fade`** (entrance/exit, transition): as a transition, `enable` picks which side fades - `enable`: `both` \| `first-only` \| `second-only`; default `"both"` — transitions only: `first-only` fades the outgoing element out while the incoming one is already there, `second-only` fades the incoming element in over the still-visible outgoing one - **`scale`** (entrance/exit, transition): `scope: "element"` scales the element's own drawing inside its box, after its clip; the preset's own anchors set the scale origin. Start from `"1%"` rather than `"0%"` when `fade` is off - `scope`: `composition` \| `element`; default `"composition"` - `axis`: `x` \| `y` \| `both`; default `"both"` - `start_scale`: number; ≥ 0; default `"0%"` - `end_scale`: number; ≥ 0; default `"100%"` - `x_anchor`: number; default `"50%"` - `y_anchor`: number; default `"50%"` - `fade`: `true` \| `false`; default `true` - **`slide`** (entrance/exit, transition): `distance` is a share of the scope's extent along `direction` (`"100%"` = the full width or height in that direction, from off-box to place) - `scope`: `composition` \| `element`; default `"composition"` - `direction`: number; 0–360; default `"0°"` - `distance`: number; %; ≥ 0; default `"100%"` (a percentage of the scope's extent; px is not accepted) - `fixed`: `none` \| `first-only` \| `second-only`; default `"none"` — in a transition, `second-only` keeps the outgoing element still while the new one slides in - `fade`: `true` \| `false`; default `true` - **`rotate-slide`** (entrance/exit, transition) - `direction`: `left` \| `right` \| `up` \| `down`; default `"right"` - `rotation`: number; default `"90°"` - `clockwise`: `true` \| `false`; default `true` - `fade`: `true` \| `false`; default `true` - **`pan`** (scene) - `scope`: `composition` \| `element`; default `"composition"` - `start_scale`: number; default `"150%"` - `start_x`: number; default `"0%"` - `start_y`: number; default `"50%"` - `end_scale`: number; default `"150%"` - `end_x`: number; default `"100%"` - `end_y`: number; default `"50%"` - **`wipe`** (entrance/exit, transition): also accepts `direction` (a bearing) as a shorthand that sets both angles and starts the line at the near end - `x_anchor`: number; 0–100; default `"50%"` - `start_angle`: number; default `"0°"` - `end_angle`: number; default `"0°"` - `clip`: `both` \| `first-only` \| `second-only`; default `"both"` - `fade`: `true` \| `false`; default `true` - **`color-wipe`** (entrance/exit, transition) - `color`: color; default `"#e67e22"` - `direction`: number; 0–360; default `"0°"` - `hold`: number; 0–100; default `"0%"` — how long the colour covers everything between the sweep in and the sweep out - **`circular-wipe`** (entrance/exit, transition) - `ring_color`: color; default `"None"` - `ring_width`: number; ≥ 0; default `"50%"` - `scope`: `composition` \| `elements`; default `"composition"` - `x_anchor`: number; default `"50%"` - `y_anchor`: number; default `"50%"` - `clip`: `both` \| `first-only` \| `second-only`; default `"both"` - `fade`: `true` \| `false`; default `true` - **`film-roll`** (entrance/exit, transition) - `direction`: `left` \| `right` \| `up` \| `down`; default `"right"` - `repetitions`: number; ≥ 2; default `2` - `fade`: `true` \| `false`; default `false` - **`squash`** (entrance/exit, transition) - `direction`: `left` \| `right` \| `up` \| `down`; default `"right"` - `amount`: number; 0–100; default `"100%"` - `fade`: `true` \| `false`; default `true` - **`spin`** (entrance/exit, transition) - `scope`: `composition` \| `element`; default `"element"` - `rotation`: number; default `"180°"` - `x_anchor`: number; default `"50%"` - `y_anchor`: number; default `"50%"` - `fade`: `true` \| `false`; default `true` - **`stripe`** (entrance/exit, transition) - `slices`: number; 1–100; default `10` - `overlap`: number; 0–100; default `"25%"` — the stagger: `100%` starts all bands together, `0%` runs them strictly one after another - `direction`: number; 0–360; default `"0°"` — the reveal crossing to the right at `0°`; the bands stand across it and fill a quarter turn clockwise from it - `fade`: `true` \| `false`; default `false` - **`flip`** (entrance/exit, transition) - `x_rotation`: number; default `"0°"` - `y_rotation`: number; default `"180°"` - `x_anchor`: number; default `"50%"` - `y_anchor`: number; default `"50%"` - `backface_visible`: `true` \| `false`; default `false` - `fade`: `true` \| `false`; default `true` - **`shake`** (scene): an oscillator: prefer `count` over `frequency`, which is not a true clock across different element lengths - `count`: number; ≥ 0.01; default `3` - `frequency`: number; Hz; ≥ 0.01; no fixed default — the rate is `count ÷ duration` unless `frequency` is given alone; when both are given, `count` wins - `phase`: number; default `"0°"` - `ramp`: `both` \| `decay`; default `"both"` — `decay` starts at full swing and settles, which makes the oscillator an arrival - `ramp_duration`: number; %; 0 to `count` × 50 (150 at the default count); default `"100%"` - `randomness`: number; 0–100; default `"0%"` — deterministic per element - `fade`: `true` \| `false`; default `false` - `direction`: number; 0–360; default `"0°"` - `distance`: number; ≥ 0; default `"5%"` - **`bounce`** (scene): an oscillator: prefer `count` over `frequency`, which is not a true clock across different element lengths - `count`: number; ≥ 0.01; default `3` - `frequency`: number; Hz; ≥ 0.01; no fixed default — the rate is `count ÷ duration` unless `frequency` is given alone; when both are given, `count` wins - `phase`: number; default `"0°"` - `ramp`: `both` \| `decay`; default `"both"` — `decay` starts at full swing and settles, which makes the oscillator an arrival - `ramp_duration`: number; %; 0 to `count` × 50 (150 at the default count); default `"100%"` - `randomness`: number; 0–100; default `"0%"` - `fade`: `true` \| `false`; default `false` - `axis`: `x` \| `y` \| `both`; default `"both"` - `scale`: number; ≥ 0; default `"20%"` - `x_anchor`: number; default `"50%"` - `y_anchor`: number; default `"50%"` - **`wiggle`** (scene): an oscillator: prefer `count` over `frequency`, which is not a true clock across different element lengths - `count`: number; ≥ 0.01; default `3` - `frequency`: number; Hz; ≥ 0.01; no fixed default — the rate is `count ÷ duration` unless `frequency` is given alone; when both are given, `count` wins - `phase`: number; default `"0°"` - `ramp`: `both` \| `decay`; default `"both"` — `decay` starts at full swing and settles, which makes the oscillator an arrival - `ramp_duration`: number; %; 0 to `count` × 50 (150 at the default count); default `"100%"` - `randomness`: number; 0–100; default `"0%"` - `fade`: `true` \| `false`; default `false` - `x_rotation`: number; default `"0°"` - `y_rotation`: number; default `"0°"` - `z_rotation`: number; default `"5°"` - `x_anchor`: number; default `"50%"` - `y_anchor`: number; default `"50%"` - **`shift`** (scene): content scrolls through the element, clipped, and wraps around: a dynamic value repeats in time, not side by side - `direction`: `left` \| `right` \| `up` \| `down`; default `"right"` - `repetitions`: number; ≥ 1; default `"1"` - **`text-appear`** (entrance/exit, text): its easing shapes the stagger, not each letter's motion; use `linear` for a plain fade - `split`: `line` \| `word` \| `letter`; default `"word"` - `overlap`: number; 0–100; default `"25%"` - `order`: `default` \| `reversed` \| `random`; default `"default"` - `fade`: `true` \| `false`; default `true` - `start_opacity`: number; 0–100; default `"0%"` - `background_effect`: `disabled` \| `animated`; default `"disabled"` - **`text-scale`** (entrance/exit, text): still staggers per split with `scope: "element"`; a whole-block pop is `x_scale`/`y_scale` keyframes - `split`: `line` \| `word` \| `letter`; default `"word"` - `overlap`: number; 0–100; default `"25%"` - `order`: `default` \| `reversed` \| `random`; default `"default"` - `fade`: `true` \| `false`; default `true` - `axis`: `x` \| `y` \| `both`; default `"both"` - `start_scale`: number; ≥ 0; default `"0%"` - `scope`: `element` \| `text` \| `split`; default `"split"` - `x_anchor`: number; default `"50%"` - `y_anchor`: number; default `"50%"` - **`text-slide`** (entrance/exit, text): moves each split by a share of its own width; `scope: "split-clip"` slides each unit inside its own clip (a rolling number) - `split`: `line` \| `word` \| `letter`; default `"word"` - `overlap`: number; 0–100; default `"25%"` - `order`: `default` \| `reversed` \| `random`; default `"default"` - `fade`: `true` \| `false`; default `true` - `scope`: `composition` \| `element` \| `split` \| `split-clip`; default `"composition"` - `direction`: number; 0–360; default `"0°"` - `distance`: number; default `"100%"` - `background_effect`: `disabled` \| `sliding` \| `scaling` \| `scaling-clip`; default `"disabled"` - **`text-reveal`** (entrance/exit, text): a clip-box reveal along `axis` - `split`: `line` \| `word` \| `letter`; default `"word"` - `overlap`: number; 0–100; default `"25%"` - `order`: `default` \| `reversed` \| `random`; default `"default"` - `axis`: `x` \| `y` \| `both`; default `"both"` - `x_anchor`: number; 0–100; default `"50%"` - `y_anchor`: number; 0–100; default `"50%"` - **`text-fly`** (entrance/exit, text): flies the splits in with perspective - `split`: `line` \| `word` \| `letter`; default `"word"` - `overlap`: number; 0–100; default `"25%"` - `order`: `default` \| `reversed` \| `random`; default `"default"` - `fade`: `true` \| `false`; default `true` - `perspective`: number; 0–100; default `"100%"` - `x_anchor`: number; default `"50%"` - `y_anchor`: number; default `"50%"` - **`text-spin`** (entrance/exit, text) - `split`: `line` \| `word` \| `letter`; default `"word"` - `overlap`: number; 0–100; default `"25%"` - `order`: `default` \| `reversed` \| `random`; default `"default"` - `fade`: `true` \| `false`; default `true` - `direction`: number; 0–360; default `"0°"` - `distance`: number; default `"100%"` - `rotation`: number; -1080–1080; default `"360°"` - `x_anchor`: number; default `"50%"` - `y_anchor`: number; default `"50%"` - **`text-wave`** (entrance/exit, text): `speed` in Hz (`frequency` still reads as the released alias) - `split`: `line` \| `word` \| `letter`; default `"word"` - `overlap`: number; 0–100; default `"25%"` - `order`: `default` \| `reversed` \| `random`; default `"default"` - `fade`: `true` \| `false`; default `true` - `distance`: number; default `"25%"` - `speed`: number; ≥ 0; default `"0.5 Hz"` - `wavelength`: number; ≥ 1; default `"100%"` - **`text-counter`** (entrance/exit, text): animates the digit run in the text and rewrites every number in the value while it runs, so keep it off dynamic values whose digits must not change - `direction`: `up` \| `down`; default `"up"` - `counting_start`: number; seconds inside the animation's own window, 0 to its duration; default `0` - `counting_duration`: number; seconds, 0 to the animation's duration; default `0` — `0` = the whole span - **`text-typewriter`** (entrance/exit, text): the cursor is still blinking at `snapshot_time` unless `cursor_frequency: 0` - `typing_start`: number; seconds inside the animation's own window, 0 to its duration; default `0.5` - `typing_duration`: number; seconds, 0 to the animation's duration; default `0` — `0` = the whole span - `cursor_frequency`: number; ≥ 0; default `"2 Hz"` **Exits — the classic trap:** presets animate from effect to normal, i.e. they are **entrances** by default. For an exit, set `"reversed": true` (typically with `"time": "end"`): a fade-out is `{ "type": "fade", "time": "end", "duration": 1, "reversed": true }`. Without `reversed` the element sits in its effect state the whole time and animates IN at the very end. The `text-*` presets are entrances only: pair them with a generic reversed preset (e.g. reversed `fade`) for the exit. **Transitions:** `"transition": true` on an animation of the SECOND of two consecutive same-track elements turns it into a transition between them. The overlap **subtracts** from the timeline — the video gets shorter. Presets flagged for transitions: fade, scale, slide, rotate-slide, wipe, color-wipe, circular-wipe, film-roll, squash, spin, stripe, flip. **Angles**: `0°` points right and degrees run counterclockwise, so up is 90, left is 180, down is 270. `direction` is a bearing (0–360), `axis` the same angle unsigned (0–180), `angle` where a line sits, `rotation` how far something turns. Degrees and `left`/`right`/`up`/`down` are interchangeable. **Easings:** `linear`; `elastic-`, `bounce-`, `sinusoid-`, `quadratic-`, `cubic-`, `quartic-`, `quintic-`, `exponential-`, `circular-`, `back-` each with `-in`, `-out`, `-in-out`; `steps(n)`; `cubic-bezier(x1,y1,x2,y2)`. The default is `quadratic-in-out`. A `cubic-bezier` whose y values leave 0–1 is honoured (overshoot and anticipation). Caution: a misspelled easing name does NOT error — it silently falls back to `linear`. Animation type names, by contrast, fail loudly with the full valid list. **Direct keyframes** on a property: ```json { "x_rotation": [ { "time": 0, "value": "0°" }, { "time": 2, "value": "360°", "easing": "cubic-in-out" } ] } ``` Easing is per segment and belongs to the keyframe being **approached** — `easing` on a keyframe shapes the movement TO it (as in the example above); an easing on the first keyframe of a pair has no effect. See the structural exceptions in "Properties on all elements". A keyframe given a `name` and `dynamic: true` becomes modifiable per render — see "Modifications". ### Preset behaviour worth knowing - `scale` with `scope: "element"` scales the element's own drawing inside its box, after the element's clip (an image with `"clip": true` grows in place: the Ken Burns move); `scope: "composition"` scales the whole composition. The preset's own `x_anchor`/`y_anchor` set the scale origin independently of the element's anchors. `x_scale`/`y_scale` keyframes scale the element's box itself. Two scale animations on one element multiply. Start from `"1%"` rather than `"0%"` when `fade` is off; a zero scale blanked frames on older engines. - `slide`: `distance` is a share of the scope's extent along `direction` (`"100%"` travels the element's full width or height in that direction, from off-box to place); in a transition, `fixed: "second-only"` keeps the outgoing element still while the new one slides in. - **Entrance and exit presets fade by default** (`fade: true`): a `scale` or `slide` needs no `fade` beside it, and a preset that spans an element's whole life, such as the slow zoom on a photo, needs `"fade": false` or the element fades in over the entire video. - `bounce-out` touches down at 36% of its segment and rebounds at 73% and 91%: time a reaction (a shadow, a floor kick) to those moments, not to the end. - The oscillators' `frequency` is not a true clock across different element lengths; prefer `count`. A constant rate at any length is a linear keyframe pair far past the element's end ("Units & properties on all elements", keyframe rules). - **Text presets always split the value first** (`split`, default word). `text-scale` with `scope: "element"` still staggers per split, word by word at the default; a whole-block pop is `x_scale`/`y_scale` keyframes or the plain `scale` preset. `overlap: "100%"` starts every split together. `text-appear`'s easing shapes the stagger, not each letter's motion (use `linear` for a plain fade). `text-slide` moves each split by a share of its own width. `text-counter` rewrites every digit run in the value while it runs, so keep it off dynamic values whose digits must not change. `text-typewriter`'s cursor is still blinking at `snapshot_time` unless `cursor_frequency: 0`. ### How it is used **Motion** - **Rise and fade in** (the entrance most designs use): keyframes on `y` and `opacity` to the settled position, `quartic-out` for a crisp arrival, `cubic-out` for a soft one. ```json { "y": [ { "time": 0, "value": "58%" }, { "time": 0.5, "value": "54%", "easing": "quartic-out" } ], "opacity": [ { "time": 0, "value": "0%" }, { "time": 0.5, "value": "100%", "easing": "quartic-out" } ] } ``` Keyframe values are absolute positions, never offsets. Inside a flow use `{ "type": "slide", "direction": 90, "distance": "30%", "duration": 0.5, "easing": "quartic-out" }` instead. - **Pop, stamp, settle**: `x_scale`/`y_scale` keyframes landing at `"100%"`. Pop: from 60–80 % with `back-out`. Stamp: from 130 % with `back-out` and a fade. Settle: 106 % → 100 % with `quartic-out`. The anchors are the pivot. - **A hold before a move**: repeat the value at the moment the move starts. A value interpolates over the whole gap between two keyframes, so without a hold a one-second move spreads over the element's whole life. - **A rule, bar or progress line drawing itself**: `x_scale` from `"0%"` to `"100%"` with `"x_anchor": "0%"` at the edge it grows from; for a ring or a scribble, `stroke_end` keyframes from `"0%"` on a shape path. - **Constant rotation or scroll at any length**: one linear pair far past the element's end, e.g. `"z_rotation": [ { "time": 0, "value": "0°" }, { "time": 1000, "value": "360000°", "easing": "linear" } ]`. Keyframes past the end keep interpolating, so the rate holds however long the element runs. - **A stagger**: the same keyframes on each element, shifted by 0.1–0.2 s per element. For letters and words use the split text presets instead. - **A drop with a bounce**: `y` keyframes with `bounce-out`. It touches down at 36 % of the segment and rebounds at 73 % and 91 %, so time a shadow or a floor reaction to those moments. - **Flip, fold, flap**: real 3D is `y_rotation` or `x_rotation` with `perspective` and `backface_visible`; the flat version is `y_scale` keyframes with `y_anchor` at the hinge. Two-faced cards are two compositions, one per face. - **An exit**: a reversed preset at the end, `{ "type": "fade", "time": "end", "duration": 0.5, "reversed": true }`. **Text effects** - **Words landing one by one**: `{ "type": "text-slide", "split": "word", "direction": "up", "duration": 1 }`. Letters typing: `text-typewriter`. A phrase wiping on: `text-reveal` along the x axis. Each text preset splits the value first, so a whole-block pop is `x_scale`/`y_scale` keyframes, not `text-scale`. - **A number counting up** (static numbers only): `text-counter`. It rewrites every digit run in the value while it runs, so keep it off dynamic values whose digits must not change. - **Captions that follow a voice**: `transcript_source` naming the audio element plus `transcript_effect` (`highlight` draws a chip behind the spoken word, `karaoke` fills progressively, `color` tints the current word); `transcript_maximum_length` sets how much text one page shows (`1` = one word at a time). No keyframes and no second animation on top: "Subtitles". ### Modifications The `modifications` object is the automation contract — the same language the editor bridge speaks. Keys select elements/properties; values set them. The template is a kit's or the user's own ("Template kits: the short list"); modifications change it per render, and the property names are the RenderScript reference's: `"Title.fill_color"` recolours a text, `"Logo.x_alignment"` re-anchors it. Look a name up ("Units & properties on all elements" and the element-type pages) rather than guess it. | Key form | Effect | |---|---| | `"Name": value` | set the element's main property: text → `text`, image/video/audio → `source` (on a TTS audio element that is the spoken text), shape → `fill_color`, composition → `elements` | | `"Name.property": value` | set any property; nested paths allowed, numeric segments index arrays | | `"Name.text.Placeholder": value` | fill a `{{Placeholder}}`; a bare `"Placeholder": value` fills it in every text element that carries it | | `"Name.property.Keyframe": value` | set a named dynamic keyframe's value; also `.value`, `.time`, `.duration`. Keyframe names match case-SENSITIVELY (unlike element names) | | `"Name.property.add": item or [items]` | append to any array property: `"fonts.add"`, `"Name.animations.add"`, `"Comp.add"` (append child elements) | | `"Comp.elements": [ …element JSON… ]` | replace a composition's children — THE mechanism for render-time-variable scene counts (code/n8n only, never Zapier/Make) | | `"Name": {}` | remove the element | | `"width"`, `"duration"`, … | root properties as bare keys (applied when no element matches the name) | | `"Name": null` / `"Name.property": null` | restore automatic behavior (un-pin) | Rules and gotchas: - Values may be URLs, uploaded-asset IDs, or AI prompts (with the element's `provider` set — see "AI generation providers"). - **Matching:** by `id` first, then by `name` (case- and accent-insensitive), then by bare type (`"text"`, `"image"`) — but type matching only hits elements that have NO name. Every match is modified, at any depth — so duplicated names get hit together. Prefer unique names. - **`{{ }}` substitution happens only inside a text element's `text` value.** The engine never substitutes placeholders in a `source`, a color, or any other property — there the braces pass through as literal text. To drive any other property per render, set it with a modification key (`"Name.fill_color": value`), or mark the property dynamic (`dynamic: ["text", "fill_color"]` — see "Units & properties on all elements") so it appears as a fillable field in feeds/Zapier/forms. - **Fully per-render text is not a placeholder case:** give the element realistic sample text and set `dynamic: true` — the design auto-fits against real text, a missing value renders the sample instead of an empty string, and `"Name": value` targets it directly. Use `{{ }}` when static and dynamic text mix ("Hello {{name}}!"). - **Placeholder protection:** `"Name": value` on a text element whose text contains `{{placeholders}}` is silently IGNORED — target the placeholder (`"Name.text.Placeholder": value`) instead. - A `{{Placeholder|default}}` cannot be blanked: an empty-string value falls back to the default. - **Type switching:** setting a video URL on an image slot converts the element to video (and vice versa), sniffed by extension: jpg/jpeg/png/svg ↔ mp4/webm; mp3 = audio. Other extensions or extension-less URLs do NOT switch the type — the element stays what it was, silently. - When building element arrays (`Comp.elements`, `Comp.add`) or duplicating scenes in raw RenderScript: give every copy a unique `name` and OMIT `id` fields — the engine assigns fresh ids, and name collisions make modifications hit all copies at once. - Pass children as arrays of full element JSON via `Comp.elements`/`Comp.add` — never as nested objects keyed by child names. **One value in several places** (a name on a card and again in a footer): give every copy the same `name` and mark them all `dynamic`; a modification by name fills them all. Watch for unintended matches: any element with that name is filled. ## Reading validation errors **Invalid values fail loud and self-describing.** The parser names the element and property and states what it expects: - `Text-1.x_padding: Expected a number ending with px or vw or vh or vmin or vmax` - `…: Expected a number between 0 and 100` / `…at least 0` - `…: Expected one of these values: …` (enum errors list every valid value) - `…duration: expected a number or the value 'media'` — time properties similarly accept `'end'` - `Property 'x' does not support keyframes` — you keyframed a structural property - `Shouldn't be null or undefined` — this property cannot be null. In a stored source, give it a value. As a modification, `null` means "restore automatic" and only works where an automatic state exists: image and video `width`/`height` are nullable (`null` sizes the element by the asset's own proportions), `opacity` and `x_scale` are not. **Invented NAMES fail silently.** Parsing reads only known properties: an unknown property name is ignored — no error, the render succeeds, nothing changes. The same holds for modifications keyed to nonexistent element or property names, and for misspelled easing names (silent `linear`). The heuristic: **no effect + no error = the name doesn't exist.** Example: `text_shadow` does nothing; the real properties are `shadow_color`, `shadow_blur`, `shadow_x`, `shadow_y`. A real property on the wrong element TYPE is silently inert the same way: `animations` on an audio element (visual-only — fade audio with `audio_fade_in`/`audio_fade_out`) and `fill_color` on an image/video (use `color_overlay`). So the full heuristic: no effect + no error = the name doesn't exist **or doesn't apply to this element type**. **Where validation happens.** Three surfaces catch problems, all speaking the messages above: - **Saving a template** (`POST`/`PATCH /v2/templates`) rejects invalid values with a **400** and returns `warnings` for parts it will ignore (unknown keys, and the like). A save that returns 400 changed nothing; fix the values and retry. - **Creating a render** returns advisory `errors`/`warnings` in the response but still queues (see "Quick start") — so read them. - **`dry_run: true`** is the explicit pre-flight: validate the effective source (template + modifications) with no render, no credits (see "Quick start"). Use it before a real render, especially for raw RenderScript or name-based modifications. Two gaps remain your job to catch by reading this reference: **invented names still fail silently** (above — validation reports them as warnings on the render path, but never as errors), and **`dry_run` checks validity, not whether it looks right** (that is the visual self-check: "Seeing a render") or whether assets and provider keys resolve. ## AI generation providers The user's own provider keys power AI generation, configured at **Project Settings → Integration**. Defer provider setup until after the first stock-asset render — a missing key is one of the most common render failures, and it interrupts the first win. - **ElevenLabs speech** (recommended default): on an audio element, `provider: "elevenlabs model_id=eleven_multilingual_v2 voice_id=XrExE9yKIg1WjnnlVkGX"` with `source` set to the words to speak (that `voice_id` is the stock default voice; swap it for the user's chosen voice). Includes word-level timestamps, so subtitles generate without a transcription step. - **OpenAI speech**: `provider: "openai model=tts-1-hd voice='alloy'"` with `source` set to the words to speak. - **Stability AI images**: on an image element, `provider: "stabilityai model=sic"` with `source` set to the text prompt. `model` is required: `sic` (Stable Image Core, the editor's default), `sd3` or `sd3-turbo` (Stable Diffusion 3). Options: `aspect_ratio=` one of `16:9`, `1:1` (default), `21:9`, `2:3`, `3:2`, `4:5`, `5:4`, `9:16`, `9:21`; with `sic` also `style_preset=` one of `3d-model`, `analog-film`, `anime`, `cinematic`, `comic-book`, `digital-art`, `enhance`, `fantasy-art`, `isometric`, `line-art`, `low-poly`, `modeling-compound`, `neon-punk`, `origami`, `photographic`, `pixel-art`, `tile-texture`. - **OpenAI images**: on an image element, `provider: "openai model=gpt-image-2"` with `source` set to the text prompt. `model` is required: `gpt-image-2` (the editor's default), `gpt-image-1.5`, `gpt-image-1` or `gpt-image-1-mini`; `dall-e-2` and `dall-e-3` are discontinued and fail the render. Options: `size=WIDTHxHEIGHT` (default `1024x1024`; `gpt-image-2` also takes `auto`, `1536x1024`, `1024x1536`, `2048x2048`, `2048x1152`, `1152x2048`, `3840x2160`, `2160x3840`, or any size whose edges are multiples of 16, at most 3840 px, within 3:1 of each other and 655,360 to 8,294,400 pixels in total; the other models take `1024x1024`, `1536x1024` or `1024x1536`) and `quality=low|medium|high` (default `medium`; `auto` on `gpt-image-2` only). Example: `"provider": "openai model=gpt-image-2 size=1024x1536 quality=high"`. If a render fails with a missing-key error, the fix is Project Settings → Integration — or keep momentum with a stock voiceover / an audio file URL (see "Stock assets") and configure the provider afterward. ## Recipes One canonical shape per job — copy, then adjust. **1. Multi-scene voiceover video (the faceless pattern):** ``` root (mp4, 1080×1920) ├─ track 1: Scene-1 (composition) │ ├─ track 1: Background-1 video, duration: null, loop: true │ ├─ track 2: Voiceover-1 audio (drives the scene length) │ └─ track 3: Subtitle-1 text, transcript_source: "Voiceover-1" ├─ track 1: Scene-2 (same shape: Background-2, Voiceover-2, Subtitle-2 → "Voiceover-2") └─ track 2: Music-1 audio, duration: null, loop: true, volume: "20%" ``` The music bed at `volume: "20%"` under voiceovers is the canonical balance. **2. Fixed-length slideshow:** composition with a fixed `duration`; each image `duration: "1 fr"`; images after the first carry `{"type": "fade", "transition": true, "duration": 0.5}`. Remember transitions shorten the total. **3. Text-only scene:** give the composition an explicit `duration` (e.g. 4) — otherwise it renders as exactly 5 seconds (the fallback). **4. Voiceover-driven slides:** per scene, an image (`duration: null`) + a voiceover + a subtitle wired to that scene's voiceover. Each scene lasts exactly as long as its narration. **5. Logo watermark:** an image element on a top-level track above the scenes with `time: 0, duration: null` (spans everything), `fit: "contain"`, positioned via `x`/`y` (e.g. `"85%"`/`"8%"`), `width: "20%"`, `opacity: "60%"`. **6. Dynamic scene count (code/n8n only — never Zapier/Make):** a template with a `Slides` composition; your code builds an array of scene JSON and sends `{"Slides.elements": [ … ]}`. Unique names per scene, no `id` fields. **7. Caption-driven audiogram (no waveform exists — this is the answer):** ```json { "output_format": "mp4", "width": 1080, "height": 1920, "elements": [ { "type": "composition", "name": "Scene-1", "track": 1, "elements": [ { "type": "image", "name": "Cover-1", "track": 1, "source": "https://cdn.creatomate.com/demo/image1.jpg" }, { "type": "audio", "name": "Audio-1", "track": 2, "source": "https://cdn.creatomate.com/demo/voiceover-1.mp3" }, { "type": "text", "name": "Subtitle-1", "track": 3, "transcript_source": "Audio-1", "transcript_effect": "karaoke", "y": "75%", "width": "85%", "fill_color": "#ffffff" } ] } ] } ``` The audio's `"media"` duration drives the clip length; captions transcribe from it automatically. Swap the two sources for the user's episode audio and cover image (uploaded in the editor or hosted anywhere). **8. Left-margin text block (title or caption with a 7% left margin):** ```json { "type": "text", "name": "Title-1", "track": 2, "x": "7%", "x_anchor": "0%", "y": "75%", "y_anchor": "0%", "width": "70%", "height": "12%", "fill_color": "#ffffff" } ``` The `0%` anchors make `x`/`y` mean the top-left corner; without them they place the element's center, and `"x": "7%"` hangs the box half off the canvas. ### Layout blocks One complete source per frame shape, rendered and checked at the settled frame and mid-arrival. Each is a scene on its own: copy it, put the user's content in the named slots (every slot is `dynamic`), keep or change the arrival, and check it before rendering. A scene of a longer video is one of these dropped into a composition; the structure recipes above say how the scenes line up, and the "How it is used" sections of the element pages hold the smaller moves. Every size is in `vmin` or `%`, so another format is a change of `width`/`height` only; the format each was checked at is named. Two facts these rely on: a text with `"width": null` hugs its line and one with `"width": "100%"` wraps inside its parent, and **entrance presets fade by default** (`fade: true` on `scale`, `slide` and the rest), so no separate `fade` is needed next to them, and a preset that spans an element's whole life (the slow zoom on a photo) needs `"fade": false` or the photo fades in over the entire video. **Story frame (9:16)** A full-bleed photo with a slow zoom, a gradient scrim over the lower half, and a copy stack that rises into place at bottom-left. Slots: `Photo`, `Kicker`, `Headline`, `Subline`. - The copy is one hugging flow composition anchored at its bottom-left corner (`x_anchor: 0%`, `y_anchor: 100%`), so a longer headline grows the stack upward and the subline never leaves the frame; the scrim's 55 % covers up to four headline lines. - The stack's `slide` at `direction: 90` comes from below; its fade is the preset's own. The photo's zoom carries `"fade": false`. ```json { "output_format": "mp4", "width": 1080, "height": 1920, "frame_rate": 30, "duration": 4, "snapshot_time": 3, "elements": [ { "type": "image", "name": "Photo", "track": 1, "fit": "cover", "dynamic": true, "source": "https://cdn.creatomate.com/demo/mountain-ridge-v.jpg", "animations": [ { "type": "scale", "scope": "element", "start_scale": "100%", "end_scale": "108%", "easing": "linear", "fade": false } ] }, { "type": "shape", "name": "Scrim", "track": 2, "x": "50%", "y": "100%", "y_anchor": "100%", "width": "100%", "height": "55%", "path": "M 0 0 L 100 0 L 100 100 L 0 100 Z", "fill_mode": "linear", "fill_x0": "50%", "fill_y0": "0%", "fill_x1": "50%", "fill_y1": "100%", "fill_color": [ { "offset": "0%", "color": "rgba(0,0,0,0)" }, { "offset": "100%", "color": "rgba(0,0,0,0.75)" } ] }, { "type": "composition", "name": "Copy", "track": 3, "x": "8%", "y": "92%", "x_anchor": "0%", "y_anchor": "100%", "width": "84%", "height": null, "flow_gap": "2 vmin", "animations": [ { "type": "slide", "duration": 0.6, "direction": 90, "distance": "20%", "easing": "quartic-out" }, { "type": "fade", "duration": 0.6 } ], "elements": [ { "type": "text", "name": "Kicker", "y": null, "width": "100%", "height": null, "dynamic": true, "text": "NEW ROUTE", "font_size": "3.2 vmin", "font_weight": "700", "letter_spacing": "200%", "fill_color": "#ffd166" }, { "type": "text", "name": "Headline", "y": null, "width": "100%", "height": null, "dynamic": true, "text": "The ridge walk that\nchanged our weekend", "font_size": "7.5 vmin", "font_weight": "800", "line_height": "105%", "fill_color": "#ffffff" }, { "type": "text", "name": "Subline", "y": null, "width": "100%", "height": null, "dynamic": true, "text": "12 km, 900 m of climbing, one view you will not forget", "font_size": "4 vmin", "line_height": "125%", "fill_color": "#e6e6e6" } ] } ] } ``` **Centred card (1:1, the post)** A photo or colour behind a white card that pops in with a kicker, a title, a body and a button. Slots: `Background`, `Kicker`, `Title`, `Body`, `CTA`. - The card hugs its content (`height: null`) and stays centred because its `y` is the default 50 %; more body text makes it taller, not longer than the frame. `x_alignment: 50%` centres the lines inside their 100 % boxes. - The button is a text with a background: `width: null` hugs the label, and it sits centred because a flow child's cross-axis `x` defaults to 50 %. `color_overlay` on the photo is the tint that keeps the card readable on any picture. ```json { "output_format": "mp4", "width": 1080, "height": 1080, "frame_rate": 30, "duration": 4, "snapshot_time": 3, "fill_color": "#0f172a", "elements": [ { "type": "image", "name": "Background", "track": 1, "fit": "cover", "dynamic": true, "source": "https://cdn.creatomate.com/demo/coffee-beans-h.jpg", "color_overlay": "rgba(15,23,42,0.6)" }, { "type": "composition", "name": "Card", "track": 2, "width": "76%", "height": null, "x_padding": "6 vmin", "y_padding": "6 vmin", "fill_color": "#ffffff", "border_radius": "3 vmin", "flow_gap": "2.5 vmin", "animations": [ { "type": "scale", "duration": 0.5, "start_scale": "80%", "easing": "back-out" } ], "elements": [ { "type": "text", "name": "Kicker", "y": null, "width": "100%", "height": null, "x_alignment": "50%", "dynamic": true, "text": "THIS WEEK", "font_size": "3 vmin", "font_weight": "700", "letter_spacing": "200%", "fill_color": "#b45309" }, { "type": "text", "name": "Title", "y": null, "width": "100%", "height": null, "x_alignment": "50%", "dynamic": true, "text": "Single-origin\nEthiopia is back", "font_size": "7 vmin", "font_weight": "800", "line_height": "105%", "fill_color": "#111111" }, { "type": "text", "name": "Body", "y": null, "width": "100%", "height": null, "x_alignment": "50%", "dynamic": true, "text": "Washed Yirgacheffe, roasted on Tuesday. Notes of bergamot, peach and black tea.", "font_size": "3.6 vmin", "line_height": "130%", "fill_color": "#444444" }, { "type": "text", "name": "CTA", "y": null, "width": null, "height": null, "dynamic": true, "text": "Order today", "font_size": "3.6 vmin", "font_weight": "700", "fill_color": "#ffffff", "background_color": "#b45309", "background_x_padding": "70%", "background_y_padding": "45%", "background_border_radius": "50%" } ] } ] } ``` **Lower third (16:9)** A translucent box with an accent bar, a name and a role, sliding in from the left half a second in and staying. Slots: `Clip`, `Name`, `Role`. - A horizontal flow (accent bar, then a vertical stack) that hugs in both directions: `width: null` on the box and on the texts, so a long name widens the box instead of wrapping (add `max_width` to `Name` to cap it). The accent's fixed height is what sets the box height; `y: 50%` on both children centres them on it. - `time: 0.5, duration: null` = appears at half a second and lasts to the end. `direction: 0` on the `slide` is from the left; the distance is a share of the frame width. ```json { "output_format": "mp4", "width": 1920, "height": 1080, "frame_rate": 30, "duration": 5, "snapshot_time": 3, "elements": [ { "type": "video", "name": "Clip", "track": 1, "fit": "cover", "duration": null, "dynamic": true, "source": "https://cdn.creatomate.com/demo/city-h.mp4" }, { "type": "composition", "name": "Lower-Third", "track": 2, "time": 0.5, "duration": null, "x": "6%", "y": "90%", "x_anchor": "0%", "y_anchor": "100%", "width": null, "height": null, "flow_direction": "horizontal", "flow_gap": "2.5 vmin", "x_padding": "3 vmin", "y_padding": "2.2 vmin", "fill_color": "rgba(0,0,0,0.55)", "border_radius": "1 vmin", "animations": [ { "type": "slide", "duration": 0.6, "direction": 0, "distance": "60%", "easing": "quartic-out" } ], "elements": [ { "type": "shape", "name": "Accent", "x": null, "y": "50%", "width": "1.2 vmin", "height": "10 vmin", "path": "M 0 0 L 100 0 L 100 100 L 0 100 Z", "fill_color": "#ffd166" }, { "type": "composition", "name": "Stack", "x": null, "y": "50%", "width": null, "height": null, "flow_gap": "0.8 vmin", "elements": [ { "type": "text", "name": "Name", "y": null, "width": null, "height": null, "dynamic": true, "text": "Maria Okafor", "font_size": "5 vmin", "font_weight": "800", "fill_color": "#ffffff" }, { "type": "text", "name": "Role", "y": null, "width": null, "height": null, "dynamic": true, "text": "Head of Product, Northwind", "font_size": "3.2 vmin", "fill_color": "#e6e6e6" } ] } ] } ] } ``` **Split (1:1)** A photo beside a copy column, as one horizontal flow cut into two equal halves. Slots: `Photo`, `Kicker`, `Title`, `Body`, `Price`, `Chip`. - `flow_stretch: "1 fr"` on both children shares the width; `2 fr` and `1 fr` give the photo two thirds. The photo fills its half (`height: 100%`, `fit: cover`, `clip` with `border_radius`); the column hugs and is centred on the photo by `y: 50%`. - The price row is a nested horizontal flow whose children carry `y: 50%`, which is what lines the chip up with the price. `x: null` on the column's children is the flow's start alignment, so the text is left-aligned. ```json { "output_format": "mp4", "width": 1080, "height": 1080, "frame_rate": 30, "duration": 4, "snapshot_time": 3, "fill_color": "#f5f0e8", "elements": [ { "type": "composition", "name": "Split", "track": 1, "width": "88%", "height": "80%", "flow_direction": "horizontal", "flow_gap": "5 vmin", "elements": [ { "type": "image", "name": "Photo", "x": null, "y": "50%", "width": null, "height": "100%", "flow_stretch": "1 fr", "fit": "cover", "clip": true, "border_radius": "3 vmin", "dynamic": true, "source": "https://cdn.creatomate.com/demo/coffee-bag-h.jpg", "animations": [ { "type": "scale", "duration": 0.6, "start_scale": "94%", "easing": "quartic-out" } ] }, { "type": "composition", "name": "Copy", "x": null, "y": "50%", "width": null, "height": null, "flow_stretch": "1 fr", "flow_gap": "2 vmin", "animations": [ { "type": "slide", "time": 0.2, "duration": 0.6, "direction": 180, "distance": "20%", "easing": "quartic-out" } ], "elements": [ { "type": "text", "name": "Kicker", "x": null, "y": null, "width": "100%", "height": null, "dynamic": true, "text": "NEW ARRIVAL", "font_size": "3 vmin", "font_weight": "700", "letter_spacing": "200%", "fill_color": "#b45309" }, { "type": "text", "name": "Title", "x": null, "y": null, "width": "100%", "height": null, "dynamic": true, "text": "Ethiopia\nYirgacheffe", "font_size": "7 vmin", "font_weight": "800", "line_height": "105%", "fill_color": "#1a1a1a" }, { "type": "text", "name": "Body", "x": null, "y": null, "width": "100%", "height": null, "dynamic": true, "text": "Washed, light roast. Bergamot, peach and black tea. 250 g whole bean.", "font_size": "3.4 vmin", "line_height": "130%", "fill_color": "#444444" }, { "type": "composition", "name": "Price-Row", "x": null, "y": null, "width": null, "height": null, "flow_direction": "horizontal", "flow_gap": "2 vmin", "elements": [ { "type": "text", "name": "Price", "x": null, "y": "50%", "width": null, "height": null, "dynamic": true, "text": "€ 14,50", "font_size": "5.5 vmin", "font_weight": "800", "fill_color": "#1a1a1a" }, { "type": "text", "name": "Chip", "x": null, "y": "50%", "width": null, "height": null, "dynamic": true, "text": "Save 15%", "font_size": "3 vmin", "font_weight": "700", "fill_color": "#ffffff", "background_color": "#b45309", "background_x_padding": "60%", "background_y_padding": "40%", "background_border_radius": "50%" } ] } ] } ] } ] } ``` **End card (9:16)** A dark ground with a radial glow, a centred stack of logo, headline, subline and a link button, and a fade to black at the end. Slots: `Logo`, `Headline`, `Subline`, `CTA`. - The logo box is `width: 44%`, `height: 8 vmin`, `fit: contain`, so a logo of any aspect fits without being stretched. The glow is a shape with `fill_mode: radial`; the stack pops in with one `scale`. - The `End-Fade` shape carries an unreversed `fade` preset at `"time": "end"`: it stays invisible until the last 0.4 s, then fades in, whatever the video's length becomes. A property keyframe has no `duration`, so an opacity pair at `"time": "end"` would sit on the last frame and never fade. ```json { "output_format": "mp4", "width": 1080, "height": 1920, "frame_rate": 30, "duration": 4, "snapshot_time": 3, "fill_color": "#0f172a", "elements": [ { "type": "shape", "name": "Glow", "track": 1, "width": "100%", "height": "100%", "path": "M 0 0 L 100 0 L 100 100 L 0 100 Z", "fill_mode": "radial", "fill_x0": "50%", "fill_y0": "45%", "fill_radius": "45%", "fill_color": [ { "offset": "0%", "color": "rgba(59,130,246,0.45)" }, { "offset": "100%", "color": "rgba(15,23,42,0)" } ] }, { "type": "composition", "name": "Stack", "track": 2, "width": "80%", "height": null, "flow_gap": "3 vmin", "animations": [ { "type": "scale", "duration": 0.6, "start_scale": "90%", "easing": "back-out" } ], "elements": [ { "type": "image", "name": "Logo", "y": null, "width": "44%", "height": "8 vmin", "fit": "contain", "dynamic": true, "source": "https://cdn.creatomate.com/demo/logoipsum.png" }, { "type": "text", "name": "Headline", "y": null, "width": "100%", "height": null, "x_alignment": "50%", "dynamic": true, "text": "See the full tour", "font_size": "7 vmin", "font_weight": "800", "line_height": "105%", "fill_color": "#ffffff" }, { "type": "text", "name": "Subline", "y": null, "width": "100%", "height": null, "x_alignment": "50%", "dynamic": true, "text": "Every Sunday at 14:00, Lindenstraat 24", "font_size": "4 vmin", "line_height": "125%", "fill_color": "#cbd5e1" }, { "type": "text", "name": "CTA", "y": null, "width": null, "height": null, "dynamic": true, "text": "oaklane.example/tour", "font_size": "3.8 vmin", "font_weight": "700", "fill_color": "#ffffff", "background_color": "#3b82f6", "background_x_padding": "70%", "background_y_padding": "45%", "background_border_radius": "50%" } ] }, { "type": "shape", "name": "End-Fade", "track": 3, "width": "100%", "height": "100%", "path": "M 0 0 L 100 0 L 100 100 L 0 100 Z", "fill_color": "#000000", "animations": [ { "type": "fade", "time": "end", "duration": 0.4, "easing": "cubic-in-out" } ] } ] } ``` ### A designed template, complete A square open-house card, rendered and checked frame by frame: the photo drifts inside its clipped box, a scrim keeps the headline legible, the headline rises and fades in, a bar draws itself, the old price is struck through by a same-named twin while the new price pops, the card slides up as one hugging flow, and the whole frame fades out. Every field an automation fills is named and `dynamic`; `snapshot_time` sits on the settled frame. The moves are explained in the "How it is used" sections of "Text", "Composition elements", "Shapes", "Image, video & audio" and "Animations". ```json { "output_format": "mp4", "width": 1080, "height": 1080, "frame_rate": 30, "duration": 4, "snapshot_time": 3.2, "fill_color": "#1d2027", "elements": [ { "type": "image", "name": "Photo", "track": 1, "x": "50%", "y": "30%", "width": "100%", "height": "60%", "fit": "cover", "clip": true, "dynamic": true, "source": "https://cdn.creatomate.com/demo/living-room.jpg", "animations": [ { "type": "scale", "scope": "element", "start_scale": "100%", "end_scale": "108%", "easing": "linear", "fade": false } ] }, { "type": "shape", "name": "Scrim", "track": 2, "x": "50%", "y": "30%", "width": "100%", "height": "60%", "path": "M 0 0 L 100 0 L 100 100 L 0 100 Z", "fill_mode": "linear", "fill_x0": "50%", "fill_y0": "0%", "fill_x1": "50%", "fill_y1": "100%", "fill_color": [ { "offset": "0%", "color": "rgba(0,0,0,0)" }, { "offset": "100%", "color": "rgba(0,0,0,0.65)" } ] }, { "type": "text", "name": "Badge", "track": 3, "x": "92%", "y": "8%", "x_anchor": "100%", "y_anchor": "0%", "dynamic": true, "text": "NEW", "font_size": "3.5 vmin", "font_weight": "700", "fill_color": "#ffffff", "background_color": "#ff4757", "background_x_padding": "80%", "background_y_padding": "40%", "background_border_radius": "50%", "animations": [ { "type": "scale", "time": 1.2, "duration": 0.4, "start_scale": "1%", "easing": "back-out" } ] }, { "type": "text", "name": "Headline", "track": 4, "x": "8%", "x_anchor": "0%", "y_anchor": "0%", "width": "84%", "height": null, "dynamic": true, "text": "Open house\nthis Sunday", "font_size": "7 vmin", "font_weight": "800", "line_height": "105%", "fill_color": "#ffffff", "y": [ { "time": 0, "value": "40%" }, { "time": 0.5, "value": "36%", "easing": "quartic-out" } ], "opacity": [ { "time": 0, "value": "0%" }, { "time": 0.5, "value": "100%", "easing": "quartic-out" } ] }, { "type": "shape", "name": "Bar", "track": 5, "x": "8%", "y": "56%", "x_anchor": "0%", "y_anchor": "0%", "width": "84%", "height": "1 vmin", "path": "M 0 0 L 100 0 L 100 100 L 0 100 Z", "fill_color": "#ffa502", "x_scale": [ { "time": 0.4, "value": "0%" }, { "time": 1.2, "value": "100%", "easing": "quartic-out" } ] }, { "type": "text", "name": "Was", "track": 6, "x": "8%", "y": "62%", "x_anchor": "0%", "y_anchor": "50%", "width": null, "height": null, "dynamic": true, "font_size": "4 vmin", "fill_color": "#aab2c0", "text": "€ 495.000" }, { "type": "text", "name": "Was", "track": 7, "x": "8%", "y": "62%", "x_anchor": "0%", "y_anchor": "50%", "width": null, "height": null, "font_size": "4 vmin", "fill_color": "rgba(0,0,0,0)", "text": "€ 495.000", "background_color": "#aab2c0", "background_x_padding": "0%", "background_y_padding": "0%", "y_scale": "8%" }, { "type": "text", "name": "Price", "track": 8, "x": "30%", "y": "62%", "x_anchor": "0%", "y_anchor": "50%", "width": null, "height": null, "dynamic": true, "font_size": "5 vmin", "font_weight": "800", "fill_color": "#ffa502", "text": "€ 465.000", "x_scale": [ { "time": 0.8, "value": "60%" }, { "time": 1.2, "value": "100%", "easing": "back-out" } ], "y_scale": [ { "time": 0.8, "value": "60%" }, { "time": 1.2, "value": "100%", "easing": "back-out" } ] }, { "type": "composition", "name": "Card", "track": 9, "x": "50%", "y": "84%", "width": "80%", "height": null, "x_padding": "5 vmin", "y_padding": "5 vmin", "fill_color": "#ffffff", "border_radius": "3 vmin", "flow_gap": "2 vmin", "animations": [ { "type": "slide", "time": 0.6, "duration": 0.6, "direction": 90, "distance": "30%", "easing": "quartic-out" } ], "elements": [ { "type": "text", "name": "Title", "y": null, "width": "100%", "height": null, "font_size": "6 vmin", "fill_color": "#111111", "text": "Lindenstraat 24, Utrecht", "dynamic": true }, { "type": "text", "name": "Body", "y": null, "width": "100%", "height": null, "font_size": "3.5 vmin", "fill_color": "#333333", "text": "Three bedrooms, a garden, parking. Sunday 12:00 to 15:00.", "dynamic": true } ] }, { "type": "shape", "name": "End-Fade", "track": 10, "x": "50%", "y": "50%", "width": "100%", "height": "100%", "path": "M 0 0 L 100 0 L 100 100 L 0 100 Z", "fill_color": "#000000", "animations": [ { "type": "fade", "time": "end", "duration": 0.4, "easing": "cubic-in-out" } ] } ] } ``` To adapt it: change the format by editing `width`/`height` only, since every size is in `vmin` or `%`; swap the photo by modification (`"Photo": "https://…"`), the texts by name; drop the strike-through pair for a listing without a previous price. Check the result before rendering ("Seeing a render"). ### Making a kit's template match a brief This page applies when no kit matches the user's brief exactly, or when a kit's template must change to match a described look or a reference (an HTML animation, a design, a video). The template still comes from a kit: the nearest one by job, then by structure (scene count, the media it takes, its format). Hand the user its link with the closest options set ("Template catalog: the built-in kits" (https://creatomate.com/llms/template-catalog.md)), and once they have created it, work on that template: have them paste its JSON ("Seeing the user's template"), fetch it over MCP, or read it in the editor through the bridge. A template the user already has is the same kind of starting point. A reference is intake, not a different method: read the sizes, colours, fonts, timings and easings off it, then apply them to the template the same way. **1. Intake, one or two questions at a time** - The job and where the result goes: the platform decides the format (9:16 for Stories, Reels and Shorts, 1:1 for feeds, 16:9 for YouTube and web) and the length (5–15 s for social, longer for explainers); a kit's `format` option covers the first, its structure controls most of the second. - What changes per render: every field an automation will fill, with the **longest realistic value** of each (a 40-character product name is a two-line name), and which fields can be empty. - The brand: colours, a font family (check it in "Built-in fonts" (https://creatomate.com/llms/built-in-fonts.md) or add it as a custom font), a logo and the photos or clips available. Stock assets are fine for a first version ("Stock assets"). - The tone in a few words (bold, calm, playful, editorial), and any reference. **2. Read the template before you change it.** The kit has already decided most of the design, and every change follows what it decided: - Its scenes: compositions in sequence on track 1, each with its own arrival, hold and handoff. Where scenes overlap so one arrives over the last, a scene you add overlaps its neighbours the same way, or its arrival plays over an empty frame. A scene you add is built like its neighbours: duplicate the nearest one, give every element a new `name`, omit the `id` fields so the engine assigns fresh ones. - Its text: hugging flow compositions for blocks that hold varying text, `font_size` floors and ceilings, the units in use (`vmin` for sizes and paddings, `%` for positions). A new element uses the same. - Its timing: how long a scene holds, how arrivals are eased, where the holds are. A retimed video keeps the kit's proportions unless the brief says otherwise. - Its fields: the names an automation will fill. Keep every name the user may already have wired (golden rule 7 in "Start here"); add fields with `"dynamic": true` and a clear name. List what the brief needs that the template lacks, and what the template has that the brief does not. That list is the plan. **3. The plan, before any JSON.** Show it and get a yes: - The changes, one line each: what is added, removed, retimed or restyled, and in which scene. - The dynamic fields after the change, with the longest value each must hold. - For a new or reworked scene: what is on screen, how it arrives, how long it holds, how it hands over. **4. Change**, in the template's own terms. Where the kit gives no precedent (a new kind of element, a still from a video kit), these are the floors Creatomate's own templates use: - **Structure**: every element visible at the same moment has its own track; a card, a lower third or any block that must hold varying text is a hugging flow composition ("Composition elements"). A layout block from "Recipes" (story frame, centred card, lower third, split, end card) can be dropped in when nothing in the kit matches. - **Fields**: text boxes sized for the longest value with about 10 % headroom, `font_size: null` with a floor and a ceiling for text that varies; an empty optional value must leave a clean frame (a text's background box already does). - **Type**: at a 1080 px short side, headline ≥ 7 vmin, primary text ≥ 4.5 vmin, secondary ≥ 3.2 vmin, logo height ≥ 2.6 vmin; 5–8 vmin of margin to the frame edge; social platforms cover the top and bottom 10–15 % of a 9:16 frame with their own UI. - **Motion**: arrivals of 0.3–0.6 s with `quartic-out` or `cubic-out`, overshoot with `back-out`, a hold keyframe wherever something must wait, exits as reversed presets at `"time": "end"`, one legible thing per moment. Judge the timeline with `getTimeline()` before you judge pixels. - **Stills**: `snapshot_time` on a settled frame inside the duration. **5. Check, by looking** (the options in "Seeing a render"), in this order: 1. The settled frame: every element present, nothing off-canvas, nothing overlapping, contrast against the actual pixels behind each text. 2. Each arrival and each handoff: seek to the moment, screenshot, ask whether exactly one thing is legible. 3. In a browser preview, `getAssets()` clean: a failed asset draws nothing there but fails the render on the server. 4. When exact pixels matter, one API still. Fix the cause in one `applyModifications` call per round, then look again; do not eyeball a layout problem into place with offsets. **6. Deliver** - In the editor through the bridge, the change is already in place; over MCP, `update_template` on the template the user created (never a second template beside it); otherwise, the JSON to paste back into the editor's source view. In every case, tell the user the dynamic fields by name and how to fill them (Zapier, Make, n8n, feeds or the API through `modifications`; "Choosing the right path"). - Say what you checked and what you could not (audio, exact fonts, server-only features). **Traps that cost the most**: a duplicated scene that keeps its `id`s or its element names (modifications hit the wrong copy); a renamed field (every automation using it breaks); two elements on one track at the same moment (the later one silently wins); a keyframe pair without a hold (the move spreads over the whole element); a font that is not built in (renders as Aileron without an error); `output_format` left at its `jpg` default; a `snapshot_time` after the 5 s fallback; `text_transform: "uppercase"` on a field with units; an unreachable asset (fails the whole render). ## Failure modes → fixes | Symptom | Cause | Fix | |---|---|---| | Black screen or black segment | an element has no `source` (structural template rendered without modifications); media sits outside its composition's resolved time; or a pinned `time` leaves an empty track gap (gaps render black and lengthen the video) | supply the `source`; check the duration cascade; un-pin: `applyModifications({"El.time": null})` or right-click → Auto Arrange → Start: Auto | | Video freezes, then continues | background video without `loop: true`, shorter than its scene | `{"Background-1.loop": true}` (drop any trims) | | Whole video is exactly 5 s | no resolvable duration anywhere | give one element a real duration: voiceover (`"media"`), fixed comp duration, or a number — `applyModifications({"Scene-1.duration": 6})` | | Got a JPG instead of a video | `output_format` defaults to jpg | set `"output_format": "mp4"` — `applyModifications({"output_format": "mp4"})` | | Subtitles missing | `transcript_source` matches no audio/video element name/id; or output isn't mp4/gif | fix the reference — `applyModifications({"Subtitle-1.transcript_source": "Voiceover-1"})`; set mp4 | | Voiceover render fails: no provider key | ElevenLabs/OpenAI key not configured | Project Settings → Integration; meanwhile a stock voiceover or an audio file URL keeps things moving | | Media download failed | the asset URL isn't public/direct | host it publicly; prove the pipeline with a stock URL meanwhile | | "Expected a file" error | the URL points at a web page, not a file — classic with Google Drive/Dropbox share links and YouTube URLs | use a direct-download file link | | Render timeout | very long or heavy render | lower `render_scale`/resolution/duration, or split the video; contact support if it persists | | ElevenLabs "unusual activity" block | ElevenLabs blocks free-tier API usage on their side | an ElevenLabs limitation, not a Creatomate issue — a paid ElevenLabs key fixes it; stock voiceover meanwhile | | Provider "didn't respond" | temporary provider downtime | retry later; nothing to fix in the template | | Unexpected crossfade + video slightly short | a drag on the timeline auto-inserted a transition; its overlap subtracts | `{"Scene-2.animations": []}` | | Automation renders the old design | the template is in draft mode | Version History → publish. (Inverse rule: by default every editor save is live immediately) | | Music keeps playing after its scene or after the visuals end | audio `duration: "media"` (or absent — the audio default) is not clipped by its composition, even one with a fixed duration | `{"Music-1.duration": null}` (+ `"loop": true`) so it follows the composition; do NOT pin `trim_duration`. If the music should drive the scene length instead, keep `"media"` and un-pin the composition's duration | | Audio doesn't fade in/out | a `fade` animation on an audio element — animations are visual-only and silently inert on audio | `audio_fade_in`/`audio_fade_out` — `applyModifications({"Music-1.audio_fade_out": 2})` | | Paired elements drift apart across renders | cross-track visual alignment is not linkage | group them into one composition | | Text overflows its box | fixed font + fixed box + long text | `font_size: null` for auto-fit — `applyModifications({"Title.font_size": null})` — or enlarge the box; connect this to dynamic text | | Auto-fit text is tiny | long text drove the fit to the floor | raise the floor — `applyModifications({"Title.font_size_minimum": "4 vmin"})` — shorten the text, or enlarge the box | | Text wraps onto a second line | box-width fix won't survive longer values | `text_wrap: false`; dynamic text: + `font_size: null` + fixed `width` | | An element or its text sticks out past the canvas edge (half off-screen) | `x`/`y` treated as the top-left corner; they place the element's center (anchors default 50%) | recenter with `"x": "50%"` (never `null`), or anchor the edge you meant: `applyModifications({"Title.x": "7%", "Title.x_anchor": "0%"})` = left edge at 7% | | A modification did nothing | element name mismatch (names reformat on entry: dots stripped, spaces → dashes); or the element was renamed — the editor re-wires `transcript_source` on rename but never your Zap/scenario/code | copy names exactly from the template; treat renames as breaking changes | | A property does nothing, no error | the name doesn't exist (silent-failure rule) | find the real property in this reference | | API 4xx | 400 invalid payload · 401 bad key · 402 out of credits · 404 missing id · 429 rate limit — all carry a `hint` | read the hint and act on it; on 429, wait the `Retry-After` seconds before retrying | | An older render's `url` is dead, or a past render is gone | renders and their files are deleted 30 days after creation | re-render to get a fresh file; fix the workflow so finished files transfer to the user's own storage on completion | | Everything stacked in the same frame | elements share one track inside one composition | scenes = compositions in sequence on track 1 | ## Out of scope — never promise these - **Audio waveform visualizations** — no waveform element exists. Closest working alternative: cover art + karaoke captions (the audiogram recipe). - **A split-screen preset** — none exists; manual positioning IS possible (two videos at `"width": "50%"`, one at `"x": "25%"`, the other at `"x": "75%"`) in the editor or via the API. Offer the sequenced version first. - **Kinetic typography beyond the text presets** — the `text-*` presets are the catalog; combine presets and keyframes instead of promising per-word choreography. - **Variable scene counts in Zapier/Make** — use a fixed-scene template and remove unused scenes per render (`"Scene-N": {}`), or route to n8n/code with `Comp.elements`. - **Pixel-exact brand layouts from chat** — build the structure, then polish in the editor, with eyes. **Engine limits, with the working alternative** (learned building Creatomate's own templates): - **Text decoration**: no strikethrough, underline, gradient-filled text or text on a path → a shape line, or a same-named transparent-glyph twin whose background box is the rule ("Text"). - **Hyphenation** never happens and a hyphen is not a break point → a wide enough column, or a no-break space to hold a phrase together. - **Corners and strokes**: one `border_radius` for all four corners; no dashed or dotted strokes → a path of short segments or arcs; the stroke is centred on the path, so inset a bordered rectangle by half the stroke. - **Fills**: solid, linear or radial only; no repeating gradients, patterns or textures; the radial fill is a circle, never an ellipse → N shapes for a tile, an uploaded texture image for grain or paper. - **Effects**: one shadow per element (no spread, inset or second shadow); no backdrop blur → `blur` on a copy behind; film grain and halftone are experimental and not available in production; effects apply in a fixed order (colour filter, blur, shadow). - **Clipping** is the rectangular box rounded by `border_radius` → other shapes through `mask_mode` on the element above, or by drawing the shape. - **No `calc()`, expressions, conditionals or aspect queries**: one source cannot re-arrange itself per format → `vmin` units for what must stay identical, or one template per format. - **Audio**: no audio-only output (an mp3 is transcoded out of the mp4), no beat detection or audio-reactive motion. State the limit plainly, then pivot to the alternative. ## Stock assets All demo assets live under `https://cdn.creatomate.com/demo/` — these URLs and no others. **Naming**: a file ending `-h` is landscape and `-v` is portrait. Some subjects exist one way only. Pick the one matching **the box the picture fills, not the template's frame** — a design can hang a portrait print inside a 16:9 page — because a landscape source in a 9:16 box loses about 60% of its width to the crop, and the reverse loses as much height. Where both exist, a **photo** pair is the same subject shot both ways rather than one cropped; a **video** pair is the same subject but not the same take, since one location filmed both ways is not something stock footage offers. Files with no suffix predate the rule and are landscape. - **Generic clip set** (8 s, silent, `-h` 1920×1080 and `-v` 1080×1920) — nature and weather, deliberately off-topic so one clip stands in under any script: `clouds` (drifting cumulus), `forest` (canopy and trunks), `waves` (surf from above), `mountains` (rock ridge), plus landscape-only `desert-h` (dune field, long horizon) and `city-h` (skyline from the air), and portrait-only `rain-window-v` (rain on glass), `city-night-v` (wet street, light trails), `road-v` (open highway from a moving car), `sunrise-v` (first light over a ridge). `placeholder-h.mp4` and `placeholder-v.mp4` are one drone take over a sunny alpine valley at **19 s**, for a structure needing one unbroken take longer than 8 s. **Use these wherever a video needs a bed and the subject does not matter** — nothing in them is readable, branded or identifiable - **Videos** (1280×720, no audio): `video1.mp4` (3 s ocean), `video2.mp4` (3 s golden sky), `video3.mp4` (3 s coastal highway), `video4.mp4` (5 s waterfall), `video5.mp4` (20 s coastal cliffs), `big-buck-bunny.mp4` (1080p, 30 s) - **Outdoor photo set** — one hiking weekend, twelve subjects, each as `-h` (1536×1024) and `-v` (1024×1536): `sunrise-mountains` (dawn over layered ridges), `forest-trail` (trail through conifers), `forest-bridge` (mossy footbridge over a creek), `creek-crossing` (stepping stones, running water), `waterfall` (thin fall on wet rock), `mountain-path` (switchbacks above the treeline), `trail-sign` (blank weathered marker post), `hiking-boots` (boots and pack on a rock), `mountain-ridge` (ridge over a valley), `summit-view` (the view from the top), `alpine-lake` (range reflected in still water), `campsite` (lit tent at dusk). **Use these for any outdoor, travel, lifestyle or generic-background need** — they are the deepest coherent set in the library and read as one trip. - **Roastery photo set** — one small specialty coffee roastery on one morning, window light from the left, dark wood and steel, nothing branded: `coffee-beans-h` and `coffee-beans-v` (roasted beans tumbling in a steel cooling tray; uniform texture with no focal point, so it holds a wash and type over it), `coffee-pour-h` (a pour-over being made, steam rising), `coffee-bag-h` (an unlabelled kraft bag of whole beans on a counter), `coffee-counter-h` (grinder, scales and cups, nobody in frame). Landscape is 1536×1024 and portrait 1024×1536. **Use these for any food, drink, retail, small-business or craft-brand need**, and `coffee-bag-h` wherever a blank package is wanted - **Editorial photo set** — five subjects from one ordinary American city, plain daylight except the stadium, each as `-h` (1536×1024) and `-v` (1024×1536): `street-march` (a crowd from behind carrying blank placards), `grocery-aisle` (a produce aisle, nobody in frame), `stadium-crowd` (a full stand under floodlights at night), `conference-speaker` (a speaker on a small stage, seen from the back of the room), `sky` (plain daytime sky with cumulus, no horizon). Nothing readable, no identifiable face, no recognisable landmark. **Use these for news, current-affairs, civic, event or corporate needs**, and `sky` for any weather card or a clean ground for type - **Images** (1920×1280, landscape): `image1.jpg` (forest bridge), `image2.jpg` (red-rock waterfall), `image3.jpg` (granite valley) - **Logos** (501×123): `logoipsum.png` (white), `logoipsum-dark.png` (black) - **Music**: `music1.mp3` (22 s acoustic), `music2.mp3` (16 s upbeat, loops), `music3.mp3` (60 s calm — real estate), `music4.mp3` (97 s piano — tribute/memorial, loops) - **First-render voiceover set** ("better sleep"): `voiceover-1.mp3` (the title line) and `voiceover-2.mp3` … `voiceover-8.mp3` (seven spoken tips, 4–10 s each), plus `voiceover.mp3` (30.5 s, the title and first three tips read straight through) for a structure needing one continuous narration. Matching images `better-sleep-1.jpg` … `better-sleep-4.jpg` (1097×1920, portrait) — use these for a first voiceover render before any TTS key exists - **News voice set** ("The Riverton Post", one reader, a different voice from the sleep set): `news-1.mp3` (10.9 s, a march downtown), `news-2.mp3` (5.8 s, grocery prices), `news-3.mp3` (5.9 s, a sold-out stadium) — three read news stories, the demo audio for a narrated bulletin and what generated narration is matched against - **Quiz voice set** ("better sleep" trivia, one question and one answer per round): `quiz-question-1.mp3` … `quiz-question-5.mp3` (2–3 s) and `quiz-answer-1.mp3` … `quiz-answer-5.mp3` (under 2 s) - **Real estate**: `house1.mp4` … `house4.mp4` (8 s each), `living-room.jpg` (1920×1920), `bathroom.jpg` (1280×1920), `kitchen.jpg` (1440×1920), `house.jpg` (1920×1280 exterior) - **Podcast clips** (1280×720, h264 + aac, with speech) — the source material for anything that transcribes or captions: `podcast.mp4` (46 s, one conversation) plus its four beats as separate cuts, `podcast-1.mp4` (10.4 s), `podcast-2.mp4` (14.1 s), `podcast-3.mp4` (8.7 s), `podcast-4.mp4` (10.8 s). Also `the-daily-stoic-podcast.mp4` (23 s, an older cut of the same conversation) and `the-knowledge-project.mp4` (37 s) - **Fonts**: `coolvetica-rg.otf`, `coolvetica-rg-it.otf` (custom-font demos) Music mood mapping: real estate → music3 · upbeat/product → music2 · memorial/piano → music4 · acoustic → music1. ## Seeing a render (visual self-check) Look before concluding — the same complaint ("looks wrong", "text doesn't fit") can have opposite fixes, and only the pixels tell you which. Choose the cheapest sufficient check: - A still reliably shows mispositioned/off-canvas elements, wrong aspect, overlaps, empty/black slots, clipped content, roughly-wrong colors. It will NOT settle exact alignment, small-text legibility, or precise colors. - Timing, duration, and format questions need no pixels: read the render object (`duration`, `width`, `height`, `output_format`) or the resolved timeline. - Audio is never checkable from a still. Judge voiceovers by the subtitle text and durations. - For image outputs, fetch the render's `url` and look. For videos, check the SUSPECT moments — the reported time and the animation events (entrances, transitions, exits), not just t = 0. Options, cheapest first: - **The user's own browser with the editor open** — when the user shares their browser (e.g. a browser extension session). To inspect a candidate RenderScript there, use a new or blank template — never overwrite their work — then `setSource`, `whenIdle`, screenshot, `getTimeline`. - **Local ffmpeg** — extract frames from an already-rendered file: `ffmpeg -ss 12 -i output.mp4 -frames:v 1 frame.png`; use `ffprobe` to explain a failure (codec? no CORS?). - **API still** (ground truth, 1 credit per frame) — render an image of the exact moment; a video or GIF render returns a free `snapshot_url` only when its source sets `snapshot_time` ("RenderScript basics"): ```json { "output_format": "png", "width": 1080, "height": 1920, "snapshot_time": 4.7, "elements": [ { "type": "video", "source": "" } ] } ``` **A blank region in a browser preview does NOT mean the template is broken.** Browsers fail where the render servers don't: cross-origin assets without CORS headers, codecs the browser can't decode (only H.264/VP9/AV1 and common image formats play in-browser), memory on large templates, exact fonts/pixels. Never tell the user "your template is broken" from a preview-only asset failure — confirm on the server (an API still) first — and read `getAssets()` first, which names the asset and the reason. ## No-code handoffs & exact editor paths The **Use Template** button (top right of the editor) is the hub: - **Zapier, Make & n8n** — the setup path for this template in each tool; all three have an official Creatomate integration. - **Spreadsheet** — feeds: spreadsheet rows → renders; CSV import built in (the Google Sheets bridge; live sync via Zapier/Make/n8n). Full model + operating a feed: "Feeds". - **Developer API** — template ID, API key, and request code in 9 languages. - **Shareable form** — a fill-in form for non-technical teammates. **Only what is marked Dynamic appears as a field** in Zapier, Make, the n8n node, feeds, and forms. Mark an element Dynamic with the switch on the "Dynamic" row at the top of the editor's properties panel (exposes its main property: text or source); to mark any other property, click the text on that row and tick it in the dialog. If a field is missing in Zapier, Make, or the n8n node, this is why. **n8n**: the official **Creatomate node** (npm package `@creatomate/n8n-nodes-creatomate`; source and README at https://github.com/Creatomate/n8n-nodes-creatomate) is a verified community node: search for Creatomate when adding a node, on n8n Cloud or self-hosted — n8n's guide: https://docs.n8n.io/integrations/community-nodes/installation-and-management/. Credential **Creatomate API** = the project's API key. **Render → Create** picks a template from the project, lists its Dynamic elements as fields (or maps incoming item keys onto them automatically), and with **Wait for Completion** on (the default) returns the finished file's `url` — no Wait/status-check loop needed. **Render From → RenderScript** renders raw RenderScript JSON without a template (full JSON control). The **Additional Modifications** option (selector + value) changes any other property, and a Value that is a single expression resolving to an array or object is passed through as such — so variable scene counts work on the node too: selector `Slides.elements`, Value `{{ $json.scenes }}` with the scene array built in a Code node. n8n without the node (an instance that cannot install it): the **HTTP Request node** against the render API does the same job by hand — create the render, wait, then check its status; the request shapes are in "API". Every n8n tutorial teaches the node. Tutorial walkthroughs to hand off — pick the user's tool, then the closest use case. Describe a third-party tool's own UI only as far as the linked tutorial shows it; those UIs change without notice, so never invent module, action, or menu names. **n8n** — start: https://creatomate.com/blog/how-to-automate-video-creation-with-n8n (every n8n tutorial teaches the Creatomate node; this page and the AI voice-overs page carry their finished workflow behind a copy button, ready to paste onto an empty n8n canvas) - Auto-generated subtitles: https://creatomate.com/blog/how-to-automatically-add-subtitles-to-videos-with-n8n - YouTube Shorts: https://creatomate.com/blog/how-to-automatically-create-youtube-shorts-with-n8n - AI voice-overs (ElevenLabs): https://creatomate.com/blog/how-to-create-videos-with-ai-voice-overs-using-n8n - Videos from images (Airtable-driven): https://creatomate.com/blog/how-to-auto-create-dynamic-videos-from-images-using-n8n - Videos from JSON (dynamic structure): https://creatomate.com/blog/how-to-create-videos-from-json-using-n8n **Make** — start: https://creatomate.com/blog/how-to-automate-video-creation-with-make - Auto-generated subtitles: https://creatomate.com/blog/how-to-automatically-add-subtitles-to-videos-using-make - Instagram Reels (create + post): https://creatomate.com/blog/automatically-create-and-post-instagram-reels-using-make - YouTube Shorts: https://creatomate.com/blog/how-to-automatically-create-youtube-shorts-using-make - TikTok videos: https://creatomate.com/blog/how-to-automatically-create-tiktok-videos-using-make - Videos from Google Sheets rows: https://creatomate.com/blog/automated-content-creation-using-google-sheets-and-make.com - Videos from Airtable records: https://creatomate.com/blog/how-to-automate-content-creation-with-airtable-and-make.com - AI voice-overs (ElevenLabs): https://creatomate.com/blog/create-videos-with-voice-overs-using-text-to-speech-ai-and-make **Zapier** — start: https://creatomate.com/blog/how-to-automate-video-creation-with-zapier - Auto-generated subtitles: https://creatomate.com/blog/how-to-automatically-add-subtitles-to-videos-using-zapier - Instagram posts: https://creatomate.com/blog/how-to-automatically-create-instagram-posts-using-zapier - YouTube Shorts (create + post): https://creatomate.com/blog/how-to-automatically-create-and-post-youtube-shorts-using-zapier - TikTok (create + post via Buffer): https://creatomate.com/blog/how-to-automatically-create-and-post-tiktok-videos-using-zapier - Videos from Google Sheets rows: https://creatomate.com/blog/how-to-auto-generate-videos-using-google-sheets-and-zapier - Videos from Airtable records: https://creatomate.com/blog/how-to-automate-content-creation-with-airtable-and-zapier - AI voice-overs (ElevenLabs): https://creatomate.com/blog/how-to-create-videos-with-ai-voice-overs-using-zapier **Shareable form** (built in, no third-party tool): https://creatomate.com/blog/how-to-generate-marketing-videos-using-a-simple-form Full tutorial catalog, browsable per tool and topic: https://creatomate.com/docs/tutorials — per tool: https://creatomate.com/docs/tutorials/zapier, https://creatomate.com/docs/tutorials/make, https://creatomate.com/docs/tutorials/n8n. Bulk generation from a spreadsheet inside Creatomate itself is covered by "Feeds", not a tutorial. Documentation home: https://creatomate.com/docs · API key: **Project Settings → API**. ## Feeds — the bulk-render spreadsheet Users say "the spreadsheet"; the product calls it a **feed**: a data table inside the Creatomate dashboard where every row becomes one rendered video or image. Feeds are a dashboard feature operated in the browser — there is no feed write API and no MCP feed tools (see negative space below). Find them under **Feeds** in the project sidebar — the list shows per-feed rendering/failed badges; **New Feed → Import From File** creates one from a CSV. **The model:** a feed holds **rows** of data in named **columns**. Connecting a **template** creates a per-template **mapping** from columns to the template's dynamic fields; a feed can have several connected templates, and the **Template dropdown** in the action bar picks which one you are viewing and rendering. Nothing renders on its own: the user selects rows and clicks **Create Renders**, which renders row × selected template — rows whose render is already up to date are skipped. Each row shows a status for the selected template: **Rendered** · **Outdated** (rendered before the data, template, or mapping changed) · **Failed** (the render error is on the row) · **Rendering** · **Expired** (renders are removed after 30 days — Dispose Renders, then re-render) · **Not rendered** · **Unsaved** (edits and new rows stage as amber Unsaved rows; nothing is stored until **Save**). ## Operating a feed (browser agents) On a feed page, `window.creatomate.feed` exists (run `creatomate.help()` anywhere in the app to list everything). Every call returns `{ ok: true, … }` or `{ ok: false, error: "…" }`, and errors state the correct next call. Rows are addressed by their **1-based position on the current page** (matching the "Select row 3" checkbox labels); columns by their **visible header names**. Translate a user's global row number with `rowsPerPage`/`rowCount` from `getRows()`: row 543 at 50 rows per page = page 11, position 43. **Read first, always:** `getRows()` — the visible page: per row its position, selection, unsaved flag, status **with the actual failure message**, values, and `renderUrl` (present only for finished outputs). Also the feed name, the template the statuses refer to, page/pageCount, and the active status filter. `await getConnections()` — every connected template with its mapping analysis: each dynamic field's mapped column, or — critically — **unmapped, with the default value it silently renders instead** (the number-one cause of "the feed doesn't follow my template"), plus dead rules (their element no longer exists) and deliberate custom rules. **View state:** `setStatusFilter('failed')` (groups: `rendered` · `outdated` · `failed` · `rendering` · `expired` · `notRendered`; `null` clears) · `selectAll()` (the whole feed, all pages) · `clearSelection()` · `selectRows([1, 4, 5])` · `goToPage(n)`. Create Renders and Dispose honor the active filter, so "re-render the failures" is: filter to failed → select all → the menu. **Writing data:** `setRows([{ row: 3, values: { "Column name": "new value" } }])` edits cells; `addRows([{ "Column name": "value" }])` appends new rows (the view jumps to them). Both only **stage** amber Unsaved rows — then `save()` stores them (the same commit as the Save button; it returns the saved count). Never leave staged rows unsaved without telling the user. **The one hard rule: never click Create Renders without the user's explicit go.** It spends their credits, which is real money — prepare everything (fix, filter, select, open the **Row actions** menu), state how many renders it will create, and let the user click, unless they have explicitly told you to render on their behalf. Everything else you may operate through the page's named controls: **Save**, **Add Row**, **Import Data**, **Export**, **Edit Columns**, **Connect**, and the Row actions menu (**Create Renders** · **Download Renders** · **Dispose Renders** · **Delete Rows** · **Undo Changes** — the destructive ones show confirmation dialogs). Always the user's: **file picks** (CSV import, media uploads into cells) and anything in subscription/billing (credit or row-quota limits — the error messages carry the numbers). ## Mapping — connect the data to a template Mapping is edited in **one place only**: the **Connect** button (new connection) / **Review Mapping** (existing) dialog. It lists the template's **dynamic fields** — one per dynamic property ("Title – Fill Color") and one per placeholder inside a dynamic text; one element can contribute several. Each field gets a column ("Column for X" dropdowns): an existing column, **New column** (created on Apply — existing columns and data are never changed), or **Don't map** — an unmapped field renders its template default on every row, silently. The dialog shows only fields the template **declares**: a property missing here is not marked dynamic yet, not unsupported — any property can be made dynamic in the template editor. The dialog also lists **dead rules** (mapping rules whose element no longer exists in the template) with a Keep/Remove choice. Never try to write mapping through the bridge or the API; read it with `getConnections()`, fix it in the dialog. **When the template is the problem** — the user's column has no dynamic field to map to, or the dialog says the template has no dynamic fields: the fix is in the template editor, not the feed. Open the template and mark the element or property Dynamic (or add a text placeholder), then return and Review Mapping. Browser agents: the editor bridge is `creatomate.editor.*` — see "Operating the editor"; without browser control, see "Seeing the user's template". A connection whose template was deleted shows as broken: the rows and data are intact; connect another template. **Import & export:** the Import Data dialog maps CSV columns to feed columns; its optional **merge column** (a unique ID/SKU/email) updates matching rows instead of appending duplicates. Getting videos out: **Download Renders** in the Row actions menu (pick the file-name column), the **Export** button (whole feed as CSV), or compose any subset in chat from `getRows()`'s values + `renderUrl`. ## Negative space — say no honestly - **No feed write API**: the public API cannot add or edit rows. Code-driven volume belongs on the render API directly — "Quick start"; a feed is for humans working in the dashboard. - **No live sheet sync**: Creatomate does not watch Google Sheets. Recurring flows = Zapier/Make/n8n watching the sheet and calling the render API — "No-code handoffs"; or re-import the CSV. - **No scheduler, no social posting**: the workflow tool owns time and distribution. ## Seeing the user's template If the question answers itself from knowledge, answer — no tools, no pastes. Never claim a capability you don't have. **Fetched copies expire — the user edits in parallel.** Whatever you fetched or were pasted is a snapshot, not a live view: between your turns the user may move, retime, rename, or delete elements in the editor. When the user reports a new symptom ("now it doesn't work anymore"), says they changed something, or describes behavior that doesn't match the copy you hold, assume the template has changed and re-sync BEFORE diagnosing — MCP: `get_template` again; browser: re-read `getTimeline`; paste flow: ask for a fresh copy of the source JSON. When their description and your copy disagree, trust their description — your copy is the stale one. Re-sync before any write too: a stale write either misses silently (modifications keyed to renamed elements are ignored) or overwrites the user's newer edits (full-source updates). 1. **Paste (the common case):** the user opens the editor's source editor (the `{ }` icon or F12), selects all, and pastes the template JSON to you. That is the RAW stored source, the same thing `get_template` returns: no resolved timeline comes with it, so resolve computed start/end times mentally AND say you did when timing is the question. Deliver fixes as one of: (a) corrected full JSON the user pastes back into the same source editor — one paste, one undo step; (b) exact editor steps; (c) a corrected API call. 2. **Browser control** (the user shares their browser with the editor open): see Operating the template editor. 3. **MCP** (headless account access, when configured): tools `get_guide` (this reference, served whole), `list_templates`, `get_template`, `create_template`, `update_template`, `delete_template`, `create_render`, `get_render`. Limits to respect: `get_template` returns the RAW stored source — no resolved timeline exists outside a running editor, so resolve durations mentally AND say you did; with browser control, `getTimeline` is the only channel that hands you the resolved times. `update_template` saves go live to automations immediately — recommend draft mode first on production templates. Writing while the user's editor is open is safe — the editor detects outside changes (a clean editor reloads them; unsaved work gets a keep-mine/load-latest prompt) — but your own risk remains the stale copy: re-fetch with `get_template` before writing, or your write lands on top of edits you never saw. Headless and bulk jobs belong here, never in the browser. **Setting it up** (only if the user wants it — see channel etiquette in "Choosing the right path"): the endpoint is `https://api.creatomate.com/mcp`. **OAuth is the preferred way to connect**: the user adds the URL, signs in on a Creatomate page, and picks the project to grant — no API key changes hands. That is the whole setup in **claude.ai** (paid plans: Settings → Connectors → Add custom connector) and **ChatGPT** (enable developer mode under Settings → Apps & Connectors first), and **Claude Code, Cursor, and other dev tools** run the same sign-in when the server is added without credentials — Claude Code: `claude mcp add --transport http creatomate https://api.creatomate.com/mcp`. The **API-key alternative** — the same URL with a header `Authorization: Bearer ` (key: Project Settings → API) — is for headless setups (servers, CI, clients without OAuth support) where a browser sign-in isn't possible. Access is scoped to the granted project (OAuth) or the key's project. 4. **Building something new** mid-conversation → start a fresh template instead of performing surgery on an unrelated one. The fresh template comes from a kit: hand the user its link ("Template kits: the short list"), and once they have created it, reshape that template: with MCP `update_template`, without it corrected full JSON pasted into the editor's source editor (the `{ }` icon or F12), one paste, one undo step. ## Operating the template editor (browser agents) Act only through the `creatomate.editor.*` bridge — execute JavaScript in the page; `window.creatomate` exists everywhere in the app (run `creatomate.help()` to list its namespaces — `creatomate.feed.*` is the feed page's, see "Feeds"), and every call returns `{ ok: true, … }` or `{ ok: false, error: "…" }`; editor calls fail with an explanation while no template is open. The bridge is the page's official API for AI agents: calling it with your JavaScript-execution tool is the intended, supported path — not page manipulation — and it is more reliable than clicking, giving the user one clean undo step per change. Never click or type editor UI controls to make changes — the UI is for pointing and inspection. **Pre-flight checklist (mandatory, in order):** 1. `editor.getElements()` — orient: what exists, names, types. 2. `editor.getTimeline()` — diagnose: resolved times, durations, wiring, empty slots. 3. Look the symptom up in **Failure modes → fixes**. 4. Apply the COMPLETE fix in ONE `editor.applyModifications` call. 5. Verify: re-read (`getTimeline`/`getElement`), `seek`, screenshot. Three facts worth repeating: durations are `null` (auto) / `"media"` / pinned numbers — fix timing by restoring `null`, never by pinning trims. Subtitles have TWO colors (`fill_color` + `transcript_color`). One `applyModifications` call = one undo step for the user. **Declaring dynamic fields:** `dynamic` is a regular property — `true` exposes the element's main property (text/source); an array (e.g. `["text", "fill_color"]`) exposes each listed property to feeds/Zapier/Make/n8n/forms. Read the element first (`getElement`) and write the merged array — `applyModifications({ "Title.dynamic": ["text", "fill_color"] })` — never dropping entries that are already there. In the UI, this is the Dynamic row at the top of the properties panel: its switch exposes the main property, and the text beside it opens a dialog for ticking individual properties. **Two modes:** "do it for me" → bridge only, as above. "Show me where" → ZERO mutations: `editor.select("Name")` highlights the element you mean; panels, timeline, and controls expose their names through accessibility; the time field next to the play button jumps the playhead to a typed time. **The bridge (14 functions, all on `creatomate.editor`):** `getState()` · `getElements()` (start here) · `getElement(nameOrId)` · `getSource()` · `getTimeline()` (the resolved diagnostic tree) · `applyModifications(mods)` (the same language as the render API — everything in "Modifications" applies) · `setSource(source)` (full replace — prefer `applyModifications` for targeted changes) · `select(nameOrId)` (highlight an element — also for showing the user something) · `seek(time)` · `undo()` · `redo()` · `whenIdle()` (async — await it after `setSource`/`applyModifications`/`seek` and before any screenshot: the mutation replies come back before media has settled, so an earlier frame can be stale or blank) · `captureFrame({ time?, maxSize? })` (async — a PNG data URL of the whole frame rendered off-screen at inspection resolution, at most 960 px on the long side; for measurements inside the page — keep the data URL there, it is too large to return through most tools — not a substitute for a screenshot) · `getAssets()` (every asset with its load status and error — the failing URL that a blank region will never tell you). Prefer scoped getters (`getElement`) over full dumps — large returns can be truncated by extension tooling. **The two "Color" rows (subtitle restyle trap):** in the properties panel, the Fill section's "Color" is `fill_color` (base text) and the Style section's "Color" is `transcript_color` (spoken-word accent). "Change the subtitle color" = both. **Working rhythm:** reads are free and create no undo points — investigate as much as needed (for timing complaints, `getTimeline()` alone usually holds the full diagnosis); mutate once, completely; fix the cause, not the symptom; scope changes to the user's complaint — do not tidy unrelated authored values; verify after every mutation. The user sees your words, not your calls: when no concrete goal has been stated yet, ask what they'd like to change before touching anything, and report results by visible effect ("the title is blue now"), never by bridge or function names. **Editor conventions:** text inputs commit on Enter · section headers collapse/expand · Ctrl-click multi-selects; in the layer panel Shift-click selects a range and double-clicking a layer name renames it · the `{ }` icon (or F12) opens the built-in source editor · the top bar shows Saved/Modified (auto-save) · dragging elements to overlap on the timeline auto-inserts a transition. **Live templates & recovery:** every editor save is live to automations immediately. Before larger edits to a production template, recommend **draft mode**: Version History → pause; automations keep using the last published version until the user publishes. Recovery ladder: Ctrl+Z first; then Version History restore points (grouped in 15-minute intervals). ## Self-check before you answer 1. Does every scene have a duration driver (voiceover, `"media"`, or an explicit duration)? 2. Does every `null`-duration background video have `"loop": true`? 3. Does every `transcript_source` match an existing audio/video element's exact name or id? (Its own composition is searched first, then the whole document — prefer the same composition so timing stays linked.) 4. Is `output_format: "mp4"` set on every video? 5. Did I use only property/animation/parameter/endpoint names that appear in this reference (the element reference + the per-type sections)? 6. Are all example URLs from the stock list, with EVERY dynamic element filled? 7. Am I proposing the smallest working version, and did I tell the user the next step after it works? 8. Before a real render — especially raw RenderScript or name-based modifications — did I `dry_run` to confirm `valid: true` and that every modification resolved (no unmatched-name warnings)? 9. If something is impossible or failed, did I give a concrete alternative instead of a dead end? 10. Do elements with an explicit `x`/`y` stay on the canvas? `x`/`y` place the center (box spans x ± width/2) unless the anchors say otherwise.