Driving FFmpeg Desk from your own code
Everything the web app's four AI lanes do is available over HTTP. Send a task, the ffmpeg command line you are about to run, and the parse the browser made of it, and get back one JSON object. The command-line reader the browser runs for free — the shell-accurate tokeniser, the option arity table, the split into globals, per-input and per-output regions, the -filter_complex graph with its labelled pads, every -map resolved, the credential detector and all thirty-two checks — is not re-run server-side. If you drive the API directly you must send your own facts object, because that object is the only thing the model is held accountable to.
What this is
One app, four lanes, one command line. You paste (or POST) a single ffmpeg command; a lane reads it, walks its filter graph, sorts out its subtitle path or rewrites it for a delivery target. Every lane returns a runnable command as its artifact, and in the web app that returned command goes straight back through the same reader before it is shown — a rewrite that still trips a check says so under the rewrite. If you build your own consumer, that re-parse is yours to reproduce; see the output contract.
Three things this API does not do, said once at the top so nothing below surprises you. It runs no ffmpeg. Nothing here executes, probes, or touches your media, and no reply may claim otherwise. It has not seen your source. Resolution, duration, frame rate, bitrate and output size are unknown unless your command or your note states them, and the prompt forbids inventing them. It does not remember. Every run is independent; continuity between lanes is something you pass in through prior_read.
Base URL and headers
| Thing | Value |
|---|---|
| Base URL | https://api.skillsafe.ai/v1/app-api |
| Auth | Authorization: Bearer <token> |
| Body | Content-Type: application/json. The body is the input object — there is no {"input": ...} wrapper, and wrapping it returns 200 while hiding task from the model |
| App identity | carried by the token. There is no X-App-Slug header. The one place the slug ffmpeg-desk appears is the body of POST /guest |
| Idempotency | Idempotency-Key: <string> on /run and /run-stream. See the closing section |
| Call | Path | Costs |
|---|---|---|
| Mint a guest token | POST /v1/app-api/guest | free, and the only call whose body is not a lane input |
| Who am I | GET /v1/app-api/me | free |
| Price an input | POST /v1/app-api/estimate | free, creates no job |
| Run a lane | POST /v1/app-api/run | metered; reserves hold_credits |
| Run a lane, streamed | POST /v1/app-api/run-stream | metered; the same run as /run |
| Poll a job | GET /v1/app-api/jobs/{job_id} | free |
The response envelope
Every response, success or failure, is the same shape. Read ok before you touch data.
{"ok": true, "data": { ... }}
{"ok": false, "error": {"code": "payment_required", "message": "...", "details": { ... }}}
Do not skip the check. An error response is still HTTP-shaped JSON with a 200-looking body structure, and ignoring ok turns a 402 into a confusing null three lines later.
Error codes
| HTTP | error.code | What it means and what to do |
|---|---|---|
| 400 | VALIDATION_ERROR | The body was not a JSON object, or a field was the wrong type — command as an array, facts as a string, context_note as null. Fix the body; a retry will not help. Note what this is not: an unknown task is not a validation error, because the model infers a lane and reports task_inferred. Neither is an unknown target — the browser normalizes anything outside the eight values to general before it sends, and you should too. |
| 400 | invalid_request | POST /guest without a slug. The slug is what binds the token to this app. |
| 401 | unauthorized | No token, a malformed one, or one that has expired. Mint a new guest token (step 2), or sign in on the token page for a personal one. |
| 402 | payment_required | The balance cannot cover min_credits. Check /estimate against /me before submitting, which is exactly what the app does so this never fires: its run button is disabled with the shortfall named rather than submitting into a 402. |
| 404 | not_found | A job id that does not exist, one belonging to another subject, or a slug on /guest that is not a deployed app. |
| 409 | idempotency_conflict | The same Idempotency-Key was reused with a different body. Keys must be derived from the body, not from a counter. |
| 429 | rate_limited | Back off and retry with the same idempotency key. Never tight-loop; a polling loop with no sleep is the usual cause. |
| 500, 502, 503 | internal | Anything 5xx is retryable. Retry once with the same idempotency key, which is exactly what the key is for — a 5xx after the job was created returns the same job rather than starting a second charged run. |
truncated: true is not an error. It comes back on a successful job, alongside charged_credits, and it means the run started with a reduced output cap because the balance covered min_credits but not hold_credits. The lane ran, it was charged, and the reply is real but cut short: the JSON may end mid-document, and if it does, the client's own parse fails and it retries once with a reformat instruction in retry_note. Treat truncated: true as "this answer is incomplete, say so to your user" — never as "this answer is finished". A deliver reply whose command stops halfway through a -filter_complex is the failure mode to expect, and a half-written ffmpeg command is worse than none.
The one field that decides everything: task
Document this first because it selects the entire rest of the contract. task is one of four lanes, each with its own prompt, its own body shape and its own idea of what verdict means. They are meant to run in sequence over one command, and each one hands the next its conclusions through prior_read.
task | The app's own label | The question it answers | body keys |
|---|---|---|---|
read | Read this command | What does this line actually do, option by option, before anyone runs it. Where does each stream go, which options are in a position where they do nothing, and what dominates the wall-clock. | what_it_does, walkthrough, streams, cost_shape |
graph | Walk the graph | The -filter_complex wiring: pads, labels and order. What each node contributes, what each label carries, and which reorderings make it cheaper. | graph_reads_as, nodes, uncovered_nodes, wiring_notes, simplifications |
subs | Sort the subtitles | Burned in, muxed as a soft track, or absent and needing to be added — and whether the text will actually appear on the player the user cares about. | path_call, steps, compat, styling |
deliver | Rewrite it for delivery | The command to ship for the chosen target, with the diff against the user's own, the trade-offs it makes, and how to verify the result. | target_call, changes, ladder, tradeoffs, verify |
Every lane's body also carries command and command_note — the runnable ffmpeg command is the artifact of all four lanes, not just deliver. Absent or unrecognised, task is not an error: the model picks the closest lane, sets task_inferred to true, and sets task to the lane it actually answered, rather than blending two contracts into one reply. A reply naming no known lane at all is a parse failure, not a lane, and the client throws reply did not name a known task.
The request body is the input object
There is no {"input": ...} wrapper. Wrapping it returns 200 while hiding task from the model, which is the single most expensive mistake available here — you get a fluent answer to a question you did not ask. There is no X-App-Slug header either: app identity rides on the token, and the only place the slug ffmpeg-desk appears is the body of POST /guest.
Worked request — task: "read"
A lecture recording being clipped with -c copy, which is the mistake this lane exists to catch: the seek is on the wrong side of -i and a filter is asked for alongside a stream copy. Everything under facts was parsed in the browser from the complete command; rules is trimmed here for length but on the wire every check that ran is present.
POST /run
{
"task": "read",
"command": "ffmpeg -i lecture.mkv -ss 00:12:30 -t 90 -c copy -vf scale=1280:720 -movflags +faststart clip.mp4",
"target": "general",
"context_note": "I only want a 90 second clip out of the middle of a lecture recording, and I assumed copying would make it instant.",
"facts": {
"readable": true,
"binary": "ffmpeg",
"token_count": 14,
"globals": [],
"inputs": [
{"id": "in0", "url": "lecture.mkv", "ext": "mkv", "protocol": "", "format": "",
"is_image": false, "is_subtitle": false, "is_stream": false, "is_device": false,
"is_concat_demuxer": false, "loop": "", "framerate": "", "hwaccel": "", "options": []}
],
"outputs": [
{"id": "out0", "url": "clip.mp4", "ext": "mp4", "format": "",
"container": "mp4", "container_label": "MP4",
"vcodec": "copy", "acodec": "copy", "scodec": "copy",
"crf": "", "video_bitrate": "", "audio_bitrate": "", "preset": "", "pix_fmt": "",
"movflags": "+faststart", "vf": "scale=1280:720", "af": "", "maps": [],
"no_video": false, "no_audio": false,
"options": ["-ss 00:12:30", "-t 90", "-c copy", "-vf scale=1280:720",
"-movflags +faststart"]}
],
"filter_graph": null,
"maps": [],
"rules": [
{"id": "R03", "title": "Overwrite behaviour is decided", "severity": "medium",
"state": "FAIL",
"detail": "neither -y nor -n is set - in a script this blocks forever on \"File exists. Overwrite?\""},
{"id": "R05", "title": "Stream copy is not combined with filtering", "severity": "high",
"state": "FAIL",
"detail": "out0: video is set to copy while -vf asks for filtering - filtering requires re-encoding, so ffmpeg refuses the combination"},
{"id": "R06", "title": "Seeking is on the right side of -i", "severity": "high",
"state": "FAIL",
"detail": "out0: -ss sits AFTER the input while the stream is copied, so ffmpeg decodes and throws away everything before the cut - move -ss in front of -i"},
{"id": "R07", "title": "The pixel format is one every player decodes", "severity": "high",
"state": "not-applicable",
"detail": "no H.264 or HEVC output into an MP4-family container"},
{"...": "one row per check that ran, pass, FAIL or not-applicable, with the evidence"}
],
"flags": [
{"id": "FF-001", "severity": "high", "location": "out0", "rule": "R05",
"label": "Stream copy is not combined with filtering",
"why": "out0: video is set to copy while -vf asks for filtering - filtering requires re-encoding, so ffmpeg refuses the combination"},
{"id": "FF-002", "severity": "high", "location": "out0", "rule": "R06",
"label": "Seeking is on the right side of -i",
"why": "out0: -ss sits AFTER the input while the stream is copied, so ffmpeg decodes and throws away everything before the cut - move -ss in front of -i"},
{"id": "FF-003", "severity": "medium", "location": "command", "rule": "R03",
"label": "Overwrite behaviour is decided",
"why": "neither -y nor -n is set - in a script this blocks forever on \"File exists. Overwrite?\""}
],
"required_flag_ids": ["FF-001", "FF-002"],
"secrets_found": 0,
"stats": {"token_count": 14, "input_count": 1, "output_count": 1, "filter_count": 0,
"chain_count": 0, "label_count": 0, "map_count": 0, "global_count": 0,
"container": "mp4", "container_label": "MP4",
"vcodec": "copy", "acodec": "copy", "scodec": "copy",
"rules_run": 15, "rules_failed": 3, "flag_count": 3, "required_flag_count": 2,
"secret_count": 0, "has_graph": false, "is_transcode": false,
"is_copy_only": true},
"note": "Every item above was parsed in the user's browser from the COMPLETE command line. Treat it as authoritative about what the command contains. You must answer for every id in required_flag_ids."
}
}
Worked request — task: "graph"
A different command, because this lane needs a graph to walk: a watermark overlay that also tries to take a thumbnail crop out of the same scaled pad. The new field is prior_read — the previous read reply's own conclusions, handed forward so this lane builds on the reading instead of re-deriving it. Send the whole facts object again; runs are stateless and nothing is remembered between them.
POST /run
{
"task": "graph",
"command": "ffmpeg -i clip.mp4 -i logo.png \\\n -filter_complex \"[0:v]scale=1280:-1[base];[1:v]scale=140:-1[wm];[base][wm]overlay=W-w-24:H-h-24[v];[base]crop=1280:600:0:60[thumb]\" \\\n -map \"[v]\" -map 0:a \\\n -c:v libx264 -crf 21 -preset slow -b:v 4M \\\n -c:a copy \\\n out.mp4",
"target": "web",
"context_note": "The watermark is for a client review cut. I also wanted a thumbnail crop out of the same render, which is why the crop is in there.",
"facts": {
"readable": true,
"binary": "ffmpeg",
"token_count": 22,
"globals": ["-filter_complex [0:v]scale=1280:-1[base];[1:v]scale=140:-1[wm];[base][wm]overlay=W-w-24:H-h-24[v];[base]crop=1280:600:0:60[thumb]"],
"inputs": [
{"id": "in0", "url": "clip.mp4", "ext": "mp4", "is_image": false, "options": []},
{"id": "in1", "url": "logo.png", "ext": "png", "is_image": true, "options": []}
],
"outputs": [
{"id": "out0", "url": "out.mp4", "ext": "mp4", "container": "mp4",
"container_label": "MP4", "vcodec": "libx264", "acodec": "copy",
"crf": "21", "video_bitrate": "4M", "preset": "slow", "pix_fmt": "", "movflags": "",
"maps": ["[v]", "0:a"],
"options": ["-map [v]", "-map 0:a", "-c:v libx264", "-crf 21", "-preset slow",
"-b:v 4M", "-c:a copy"]}
],
"filter_graph": {
"chains": 4,
"filters": [
{"id": "f1", "chain": 1, "name": "scale", "args": "1280:-1",
"in_labels": ["0:v"], "out_labels": ["base"], "expected_pads": null},
{"id": "f2", "chain": 2, "name": "scale", "args": "140:-1",
"in_labels": ["1:v"], "out_labels": ["wm"], "expected_pads": null},
{"id": "f3", "chain": 3, "name": "overlay", "args": "W-w-24:H-h-24",
"in_labels": ["base", "wm"], "out_labels": ["v"],
"expected_pads": {"in": 2, "out": 1}},
{"id": "f4", "chain": 4, "name": "crop", "args": "1280:600:0:60",
"in_labels": ["base"], "out_labels": ["thumb"], "expected_pads": null}
],
"labels_defined": ["base", "wm", "v", "thumb"],
"terminal_pads": ["v", "thumb"],
"problems": ["[base] is consumed 2 times (f3, f4) - one pad can only feed one place, so this needs a split"]
},
"maps": [
{"spec": "[v]", "output": "out0", "kind": "label", "resolves": true, "detail": ""},
{"spec": "0:a", "output": "out0", "kind": "input", "resolves": true, "detail": ""}
],
"rules": [{"...": "one row per check that ran"}],
"flags": [
{"id": "FF-001", "severity": "critical", "location": "command", "rule": "R18",
"label": "The filter graph's labels are wired correctly",
"why": "[base] is consumed 2 times (f3, f4) - one pad can only feed one place, so this needs a split"},
{"id": "FF-005", "severity": "high", "location": "f4", "rule": "R17",
"label": "Every labelled filter output is mapped",
"why": "[thumb] (from f4) never reach an output - ffmpeg refuses to start with \"Output with label ... does not exist in any defined filter graph\" or silently drops the work"},
{"...": "FF-002 through FF-004 and FF-006, FF-007, in severity order"}
],
"required_flag_ids": ["FF-001", "FF-002", "FF-003", "FF-004", "FF-005"],
"secrets_found": 0,
"stats": {"input_count": 2, "output_count": 1, "filter_count": 4, "chain_count": 4,
"label_count": 4, "map_count": 2, "rules_run": 21, "rules_failed": 7,
"flag_count": 7, "required_flag_count": 5, "has_graph": true,
"is_transcode": true}
},
"prior_read": {
"verdict": "fix-first",
"what_it_does": "Scales the clip to 1280 wide, scales the logo to 140 wide, overlays it 24 px in from the bottom right, and encodes to H.264 in an MP4 alongside a copied audio track. A second chain crops a thumbnail out of the scaled video and goes nowhere.",
"cost_shape": "The x264 encode at -preset slow dominates; the two scales and the overlay are cheap by comparison, and the audio is copied so it costs nothing.",
"ignored_options": [
{"group": "out0", "option": "-b:v 4M", "state": "suspect"}
],
"streams": [
{"stream": "video", "from": "in0", "to": "out0", "action": "re-encoded to libx264 crf 21"},
{"stream": "video", "from": "in1", "to": "out0", "action": "overlaid as a watermark"},
{"stream": "audio", "from": "in0", "to": "out0", "action": "copied"}
],
"findings": [
{"id": "FFD-001", "severity": "critical", "location": "f4",
"title": "[base] feeds two filters, which one pad cannot do"}
]
}
}
prior_read is exactly the subset the app forwards, and nothing else from the read reply travels: verdict, what_it_does, cost_shape, an ignored_options list built from the walkthrough rows whose state was ignored or suspect and trimmed to {group, option, state}, a streams list trimmed to {stream, from, to, action}, and a findings list trimmed to {id, severity, location, title}.
Worked request — task: "subs"
The subtitle lane, on the classic failure: a subtitles= path containing a space, unquoted. The shell split it before ffmpeg saw the line, so what the user thinks is a filter argument is parsed as a second output — and the browser's parse says so, which is why facts.outputs has an out0 nobody meant to write. target: "social" matters here: on a feed, burn-in is usually the only path that works.
POST /run
{
"task": "subs",
"command": "ffmpeg -y -i talk.mp4 -i talk.srt \\\n -vf subtitles=/Users/me/My Videos/talk.srt:force_style='FontSize=24' \\\n -c:v libx264 -crf 20 -pix_fmt yuv420p \\\n -c:a copy -c:s srt \\\n -movflags +faststart talk-subbed.mp4",
"target": "social",
"context_note": "The captions have to be visible in a feed where nobody turns subtitles on, and the file lives in a folder with a space in the name.",
"facts": {
"readable": true,
"binary": "ffmpeg",
"token_count": 22,
"globals": ["-y"],
"inputs": [
{"id": "in0", "url": "talk.mp4", "ext": "mp4", "is_subtitle": false, "options": []},
{"id": "in1", "url": "talk.srt", "ext": "srt", "is_subtitle": true, "options": []}
],
"outputs": [
{"id": "out0", "url": "Videos/talk.srt:force_style=FontSize=24", "ext": "",
"container": "", "container_label": "", "vcodec": "", "acodec": "", "options": []},
{"id": "out1", "url": "talk-subbed.mp4", "ext": "mp4", "container": "mp4",
"container_label": "MP4", "vcodec": "libx264", "acodec": "copy", "scodec": "srt",
"crf": "20", "pix_fmt": "yuv420p", "movflags": "+faststart",
"vf": "subtitles=/Users/me/My",
"options": ["-vf subtitles=/Users/me/My", "-c:v libx264", "-crf 20",
"-pix_fmt yuv420p", "-c:a copy", "-c:s srt", "-movflags +faststart"]}
],
"filter_graph": null,
"maps": [],
"rules": [{"...": "one row per check that ran"}],
"flags": [
{"id": "FF-001", "severity": "critical", "location": "out1", "rule": "R12",
"label": "The container will actually hold these codecs",
"why": "out1: MP4 cannot carry subtitle srt (srt) - the muxer rejects this before the first frame is encoded"},
{"id": "FF-002", "severity": "high", "location": "out0", "rule": "R28",
"label": "ffmpeg can tell what to write",
"why": "out0 (Videos/talk.srt:force_style=FontSize=24): no file extension and no -f, so ffmpeg cannot pick a muxer"},
{"id": "FF-003", "severity": "high", "location": "out0", "rule": "R32",
"label": "Every bare word on the line really is an output",
"why": "out0 (\"Videos/talk.srt:force_style=FontSize=24\") sits directly after the -vf value, so an unquoted space split that argument and the rest of it became an extra output"}
],
"required_flag_ids": ["FF-001", "FF-002", "FF-003"],
"secrets_found": 0,
"stats": {"input_count": 2, "output_count": 2, "filter_count": 0, "map_count": 0,
"rules_run": 17, "rules_failed": 3, "flag_count": 3,
"required_flag_count": 3, "has_graph": false, "is_transcode": true}
}
}
Worked request — task: "deliver"
The lane where target stops being metadata and starts changing the answer. Same overlay command as the graph example, same facts, but now the question is what to ship. target: "web" asks for H.264 High, -pix_fmt yuv420p, a CRF in the low twenties and -movflags +faststart; target: "hls" would ask for segments, a pinned keyframe interval and a ladder. Change nothing but target and you get a materially different command back.
POST /run
{
"task": "deliver",
"command": "ffmpeg -i clip.mp4 -i logo.png \\\n -filter_complex \"[0:v]scale=1280:-1[base];...\" \\\n -map \"[v]\" -map 0:a -c:v libx264 -crf 21 -preset slow -b:v 4M -c:a copy out.mp4",
"target": "web",
"context_note": "Goes on the marketing site. It has to play in Safari on an iPhone and start before it finishes downloading.",
"facts": { "...": "the identical parsed object shown above, verbatim" },
"prior_read": { "...": "the same trimmed read-lane object shown above" }
}
Two fields not shown above appear only in specific circumstances, and both are documented in full in the input table: clip_note, which the app sends when the command was too long to send whole, and retry_note, which it sends when a previous reply failed to parse.
The input object, field by field
This is the whole contract on the way in. It is what readForm() in the app's own app.js builds, field for field, so what the browser sends and what you send are the same object.
| Field | Type | Meaning |
|---|---|---|
task | string, required | Documented first, above, because it selects everything else. One of read, graph, subs, deliver. Absent or unrecognised, the model picks the closest lane, sets task_inferred to true and sets task to the lane it actually answered, rather than blending two contracts into one reply. |
command | string, required | The ffmpeg command line, verbatim. Send it exactly as the user would run it — the quoting is the bug in a large share of broken ffmpeg lines, so normalising it before you send destroys the evidence. Backslash-newline continuations are fine and are read as continuations, not as arguments. The browser clips this at 16,000 characters, cutting from the middle on whole token boundaries with roughly 62% of the budget given to the head and the rest to the tail, and inserting a marker comment where the cut fell; when it clips, it also sends clip_note. The app requires at least 8 characters before it will run at all. |
target | string, required | The delivery target. One of exactly eight, listed below. Anything outside that set normalizes to general rather than erroring, so a typo does not fail the call — it quietly changes the answer, which is worse. It is read by every lane but it decides the deliver lane: the same command with target: "web" and target: "archive" comes back as two different commands. |
context_note | string, optional | Free text. What the user is trying to do, or what went wrong. The single most useful optional field: it outranks the model's assumptions about intent, and it is what turns a flagged -b:v alongside a CRF into a deliberate bitrate cap. It never outranks a parse — the browser's reading of the command always wins. |
facts | object, optional but the point of the whole thing | Everything the browser parsed out of the command. See below — this is the field that decides whether you get an audited answer or an unaudited one. It is always present in the app's own requests, even when the parse failed, in which case it is the honest failure shape {"readable": false, "reason": "...", "note": "..."}. |
prior_read | object, optional | The handoff, sent by the three later lanes when the user came from read. Exactly {verdict, what_it_does, cost_shape, ignored_options[], streams[], findings[]}, where ignored_options[] is the walkthrough rows whose state was ignored or suspect, trimmed to {group, option, state}; streams[] is trimmed to {stream, from, to, action}; and findings[] is trimmed to {id, severity, location, title}. With it present, the later lane agrees with, refines or explicitly disagrees with the reading instead of starting over; without it, the four lanes read as four unrelated tools over one command. |
clip_note | string, optional | Present only when command was clipped. It says how many tokens there were and how many were sent, states that the cut fell on whole token boundaries with both ends kept, and states that every item in facts was parsed over the complete command — so the model treats facts as authoritative about structure and the excerpt as a sample it may quote from. Without it, a model that sees six options in the text and twenty in facts has no way to know which to believe, and the instruction "do not claim anything about the tokens you were not given" has nothing to attach to. |
retry_note | string, optional | Only when a previous reply failed to parse. The app puts the exact reformat instruction here — name the envelope, task set to the lane, every array present even when empty, findings ids sequential from FFD-001, one reconciliation entry per required flag id sent, the lane's body exactly as specified including command and command_note, no prose and no code fence — and it bumps the attempt counter in the idempotency key, so the retry is a deliberate second run rather than an accidental duplicate. It retries once and once only, then shows the raw text. |
The eight target values
These are the exact strings in TARGETS in ffscan.js. The label column is what the app shows a user, and it is a fair summary of what the deliver lane is being asked for.
target | What it means |
|---|---|
general | No particular target — just make it correct. Keep the user's intent, fix what is wrong, change nothing that cannot be justified. |
web | Web MP4, progressive download in a browser. H.264 High, -pix_fmt yuv420p, CRF in the low 20s, -movflags +faststart, AAC audio. Compatibility beats cleverness. |
hls | HLS, segmented adaptive streaming. Keyframe interval pinned to the segment duration, and a ladder whose rungs are far enough apart to be worth switching between. This is the only target for which ladder is expected to be non-empty. |
social | A vertical or square cut for a feed. The platform re-encodes whatever you send, so send something clean and conservative; framing usually means scale plus crop or pad, and captions burned in rather than muxed. |
archive | Visually lossless or truly lossless intermediate or master: high-bit-depth 4:2:2 or 4:4:4, ProRes, DNxHR, FFV1, or x264 CRF in the low teens. Size is not the constraint. |
audio | Audio only. Drop the video with -vn, pick the codec for where it is going, and do not resample without a reason. |
gif | Animated GIF or WebP: palettegen and paletteuse in a two-pass filtergraph with fps and scale ahead of them. |
stream | Live RTMP or SRT ingest: -f flv for RTMP, a fixed GOP, a fast preset, -g at twice the frame rate, and CBR-ish rate control with -maxrate and -bufsize. |
Sending your own facts
Be clear about what this object is, because it is easy to mistake it for decoration. facts is what a real ffmpeg command-line reader running in the user's own tab parsed: the binary and the token count; every global option; one row per -i input with its URL, extension, protocol, forced format and the flags that say whether it is an image, a subtitle file, a network stream, a capture device or a concat demuxer list; one row per output with its container, its video, audio and subtitle codecs, its CRF, bitrates, preset, pixel format, movflags, -vf, -af and every option assigned to it; the -filter_complex graph as chains, nodes with ids f1, f2, ..., their arguments, their in and out pad labels, the pad counts they are expected to have, the labels defined, the terminal pads and the structural problems found; every -map resolved against the inputs and the labels with a resolves boolean; one row per rule with a state of pass, FAIL or not-applicable; a numbered flags array; the required_flag_ids list; how many credentials were found; and a stats block.
Three consequences follow, and they are the reason to send it:
- The parse is the ground truth and the model is told so. House rule three: if the model's reading disagrees with
facts, the model is wrong about the parse and must say so explicitly rather than quietly asserting the other thing. This is what stops a model reading-ssas an input seek when the parse put it on the output side. - Every id in
required_flag_idsmust be answered, by id. That list is every flag whoseseverityiscriticalorhigh. The model owes exactly onereconciliationentry per required flag — no more, no fewer — naming the flag's own id. A required flag with no entry is displayed as unaccounted for, which is what makes a reply auditable rather than merely fluent.mediumandlowflags may be answered but need not be. - Every id the model cites is checked back against it. A
location, a walkthroughgroup, a stream'sfromorto, a nodeidor a wiring note'slabelthat is not a real input, output, filter node, graph label, rule id, flag id or option flag from yourfactsis kept and marked ungrounded, never silently dropped. So a filter this command does not have is visible on screen to your users, not only to you. On thegraphlane there is a second check in the other direction: any node infacts.filter_graph.filtersthat the reply'snodesarray did not mention comes back inuncovered_nodes, because a node nobody read is a node nobody checked.
So: omit facts and the model works from your raw command text alone. It will still answer, and the answer will read exactly as confident. But nothing holds it to the parse, no reconciliation is possible, no citation can be grounded, and the app's own honesty machinery — the ungrounded-id marking, the unanswered-flag count, the uncovered-node list on graph — has nothing to compare against. That is a much weaker answer, and it is weaker in a way that is invisible unless you know to look for it. A model asked to eyeball a 400-character one-liner will happily tell you that -b:v in front of the first -i sets the video bitrate.
And fabricating a facts object is worse than omitting it. You would not be decorating a prompt; you would be lying to a model that has been instructed to trust you over its own reading, about a command that is about to run on a real render. If you did not parse it, do not assert it. If the input did not parse at all, send the honest failure shape and the model returns verdict: "unreadable" instead of inventing a reading:
{
"readable": false,
"reason": "the paste did not read as an ffmpeg command line",
"note": "The browser could not parse this as an ffmpeg command, so there are NO measured facts to hold you to. Work from the raw text and say plainly that the parse failed."
}
A minimal hand-written facts that is already worth sending
You do not need the whole object. Everything below is either derivable with a short argument walk or already known to you, and it is enough for the model to be held to the parse, for a rule to be reviewed and for a flag to be reconciled. Omit any key you did not compute rather than guessing at it.
{
"readable": true,
"binary": "ffmpeg",
"globals": ["-y"],
"inputs": [
{"id": "in0", "url": "lecture.mkv", "ext": "mkv", "options": []}
],
"outputs": [
{"id": "out0", "url": "clip.mp4", "ext": "mp4", "container": "mp4",
"vcodec": "copy", "acodec": "copy", "vf": "scale=1280:720",
"options": ["-ss 00:12:30", "-t 90", "-c copy", "-vf scale=1280:720"]}
],
"filter_graph": null,
"maps": [],
"rules": [
{"id": "R05", "title": "Stream copy is not combined with filtering",
"severity": "high", "state": "FAIL",
"detail": "out0: video is set to copy while -vf asks for filtering"}
],
"flags": [
{"id": "FF-001", "severity": "high", "location": "out0", "rule": "R05",
"label": "Stream copy is not combined with filtering",
"why": "filtering requires re-encoding, so ffmpeg refuses the combination"}
],
"required_flag_ids": ["FF-001"],
"secrets_found": 0
}
Ids are the citation vocabulary and they are structural, not decorative. Inputs run in0, in1, ... in the order the -i options appear; outputs out0, out1, ...; filter nodes f1, f2, ... across the whole graph rather than per chain; graph labels are cited either bare (base) or bracketed ([base]) and both are accepted; rules are R01 through R32; flags are FF-001, FF-002, ... assigned after sorting by severity, so FF-001 is always the most severe thing found. Option flags are citable exactly as they appear on the line: -crf, -c:v, -movflags. The bare word command is always citable and means the line as a whole. Any id scheme works as long as your own grounding check agrees with it, but if you renumber flags after sorting, sort first — the app does.
Whose command is this
An ffmpeg command line routinely carries a live credential, and that changes what sending one means. Three things follow, and they are different from the web app's situation because you are now the one assembling the request.
First, in the browser nothing leaves the tab until a lane runs. The tokeniser, the region split, the filter graph, the map resolution, all thirty-two checks and all eight exports are JavaScript in the page and need no account at all. If all you want is the parse, use the app and send nothing anywhere.
Second, the credential detector is client-side too, and it runs before anything else. An RTMP ingest URL ends in a live stream key; a signed URL carries its signature in the query string; an inline password is a password. The browser counts what it found into facts.secrets_found and raises a critical flag at command, and the prompt requires the model to name that the credential is there, tell the user to rotate it, and substitute a placeholder rather than repeat the value — it writes rtmp://live.example.com/app/STREAM_KEY and notes the substitution. That protects the reply. It does not protect the request: the command you send is the command you sent. Rotate the key.
Third, the run is stateless. No command you send is retained beyond the run that used it, and continuity between lanes is something you pass in via prior_read.
The output contract, lane by lane
One JSON object, and nothing else — no prose before it, no prose after it, no code fence. The envelope is identical across all four lanes, which is what lets one renderer, one history writer and one export path serve every lane; only body differs. Every key is present on every reply, and every array is present even when it is empty: an omitted key never means "none".
{
"task": "read", // one of read | graph | subs | deliver
"task_inferred": false, // true when the lane was chosen, not given
"title": "short name for what this command does, under 70 characters",
"verdict": "runs-clean | fix-first | will-fail | unreadable",
"summary": "two to four sentences a person can act on",
"assumptions": ["what had to be assumed because the command does not say"],
"open_questions": ["what you would need to know to be sure"],
"findings": [
{"id": "FFD-001",
"severity": "critical | high | medium | low",
"location": "out0",
"title": "one line",
"why": "what actually goes wrong, in terms of what the user will observe",
"fix": "the specific change, naming the option"}
],
"reconciliation": [
{"flag_id": "FF-001",
"status": "confirmed | noted | set-aside | superseded",
"note": "one line"}
],
"next_lane": {"lane": "graph", "reason": "why that lane, on this command"},
"body": { ... } // the lane-specific half, below
}
| Envelope key | Meaning |
|---|---|
task | The lane that was actually answered. Compare it to what you sent. A reply naming no known lane throws rather than rendering. |
task_inferred | true only when task was missing or unrecognised and the lane was chosen. summary then says which and why. Two lanes are never blended. |
title | A short name for what this command does, under 70 characters. Defaults to Untitled command if the model returns nothing. |
verdict | Four values and nothing else. runs-clean = ffmpeg will start and produce what was asked for. fix-first = it runs, but the result is wrong, unplayable or wasteful. will-fail = ffmpeg exits with an error, or the shell mangles the line before ffmpeg ever sees it. unreadable = the paste is not an ffmpeg command. Anything unrecognised normalizes to unreadable, so a missing verdict fails loud rather than reading as a pass. |
summary | Two to four sentences a person can act on: the answer, the reason, the next move. |
assumptions | What had to be assumed because the command does not say it. Empty is a legitimate answer. |
open_questions | What only the user can settle — the source resolution, what player has to play this, whether the stream key is still live. |
findings | Ids run FFD-001, FFD-002, ..., and the array is re-sorted critical first by the client. location must be a citable id; an unrecognised severity normalizes to medium. An entry with no id gets one assigned from its position. |
reconciliation | Exactly one entry per id in facts.required_flag_ids, keyed by that flag's own id. An entry with no flag_id is dropped; an unrecognised status normalizes to confirmed. |
next_lane | {lane, reason}, the honest next step across read, graph, subs and deliver. Empty when there is genuinely nothing to hand off, and cleared outright if it names the lane that just ran. |
reconciliation[].status is the audit vocabulary and each word means something specific: confirmed agrees the flag is a real problem and the findings cover it; noted agrees it is real but says it is minor here; set-aside says it does not apply to this command and must say why; superseded says a bigger finding in the same reply replaces it.
Two things the client does to this object before it renders, worth knowing if you are writing your own consumer. It normalizes, as the table above describes. And it grounds: every location, walkthrough group, stream from and to, node id and wiring-note label is checked against the ids your facts actually contained, and a companion boolean (location_ungrounded, group_ungrounded, from_ungrounded, to_ungrounded, id_ungrounded, label_ungrounded) is added next to it. A label may be cited with or without its brackets and both ground. It also adds command_scan to every body, and uncovered_nodes to the graph body. Those keys are client-side additions, not model output: do not expect them on the wire, and do add your own equivalent if you are reusing this contract, because an invented filter node that nobody marks is an invented filter node nobody notices.
What every body carries, whatever the lane
body key | Type | Meaning |
|---|---|---|
command | string | The artifact. A complete, runnable, single ffmpeg command — the binary, every option, every input, the output — using the same file names the user gave. Written as a shell command with \ line continuations, one logical group per line, with any -filter_complex, -vf or -af value quoted when it contains ;, [, ', * or a space. If a lane has nothing to change, it returns the user's own command reformatted and says so. A credential is never repeated here: the model substitutes a placeholder and notes it. |
command_note | string | What changed and why, or that nothing changed. |
command_scan | object (client-added) | The returned command put straight back through the same reader that produced facts. {checked, reason, flags_remaining, remaining_rules[], cleared, introduced[], stats}: checked is false with a reason when the returned command was empty or did not parse; remaining_rules is the failing rule ids after the rewrite, cleared counts the ones the rewrite fixed, and introduced is the ones it created that were not failing before. This is the highest-value four lines in the whole client: a rewrite that still trips a check says so under the rewrite, rather than at 3am on a render farm. |
task: "read" — Read this command (what does this line actually do)
Assume the user inherited the command from a blog post, a colleague or a model, and does not trust it. The browser has parsed the line; this lane resolves what it means.
body key | Type | Meaning |
|---|---|---|
what_it_does | string | One paragraph: the whole command in plain language, start to finish. |
walkthrough[] | array | {group, option, means, state}, plus a client-added group_ungrounded. group is global, an input id or an output id. state is one of correct, suspect, redundant, ignored, deprecated; anything else normalizes to correct. Covers every option group the command has, in the order they appear. ignored is the important one: an output option sitting in front of -i is read as a demuxer option and does nothing at all, and the user needs to be told that in those words. |
streams[] | array | {stream, from, through, to, action}, plus client-added from_ungrounded and to_ungrounded. stream is one of video, audio, subtitle, data; anything else normalizes to video. through is a filter node id or - when nothing filters it. Covers every stream that reaches an output, plus any deliberately dropped by -an, -vn or a negative map. |
cost_shape | string | What dominates the wall-clock and why — the encoder, the filter, the seek, the container. Not a duration, and not a file size: this app has not seen the media. |
command, command_note | string | The user's own command, reformatted onto readable lines, with nothing changed except quoting that was outright broken. This lane explains; it does not redesign. If you want the redesign, that is deliver. |
Worked response — the read request from further up
{
"task": "read",
"task_inferred": false,
"title": "90-second clip out of a lecture, copied rather than re-encoded",
"verdict": "will-fail",
"summary": "This command will not run. It asks for a stream copy and a scale
filter at the same time, and ffmpeg refuses that combination
outright. The -ss is also on the output side, so even without the
filter it would decode and throw away the first twelve and a half
minutes rather than seeking instantly, which is the opposite of
what you assumed.",
"assumptions": ["That the 1280x720 scale is wanted at all - if the source is
already 720p, dropping -vf makes the copy work as intended."],
"open_questions": ["Whether lecture.mkv is already 1280x720, which decides
whether this is a copy job or a transcode job."],
"findings": [
{"id": "FFD-001", "severity": "high", "location": "out0",
"title": "-c copy and -vf cannot both apply to the same stream",
"why": "Filtering means decoding, filtering and re-encoding. Copying means
moving the compressed packets through untouched. ffmpeg exits with
\"Filtergraph ... was defined for a stream that is being copied\"
before it writes a frame.",
"fix": "Drop -vf if the copy is what you want, or drop -c copy and encode
with -c:v libx264 -crf 20 -preset veryfast if the scale is."},
{"id": "FFD-002", "severity": "high", "location": "-ss",
"title": "the seek is after -i, so it decodes everything before the cut",
"why": "An -ss in front of -i is an input seek and lands on the nearest
keyframe almost instantly. After -i it is an output seek: ffmpeg
reads and discards every packet before 00:12:30 first.",
"fix": "Move -ss 00:12:30 in front of -i lecture.mkv and keep -t 90 where
it is."},
{"id": "FFD-003", "severity": "medium", "location": "command",
"title": "neither -y nor -n is set",
"why": "In a script this blocks forever on \"File exists. Overwrite?\"
rather than failing.",
"fix": "Add -y if the clip is disposable, -n if it is not."}
],
"reconciliation": [
{"flag_id": "FF-001", "status": "confirmed",
"note": "Copy plus -vf on out0 is the reason this does not start."},
{"flag_id": "FF-002", "status": "confirmed",
"note": "Output-side -ss with a copy is exactly the slow path you were
trying to avoid."}
],
"next_lane": {"lane": "deliver",
"reason": "Once you decide between copying and scaling, the
deliver lane writes the command for where the clip
is going."},
"body": {
"what_it_does": "It opens lecture.mkv, takes a 90 second window starting at
12:30, asks for the video to be scaled to 1280x720, asks
for every stream to be copied rather than re-encoded, moves
the MP4 index to the front, and writes clip.mp4. The scale
and the copy contradict each other, so it stops there.",
"walkthrough": [
{"group": "in0", "option": "-i lecture.mkv", "means": "the only input",
"state": "correct"},
{"group": "out0", "option": "-ss 00:12:30",
"means": "an output-side seek: decode from the start and discard until
12:30, rather than seeking the input",
"state": "suspect"},
{"group": "out0", "option": "-t 90", "means": "stop after 90 seconds of
output", "state": "correct"},
{"group": "out0", "option": "-c copy",
"means": "copy every stream's packets without re-encoding",
"state": "suspect"},
{"group": "out0", "option": "-vf scale=1280:720",
"means": "scale the video - which requires re-encoding, so it conflicts
with -c copy",
"state": "suspect"},
{"group": "out0", "option": "-movflags +faststart",
"means": "move the MP4 index to the front so it plays before it finishes
downloading",
"state": "correct"}
],
"streams": [
{"stream": "video", "from": "in0", "through": "-", "to": "out0",
"action": "copied - but the -vf makes this impossible as written"},
{"stream": "audio", "from": "in0", "through": "-", "to": "out0",
"action": "copied"}
],
"cost_shape": "As written, nothing: it exits before encoding. Resolve the
conflict by dropping -vf and the cost is dominated by the
output-side seek, which reads 12.5 minutes of file before it
writes anything. Move -ss in front of -i and the whole job is
a keyframe seek plus 90 seconds of packet copying.",
"command": "ffmpeg -y -ss 00:12:30 -i lecture.mkv \\\n -t 90 \\\n -c copy \\\n -movflags +faststart \\\n clip.mp4",
"command_note": "Your own command with two changes and nothing else: -ss
moved in front of -i, and -vf removed so the copy is legal.
If you actually need 1280x720, this is a transcode, not a
copy - that is the deliver lane.",
"command_scan": {"checked": true, "reason": "", "flags_remaining": 0,
"remaining_rules": [], "cleared": 3, "introduced": []}
}
}
task: "graph" — Walk the graph (pads, labels and order)
The lane about wiring. Filter order is a first-class concern here: scaling before overlaying is cheaper than after, fps before an expensive filter halves its work, format in the wrong place forces a conversion per frame.
body key | Type | Meaning |
|---|---|---|
graph_reads_as | string | One paragraph: what the graph does, chain by chain, in the order data flows. If the command has no -filter_complex at all, this says so plainly and describes the -vf chain instead — and command then shows how the same work would be written as a -filter_complex, which is the useful answer rather than an empty body. |
nodes[] | array | {id, does, pads_in, pads_out, concern}, plus a client-added id_ungrounded. One entry per filter node in facts.filter_graph.filters, using the same id. Do not invent a node and do not skip one. concern is an empty string when there is nothing wrong with that node — empty is the normal case, not a gap. |
uncovered_nodes[] | array of string (client-added) | The node ids in facts.filter_graph.filters that nodes[] did not mention. Displayed as a hole in the reading. If this is non-empty, the reply did not do the one thing this lane exists for. |
wiring_notes[] | array | {label, note}, plus a client-added label_ungrounded. What each label carries and anything wrong with how it is used — a label consumed twice needs a split, a label defined twice is ambiguous, a label consumed but never defined stops ffmpeg before the first frame. |
simplifications[] | array | {change, why}. The concrete edit, and what it buys: fewer decodes, one less scale, clearer order. Empty when the graph is already the shape it should be. |
command, command_note | string | The rewired graph as a runnable command, with the filtergraph quoted. |
task: "subs" — Sort the subtitles (will the text actually appear)
The two paths are genuinely different and the contract does not let them be blurred. Burn-in is the subtitles= or ass= filter: it re-encodes the video, the text cannot be turned off, and every player shows it. Soft mux is -c:s: the text stays selectable, costs nothing to encode, and depends entirely on the container — MP4 takes mov_text and nothing else, Matroska takes srt and ass, WebM takes webvtt.
body key | Type | Meaning |
|---|---|---|
path_call | string | One of burn-in, soft-mux, both, none-present; anything else normalizes to none-present. none-present is a real answer, not a failure: when there is no subtitle work in the command the lane says so, then uses command to show the smallest correct way to add it for this output's container, with the reasoning in steps. |
steps[] | array | {step, detail}. What happens, and the option or filter that makes it happen. Escaping belongs here: the filtergraph is parsed twice, so a colon, a space or an apostrophe in a subtitles= path has to survive both passes. |
compat[] | array | {player, result, why}. result is one of shows, no subtitles, fails to play; anything else normalizes to shows. why names the specific reason — the codec, the container, the disposition flag — not a general remark about support. |
styling[] | array | {aspect, setting, note} where aspect is font, size, position, colour, timing or encoding: what it is now, and what to change. |
command, command_note | string | The subtitle path made to work, quoted so it survives the shell and both filtergraph passes. |
task: "deliver" — Rewrite it for delivery (the command you ship)
The lane that produces the command the user will actually run. It is driven by target: see the eight values for what each one asks for.
body key | Type | Meaning |
|---|---|---|
target_call | string | Which target was encoded for and what that decides. When target is general this says what was inferred from the command and the note instead. |
changes[] | array | {from, to, why, impact}. A diff, not a rewrite log: one entry per meaningful difference between the user's command and the returned one, with from quoting what they had, or the literal "(absent)" when something is being added. impact is one of quality, size, speed, compatibility; anything else normalizes to quality. |
ladder[] | array | {name, settings, note}. The adaptive rungs, for target: "hls" and adaptive delivery generally. For every other target this is an empty array rather than invented rungs. |
tradeoffs[] | array | {choice, cost, benefit}. What was chosen, what it costs, what it buys. This is where "CRF 21 at preset slow" gets an honest price in encode time. |
verify[] | array | {check, expected}. Runnable or observable: an ffprobe line, a thing to look for in the output, a player to try. Never "check it looks right". |
command, command_note | string | The command to ship. This is the one lane where the returned command is expected to differ substantially from the input, which is exactly why command_scan matters most here. |
Step 1 — a tiny client
Everything below uses this one helper. It does the single thing that matters: it reads the envelope and raises on ok: false, because an error response is still HTTP-shaped JSON and ignoring it turns a 402 into a confusing null three lines later. Tokens come from the token page — it shows the token this browser already holds, reveals it, copies it, and can mint a fresh guest one — or from POST /guest in step 2.
# The whole client is two variables and curl.
BASE=https://api.skillsafe.ai/v1/app-api
TOKEN=YOUR_TOKEN # from https://ffmpeg-desk.skillsafe.ai/tokens.html
call() { # call <path> [json-body]
if [ -n "$2" ]; then
curl -sS -X POST "$BASE$1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$2"
else
curl -sS "$BASE$1" -H "Authorization: Bearer $TOKEN"
fi
}
# Every response is {"ok":true,"data":{...}} or {"ok":false,"error":{...}}.
# Check ok before you read data, or a 402 becomes a confusing null three lines on.
check() { python3 -c '
import json,sys
p = json.load(sys.stdin)
if not p.get("ok"):
e = p.get("error") or {}
sys.exit(str(e.get("code")) + ": " + str(e.get("message")))
print(json.dumps(p["data"], indent=2))
'; }
import json
import urllib.error
import urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://ffmpeg-desk.skillsafe.ai/tokens.html
class ApiError(Exception):
def __init__(self, code, message, details=None):
super().__init__("%s: %s" % (code, message))
self.code, self.message, self.details = code, message, details or {}
def call(path, body=None, token=None, extra_headers=None):
"""POST when there is a body, GET when there is not. Raises on ok:false."""
headers = {"Authorization": "Bearer " + (token or TOKEN)}
data = None
if body is not None:
data = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
headers.update(extra_headers or {})
req = urllib.request.Request(BASE + path, data=data, headers=headers)
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e) # errors are JSON too - read them
if not payload.get("ok"):
err = payload.get("error") or {}
raise ApiError(err.get("code", "unknown"), err.get("message", ""), err.get("details"))
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from https://ffmpeg-desk.skillsafe.ai/tokens.html
class ApiError extends Error {
constructor(code, message, details) {
super(`${code}: ${message}`);
this.code = code;
this.details = details || {};
}
}
async function call(path, body, token, extraHeaders) {
const headers = { Authorization: `Bearer ${token || TOKEN}`, ...(extraHeaders || {}) };
if (body !== undefined) headers["Content-Type"] = "application/json";
const res = await fetch(BASE + path, {
method: body === undefined ? "GET" : "POST",
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await res.json(); // an error response is JSON as well
if (!payload.ok) {
throw new ApiError(payload.error?.code, payload.error?.message, payload.error?.details);
}
return payload.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
// Read it from the environment with os.Getenv, or paste one from
// https://ffmpeg-desk.skillsafe.ai/tokens.html
var token = os.Getenv("SKILLSAFE_TOKEN")
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
Details json.RawMessage `json:"details"`
} `json:"error"`
}
// call POSTs when body is non-nil and GETs when it is nil. It returns the raw
// data member so each step can unmarshal into whatever shape it needs.
func call(path string, body any, hdr map[string]string) (json.RawMessage, error) {
var rdr io.Reader
method := http.MethodGet
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
rdr = bytes.NewReader(b)
method = http.MethodPost
}
req, err := http.NewRequest(method, base+path, rdr)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
for k, v := range hdr {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
import java.util.Map;
public final class FfmpegDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = "YOUR_TOKEN"; // ffmpeg-desk.skillsafe.ai/tokens.html
static final HttpClient HTTP = HttpClient.newHttpClient();
static class ApiException extends RuntimeException {
ApiException(String m) { super(m); }
}
/** POST when body is non-null, GET otherwise. Throws on ok:false. */
static String call(String path, String jsonBody, Map<String, String> extra)
throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody == null) {
b.GET();
} else {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
}
if (extra != null) extra.forEach(b::header);
HttpResponse<String> res = HTTP.send(b.build(),
HttpResponse.BodyHandlers.ofString());
String body = res.body();
// Any real client parses this with Jackson or Gson; the point here is
// only that ok:false must be read before data is touched.
if (body.contains("\"ok\":false")) throw new ApiException(body);
return body;
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://ffmpeg-desk.skillsafe.ai/tokens.html
class ApiError < StandardError
attr_reader :code, :details
def initialize(code, message, details = {})
super("#{code}: #{message}")
@code = code
@details = details
end
end
# POST when a body is given, GET when it is not. Raises on ok:false.
def call(path, body = nil, extra = {})
uri = URI(BASE + path)
req = body.nil? ? Net::HTTP::Get.new(uri) : Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
extra.each { |k, v| req[k] = v }
unless body.nil?
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
unless payload["ok"]
e = payload["error"] || {}
raise ApiError.new(e["code"], e["message"], e["details"])
end
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from https://ffmpeg-desk.skillsafe.ai/tokens.html
class ApiError extends Exception {
public $apiCode;
public $details;
public function __construct($code, $message, $details = []) {
parent::__construct("$code: $message");
$this->apiCode = $code;
$this->details = $details;
}
}
/** POST when $body is given, GET when it is null. Throws on ok:false. */
function call(string $path, $body = null, array $extra = []) {
$headers = array_merge(["Authorization: Bearer " . TOKEN], $extra);
$opts = ["http" => [
"method" => $body === null ? "GET" : "POST",
"ignore_errors" => true, // read the JSON body of a 4xx too
]];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
$opts["http"]["content"] = json_encode($body);
}
$opts["http"]["header"] = implode("\r\n", $headers);
$raw = file_get_contents(BASE . $path, false, stream_context_create($opts));
$payload = json_decode($raw, true);
if (empty($payload["ok"])) {
$e = $payload["error"] ?? [];
throw new ApiError($e["code"] ?? "unknown", $e["message"] ?? "", $e["details"] ?? []);
}
return $payload["data"];
}
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
public static class FfmpegDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Token = "YOUR_TOKEN"; // ffmpeg-desk.skillsafe.ai/tokens.html
static readonly HttpClient Http = new HttpClient();
public class ApiException : Exception
{
public string Code;
public ApiException(string code, string message) : base(code + ": " + message)
=> Code = code;
}
/// POST when body is non-null, GET otherwise. Throws on ok:false.
public static async Task<JsonElement> Call(
string path, object body = null, Dictionary<string, string> extra = null)
{
var req = new HttpRequestMessage(
body == null ? HttpMethod.Get : HttpMethod.Post, Base + path);
req.Headers.Add("Authorization", "Bearer " + Token);
if (extra != null)
foreach (var kv in extra) req.Headers.Add(kv.Key, kv.Value);
if (body != null)
req.Content = new StringContent(
JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req);
var payload = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
if (!payload.GetProperty("ok").GetBoolean())
{
var e = payload.GetProperty("error");
throw new ApiException(e.GetProperty("code").GetString(),
e.GetProperty("message").GetString());
}
return payload.GetProperty("data");
}
}
Step 2 — get a token
The friendly route is the token page: it shows the token this browser already holds, reveals it, copies it, and can mint a fresh guest one without you writing any code. Programmatically, POST /guest is the whole story, and it is the one call whose body is not a lane input: it takes {"slug": "ffmpeg-desk"}, and that slug is what binds the returned token to this app. Omit it and you get a 400 invalid_request; misspell it and you get a 404 not_found. Everything after this reads the app identity off the token, which is why no later call carries a slug and there is no X-App-Slug header anywhere.
What a guest token can do: GET /me, POST /estimate, and GET /jobs/{id} for jobs the guest itself created. That is enough to validate an input shape, learn which model the app is bound to and price a lane, all without an account. What it cannot do: run a metered lane. POST /run and POST /run-stream need a personal token — a guest wallet has no credits, so the run fails the balance check rather than the auth check, which is why a guest attempt usually surfaces as 402 payment_required and not 401. The exception is a sponsored app: /estimate returns sponsor_enabled, and when that is true the app's owner is paying and a guest can run. The web app checks exactly that before it decides whether to ask you to sign in. A guest token expires roughly a month out, at data.expires_at.
# mint a guest token - free, and enough for /me and /estimate
call /guest '{"slug": "ffmpeg-desk"}' | check
# data.token is the bearer, data.guest_id identifies the wallet, and
# data.expires_at is roughly a month out. Put the token in TOKEN and carry on.
# For a personal token instead: https://ffmpeg-desk.skillsafe.ai/tokens.html
# mint a guest token - free, and enough for /me and /estimate
data = call("/guest", {"slug": "ffmpeg-desk"})
print(data["token"], data["guest_id"], data["expires_at"])
# Running a lane is metered: swap in a personal token from
# https://ffmpeg-desk.skillsafe.ai/tokens.html before step 5.
TOKEN = data["token"]
// mint a guest token - free, and enough for /me and /estimate
const guest = await call("/guest", { slug: "ffmpeg-desk" });
console.log(guest.token, guest.guest_id, guest.expires_at);
// Running a lane is metered: use a personal token from
// https://ffmpeg-desk.skillsafe.ai/tokens.html before step 5.
// mint a guest token - free, and enough for /me and /estimate
raw, err := call("/guest", map[string]any{"slug": "ffmpeg-desk"}, nil)
if err != nil {
panic(err)
}
var guest struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
ExpiresAt string `json:"expires_at"`
}
json.Unmarshal(raw, &guest)
fmt.Println(guest.Token, guest.GuestID, guest.ExpiresAt)
// A personal token, for the metered lanes, comes from
// https://ffmpeg-desk.skillsafe.ai/tokens.html
// mint a guest token - free, and enough for /me and /estimate
String data = FfmpegDesk.call("/guest", "{\"slug\": \"ffmpeg-desk\"}", null);
System.out.println(data); // token, guest_id, expires_at
// Running a lane is metered: use a personal token from
// https://ffmpeg-desk.skillsafe.ai/tokens.html before step 5.
# mint a guest token - free, and enough for /me and /estimate
guest = call("/guest", { "slug" => "ffmpeg-desk" })
puts guest["token"], guest["guest_id"], guest["expires_at"]
# Personal tokens: https://ffmpeg-desk.skillsafe.ai/tokens.html
<?php
// mint a guest token - free, and enough for /me and /estimate
$guest = call("/guest", ["slug" => "ffmpeg-desk"]);
echo $guest["token"], " ", $guest["guest_id"], " ", $guest["expires_at"], "\n";
// Personal tokens: https://ffmpeg-desk.skillsafe.ai/tokens.html
// mint a guest token - free, and enough for /me and /estimate
var guest = await FfmpegDesk.Call("/guest", new { slug = "ffmpeg-desk" });
Console.WriteLine(guest.GetProperty("token").GetString());
Console.WriteLine(guest.GetProperty("expires_at").GetString());
// Personal tokens: https://ffmpeg-desk.skillsafe.ai/tokens.html
Step 3 — who am I, and can I afford this
GET /me returns the subject and the balance. Comparing it against /estimate before you submit is what turns a 402 from an error your user sees into a button you disabled. It is also how you tell a guest token from a personal one: subject_type reads guest on the first and user on the second, and only the second can run a metered lane on an unsponsored app. credits is the spendable balance in the same unit hold_credits and min_credits are quoted in, so the comparison is a plain integer comparison and nothing needs converting.
# subject_type, username, credits
call /me | check
# subject_type, username, credits
me = call("/me")
print(me["subject_type"], me.get("username"), me.get("credits"))
# The app's own preflight, in one line: never submit into a 402.
can_run = me["subject_type"] == "user" and me.get("credits", 0) >= est["min_credits"]
// subject_type, username, credits
const me = await call("/me");
console.log(me.subject_type, me.username, me.credits);
// The app's own preflight: disable the button, name the shortfall, never 402.
const canRun = me.subject_type === "user" && (me.credits || 0) >= est.min_credits;
// subject_type, username, credits
raw, err := call("/me", nil, nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Username string `json:"username"`
Credits int `json:"credits"`
}
json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Username, me.Credits)
// subject_type, username, credits
String data = FfmpegDesk.call("/me", null, null);
System.out.println(data);
# subject_type, username, credits
me = call("/me")
puts "#{me["subject_type"]} #{me["credits"]}"
<?php
// subject_type, username, credits
$me = call("/me");
echo $me["subject_type"], " ", $me["credits"] ?? 0, "\n";
// subject_type, username, credits
var me = await FfmpegDesk.Call("/me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
Console.WriteLine(me.GetProperty("credits").GetInt32());
Step 4 — estimate, free
/estimate creates no job and charges nothing. It is also the authoritative check that your input shape is valid and that the app is bound to the model you think it is: the reply carries model, model_alias and markup_bps alongside hold_credits, min_credits, sponsor_enabled and byok. Estimate per lane and per input: the four lanes have different prompts and different output caps, so one lane's hold is not another lane's price, and a deliver reply carrying a ladder and a rewritten filtergraph is not priced like a read reply.
| Field | What it means |
|---|---|
hold_credits | A reservation, not the price. It is the worst case that gets held when the run starts, computed against the full output cap, and almost no run reaches it — the real cost comes back as charged_credits on the finished job and is usually far below. Show it to a user as RESERVED. Showing it as the price is the most common way to make a cheap app look expensive. |
min_credits | The floor below which the run will not start at all. Below this you get 402 payment_required. Between min_credits and hold_credits the run executes with a reduced cap and comes back truncated: true. |
model | The exact model id the run will be executed on. This is what "bound to" means: it is not a preference, it is what you will be charged for. |
model_alias | The friendlier name for the same thing, suitable for showing a user. |
markup_bps | The app's markup over raw model cost, in basis points. 0 means none. |
sponsor_enabled | True when the app owner is paying, which is the one case where a guest subject can run a metered lane. The web app calls /estimate specifically to read this before deciding whether to prompt for sign-in. |
byok | True when the run will use your own provider key rather than metered credits. |
INPUT='{
"task": "read",
"command": "ffmpeg -i lecture.mkv -ss 00:12:30 -t 90 -c copy -vf scale=1280:720 -movflags +faststart clip.mp4",
"target": "general",
"context_note": "I only want a 90 second clip out of the middle of a lecture.",
"facts": {"readable": true, "binary": "ffmpeg",
"inputs": [{"id": "in0", "url": "lecture.mkv", "ext": "mkv"}],
"outputs": [{"id": "out0", "url": "clip.mp4", "container": "mp4",
"vcodec": "copy", "vf": "scale=1280:720"}],
"flags": [{"id": "FF-001", "severity": "high", "location": "out0",
"rule": "R05",
"label": "Stream copy is not combined with filtering",
"why": "filtering requires re-encoding"}],
"required_flag_ids": ["FF-001"]}
}'
# Free. No job is created and nothing is charged.
call /estimate "$INPUT" | check
# hold_credits - RESERVED worst case, not the price. charged_credits is the price.
# min_credits - below this the run will not start at all.
# model, model_alias, markup_bps - what you are actually bound to.
# sponsor_enabled - when true, a guest subject may run this app.
command = ("ffmpeg -i lecture.mkv -ss 00:12:30 -t 90 -c copy "
"-vf scale=1280:720 -movflags +faststart clip.mp4")
input_obj = {
"task": "read", # read | graph | subs | deliver
"command": command, # verbatim - do not normalise the quoting
"target": "general", # one of the eight; anything else means general
"context_note": "I only want a 90 second clip out of the middle of a lecture.",
# facts is what the model is held accountable to. Send yours - see above.
"facts": {
"readable": True,
"binary": "ffmpeg",
"inputs": [{"id": "in0", "url": "lecture.mkv", "ext": "mkv"}],
"outputs": [{"id": "out0", "url": "clip.mp4", "container": "mp4",
"vcodec": "copy", "vf": "scale=1280:720"}],
"filter_graph": None, # or the parsed graph: chains, filters, labels
"maps": [], # one row per -map, with resolves true/false
"rules": [], # one row per check, state pass/FAIL/not-applicable
"flags": [], # each critical/high one must come back reconciled
"required_flag_ids": [], # the ids that MUST appear in reconciliation
},
}
est = call("/estimate", input_obj) # free: no job, no charge
print(est["model"], est["model_alias"], est["markup_bps"])
print("reserved:", est["hold_credits"], "minimum:", est["min_credits"])
# The hold prices the full output cap. Between min_credits and hold_credits the
# run still executes with a reduced cap and comes back "truncated": true.
const command =
"ffmpeg -i lecture.mkv -ss 00:12:30 -t 90 -c copy " +
"-vf scale=1280:720 -movflags +faststart clip.mp4";
const inputObj = {
task: "read", // read | graph | subs | deliver
command, // verbatim - the quoting IS the evidence
target: "general", // one of the eight; anything else means general
context_note: "I only want a 90 second clip out of the middle of a lecture.",
// facts is what the model is held accountable to. Send yours - see above.
facts: {
readable: true,
binary: "ffmpeg",
inputs: [{ id: "in0", url: "lecture.mkv", ext: "mkv" }],
outputs: [{ id: "out0", url: "clip.mp4", container: "mp4",
vcodec: "copy", vf: "scale=1280:720" }],
filter_graph: null,
maps: [],
rules: [],
flags: [],
required_flag_ids: [],
},
};
const est = await call("/estimate", inputObj); // free: no job, no charge
console.log(est.model, est.model_alias, est.markup_bps);
console.log("reserved:", est.hold_credits, "minimum:", est.min_credits);
command := "ffmpeg -i lecture.mkv -ss 00:12:30 -t 90 -c copy " +
"-vf scale=1280:720 -movflags +faststart clip.mp4"
inputObj := map[string]any{
"task": "read", // read | graph | subs | deliver
"command": command,
"target": "general",
"context_note": "I only want a 90 second clip out of the middle of a lecture.",
// facts is what the model is held accountable to - see above.
"facts": map[string]any{
"readable": true,
"binary": "ffmpeg",
"inputs": []any{map[string]any{
"id": "in0", "url": "lecture.mkv", "ext": "mkv"}},
"outputs": []any{map[string]any{
"id": "out0", "url": "clip.mp4", "container": "mp4",
"vcodec": "copy", "vf": "scale=1280:720"}},
"filter_graph": nil,
"maps": []any{},
"rules": []any{},
"flags": []any{},
"required_flag_ids": []any{},
},
}
raw, err := call("/estimate", inputObj, nil) // free: no job, no charge
if err != nil {
panic(err)
}
var est struct {
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
MarkupBps int `json:"markup_bps"`
HoldCredits int `json:"hold_credits"`
MinCredits int `json:"min_credits"`
Sponsored bool `json:"sponsor_enabled"`
}
json.Unmarshal(raw, &est)
fmt.Printf("%s (%s) reserve %d min %d\n",
est.Model, est.ModelAlias, est.HoldCredits, est.MinCredits)
String command = "ffmpeg -i lecture.mkv -ss 00:12:30 -t 90 -c copy "
+ "-vf scale=1280:720 -movflags +faststart clip.mp4";
// Build this with Jackson in real code; a map literal is shown so the shape of
// the object is readable on one screen.
String inputJson = new ObjectMapper().writeValueAsString(Map.of(
"task", "read",
"command", command,
"target", "general",
"context_note", "I only want a 90 second clip out of the middle of a lecture.",
"facts", Map.of("readable", true,
"binary", "ffmpeg",
"inputs", List.of(Map.of("id", "in0", "url", "lecture.mkv")),
"outputs", List.of(Map.of("id", "out0", "url", "clip.mp4",
"vcodec", "copy")),
"rules", List.of(),
"flags", List.of(),
"required_flag_ids", List.of())));
String est = FfmpegDesk.call("/estimate", inputJson, null); // free
System.out.println(est); // model, model_alias, markup_bps, hold_credits, min_credits
command = "ffmpeg -i lecture.mkv -ss 00:12:30 -t 90 -c copy " \
"-vf scale=1280:720 -movflags +faststart clip.mp4"
input_obj = {
"task" => "read", # read | graph | subs | deliver
"command" => command, # verbatim
"target" => "general",
"context_note" => "I only want a 90 second clip out of the middle of a lecture.",
# facts is what the model is held accountable to - see above.
"facts" => {
"readable" => true,
"binary" => "ffmpeg",
"inputs" => [{ "id" => "in0", "url" => "lecture.mkv", "ext" => "mkv" }],
"outputs" => [{ "id" => "out0", "url" => "clip.mp4", "container" => "mp4",
"vcodec" => "copy", "vf" => "scale=1280:720" }],
"filter_graph" => nil, "maps" => [], "rules" => [], "flags" => [],
"required_flag_ids" => []
}
}
est = call("/estimate", input_obj) # free: no job, no charge
puts "#{est["model"]} (#{est["model_alias"]}) reserve #{est["hold_credits"]}"
<?php
$command = "ffmpeg -i lecture.mkv -ss 00:12:30 -t 90 -c copy "
. "-vf scale=1280:720 -movflags +faststart clip.mp4";
$inputObj = [
"task" => "read", // read | graph | subs | deliver
"command" => $command, // verbatim
"target" => "general",
"context_note" => "I only want a 90 second clip out of the middle of a lecture.",
// facts is what the model is held accountable to - see above.
"facts" => [
"readable" => true,
"binary" => "ffmpeg",
"inputs" => [["id" => "in0", "url" => "lecture.mkv"]],
"outputs" => [["id" => "out0", "url" => "clip.mp4",
"vcodec" => "copy", "vf" => "scale=1280:720"]],
"filter_graph" => null,
"maps" => [],
"rules" => [],
"flags" => [],
"required_flag_ids" => [],
],
];
$est = call("/estimate", $inputObj); // free: no job, no charge
echo $est["model"], " reserve ", $est["hold_credits"], " min ", $est["min_credits"], "\n";
var command = "ffmpeg -i lecture.mkv -ss 00:12:30 -t 90 -c copy " +
"-vf scale=1280:720 -movflags +faststart clip.mp4";
var inputObj = new Dictionary<string, object>
{
["task"] = "read", // read | graph | subs | deliver
["command"] = command, // verbatim
["target"] = "general",
["context_note"] = "I only want a 90 second clip out of the middle of a lecture.",
// facts is what the model is held accountable to - see above.
["facts"] = new Dictionary<string, object>
{
["readable"] = true,
["binary"] = "ffmpeg",
["inputs"] = new object[] {
new Dictionary<string, object> { ["id"] = "in0", ["url"] = "lecture.mkv" } },
["outputs"] = new object[] {
new Dictionary<string, object> { ["id"] = "out0", ["url"] = "clip.mp4",
["vcodec"] = "copy" } },
["rules"] = new object[0],
["flags"] = new object[0],
["required_flag_ids"] = new object[0],
},
};
var est = await FfmpegDesk.Call("/estimate", inputObj); // free
Console.WriteLine($"{est.GetProperty("model")} reserve {est.GetProperty("hold_credits")}");
Step 5 — run and poll
Submit, then poll the job until it is terminal. data.output.output is a string holding the JSON object — parse it a second time. charged_credits is the real cost and is usually far below the hold, which priced the full output cap. truncated comes back on the same object; see the error section for what it obliges you to tell your user.
The Idempotency-Key is not optional in practice. The app derives it from the lane, the command text, the target, the note and the attempt number, as ffmpeg-desk:<lane>:<hash>:a<attempt>, so four lanes over one command are four runs that cannot collide on one key, changing the target is a genuinely new run, and a retried POST of the same lane is free rather than a second charge. Any deterministic derivation works; a counter does not, because reusing one key with a different body is a 409.
# 1. submit. Attempt 1 of this lane over this command at this target.
KEY="ffmpeg-desk:read:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-32):a1"
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')
# 2. poll until terminal. The sleep is not optional - a tight loop earns a 429.
while :; do
OUT=$(call "/jobs/$JOB")
ST=$(printf '%s' "$OUT" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["status"])')
[ "$ST" = "succeeded" ] || [ "$ST" = "failed" ] && break
sleep 2
done
printf '%s' "$OUT" | python3 -c '
import json,sys
d = json.load(sys.stdin)["data"]
print("charged", d.get("charged_credits"), "truncated", d.get("truncated"))
print(d["output"]["output"]) # the JSON object your renderer parses
'
import hashlib
import time
# The key covers the lane AND the command text AND the target AND the note AND
# the attempt: read and graph over one command are two runs, and the same
# command at target "archive" instead of "web" is a third, because the deliver
# lane answers a different question.
raw = json.dumps([input_obj["task"], input_obj["command"], input_obj["target"],
input_obj.get("context_note", "")])
key = "ffmpeg-desk:%s:%s:a1" % (input_obj["task"],
hashlib.sha256(raw.encode()).hexdigest()[:32])
job = call("/run", input_obj, extra_headers={"Idempotency-Key": key})
while True:
st = call("/jobs/" + job["job_id"])
if st["status"] in ("succeeded", "failed"):
break
time.sleep(2) # never tight-loop; that is what 429 is for
if st["status"] == "failed":
raise SystemExit(st.get("error"))
print("charged", st.get("charged_credits"), "truncated", st.get("truncated"))
result = json.loads(st["output"]["output"]) # the one JSON object
print(result["verdict"], len(result["findings"]), "findings")
# Answer every required flag or you have not audited anything: one reconciliation
# entry per id in required_flag_ids is the contract.
answered = {r["flag_id"] for r in result["reconciliation"]}
required = set(input_obj["facts"].get("required_flag_ids", []))
print("unanswered flags:", sorted(required - answered))
# And check the citations, because an invented filter node reads exactly like a
# real one until you compare it against what you parsed.
real = {"command"}
real |= {i["id"] for i in input_obj["facts"].get("inputs", [])}
real |= {o["id"] for o in input_obj["facts"].get("outputs", [])}
print("ungrounded:", [f["location"] for f in result["findings"]
if f["location"] and f["location"] not in real])
import { createHash } from "node:crypto";
const raw = JSON.stringify([inputObj.task, inputObj.command, inputObj.target,
inputObj.context_note || ""]);
const key = `ffmpeg-desk:${inputObj.task}:${createHash("sha256").update(raw)
.digest("hex").slice(0, 32)}:a1`;
const job = await call("/run", inputObj, undefined, { "Idempotency-Key": key });
let st;
for (;;) {
st = await call(`/jobs/${job.job_id}`);
if (st.status === "succeeded" || st.status === "failed") break;
await new Promise((r) => setTimeout(r, 2000)); // back off; do not tight-loop
}
if (st.status === "failed") throw new Error(JSON.stringify(st.error));
if (st.truncated) console.warn("reply was cut short by a low balance");
const result = JSON.parse(st.output.output);
console.log(result.verdict, result.findings.length, "findings");
// Which required flags went unanswered - the audit that makes facts worth
// sending in the first place.
const answered = new Set(result.reconciliation.map((r) => r.flag_id));
const missed = (inputObj.facts.required_flag_ids || [])
.filter((id) => !answered.has(id));
console.log("unanswered flags:", missed);
// And on the graph lane: a node nobody read is a node nobody checked.
if (result.task === "graph") console.log("uncovered:", result.body.uncovered_nodes);
b, _ := json.Marshal([]any{inputObj["task"], inputObj["command"],
inputObj["target"], inputObj["context_note"]})
sum := sha256.Sum256(b)
key := fmt.Sprintf("ffmpeg-desk:%s:%x:a1", inputObj["task"], sum[:16])
raw, err := call("/run", inputObj, map[string]string{"Idempotency-Key": key})
if err != nil {
panic(err)
}
var job struct{ JobID string `json:"job_id"` }
json.Unmarshal(raw, &job)
var st struct {
Status string `json:"status"`
Charged int `json:"charged_credits"`
Truncated bool `json:"truncated"`
Output struct{ Output string `json:"output"` } `json:"output"`
}
for {
raw, err = call("/jobs/"+job.JobID, nil, nil)
if err != nil {
panic(err)
}
json.Unmarshal(raw, &st)
if st.Status == "succeeded" || st.Status == "failed" {
break
}
time.Sleep(2 * time.Second)
}
fmt.Println("charged", st.Charged, "truncated", st.Truncated)
fmt.Println(st.Output.Output) // a string holding the JSON - unmarshal it again
String raw = inputJson; // hash the same fields app.js hashes
String key = "ffmpeg-desk:read:"
+ java.util.HexFormat.of().formatHex(
java.security.MessageDigest.getInstance("SHA-256")
.digest(raw.getBytes())).substring(0, 32)
+ ":a1";
String job = FfmpegDesk.call("/run", inputJson, Map.of("Idempotency-Key", key));
String jobId = job.replaceAll(".*\"job_id\"\\s*:\\s*\"([^\"]+)\".*", "$1");
String st;
while (true) {
st = FfmpegDesk.call("/jobs/" + jobId, null, null);
if (st.contains("\"succeeded\"") || st.contains("\"failed\"")) break;
Thread.sleep(2000);
}
System.out.println(st); // data.output.output holds the one JSON object
require "digest"
raw = JSON.generate([input_obj["task"], input_obj["command"],
input_obj["target"], input_obj["context_note"]])
key = "ffmpeg-desk:#{input_obj["task"]}:#{Digest::SHA256.hexdigest(raw)[0, 32]}:a1"
job = call("/run", input_obj, { "Idempotency-Key" => key })
st = nil
loop do
st = call("/jobs/#{job["job_id"]}")
break if %w[succeeded failed].include?(st["status"])
sleep 2
end
abort(st["error"].to_s) if st["status"] == "failed"
warn "reply was cut short by a low balance" if st["truncated"]
result = JSON.parse(st["output"]["output"])
puts "#{result["verdict"]} #{result["findings"].length} findings"
puts result["body"]["command"] # the artifact: a runnable ffmpeg command
<?php
$raw = json_encode([$inputObj["task"], $inputObj["command"],
$inputObj["target"], $inputObj["context_note"]]);
$key = "ffmpeg-desk:" . $inputObj["task"] . ":" . substr(hash("sha256", $raw), 0, 32) . ":a1";
$job = call("/run", $inputObj, ["Idempotency-Key: $key"]);
do {
sleep(2);
$st = call("/jobs/" . $job["job_id"]);
} while (!in_array($st["status"], ["succeeded", "failed"], true));
if ($st["status"] === "failed") { exit(1); }
$result = json_decode($st["output"]["output"], true);
echo $result["verdict"], " ", count($result["findings"]), " findings\n";
echo $result["body"]["command"], "\n";
using System.Security.Cryptography;
var raw = JsonSerializer.Serialize(new object[] {
inputObj["task"], inputObj["command"], inputObj["target"],
inputObj["context_note"] });
var key = "ffmpeg-desk:" + inputObj["task"] + ":" +
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))
.Substring(0, 32).ToLowerInvariant() + ":a1";
var job = await FfmpegDesk.Call("/run", inputObj,
new Dictionary<string, string> { ["Idempotency-Key"] = key });
JsonElement st;
while (true)
{
st = await FfmpegDesk.Call("/jobs/" + job.GetProperty("job_id").GetString());
var status = st.GetProperty("status").GetString();
if (status == "succeeded" || status == "failed") break;
await Task.Delay(2000);
}
var result = JsonDocument.Parse(
st.GetProperty("output").GetProperty("output").GetString()).RootElement;
Console.WriteLine(result.GetProperty("verdict"));
Console.WriteLine(result.GetProperty("body").GetProperty("command"));
Step 6 — run-stream, for progress
The same run, reported as it happens, as server-sent events. This is what the web app uses, and the reason its progress card can name a real stage rather than spin: the appearance of "findings", then "body", then the lane's own keys in the accumulated delta text is a real signal about where the model is. Accumulate the deltas and parse once at the end, because a partial JSON document is not parseable.
| Lane | The five markers the app watches, in order |
|---|---|
read | "findings", "body", "walkthrough", "streams", "command" |
graph | "findings", "body", "nodes", "wiring_notes", "command" |
subs | "findings", "body", "steps", "compat", "command" |
deliver | "findings", "body", "changes", "tradeoffs", "verify" |
The events worth handling are job (accepted, the run is now billed against the hold), delta (a chunk of the JSON document, in order, as {"text": "..."}), done (terminal, with charged_credits and truncated) and error (terminal failure). A stream that dies mid-document leaves you holding unparseable text; that is exactly the case retry_note exists for, and the app retries once with the reformat instruction before it gives up and shows the raw text. Throttle whatever you paint from the deltas — the app repaints at most every 250 ms — because the chunks are small and frequent and a repaint per chunk is the easiest way to make a fast run feel slow.
curl -sS -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT"
# Server-sent events. The ones worth handling:
# event: job - accepted; the run is now billed against the hold
# event: delta - a chunk of the JSON document, in order
# event: done - terminal, with charged_credits and truncated
# event: error - terminal failure
# The app drives its progress card off the delta text: "findings", then "body",
# then the lane's own keys, is what advances a stage.
# The stream is the same run, reported as it happens. Accumulate the deltas and
# parse ONCE at the end - a partial JSON document is not parseable, and the app's
# reformat retry exists exactly because a stream can die mid-document.
req = urllib.request.Request(
BASE + "/run-stream",
data=json.dumps(input_obj).encode(),
headers={"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json",
"Idempotency-Key": key})
MARKERS = ['"findings"', '"body"', '"walkthrough"', '"streams"', '"command"']
buf = ""
with urllib.request.urlopen(req) as r:
event = None
for line in r:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: "):
payload = json.loads(line[6:])
if event == "delta":
buf += payload.get("text", "")
stage = sum(1 for m in MARKERS if m in buf)
# advance your progress display to `stage` here
elif event == "done":
print("charged", payload.get("charged_credits"),
"truncated", payload.get("truncated"))
elif event == "error":
raise SystemExit(payload)
result = json.loads(buf)
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(inputObj),
});
const MARKERS = ['"findings"', '"body"', '"walkthrough"', '"streams"', '"command"'];
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", frame = "", event = null;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
frame += dec.decode(value, { stream: true });
const lines = frame.split("\n");
frame = lines.pop();
for (const line of lines) {
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ")) {
const p = JSON.parse(line.slice(6));
if (event === "delta") {
buf += p.text || "";
const stage = MARKERS.filter((m) => buf.includes(m)).length;
// paint `stage` - throttle it, the deltas are small and frequent
} else if (event === "done") console.log("charged", p.charged_credits);
else if (event === "error") throw new Error(JSON.stringify(p));
}
}
}
const result = JSON.parse(buf);
req, _ := http.NewRequest(http.MethodPost, base+"/run-stream",
bytes.NewReader(func() []byte { b, _ := json.Marshal(inputObj); return b }()))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var buf strings.Builder
var event string
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 1<<20), 1<<22)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = strings.TrimPrefix(line, "event: ")
case strings.HasPrefix(line, "data: "):
var p struct {
Text string `json:"text"`
Charged int `json:"charged_credits"`
Truncated bool `json:"truncated"`
}
json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &p)
if event == "delta" {
buf.WriteString(p.Text)
} else if event == "done" {
fmt.Println("charged", p.Charged, "truncated", p.Truncated)
}
}
}
fmt.Println(buf.String()) // parse ONCE, here
HttpRequest req = HttpRequest.newBuilder(URI.create(FfmpegDesk.BASE + "/run-stream"))
.header("Authorization", "Bearer " + FfmpegDesk.TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(inputJson))
.build();
StringBuilder buf = new StringBuilder();
String[] event = { null };
FfmpegDesk.HTTP.send(req, HttpResponse.BodyHandlers.ofLines()).body()
.forEach(line -> {
if (line.startsWith("event: ")) {
event[0] = line.substring(7);
} else if (line.startsWith("data: ") && "delta".equals(event[0])) {
// parse {"text": "..."} with your JSON library and append it
buf.append(extractText(line.substring(6)));
}
});
System.out.println(buf); // parse ONCE, here, not per delta
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.generate(input_obj)
MARKERS = ['"findings"', '"body"', '"walkthrough"', '"streams"', '"command"']
buf = ""
event = nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event: ")
event = line[7..]
elsif line.start_with?("data: ")
p = JSON.parse(line[6..])
if event == "delta"
buf << (p["text"] || "")
stage = MARKERS.count { |m| buf.include?(m) }
end
puts "charged #{p["charged_credits"]}" if event == "done"
end
end
end
end
end
result = JSON.parse(buf)
<?php
$ctx = stream_context_create(["http" => [
"method" => "POST",
"header" => implode("\r\n", [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: $key",
]),
"content" => json_encode($inputObj),
]]);
$fh = fopen(BASE . "/run-stream", "r", false, $ctx);
$buf = "";
$event = null;
while (($line = fgets($fh)) !== false) {
$line = rtrim($line, "\r\n");
if (str_starts_with($line, "event: ")) {
$event = substr($line, 7);
} elseif (str_starts_with($line, "data: ")) {
$p = json_decode(substr($line, 6), true);
if ($event === "delta") { $buf .= $p["text"] ?? ""; }
if ($event === "done") { echo "charged ", $p["charged_credits"], "\n"; }
}
}
fclose($fh);
$result = json_decode($buf, true); // parse ONCE, here
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream");
req.Headers.Add("Authorization", "Bearer " + Token);
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(JsonSerializer.Serialize(inputObj),
Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var markers = new[] { "\"findings\"", "\"body\"", "\"walkthrough\"",
"\"streams\"", "\"command\"" };
var buf = new StringBuilder();
string ev = null, line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line.StartsWith("event: ")) ev = line.Substring(7);
else if (line.StartsWith("data: "))
{
var p = JsonDocument.Parse(line.Substring(6)).RootElement;
if (ev == "delta" && p.TryGetProperty("text", out var t))
{
buf.Append(t.GetString());
var stage = markers.Count(m => buf.ToString().Contains(m));
}
else if (ev == "done")
Console.WriteLine("charged " + p.GetProperty("charged_credits"));
}
}
var result = JsonDocument.Parse(buf.ToString()).RootElement;
Idempotency keys and rate limits
An idempotency key is what makes a retry free instead of a second charge, and it is the reason a 5xx or a dropped connection is recoverable rather than expensive. The app builds one for every run and you should too.
How the app derives it. It hashes the input, not a counter: JSON.stringify([task, command, target, context_note]) through a short non-cryptographic hash, then formats the key as ffmpeg-desk:<lane>:<hash>:a<attempt> — the slug, the lane, the hash of the input, and the attempt number. Four consequences fall straight out of that shape, and they are the properties you want:
- Four lanes over one command are four keys. The lane is in the key twice over — once literally and once inside the hash — so running
readthengraphover the same command never collides. - Editing the command, the target or the note is a new run. All three are inside the hash. Changing
targetfromwebtoarchiveasks thedeliverlane a different question, and it gets a different key. - A re-POST of the identical body is free. Same lane, same command, same target, same note, same attempt gives the same key, and the platform returns the original job rather than starting a second charged run. This is what makes "retry once on 500" safe.
- The parse retry is a deliberate second run. When a reply does not parse as JSON, the client sends it again with
retry_noteanda2in the key. That is an intentional extra run and it is charged as one, which is why it happens once and once only. Do not reusea1for it: the body changed, soa1would be a 409idempotency_conflict.
Any deterministic derivation works. A counter does not: reusing one key with a different body is a 409, and generating a fresh key per attempt turns your retry into a duplicate charge. If you cannot hash the body cheaply, hash the command text and the lane at minimum — those are the two fields that change the answer most.
Rate limits. A 429 rate_limited means back off, wait, and retry with the same idempotency key. Never tight-loop. The polling loop in step 5 is the usual offender: GET /jobs/{id} is free, but free is not unlimited, and a loop with no sleep will earn a 429 well before the job finishes. Two seconds between polls is what the app uses and is plenty; exponential backoff from one second is better still if you are polling many jobs. If you are running lanes in bulk, prefer /run-stream over a poll loop — one connection per run beats one request every two seconds per run — and serialise rather than fanning out, because a burst of concurrent submissions is the other reliable way to find the limit.
The honesty rules a caller should know about
These are not style guidance. They are enforced by the prompt, checked by the client, and they are the reason the output is worth consuming programmatically at all. If you build your own renderer, reproduce them.
- Reconciliation is keyed to
facts.required_flag_ids, one entry per id, no more and no fewer. A required flag with no entry is a defect the reply passed over in silence, and silence reads exactly like "there was nothing there". Compute the difference yourself — it is four lines — and show it. - Citations are checked against the command, and a bad one is kept and marked, never dropped. An input, output, filter node, label, rule or option flag that the command does not contain is displayed with an ungrounded marker rather than removed, so "the model named a filter this command does not have" is a thing your users can see. Dropping it would hide the one signal that tells you the answer drifted.
- The returned command goes back through the reader. Every lane's
commandis re-parsed and re-checked before it is shown, andcommand_scancarries the result: what still fails, what was cleared, what was newly introduced. A rewrite that trades one violation for another is visible in that object and nowhere else. - On the graph lane, an unread node is listed.
uncovered_nodesis every filter in the parse that the reply did not mention. A node nobody read is a node nobody checked. - No invented numbers. The model has not run the command and has not seen the media. It does not know the source resolution, duration, frame rate, bitrate or output size unless the command or your note states them, and it is told to say "depends on the source" rather than inventing "about 40 MB". What CRF 23 means and what a preset trades are knowledge about encoders and are fair game; measurements of your file are not.
- No claim of execution. Nothing here runs ffmpeg. Every word in a reply is a reading of a command line, and the prompt forbids implying otherwise.
- Credentials are named and never echoed. A stream key, a signed URL or an inline password is called out and the user is told to rotate it, and the returned
commandcarries a placeholder rather than the value.
What this API will not do
- It does not parse your command. There is no server-side ffmpeg reader. Everything in
factsis something you parsed, and the quality of the answer is bounded by it. The browser app will do the parsing for free if you would rather not write it. - It does not run anything. No encode, no probe, no muxing, no media access of any kind. Every lane returns text and a command for you to read and run yourself.
- It does not know your source. Resolution, duration, frame rate, bitrate, output size: all unknown unless your command or note says so. Asking for a file-size prediction gets you an honest refusal, which is the correct answer.
- It does not fetch anything. No codec database, no device compatibility matrix, no vendor lookup. Everything in a reply comes from what you sent plus what the model knows about encoders.
- It does not remember. Each run is independent. Continuity between lanes is something you pass in via
prior_read, and history is stored against your own account by the app, not by the model. - It is not a substitute for reading the command. The checks are a conservative reading of published FFmpeg behaviour and of what containers and players accept — not a guarantee that a command which passes will succeed on your build, your source or your target device.