{
  "markdown": "# promoshot\n\n**See it work:** [demo.md](demo.md) — twenty-four prompts, each given to a\nfresh agent with only the skill and the MCP, its result beside the\nhand-built reference. The suite is in [demos/](demos/README.md).\n\n<p align=\"center\">\n  <img src=\"docs/rendered-on-linux.png\" width=\"720\"\n       alt=\"A frame rendered by the engine on Linux: a bordered video card over a themed background, with a stroked, shadowed caption reading 'Rendered on Linux'.\">\n</p>\n\nThe rendering engine behind [PromoShot](https://promoshot.app)\n([App Store](https://apps.apple.com/us/app/promoshot-app/id6770157576)),\nand an open implementation of its project format. A `.promo` project is a\nfolder — `metadata.json` plus its media — and this workspace is everything\nneeded to validate, inspect, and render one to stills, image sequences, or\nmp4 with mixed audio: no app attached, byte-for-byte the same compositor the\napps ship.\n\nThe design bet is that **the format is the interface**. An assistant, a\nscript, or a person writes `metadata.json`; the engine renders it the same\neverywhere — the Mac and iOS apps (Metal + VideoToolbox), this repo's CLI,\nor a headless Linux box with no GPU at all (wgpu on lavapipe, ffmpeg as a\nsubprocess). The format has three faces behind one truth: an authoring\nsubset with four validated recipes (`promo schema`), the full document\n(`--full`), and a types-only JSON Schema generated from the parser's own\nstructs (`--types`) — and the parser the validator runs is the parser the\nrenderers use, so \"validates\" means \"renders\".\n\n## Crates\n\n| Crate | What it owns |\n|---|---|\n| `promo-model` | The format: wire structs, migrations, palette roles, `schema.md` |\n| `promo-timeline` | Timeline math: keyframes, trims, attachments, waits, validation |\n| `promo-gpu` | wgpu compositing: quads, borders, letterbox, vectors, color conversion |\n| `promo-text` | Caption shaping and effects (cosmic-text) |\n| `promo-engine` | Preview/export orchestration, frame cache, memory governor, PCM mixer |\n| `promo-media` | Decoder/encoder trait registry; ffmpeg-subprocess backend + conformance suite |\n| `promo-editor` | The document's edit vocabulary: commands with undo, the wizard's arrangement, theme rules — what `promo_apply` and `promo_slideshow` are built on |\n| `promo-cli` | `promo` — render a project from the command line |\n| `promoshot-mcp` | MCP server over stdio, for agents |\n\n## Build and verify\n\n```\n./check-all.sh          # fmt, clippy -D warnings, all tests, release build\n```\n\nRendering video needs `ffmpeg` (and `ffprobe`) on PATH — frames are composited\non the GPU and piped to it raw; ffmpeg only decodes and encodes. On a headless\nLinux machine, `mesa-vulkan-drivers` (lavapipe) is enough of a GPU.\n\n## The CLI\n\n```\ncargo build --release -p promo-cli     # -> target/release/promo\n\npromo schema                            # authoring subset + recipes; --full, --types\npromo validate <project>                # exit 0 == this will render\npromo inspect  <project>                # canvas, layers, missing media, undefined colours\npromo still    <project> --out f.png --time 2.5\npromo frames   <project> --out frames/ --fps 30 --from 0 --to 4\npromo video    <project> --out out.mp4 --fps 30\n```\n\nAdd `--json` to any project command for machine output — one object on\nstdout, errors included, exit codes unchanged.\n\n`promo video` mixes the soundtrack the apps would: trims and media cuts,\nheld frames, speed with pitch preserved, keyframed volume, a focused\nnarration ducking everything under it, and only the audio tracks the\nproject keeps.\n\nHeadless renders are CLEAN — no watermark, and no license, serial or key\nwill ever be asked for. (The Mac and iOS apps watermark free-tier renders;\nthat is their App Store Pro line, and it stays on their side of the fence.)\n\n## The MCP server\n\n`promoshot-mcp` speaks Model Context Protocol over stdio, so any MCP client\ncan author, inspect and render projects. It owns no rendering code — every\nrender shells to `promo` (found next to the executable, or on PATH, or via\n`--promo`), so the CLI stays the single contract.\n\n### Connect an agent\n\nTwo pieces: the MCP server (tools) and the skill (workflow).\nNeither is vendor-specific. Agents do not find this repo by themselves.\n\n**1. Build — or don't**\n\n```bash\ncargo build --release -p promo-cli -p promoshot-mcp\n# binaries: target/release/promo  target/release/promoshot-mcp\n```\n\nNo Rust toolchain? Grab the prebuilt pair from\n[Releases](https://github.com/GarAlex/promoshot/releases) (linux-x64,\nmacos-arm64), or pull the image:\n`docker pull ghcr.io/garalex/promoshot-mcp` — both carry `promo` and\n`promoshot-mcp` together.\n\nPut both on PATH, or pass `--promo` to the server. Rendering video also\nwants `ffmpeg`/`ffprobe` on PATH.\n\n**2. MCP (required for tools)**\n\nClaude Code / Cursor / any `mcp.json`:\n\n```json\n{\n  \"mcpServers\": {\n    \"promoshot\": {\n      \"command\": \"/ABS/PATH/target/release/promoshot-mcp\",\n      \"args\": [\"--workspace\", \"/ABS/PATH/Promo\", \"--root\", \"/ABS/PATH/Promo\"]\n    }\n  }\n}\n```\n\n`--workspace` is where new projects go; `--root` fences which projects the\nserver will touch — pointing both at one folder is the tidy setup. Both\noptional. `--log <file>` appends one line per tool call — when,\nwhich tool, how many milliseconds, how it went — for a session's own\naccounting; the demo pages are built from it.\n\nClient one-liners:\n\n```bash\n# Claude Code\nclaude mcp add promoshot /ABS/PATH/target/release/promoshot-mcp\n\n# Grok Build\ngrok mcp add promoshot -- /ABS/PATH/target/release/promoshot-mcp \\\n  --workspace /ABS/PATH/Promo --root /ABS/PATH/Promo\ngrok inspect   # confirms the server registered\n\n# Docker — the host needs nothing but docker (details below)\ndocker build -t promoshot-mcp .\n# then command: docker, args: [\"run\",\"-i\",\"--rm\",\"-v\",\"/ABS/PATH/Promo:/projects\",\"promoshot-mcp\"]\n```\n\n**3. Skill (the workflow)**\n\nSame file everywhere: [skill/SKILL.md](skill/SKILL.md).\n\n```bash\nREPO=https://github.com/GarAlex/promoshot\ngit clone --depth 1 $REPO /tmp/promoshot\n\n# Claude Code (Grok Build also scans this folder)\nmkdir -p ~/.claude/skills/promoshot\ncp /tmp/promoshot/skill/SKILL.md ~/.claude/skills/promoshot/SKILL.md\n\n# Grok Build explicit path\nmkdir -p ~/.grok/skills/promoshot\ncp /tmp/promoshot/skill/SKILL.md ~/.grok/skills/promoshot/SKILL.md\n\n# OpenAI Codex / many others\nmkdir -p ~/.agents/skills/promoshot\ncp /tmp/promoshot/skill/SKILL.md ~/.agents/skills/promoshot/SKILL.md\n\n# Cursor project (in the repo the user is editing, not this engine repo)\nmkdir -p .cursor/rules\ncp /tmp/promoshot/skill/SKILL.md .cursor/rules/promoshot.md\n# or: mkdir -p .agents/skills/promoshot && cp SKILL.md there\n```\n\nAny agent that reads instructions can be handed the file directly; it\nassumes only these tools (or the CLI).\n\n**4. Verify** — ask the agent for a render:\n\n> Render examples/ProductCard.promo to a still at 3s.\n\nOne `promo_validate`, one `promo_render_still`, and a device-framed app\ndemo comes back as a path. From there, \"make me a promo for <my app>\" is\nthe loop the skill teaches.\n\n### The tools\n\nTools: `promo_schema` (authoring subset + four validated recipes;\n`promo_schema_full` is the whole format; `promo_schema_types` is the format\nas a generated, types-only JSON Schema — also checked in at\n[docs/promo.schema.json](docs/promo.schema.json) for `$schema` editor\nautocomplete), `promo_validate`, `promo_inspect` (each layer listed with\nits id — the handle the editing tools take),\n`promo_render_still`, `promo_render_frames`, `promo_render_video`,\n`promo_render_gif`, `promo_workspace`; the senses — `promo_media_probe`,\n`promo_media_filmstrip` (a contact sheet of a SOURCE clip, times per cell),\n`promo_media_silences` (silence spans and their inverse) and\n`promo_media_scenes` (scene cuts and the shots between them), so an agent\nknows what footage holds before composing with it; the editor trio,\n`promo_init`, `promo_upsert_layer` and `promo_upsert_keyframe`: create a\nproject, add image/video/caption layers with placements, then animate —\na second placement keyframe is a push-in, viewport keyframes a Ken Burns;\nyour short ids are used verbatim, unnamed ones get canonical UUIDs, pixel\nsizes are stamped, and the composition keeps covering its layers. Device\nframes bake headless too — the same slab the apps draw. `promo_slideshow`\nis the wizard: pictures and clips in, a complete classic, carousel or\nstore-listing show out, a caption on any slide becoming a layer that\nlives with its picture. `promo_voices`\nlists a provider's voices and `promo_speak` synthesizes narration with the\nperson's own provider key, reusing unchanged text by receipt. The authoring tools answer\nwith an inline thumbnail of the composition, so a misplaced layer is caught\nat the moment it happens. The tools write ordinary `metadata.json`\nthrough the format's own parser — the schema stays the source of truth, and\nhand-editing remains first-class. Renders default their output into the\nproject's `Exports/` folder and return the path written, never the bytes.\n\nFlags, all optional: `--workspace <dir>` (where `promo_workspace` points;\nelse `$PROMOSHOT_WORKSPACE`, else the XDG data dir), `--root <dir>` (refuse\nprojects outside this tree), `--promo <path>`.\n\n### Narration keys\n\nNarration spends the person's own provider account, and the key never\npasses through the agent: no tool takes one, none shows one. Register it\nonce in the OS keyring — macOS Keychain, the Secret Service on Linux\n(GNOME Keyring, KWallet), the Credential Manager on Windows:\n\n```bash\npromoshot-mcp key set openai        # reads the key from stdin: paste, then Ctrl-D\npromoshot-mcp key status            # where each provider's key comes from, never the key\npromoshot-mcp key remove openai\n```\n\nProviders: `openai`, `elevenlabs`, `google`. The key is read from stdin so\nit lands in no shell history, no config file and no argument list.\n\nWhere there is no keyring — the Docker image, a CI runner — the key is\nread from a **secrets file**, the way Docker, Kubernetes and CI systems\nhand secrets over: `/run/secrets/OPENAI_API_KEY` (likewise\n`ELEVENLABS_API_KEY`, `GOOGLE_API_KEY`), or the path named by\n`OPENAI_API_KEY_FILE`. A mode-0400 file, never an environment variable\nthat `docker inspect` and every same-user process can read:\n\n```bash\ndocker run -i --rm \\\n  -v \"$HOME/.secrets/openai:/run/secrets/OPENAI_API_KEY:ro\" \\\n  -v /path/to/your/projects:/projects promoshot-mcp\n```\n\nAn agent can ask before it plans: `promo_speak` with `{\"check\": true}`\nspends nothing and reports, per provider, whether a key is present and\nwhat a real call would synthesize. A real call checks every pending\nnarration's key before buying anything, and writes each receipt back the\nmoment it is paid for, so a failure part-way never makes the next call\npay twice. Keys travel in request headers, never URLs, and nothing logs\nthem.\n\n### Docker\n\nThe image is the whole render environment — server, CLI, ffmpeg, a\nsoftware Vulkan and the fonts — so a client needs nothing on the host:\n\n```\ndocker build -t promoshot-mcp .\n```\n\n```json\n{\n  \"mcpServers\": {\n    \"promoshot\": {\n      \"command\": \"docker\",\n      \"args\": [\"run\", \"-i\", \"--rm\",\n               \"-v\", \"/path/to/your/projects:/projects\",\n               \"promoshot-mcp\"]\n    }\n  }\n}\n```\n\nProjects live under the mount; `promo_workspace` answers `/projects`. All\nthe [examples](examples/) are baked in, so the image proves itself with no\nmount at all — render `ProductCard.promo` first; the device-framed app\ndemo is the one that teaches the product-promo path. `server.json` is the MCP Registry manifest (`io.github.GarAlex/promoshot`) for the published\nimage (`ghcr.io/garalex/promoshot-mcp`). GitHub's [MCP Registry](https://github.com/mcp) consumes that feed after `mcp-publisher publish`.\n\nmcp-name: io.github.GarAlex/promoshot\n\nThe skill is drift-tested: a test pins it to the server's actual tool\nlist, so it cannot teach tools that do not exist.\n\nThe Mac app carries its own MCP server (Settings → Automation) sharing the\ncore tool names, plus app-only abilities — opening the editor, speech\nsynthesis. The authoring pair, the senses and the types schema are\nheadless-first.\n\nWhat a session looks like — three requests in, a validated project and a\nrendered frame out (the frame at the top of this page was made exactly this\nway):\n\n```json\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\"}}\n{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"promo_validate\",\"arguments\":{\"project\":\"examples/LinuxSmoke.promo\"}}}\n{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"promo_render_still\",\"arguments\":{\"project\":\"examples/LinuxSmoke.promo\",\"time\":5.5}}}\n```\n\n```json\n{\"id\":2,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"ok — nothing the renderer would quietly correct\"}]}}\n{\"id\":3,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"wrote examples/LinuxSmoke.promo/Exports/still-5.5s.png (1280x720 at 5.50s)\"}]}}\n```\n\n## One engine, every platform\n\nThe same project rendered on macOS (Metal, VideoToolbox) and on a bare\nLinux container (lavapipe software Vulkan, no GPU; ffmpeg) — SSIM 0.983\nover the full 240-frame video. The visible difference is the font: the\ncaption asks the question, the two frames answer it.\n\nTry it yourself — [examples/](examples/) holds one runnable project per\n`promo_schema` recipe (each metadata.json IS its recipe, pinned by a\ntest), from the device-framed product card to the 9:16 re-stamp — plus\nthe kitchen-sink [LinuxSmoke.promo](examples/LinuxSmoke.promo):\n\n```\npromo video examples/ProductCard.promo --out card.mp4\n```\n\n## Authoring a project\n\nStart with `promo schema`. The short version: a project folder holds\n`metadata.json` and `Resources/`; ids are unique strings (short mnemonics\nare fine — apps mint UUIDs on adoption); layers place resources\non a timeline with keyframes (hold-then-ease), placement rules, transitions\nand palette-named colours (`@accent`). Validate before rendering — the\nvalidator names what the renderer would silently correct, undefined colour\nnames included.\n\n```\nmkdir -p Demo.promo/Resources\n# write Demo.promo/metadata.json, copy media into Resources/\npromo validate Demo.promo && promo still Demo.promo --out look.png --time 1\n```\n\nOr let the MCP server spend the boilerplate (`promo_init`,\n`promo_upsert_layer`), and give your editor autocomplete by pointing\n`\"$schema\"` at [docs/promo.schema.json](docs/promo.schema.json).\n\n## Invariants and plans\n\n- `SPECS.md` — the invariants the tests pin.\n\n## License\n\nApache-2.0. The PromoShot applications built on this engine are separate,\nproprietary products.\n\n## Proxies for long sources\n\n`promo proxy <project>` builds a tier-1 proxy (960 px long edge, every\nframe a keyframe) for each video resource, in a cache outside the\npackage (`$PROMO_PROXY_DIR`, else the platform cache directory under\n`promoshot/proxies`). `still`, `frames`, `gif` and `video` take\n`--proxy auto|on|off`: `auto` (default) reads a built proxy when the\noutput's long edge fits it, `on` builds missing proxies first, `off`\nreads the source — and a full-size render always does. The MCP tools\ntake the same `proxy` argument; `promo_proxy` builds them.\n\n## Markers and chapters\n\nA project may carry `markers` — named moments on the output timeline.\n`kind: \"chapter\"` markers are written into an exported mp4's chapter\nlist (a player's chapter menu); `inspect` lists them all.\n\n## Audio effects\n\nA video or audio resource may carry `audioEffects` — `normalize`\n(loudness to a target LUFS), `compressor` and one-band `eq` entries,\napplied in order before the mix in every render the core makes. The\napps' exports take the same mix; their live preview plays the resource\ndry.\n\n## Chroma key\n\nA video or image layer may carry `chromaKey` — a colour, a tolerance\nand a softness: the plate becomes transparent before the layer's grade,\nborder and mask, in the compositor, so a green-screen clip composes\nover anything on every host alike.\n\n## Models\n\nA resource of kind `model` is a glTF 2.0 binary (`.glb`) in `Resources/`;\na layer of kind `model` draws it through a PBR-lite pass into a texture\nat the layer's size, and from there it is a picture like any other:\nplacement, opacity, transitions, masks, effects and the contact shadow\nall apply. Keyframes carry a `camera` (yaw, pitch, roll, distance in\nbounds radii, fov) and a `light`; `materials` on the resource bind a\nslot name to a colour — a palette name works, so `@accent` re-skins the\nbody with the theme — and, in the object form, to a finish: `metallic`\nand `roughness` (each 0…1) over the file's own, so one body is chrome in\nthis project and matte in the next (rung 32) — or, better, to a finish\nWORD (rung 44): `chrome`, `brushed`, `anodized`, `gloss`, `satin`,\n`matte`, `rubber`, `ceramic`, `lacquer`, `paper`, `glass`, `frosted`,\neach expanded by the engine into the numbers, the coat, the grain, the\ntransmission and the refraction it stands for, so nobody levels a\nreflection by hand; on a screen the word is the coat over the picture,\nand glass on a stage bends the bodies behind it. Lighting defaults come\nfrom the theme; a scene `environment` (studio, sunset, night; rung\n35 — or, rung 46, a `resourceID` naming a panorama in the project, a\npicture of the world the bodies mirror) is what metals mirror; a file's\nnormal map and metallic-roughness\ntexture are honoured. Rung 29. Built-in device bodies\n(phone, tablet, laptop; `promo device`) ship as generated `.glb` files\nwith `Body` and `Screen` slots, so the device shot is a model too. A\nmodel can also be a `recipe` the engine builds at load instead of a file\n— text as a body first: real type in the 3D world with `Face` and `Side`\nslots, lit and finished like any body (rung 34); a device body; and a\nbody of PARTS — boxes, spheres, cylinders, tori, a lathe, an extrude,\neach under a slot, placed by position, rotation and scale — the 3D\ncounterpart of a drawing, authored the way an SVG is (rung 37).\nLayers naming the same `stage` draw through one camera into one depth\nbuffer, models at their `depth` and pictures as billboards, the first\nmember's placement carrying the whole scene (rung 30). A stage can also\nbe one layer of kind `stage` holding its `members`, the camera and light\non its own keyframes (rung 33) — the same picture, with the stage's\nownership written down. A stage's `floor` word (rung 45) — `matte`,\n`satin`, `glossy`, `mirror` — puts a plane under the lowest body that\ncatches the key light's shadow, the darkening where a body touches, and\nthe stage mirrored in it, blurred less and less; whatever lies beneath\nthe stage layer shows through, so the table is the project's own\nbackground. The light's keyframes move the shadow; the floor stays.\n\n## A picture worn by a body\n\nA slot's picture is a screen by default: unlit, fitted, what a\nscreenshot on a phone wants. `\"mode\": \"surface\"` on the binding wears\nit instead — the image or video becomes the slot's colour under the\nlight and the finish, tiled by `repeat` and shifted by `offset`, the\nslot's own colour showing through where the picture is transparent — so\na label sits on a vase, a print on a box, and a video plays on a glossy\nwall that the key light and the environment still shade. Rung 38.\n\n## Particles\n\nA resource of kind `particles` is a recipe, not a file — an emitter, a\nrate or a burst, life, speed, gravity, wind, drag, turbulence, size and\ncolour over life, a shape — played by a drawing layer. Every particle is\na closed-form function of its birth time and the seed, so any frame\nrenders alone and identically on every host. Rung 36.\n\nA path resource can carry a route in the stage (rung 40): 3D points in\nstage radii that a member's or a camera's `motionPath` follows, fitted\nbetween two keyframes exactly as the 2D motion path is, with a camera\n`target` — the centre, ahead, a member or a point — saying where it\nlooks on the way. A spiral that keeps looking inward is one route and\none keyframe.\n\nParticles in a stage are a morph (rung 39): the recipe names two bodies,\nsamples the first's surface, and as a drawing member's `progress`\nkeyframe ramps from 0 to 1 the points fly out and gather on the second\nbody — a cube bursts into points that settle into a word. A parts box\nwith `faces: true` has six slots, one picture per side, which is what\nsuch a cube is made of.\n\n## Text with a side\n\nLegacy: the caption `depth` below is the flat compositor's 2.5D. A title\nwith a real side is a text body (rung 34) standing in a stage; the\nvalidator names the old form. It still renders.\n\nA caption style may carry `depth`: copies of the words stacked under the\nface, each a little further along and darker, so the type reads as\nsolid letters with a side — the classic extrusion, pure 2D, lit by\nchoosing the offset. A reveal extrudes each arriving piece the same way.\n`tiltX` / `tiltY` keyframes on a caption lean it in perspective, on the\nsame camera the device frames use, and the side leans with the face.\nA reveal's `flip`, `tumble` and `slide` modes bring each word in on its\nown axes — kinetic type from one rule, no keyframes.\n\n## Follow the pointer\n\nA video the Mac recorder made carries `pointer`, where the pointer went\nand where it clicked, in the recording's own time and coordinates. A\nlayer showing it may say `follow`: its viewport becomes a window\n`1/zoom` of the source that follows the smoothed pointer, and each click\ndraws a ring that grows and fades. A rule, not keyframes: re-trim the\nrecording and it stays true, on every host alike.\n\n## Image effects\n\nA layer may carry `effects`: a `blur` (round, or directional along a\n`blurAngle`), a `glow` of its bright parts, a `vignette` toward its own\ncorners, film `grain` and an unsharp `sharpen`, each on the layer's own\npixels in the compositor. Blur, glow and vignette are keyframe tracks\ntoo, so a focus pull or a glow that pulses ramps like the grade does.\nFive transitions ride the same passes — `blurDissolve`, `zoom`, `flash`,\n`glitch` and `dip` — beside the fade, wipe, slide, push and scale that\nwere there, at a layer's edges and at a resource swap alike. A video\nlayer's swap may name a composition (rung 47): the takeover, the next\nfilm arriving through any of those cuts where its `sourceTime` says, or\nwhere its clock already is. The same keyframes carry the consumer's\ntransport for anything with a clock — a video, an audio, a composition,\na sprite: `sourceTime` seeks it, `playback` pauses and resumes it, and\nits sound follows.\n\n## Looks from a `.cube`\n\nA resource of kind `lut` is a `.cube` file in `Resources/`; a layer's\n`adjustments.lutResourceID` (with `lutAmount`) applies it in the\ncompositor after the layer's own grade — a trilinear lookup on every\nhost alike.\n\n## ProRes and alpha\n\n`promo video … --codec prores422|prores4444 --out x.mov` writes ProRes;\n`--alpha` renders the project over nothing and keeps the frames' alpha\nin a ProRes 4444 (`--alpha` on `still`/`frames` gives transparent PNGs).\nSources that carry alpha (ProRes 4444, WebM with alpha, PNG sequences)\ndecode premultiplied and compose with their transparency.\n",
  "bytes": 23102,
  "sha": "ef7e4861957d67dd0a6be5d600e59eb8998e3ceff14968b3ca8d987b503813d8",
  "repo_slug": "garalex/promoshot",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_garalex_promoshot_88d71ca4/readme"
}