Developer docs/Recipes
Recipes
Copy-paste scripts for the things people most often want from the API.
Each recipe is shown two ways where it makes sense: as lines for
concat-cli api (paste into a .jsonl file, or type one at a time), and
as Python against a socket using the Concat class from
JSON-RPC → Python client.
Recipes: Rough cut from a folder · Export and show progress · A title card · A look on every clip · Contact sheet of frames · A project from a template · Vertical cut of a landscape edit · Undo, redo, save · Inspect a project
Run the CLI from the src/ folder: cargo run -p concat-cli -- api < file.jsonl.
1. Rough cut from a folder of clips
Import every file and lay them end to end on the first track.
Lines (two files shown; repeat the pattern):
{"jsonrpc":"2.0","id":1,"method":"project.create","params":{"location":"/edits","name":"Rough cut"}}
{"jsonrpc":"2.0","id":2,"method":"media.import","params":{"path":"/edits/Rough cut","file":"/footage/a.mp4"}}
{"jsonrpc":"2.0","id":3,"method":"media.import","params":{"path":"/edits/Rough cut","file":"/footage/b.mp4"}}
{"jsonrpc":"2.0","id":4,"method":"edit.apply","params":{"path":"/edits/Rough cut","command":{"op":"addClip","mediaId":"m1","trackId":"T1","start":0}}}
{"jsonrpc":"2.0","id":5,"method":"project.get","params":{"path":"/edits/Rough cut"}}
Read the clip’s duration from the project.get reply, then place the
next clip at that start. Python does the bookkeeping:
import glob, os
c = Concat(token=TOKEN)
folder = "/edits/Rough cut"
c.call("project.create", location="/edits", name="Rough cut")
at = 0.0
for file in sorted(glob.glob("/footage/*.mp4")):
view = c.call("media.import", path=folder, file=file)
media_id = view["createdId"]
view = c.call("edit.apply", path=folder,
command={"op": "addClip", "mediaId": media_id, "trackId": "T1", "start": at})
clip = next(cl for cl in view["project"]["timelines"][0]["clips"] if cl["id"] == view["createdId"])
at += clip["duration"]
c.call("project.save", path=folder)
2. Export and show progress
Lines:
{"jsonrpc":"2.0","id":1,"method":"project.open","params":{"path":"/edits/Reel"}}
{"jsonrpc":"2.0","id":2,"method":"export.run","params":{"path":"/edits/Reel","output":"/edits/reel.mp4","crf":18,"preset":"slow","codec":"h264"}}
The CLI keeps running until the job ends, printing each event.
Python, with a progress line:
def show(event):
p = event["params"]
if event["method"] == "export.progress":
print(f'\r{p["stage"]:5} {p["frame"]}/{p["total"]}', end="")
elif event["method"] == "cutout.progress":
print(f'\rcutout {p["mediaId"]} {"fetching" if p["fetching"] else "analysing"} {p["fraction"]:.0%}', end="")
c = Concat(token=TOKEN, on_event=show)
c.call("project.open", path="/edits/Reel")
started = c.call("export.run", path="/edits/Reel", output="/edits/reel.mp4", crf=18)
end = c.wait_for_job(started["job"])
print()
if end["method"] == "export.done":
print("wrote", end["params"]["output"])
else:
print("failed:", end["params"]["error"]["message"])
To stop it from another connection:
{ "jsonrpc": "2.0", "id": 9, "method": "export.cancel", "params": { "job": "j1" } }
The job then ends with export.failed and error.code = cancelled.
3. A title card
A three-second title on its own lane above the footage, then a lower third.
{"jsonrpc":"2.0","id":1,"method":"edit.apply","params":{"path":"/edits/Reel","command":{"op":"addTextClip","above":true,"start":0,"duration":3,"style":{"content":"Summer 2026","fontSize":0.14}}}}
{"jsonrpc":"2.0","id":2,"method":"edit.apply","params":{"path":"/edits/Reel","command":{"op":"addTextClip","above":true,"start":5,"duration":4,"offsetY":0.35,"style":{"content":"Ada Lovelace\nEngine designer","fontSize":0.05,"align":"left","background":"#00000099","maxWidth":0.5}}}}
Only the style fields you set change; the rest are the window’s defaults (white, bold, centred, with a shadow). All of them are in Types → TextStyle.
Change a title later with a patch. Absent fields stay, null clears:
{
"op": "updateClip",
"clipId": "c7",
"patch": { "text": { "content": "Summer 2026", "color": "#ffcc00" } }
}
4. A look on every clip
Find a filter, then set it on each video clip.
looks = c.call("catalogue.list", kind="filter")
print([p["id"] for p in looks]) # e.g. concat.warm, concat.film, concat.mono …
view = c.call("project.get", path="/edits/Reel")
timeline = next(t for t in view["project"]["timelines"] if t["id"] == view["project"]["activeTimelineId"])
commands = [
{"op": "updateClip", "clipId": clip["id"],
"patch": {"videoEffects": clip["videoEffects"] + [{"id": "concat.film", "params": {}}]}}
for clip in timeline["clips"] if clip["kind"] == "video"
]
c.call("edit.apply", path="/edits/Reel", command={"op": "batch", "commands": commands})
Notes:
videoEffectsin a patch replaces the chain, so append to what the clip has.- Empty
paramsmeans the package’s defaults. The keys and ranges are in thecatalogue.listreply. - One
batchmeans one undo step for the whole pass.
The same over everything at once, as a layer instead of per clip:
{ "op": "addLayerClip", "start": 0, "duration": 60, "effectId": "concat.film", "name": "Film look" }
5. A contact sheet of frames
One PNG a second, to a folder.
view = c.call("project.open", path="/edits/Reel")
timeline = next(t for t in view["project"]["timelines"] if t["id"] == view["project"]["activeTimelineId"])
length = max((cl["start"] + cl["duration"] for cl in timeline["clips"]), default=0)
t = 0.0
while t < length:
c.call("preview.frame", path="/edits/Reel", time=t, output=f"/edits/frames/{t:06.1f}.png", width=320, height=180)
t += 1.0
Or one frame straight into memory:
import base64
pic = c.call("preview.frame", path="/edits/Reel", time=2.5)
png_bytes = base64.b64decode(pic["png"])
6. A project from a template
templates = c.call("template.list")
intro = next(t for t in templates if t["name"] == "Intro")
for slot in intro["slots"]:
print(slot["mediaId"], slot["name"], slot["kind"], f'{slot["seconds"]:.1f}s')
fills = [
{"mediaId": intro["slots"][0]["mediaId"], "file": "/footage/logo.png"},
{"mediaId": intro["slots"][1]["mediaId"], "file": "/footage/take1.mp4"},
]
view = c.call("template.instantiate", template=intro["path"], location="/edits", name="My intro", fills=fills)
Every slot must be filled, and every file is probed before anything is made. To make a template from a project you have open:
{
"jsonrpc": "2.0",
"id": 1,
"method": "template.save",
"params": { "path": "/edits/Reel", "name": "Reel template" }
}
7. A vertical cut of a landscape edit
A second timeline in the same project, 1080×1920, with the same clips re-framed.
{"jsonrpc":"2.0","id":1,"method":"edit.apply","params":{"path":"/edits/Reel","command":{"op":"addTimeline"}}}
{"jsonrpc":"2.0","id":2,"method":"project.setVideo","params":{"path":"/edits/Reel","video":{"width":1080,"height":1920,"rateNum":30,"rateDen":1}}}
{"jsonrpc":"2.0","id":3,"method":"edit.apply","params":{"path":"/edits/Reel","command":{"op":"addClipAtFirstFree","mediaId":"m1","start":0}}}
{"jsonrpc":"2.0","id":4,"method":"edit.apply","params":{"path":"/edits/Reel","command":{"op":"setClipTransform","clipId":"c9","scale":1.8,"offsetX":-0.1}}}
{"jsonrpc":"2.0","id":5,"method":"export.run","params":{"path":"/edits/Reel","output":"/edits/reel-vertical.mp4"}}
addTimeline makes the new timeline active, so the project.setVideo
and the edits that follow land on it. Switch back with
{"op":"selectTimeline","timelineId":"TL1"}. The clip id c9 above is
whatever createdId came back from the add.
8. Undo, redo, save
{"jsonrpc":"2.0","id":1,"method":"edit.apply","params":{"path":"/edits/Reel","command":{"op":"removeClips","clipIds":["c2"],"ripple":true}}}
{"jsonrpc":"2.0","id":2,"method":"edit.undo","params":{"path":"/edits/Reel"}}
{"jsonrpc":"2.0","id":3,"method":"edit.redo","params":{"path":"/edits/Reel"}}
{"jsonrpc":"2.0","id":4,"method":"project.save","params":{"path":"/edits/Reel"}}
{"jsonrpc":"2.0","id":5,"method":"project.close","params":{"path":"/edits/Reel"}}
Every reply’s canUndo / canRedo tells you where the history stands.
A tolerated no-op (an unknown id, a value already held) records no undo
step.
9. Inspect a project without changing it
cd src
cargo run -p concat-cli -- api '{"method":"project.open","path":"/edits/Reel"}' | python3 -c '
import json, sys
view = json.loads(sys.stdin.readline())["result"]
p = view["project"]
print(view["settings"])
for t in p["timelines"]:
print(t["id"], t["name"], f'{t["video"]["width"]}x{t["video"]["height"]}')
for clip in sorted(t["clips"], key=lambda c: c["start"]):
print(f' {clip["id"]:5} {clip["kind"]:5} {clip["start"]:7.2f} +{clip["duration"]:6.2f} {clip["name"]}')
'
Opening does not change the project document; nothing in the folder is
written unless you call project.save. The only side effect is the
recents list, which moves the project to the front.