io.github.GarAlex/promoshot
Headless MCP: author and render PromoShot .promo video projects (stills, GIFs, video).
Open source Open in the app JSON README (API)
About
Headless MCP: author and render PromoShot .promo video projects (stills, GIFs, video).
Details
- Kind
- MCP servers
- Topic
- No topic detected
- Publisher
- garalex
- Origin
- official
- Category
- ferramentas
- Transport
- local
- Version
- 0.2.75
- Last push
- 2026-09-07T05:10:27Z
- Repository state
- ativo
- Language
- Rust
- License
- Apache-2.0
- Added
- 2026-09-01 00:00:07
- Updated
- 2026-09-11 00:05:07
- Origin id
io.github.GarAlex/promoshot
README
# promoshot
**See it work:** [demo.md](demo.md) — twenty-four prompts, each given to a
fresh agent with only the skill and the MCP, its result beside the
hand-built reference. The suite is in [demos/](demos/README.md).
<p align="center">
<img src="docs/rendered-on-linux.png" width="720"
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'.">
</p>
The rendering engine behind [PromoShot](https://promoshot.app)
([App Store](https://apps.apple.com/us/app/promoshot-app/id6770157576)),
and an open implementation of its project format. A `.promo` project is a
folder — `metadata.json` plus its media — and this workspace is everything
needed to validate, inspect, and render one to stills, image sequences, or
mp4 with mixed audio: no app attached, byte-for-byte the same compositor the
apps ship.
The design bet is that **the format is the interface**. An assistant, a
script, or a person writes `metadata.json`; the engine renders it the same
everywhere — the Mac and iOS apps (Metal + VideoToolbox), this repo's CLI,
or a headless Linux box with no GPU at all (wgpu on lavapipe, ffmpeg as a
subprocess). The format has three faces behind one truth: an authoring
subset with four validated recipes (`promo schema`), the full document
(`--full`), and a types-only JSON Schema generated from the parser's own
structs (`--types`) — and the parser the validator runs is the parser the
renderers use, so "validates" means "renders".
## Crates
| Crate | What it owns |
|---|---|
| `promo-model` | The format: wire structs, migrations, palette roles, `schema.md` |
| `promo-timeline` | Timeline math: keyframes, trims, attachments, waits, validation |
| `promo-gpu` | wgpu compositing: quads, borders, letterbox, vectors, color conversion |
| `promo-text` | Caption shaping and effects (cosmic-text) |
| `promo-engine` | Preview/export orchestration, frame cache, memory governor, PCM mixer |
| `promo-media` | Decoder/encoder trait registry; ffmpeg-subprocess backend + conformance suite |
| `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 |
| `promo-cli` | `promo` — render a project from the command line |
| `promoshot-mcp` | MCP server over stdio, for agents |
## Build and verify
```
./check-all.sh # fmt, clippy -D warnings, all tests, release build
```
Rendering video needs `ffmpeg` (and `ffprobe`) on PATH — frames are composited
on the GPU and piped to it raw; ffmpeg only decodes and encodes. On a headless
Linux machine, `mesa-vulkan-drivers` (lavapipe) is enough of a GPU.
## The CLI
```
cargo build --release -p promo-cli # -> target/release/promo
promo schema # authoring subset + recipes; --full, --types
promo validate <project> # exit 0 == this will render
promo inspect <project> # canvas, layers, missing media, undefined colours
promo still <project> --out f.png --time 2.5
promo frames <project> --out frames/ --fps 30 --from 0 --to 4
promo video <project> --out out.mp4 --fps 30
```
Add `--json` to any project command for machine output — one object on
stdout, errors included, exit codes unchanged.
`promo video` mixes the soundtrack the apps would: trims and media cuts,
held frames, speed with pitch preserved, keyframed volume, a focused
narration ducking everything under it, and only the audio tracks the
project keeps.
Headless renders are CLEAN — no watermark, and no license, serial or key
will ever be asked for. (The Mac and iOS apps watermark free-tier renders;
that is their App Store Pro line, and it stays on their side of the fence.)
## The MCP server
`promoshot-mcp` speaks Model Context Protocol over stdio, so any MCP client
can author, inspect and render projects. It owns no rendering code — every
render shells to `promo` (found next to the executable, or on PATH, or via
`--promo`), so the CLI stays the single contract.
### Connect an agent
Two pieces: the MCP server (tools) and the skill (workflow).
Neither is vendor-specific. Agents do not find this repo by themselves.
**1. Build — or don't**
```bash
cargo build --release -p promo-cli -p promoshot-mcp
# binaries: target/release/promo target/release/promoshot-mcp
```
No Rust toolchain? Grab the prebuilt pair from
[Releases](https://github.com/GarAlex/promoshot/releases) (linux-x64,
macos-arm64), or pull the image:
`docker pull ghcr.io/garalex/promoshot-mcp` — both carry `promo` and
`promoshot-mcp` together.
Put both on PATH, or pass `--promo` to the server. Rendering video also
wants `ffmpeg`/`ffprobe` on PATH.
**2. MCP (required for tools)**
Claude Code / Cursor / any `mcp.json`:
```json
{
"mcpServers": {
"promoshot": {
"command": "/ABS/PATH/target/release/promoshot-mcp",
"args": ["--workspace", "/ABS/PATH/Promo", "--root", "/ABS/PATH/Promo"]
}
}
}
```
`--workspace` is where new projects go; `--root` fences which projects the
server will touch — pointing both at one folder is the tidy setup. Both
optional. `--log <file>` appends one line per tool call — when,
which tool, how many milliseconds, how it went — for a session's own
accounting; the demo pages are built from it.
Client one-liners:
```bash
# Claude Code
claude mcp add promoshot /ABS/PATH/target/release/promoshot-mcp
# Grok Build
grok mcp add promoshot -- /ABS/PATH/target/release/promoshot-mcp \
--workspace /ABS/PATH/Promo --root /ABS/PATH/Promo
grok inspect # confirms the server registered
# Docker — the host needs nothing but docker (details below)
docker build -t promoshot-mcp .
# then command: docker, args: ["run","-i","--rm","-v","/ABS/PATH/Promo:/projects","promoshot-mcp"]
```
**3. Skill (the workflow)**
Same file everywhere: [skill/SKILL.md](skill/SKILL.md).
```bash
REPO=https://github.com/GarAlex/promoshot
git clone --depth 1 $REPO /tmp/promoshot
# Claude Code (Grok Build also scans this folder)
mkdir -p ~/.claude/skills/promoshot
cp /tmp/promoshot/skill/SKILL.md ~/.claude/skills/promoshot/SKILL.md
# Grok Build explicit path
mkdir -p ~/.grok/skills/promoshot
cp /tmp/promoshot/skill/SKILL.md ~/.grok/skills/promoshot/SKILL.md
# OpenAI Codex / many others
mkdir -p ~/.agents/skills/promoshot
cp /tmp/promoshot/skill/SKILL.md ~/.agents/skills/promoshot/SKILL.md
# Cursor project (in the repo the user is editing, not this engine repo)
mkdir -p .cursor/rules
cp /tmp/promoshot/skill/SKILL.md .cursor/rules/promoshot.md
# or: mkdir -p .agents/skills/promoshot && cp SKILL.md there
```
Any agent that reads instructions can be handed the file directly; it
assumes only these tools (or the CLI).
**4. Verify** — ask the agent for a render:
> Render examples/ProductCard.promo to a still at 3s.
One `promo_validate`, one `promo_render_still`, and a device-framed app
demo comes back as a path. From there, "make me a promo for <my app>" is
the loop the skill teaches.
### The tools
Tools: `promo_schema` (authoring subset + four validated recipes;
`promo_schema_full` is the whole format; `promo_schema_types` is the format
as a generated, types-only JSON Schema — also checked in at
[docs/promo.schema.json](docs/promo.schema.json) for `$schema` editor
autocomplete), `promo_validate`, `promo_inspect` (each layer listed with
its id — the handle the editing tools take),
`promo_render_still`, `promo_render_frames`, `promo_render_video`,
`promo_render_gif`, `promo_workspace`; the senses — `promo_media_probe`,
`promo_media_filmstrip` (a contact sheet of a SOURCE clip, times per cell),
`promo_media_silences` (silence spans and their inverse) and
`promo_media_scenes` (scene cuts and the shots between them), so an agent
knows what footage holds before composing with it; the editor trio,
`promo_init`, `promo_upsert_layer` and `promo_upsert_keyframe`: create a
project, add image/video/caption layers with placements, then animate —
a second placement keyframe is a push-in, viewport keyframes a Ken Burns;
your short ids are used verbatim, unnamed ones get canonical UUIDs, pixel
sizes are stamped, and the composition keeps covering its layers. Device
frames bake headless too — the same slab the apps draw. `promo_slideshow`
is the wizard: pictures and clips in, a complete classic, carousel or
store-listing show out, a caption on any slide becoming a layer that
lives with its picture. `promo_voices`
lists a provider's voices and `promo_speak` synthesizes narration with the
person's own provider key, reusing unchanged text by receipt. The authoring tools answer
with an inline thumbnail of the composition, so a misplaced layer is caught
at the moment it happens. The tools write ordinary `metadata.json`
through the format's own parser — the schema stays the source of truth, and
hand-editing remains first-class. Renders default their output into the
project's `Exports/` folder and return the path written, never the bytes.
Flags, all optional: `--workspace <dir>` (where `promo_workspace` points;
else `$PROMOSHOT_WORKSPACE`, else the XDG data dir), `--root <dir>` (refuse
projects outside this tree), `--promo <path>`.
### Narration keys
Narration spends the person's own provider account, and the key never
passes through the agent: no tool takes one, none shows one. Register it
once in the OS keyring — macOS Keychain, the Secret Service on Linux
(GNOME Keyring, KWallet), the Credential Manager on Windows:
```bash
promoshot-mcp key set openai # reads the key from stdin: paste, then Ctrl-D
promoshot-mcp key status # where each provider's key comes from, never the key
promoshot-mcp key remove openai
```
Providers: `openai`, `elevenlabs`, `google`. The key is read from stdin so
it lands in no shell history, no config file and no argument list.
Where there is no keyring — the Docker image, a CI runner — the key is
read from a **secrets file**, the way Docker, Kubernetes and CI systems
hand secrets over: `/run/secrets/OPENAI_API_KEY` (likewise
`ELEVENLABS_API_KEY`, `GOOGLE_API_KEY`), or the path named by
`OPENAI_API_KEY_FILE`. A mode-0400 file, never an environment variable
that `docker inspect` and every same-user process can read:
```bash
docker run -i --rm \
-v "$HOME/.secrets/openai:/run/secrets/OPENAI_API_KEY:ro" \
-v /path/to/your/projects:/projects promoshot-mcp
```
An agent can ask before it plans: `promo_speak` with `{"check": true}`
spends nothing and reports, per provider, whether a key is present and
what a real call would synthesize. A real call checks every pending
narration's key before buying anything, and writes each receipt back the
moment it is paid for, so a failure part-way never makes the next call
pay twice. Keys travel in request headers, never URLs, and nothing logs
them.
### Docker
The image is the whole render environment — server, CLI, ffmpeg, a
software Vulkan and the fonts — so a client needs nothing on the host:
```
docker build -t promoshot-mcp .
```
```json
{
"mcpServers": {
"promoshot": {
"command": "docker",
"args": ["run", "-i", "--rm",
"-v", "/path/to/your/projects:/projects",
"promoshot-mcp"]
}
}
}
```
Projects live under the mount; `promo_workspace` answers `/projects`. All
the [examples](examples/) are baked in, so the image proves itself with no
mount at all — render `ProductCard.promo` first; the device-framed app
demo is the one that teaches the product-promo path. `server.json` is the MCP Registry manifest (`io.github.GarAlex/promoshot`) for the published
image (`ghcr.io/garalex/promoshot-mcp`). GitHub's [MCP Registry](https://github.com/mcp) consumes that feed after `mcp-publisher publish`.
mcp-name: io.github.GarAlex/promoshot
The skill is drift-tested: a test pins it to the server's actual tool
list, so it cannot teach tools that do not exist.
The Mac app carries its own MCP server (Settings → Automation) sharing the
core tool names, plus app-only abilities — opening the editor, speech
synthesis. The authoring pair, the senses and the types schema are
headless-first.
What a session looks like — three requests in, a validated project and a
rendered frame out (the frame at the top of this page was made exactly this
way):
```json
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"promo_validate","arguments":{"project":"examples/LinuxSmoke.promo"}}}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"promo_render_still","arguments":{"project":"examples/LinuxSmoke.promo","time":5.5}}}
```
```json
{"id":2,"result":{"content":[{"type":"text","text":"ok — nothing the renderer would quietly correct"}]}}
{"id":3,"result":{"content":[{"type":"text","text":"wrote examples/LinuxSmoke.promo/Exports/still-5.5s.png (1280x720 at 5.50s)"}]}}
```
## One engine, every platform
The same project rendered on macOS (Metal, VideoToolbox) and on a bare
Linux container (lavapipe software Vulkan, no GPU; ffmpeg) — SSIM 0.983
over the full 240-frame video. The visible difference is the font: the
caption asks the question, the two frames answer it.
Try it yourself — [examples/](examples/) holds one runnable project per
`promo_schema` recipe (each metadata.json IS its recipe, pinned by a
test), from the device-framed product card to the 9:16 re-stamp — plus
the kitchen-sink [LinuxSmoke.promo](examples/LinuxSmoke.promo):
```
promo video examples/ProductCard.promo --out card.mp4
```
## Authoring a project
Start with `promo schema`. The short version: a project folder holds
`metadata.json` and `Resources/`; ids are unique strings (short mnemonics
are fine — apps mint UUIDs on adoption); layers place resources
on a timeline with keyframes (hold-then-ease), placement rules, transitions
and palette-named colours (`@accent`). Validate before rendering — the
validator names what the renderer would silently correct, undefined colour
names included.
```
mkdir -p Demo.promo/Resources
# write Demo.promo/metadata.json, copy media into Resources/
promo validate Demo.promo && promo still Demo.promo --out look.png --time 1
```
Or let the MCP server spend the boilerplate (`promo_init`,
`promo_upsert_layer`), and give your editor autocomplete by pointing
`"$schema"` at [docs/promo.schema.json](docs/promo.schema.json).
## Invariants and plans
- `SPECS.md` — the invariants the tests pin.
## License
Apache-2.0. The PromoShot applications built on this engine are separate,
proprietary products.
## Proxies for long sources
`promo proxy <project>` builds a tier-1 proxy (960 px long edge, every
frame a keyframe) for each video resource, in a cache outside the
package (`$PROMO_PROXY_DIR`, else the platform cache directory under
`promoshot/proxies`). `still`, `frames`, `gif` and `video` take
`--proxy auto|on|off`: `auto` (default) reads a built proxy when the
output's long edge fits it, `on` builds missing proxies first, `off`
reads the source — and a full-size render always does. The MCP tools
take the same `proxy` argument; `promo_proxy` builds them.
## Markers and chapters
A project may carry `markers` — named moments on the output timeline.
`kind: "chapter"` markers are written into an exported mp4's chapter
list (a player's chapter menu); `inspect` lists them all.
## Audio effects
A video or audio resource may carry `audioEffects` — `normalize`
(loudness to a target LUFS), `compressor` and one-band `eq` entries,
applied in order before the mix in every render the core makes. The
apps' exports take the same mix; their live preview plays the resource
dry.
## Chroma key
A video or image layer may carry `chromaKey` — a colour, a tolerance
and a softness: the plate becomes transparent before the layer's grade,
border and mask, in the compositor, so a green-screen clip composes
over anything on every host alike.
## Models
A resource of kind `model` is a glTF 2.0 binary (`.glb`) in `Resources/`;
a layer of kind `model` draws it through a PBR-lite pass into a texture
at the layer's size, and from there it is a picture like any other:
placement, opacity, transitions, masks, effects and the contact shadow
all apply. Keyframes carry a `camera` (yaw, pitch, roll, distance in
bounds radii, fov) and a `light`; `materials` on the resource bind a
slot name to a colour — a palette name works, so `@accent` re-skins the
body with the theme — and, in the object form, to a finish: `metallic`
and `roughness` (each 0…1) over the file's own, so one body is chrome in
this project and matte in the next (rung 32) — or, better, to a finish
WORD (rung 44): `chrome`, `brushed`, `anodized`, `gloss`, `satin`,
`matte`, `rubber`, `ceramic`, `lacquer`, `paper`, `glass`, `frosted`,
each expanded by the engine into the numbers, the coat, the grain, the
transmission and the refraction it stands for, so nobody levels a
reflection by hand; on a screen the word is the coat over the picture,
and glass on a stage bends the bodies behind it. Lighting defaults come
from the theme; a scene `environment` (studio, sunset, night; rung
35 — or, rung 46, a `resourceID` naming a panorama in the project, a
picture of the world the bodies mirror) is what metals mirror; a file's
normal map and metallic-roughness
texture are honoured. Rung 29. Built-in device bodies
(phone, tablet, laptop; `promo device`) ship as generated `.glb` files
with `Body` and `Screen` slots, so the device shot is a model too. A
model can also be a `recipe` the engine builds at load instead of a file
— text as a body first: real type in the 3D world with `Face` and `Side`
slots, lit and finished like any body (rung 34); a device body; and a
body of PARTS — boxes, spheres, cylinders, tori, a lathe, an extrude,
each under a slot, placed by position, rotation and scale — the 3D
counterpart of a drawing, authored the way an SVG is (rung 37).
Layers naming the same `stage` draw through one camera into one depth
buffer, models at their `depth` and pictures as billboards, the first
member's placement carrying the whole scene (rung 30). A stage can also
be one layer of kind `stage` holding its `members`, the camera and light
on its own keyframes (rung 33) — the same picture, with the stage's
ownership written down. A stage's `floor` word (rung 45) — `matte`,
`satin`, `glossy`, `mirror` — puts a plane under the lowest body that
catches the key light's shadow, the darkening where a body touches, and
the stage mirrored in it, blurred less and less; whatever lies beneath
the stage layer shows through, so the table is the project's own
background. The light's keyframes move the shadow; the floor stays.
## A picture worn by a body
A slot's picture is a screen by default: unlit, fitted, what a
screenshot on a phone wants. `"mode": "surface"` on the binding wears
it instead — the image or video becomes the slot's colour under the
light and the finish, tiled by `repeat` and shifted by `offset`, the
slot's own colour showing through where the picture is transparent — so
a label sits on a vase, a print on a box, and a video plays on a glossy
wall that the key light and the environment still shade. Rung 38.
## Particles
A resource of kind `particles` is a recipe, not a file — an emitter, a
rate or a burst, life, speed, gravity, wind, drag, turbulence, size and
colour over life, a shape — played by a drawing layer. Every particle is
a closed-form function of its birth time and the seed, so any frame
renders alone and identically on every host. Rung 36.
A path resource can carry a route in the stage (rung 40): 3D points in
stage radii that a member's or a camera's `motionPath` follows, fitted
between two keyframes exactly as the 2D motion path is, with a camera
`target` — the centre, ahead, a member or a point — saying where it
looks on the way. A spiral that keeps looking inward is one route and
one keyframe.
Particles in a stage are a morph (rung 39): the recipe names two bodies,
samples the first's surface, and as a drawing member's `progress`
keyframe ramps from 0 to 1 the points fly out and gather on the second
body — a cube bursts into points that settle into a word. A parts box
with `faces: true` has six slots, one picture per side, which is what
such a cube is made of.
## Text with a side
Legacy: the caption `depth` below is the flat compositor's 2.5D. A title
with a real side is a text body (rung 34) standing in a stage; the
validator names the old form. It still renders.
A caption style may carry `depth`: copies of the words stacked under the
face, each a little further along and darker, so the type reads as
solid letters with a side — the classic extrusion, pure 2D, lit by
choosing the offset. A reveal extrudes each arriving piece the same way.
`tiltX` / `tiltY` keyframes on a caption lean it in perspective, on the
same camera the device frames use, and the side leans with the face.
A reveal's `flip`, `tumble` and `slide` modes bring each word in on its
own axes — kinetic type from one rule, no keyframes.
## Follow the pointer
A video the Mac recorder made carries `pointer`, where the pointer went
and where it clicked, in the recording's own time and coordinates. A
layer showing it may say `follow`: its viewport becomes a window
`1/zoom` of the source that follows the smoothed pointer, and each click
draws a ring that grows and fades. A rule, not keyframes: re-trim the
recording and it stays true, on every host alike.
## Image effects
A layer may carry `effects`: a `blur` (round, or directional along a
`blurAngle`), a `glow` of its bright parts, a `vignette` toward its own
corners, film `grain` and an unsharp `sharpen`, each on the layer's own
pixels in the compositor. Blur, glow and vignette are keyframe tracks
too, so a focus pull or a glow that pulses ramps like the grade does.
Five transitions ride the same passes — `blurDissolve`, `zoom`, `flash`,
`glitch` and `dip` — beside the fade, wipe, slide, push and scale that
were there, at a layer's edges and at a resource swap alike. A video
layer's swap may name a composition (rung 47): the takeover, the next
film arriving through any of those cuts where its `sourceTime` says, or
where its clock already is. The same keyframes carry the consumer's
transport for anything with a clock — a video, an audio, a composition,
a sprite: `sourceTime` seeks it, `playback` pauses and resumes it, and
its sound follows.
## Looks from a `.cube`
A resource of kind `lut` is a `.cube` file in `Resources/`; a layer's
`adjustments.lutResourceID` (with `lutAmount`) applies it in the
compositor after the layer's own grade — a trilinear lookup on every
host alike.
## ProRes and alpha
`promo video … --codec prores422|prores4444 --out x.mov` writes ProRes;
`--alpha` renders the project over nothing and keeps the frames' alpha
in a ProRes 4444 (`--alpha` on `still`/`frames` gives transparent PNGs).
Sources that carry alpha (ProRes 4444, WebM with alpha, PNG sequences)
decode premultiplied and compose with their transparency.