Cavalry Control
Cavalry (/Applications/Cavalry.app) is scriptable in JavaScript (ES6, api.* / ui.* /
cavalry.* modules). Drive it through the Cavalry Bridge — a small script UI running
inside Cavalry that executes whatever is POSTed to 127.0.0.1:8731. Everything below was
proven on a real project (EMU logo sting + race animation); the reference file covers the rest of
the API surface.
- references/api-reference.md — condensed module reference (api, cavalry, ui, WebClient/WebServer, render scripts, CLI). Read it when you need something outside the cheatsheet below.
- references/cavalry-types/ — the complete API as vendored TypeScript definitions with JSDoc examples; grep it for any function the reference doesn't cover.
- scripts/cavalry-bridge.js — the bridge to install into Cavalry.
- scripts/cavalry-send.sh — send a JS file (or
-c 'inline code') to the bridge and wait for completion.--pingchecks the bridge is reachable first.
The control loop
- User starts the bridge (one-time per session): Cavalry must be running with
Cavalry Bridge.jsstarted from its Scripts menu. This cannot be done remotely — ifcurl -s -m 2 http://127.0.0.1:8731/gets no answer, ask the user to open Cavalry and run Scripts ▸ Cavalry Bridge. To install the bridge the first time, copy scripts/cavalry-bridge.js into the Cavalry Scripts folder (~/Library/Application Support/Cavalry/Scripts/— verify via Help ▸ Show Scripts Folder). - Write a build script to the scratchpad and send it:
bash scripts/cavalry-send.sh /path/to/build.js. The helper waits for the bridge's status file to change — it discovers the path from the bridge'sGET /getreply, so it lives in the user's own Cavalry preferences folder rather than a guessable/tmpname (CAVALRY_BRIDGE_STATUSoverrides). Long renders needCAVALRY_SEND_TIMEOUT=600(seconds). - Feedback comes back through files, not HTTP. The bridge only reports ok/failed;
console.logoutput stays in Cavalry's Log window. So every script must write its results to disk to be read back:- visual state →
api.setFrame(f); api.renderPNGFrame(dir + "/check_" + f, 50)at a few key frames (50 = 50% scale preview), then Read the PNGs; - data (ids, bounding boxes, attribute values) →
api.writeToFile(path, JSON.stringify(x), true).
- visual state →
- Iterate: inspect previews, fix the script, resend. Build scripts should be re-runnable —
create a fresh comp per version (
api.createComp("Thing v2")) so reruns don't stack layers into the old comp. - Final render: PNG frame loop + ffmpeg (works on every licence tier — see Rendering below).
A failed send (ok: false) usually means a JS exception; there is no stack trace over HTTP.
Re-send the script wrapped so the error lands in a file:
try { /* body */ } catch (e) { api.writeToFile("/tmp/cavalry-error.txt", String(e), true); }
Scene-building cheatsheet (proven patterns)
Comp setup — attribute names vary between Cavalry versions
Wrap api.set in a try/catch fallback chain rather than trusting one attribute name:
function trySet(id, obj) { try { api.set(id, obj); return true; } catch (e) { return false; } }
var comp = api.createComp("My animation v1"); // or api.getActiveComp()
api.setActiveComp(comp);
trySet(comp, {"resolution": [1920, 1080]});
trySet(comp, {"frameRate": 30}) || trySet(comp, {"fps": 30});
trySet(comp, {"frameRangeEnd": 240}) || trySet(comp, {"endFrame": 240}) || trySet(comp, {"outFrame": 240});
Shapes and attributes
var rect = api.primitive("rectangle", "BG"); // also: "ellipse", "polygon", "star"...
api.set(rect, {"generator.dimensions": [1980, 1120], // oversize the BG ~40px past frame edges
"position": [0, 0], // origin = comp centre, +y = up
"material.materialColor": "#121212"});
Common attribute paths: position [x,y], position.x, scale.x/scale.y, rotation,
material.materialColor (hex string), material.alpha (0–100 scale — values ≤ 1 are
near-invisible), generator.dimensions [w,h], generator.radius [rx,ry] (ellipse).
To discover names: right-click a layer → Copy Layer Id, right-click an attribute →
Copy Scripting Path, or api.getAttributes(id) / api.getAttributeDefinition(id, attr).
Sizing you can trust: measure, don't assume
Generator attrs also differ between versions (radius vs dimensions), so set what works, then
measure the real size and normalise with scale:
var e = api.primitive("ellipse", "Dot");
trySet(e, {"generator.radius": [px/2, px/2]}) || trySet(e, {"generator.dimensions": [px, px]});
var bb = api.getBoundingBox(e, true); // {x,y,width,height,centre,...} world space
var s = (bb.width > 1) ? (px / bb.width) : 1; // multiply into every later scale keyframe
The same trick sizes imported images: load, measure height, scale to target pixel height.
Keyframes and easing
api.keyframe(layer, 22, {"position.y": 940});
api.keyframe(layer, 50, {"position.y": 150});
api.magicEasing(layer, "position.y", 22, "BounceOut"); // easing is applied per-attr AT a keyframe
Magic easing presets: SlowIn/SlowOut/SlowInSlowOut, VerySlowIn/VerySlowOut/VerySlowInVerySlowOut,
SpringIn/SpringOut/SpringInSpringOut, SmallSpring… variants, AnticipateIn, OvershootOut,
AnticipateInOvershootOut, BounceIn/BounceOut/BounceInBounceOut, None.
Taste notes from production: OvershootOut for pop-in text, BounceOut for drops/landings,
SlowOut for bursts (particles, dust), SlowInSlowOut for drifts and pulses.
Cheap particle burst (no particle system needed): N small ellipses keyframed from one origin
outward on Math.cos/sin(angle) * dist, scaling to 0, SlowOut — see the reference for the
full confetti/spark snippet.
Text — and the per-letter animation recipe
var t = api.create("textShape", "Title");
api.set(t, {"horizontalAlignment": 1, "verticalAlignment": 1, // 1 = centre
"fontSize": 150, "font.font": "Nebula Sans", "font.style": "Black",
"material.materialColor": "#FFFFFF", "autoWidth": true, "autoHeight": true,
"position": [0, -190]});
trySet(t, {"text": "EMU 6-DAY RACE"}) || trySet(t, {"string": "EMU 6-DAY RACE"}); // attr name varies
Per-letter bounce-in (each glyph drops with a stagger) — the proven wiring is subMesh deformer + stagger driving its time offset:
var subMesh = api.create("subMesh", "Title bounce");
api.keyframe(subMesh, 34, {"shapePosition.y": 990}); // start above frame
api.keyframe(subMesh, 64, {"shapePosition.y": 0});
api.magicEasing(subMesh, "shapePosition.y", 34, "BounceOut");
api.connect(subMesh, "id", t, "deformers");
api.parent(subMesh, t);
var stagger = api.create("stagger", "Title stagger");
api.set(stagger, {"minimum": -12, "maximum": 0}); // frames of per-letter offset
api.flipGraph(stagger, "graph", "vertical"); // so the FIRST letter lands first
api.connect(stagger, "id", subMesh, "shapeTimeOffset");
api.parent(stagger, t);
Check fonts before using them: cavalry.fontExists(family), cavalry.getFontStyles(family).
Images and other assets
var asset = api.loadAsset("/abs/path/logo.png", false); // false = not an image sequence
var layer = api.addAssetToComp(asset);
var bb = api.getBoundingBox(layer, true);
var s = 470 / bb.height; // scale to ~470px tall
api.set(layer, {"scale.x": s, "scale.y": s});
Data-driven scenes: api.jsonFromAsset(assetId) parses a JSON/CSV asset,
api.loadGoogleSheet(spreadsheetId, sheetId) pulls a live sheet, and api.WebClient fetches
from any HTTP API — see the reference.
Rendering
Default: PNG frame loop + ffmpeg. Works on every licence tier and gives you the frames to inspect:
function pad(n) { return ("0000" + n).slice(-4); }
api.stop();
for (var f = 0; f <= END; f++) { api.setFrame(f); api.renderPNGFrame(OUT + "/name_" + pad(f), 100); }
renderPNGFrame(pathWithoutExtension, scalePercent) renders the current frame; .png is
appended. A 240-frame 1080p render takes a while — raise CAVALRY_SEND_TIMEOUT. Then assemble:
ffmpeg -framerate 30 -i name_%04d.png -c:v libx264 -pix_fmt yuv420p out.mp4 # opaque
ffmpeg -framerate 30 -i name_%04d.png -c:v qtrle out.mov # alpha overlay
For alpha output leave the comp background transparent (add no BG rectangle) — the PNGs then
carry alpha, and the qtrle .mov drops straight into DaVinci Resolve as an overlay.
api.renderSVGFrame(path, scale, skipComps) exists for vector snapshots.
Render Queue (in-app, all tiers): api.addRenderQueueItem(compId) → configure → api.render(itemId)
or api.renderAll(). Render Queue Items can carry Setup/Pre/Post render scripts (e.g. swap
assets or text per render) — see the reference.
Cavalry CLI (/Applications/Cavalry.app/Contents/Applications/CavalryCLI.app/Contents/MacOS/cavalry-cli):
headless render/list and the interactive --prompt REPL are Enterprise-licence only, so
don't plan around them unless the user confirms an Enterprise licence; version/auth work
everywhere. Formats include png, svg, gif, webm, webp, mp4, quicktime; --assetSwap replaces
assets at render time.
Gotchas (hard-won)
- The bridge is fire-and-forget — design every script around file-based feedback (status
JSON, preview PNGs, error catch-files). Never assume you'll see
console.log. api.execcode is wrapped in an IIFE by the bridge, so barevarat top level is fine but nothing persists between sends. Persist state in the scene or on disk.- Version drift: comp attrs (
frameRatevsfps), text (textvsstring), generators (radiusvsdimensions) all changed names across releases — always use thetrySetchain. material.alphais 0–100, not 0–1.- Preview cheaply: render check frames at 50% scale at a handful of story beats, not every frame; full-res full-range renders only once the previews look right.
- Re-runs:
api.createCompa fresh versioned comp per attempt; reruns into the same comp duplicate every layer. - Save the scene from script —
api.saveSceneAs("/path/scene.cv")— so work survives a crash; wrap in try/catch (fails if a modal dialog is open). - The vendored type definitions double as offline docs — nearly every function has a JSDoc example; grep before guessing a signature.
Security
Running the bridge means running a code-execution server on the user's machine. Say so before asking them to start it.
- The bridge binds
127.0.0.1:8731and executes any JavaScript POSTed to it inside the live session — the user's privileges, the user's open scene. Raw non-JSON bodies are run as JS directly. Requests carry no authentication: every local process, and every other user on a shared machine, can drive Cavalry through it. - Web pages cannot. Requests carrying an
Originheader or a cross-siteSec-Fetch-Siteare rejected with 403, so a page in the user's browser can't reach the bridge. That check is the only gate — there is no token. - Nothing listens until the user runs it. Copying
cavalry-bridge.jsinto the Scripts folder only puts it in the menu; the port opens when they pick Scripts ▸ Cavalry Bridge and stays open for that session. - To stop the bridge, quit Cavalry. There is no remote shutdown; the port is released with the process.