Assembly
Five mechanical operations that cut, join and mux media your workspace already owns.
Every other family in roasOS generates. Assembly does not. It cuts, joins and muxes media that your workspace already owns, and it invents nothing.
Assembly is the last step of an ad: you have the shots, the voice-over and the end card, and you need one file.
The assembly family is live. Every operation on this page answers a real job.
The five operations
Each operation has its own path and its own strict schema. There is no op field and no hidden switch: the five field sets share nothing, so one endpoint behind a switch would hide the very branch that decides your bill.
| Path | What it does | Result |
|---|---|---|
POST /v1/assembly/compose | Builds one video from a timeline of tracks and keyframes | video |
POST /v1/assembly/merge-videos | Joins finished clips end to end | video |
POST /v1/assembly/merge-audio-video | Replaces the audio of one clip with one audio track | video |
POST /v1/assembly/images-to-video | Plays still images as video frames | video |
POST /v1/assembly/extract-frame | Takes the first, middle or last frame out of a clip | image |
Every path needs the scope api:write and an Idempotency-Key header, and every path takes the same quote_only, quote_id, webhook_url and wait_timeout_seconds fields as the model endpoints. Refer to Jobs and Quotes and credits.
Which one to use
- Use merge-videos when the shots play one after another and nothing overlaps.
- Use compose when something overlaps: a logo over a shot, a voice-over across a cut, a hold on a still.
- Use merge-audio-video when the picture is finished and only the audio changes. It replaces the track; it does not mix.
- Use images-to-video when the source is stills, not clips.
- Use extract-frame to continue a clip: take the last frame, then send it as the first frame of the next generation.
One flat credit
Every assembly operation costs one credit, whatever it does and whatever it is given.
The price is a property of the door, not of the work. A caller pays for authentication, the quote, the job envelope, the webhook, the CDN mirror and retention. It is not metered on the length of the timeline, because the provider never states what its per-second rate measures, and a price cannot be a function of an unknown.
Two consequences follow, and both are deliberate.
- The quote is exact, always. Assembly never reconciles and never settles:
quote_only: trueanswerscredits: 1before you spend an idempotency key. - The exposure is bounded by a cap instead of by a meter. That cap is the three-minute timeline below.
The bounds
The provider documents no limit on any count, so roasOS documents its own. Every one of these is checked before the quote.
| Bound | Value | Applies to |
|---|---|---|
| Tracks | 1 to 8 | compose |
| Keyframes in one track | 1 to 64 | compose |
| Keyframes in total | 200 | compose |
| Distinct media URLs | 12 | every operation |
| Source clips | 2 to 12 | merge-videos |
| Images | 1 to 12, each 1 to 600 frames | images-to-video |
| Timeline | 180,000 ms | compose |
| Request body | 64 KiB | every operation |
timestamp and duration are integer milliseconds. The provider accepts a float; roasOS does not, because a contract that admits a request the dispatch refuses is worse than a narrow one.
Two separate sums must each stay within 180,000 ms: the sum of every keyframe duration, and the end of the timeline. Above either one, the request is refused with assembly_timeline_too_long. Split the ad and assemble it twice.
Keyframes inside one track must not overlap, because a track plays one source at a time. Tracks overlap each other freely. That is what makes a timeline.
Every URL is workspace media, and every URL is checked
Assembly publishes a deliverable. So the rule is stricter here than anywhere else in roasOS: assembly refuses offsite URLs entirely.
Each media URL must be an immutable object on https://cdn.roasos.com that your workspace owns. Put a file there with POST /v1/uploads, or use the result URL of an earlier job. Refer to Reference media and uploads.
A compose timeline hides its media two levels down, in tracks[].keyframes[].url. Every one of those URLs is checked, not the first one, and each is checked in the role that its track type implies.
| Track type | Role of every URL in it |
|---|---|
video | a source video |
audio | an audio track |
image | a source image |
A refusal names the track and the keyframe that caused it, so you can fix the one that is wrong.
| Code | Cause |
|---|---|
reference_media_not_workspace_owned | The URL is offsite, or it is a CDN object of another workspace |
assembly_timeline_too_long | The keyframe durations, or the end of the timeline, exceed 180,000 ms |
invalid_request | A bound in the table above is broken, or two keyframes in one track overlap |
A Discover creative is a competitor's ad. Assembly accepts one; you are responsible for the rights to footage you publish. Refer to Media rights.
Send quote_only: true first. The quote runs the whole schema and every media check, so a refusal reaches you before you spend an idempotency key.
The job
An assembly job is an ordinary job. It has no job kind of its own.
Four of the five operations produce a video, so their job reports kind: "video". extract-frame produces an image, so its job reports kind: "image".
{
"data": {
"id": "gen_...",
"kind": "video",
"model": "compose",
"status": "queued",
"credits_reserved": 1,
"links": {
"self": "/v1/jobs/gen_...",
"status": "/v1/jobs/gen_.../status",
"result": "/v1/jobs/gen_.../result",
"events": "/v1/jobs/gen_.../events"
}
}
}GET /v1/jobs?kind=assembly is therefore not a filter you can send. The job kinds are a closed set, and no family added one. Filter with kind=video, or list the jobs and read the model.
An assembled output is stored like every other result: an immutable object under your workspace on https://cdn.roasos.com, kept until you delete it. It is a valid media input for your next call.
Example: one 30-second ad
Three shots, a voice-over across all three, and a logo over the last five seconds. Four distinct URLs, three tracks, 30,000 ms.
curl -X POST https://api.roasos.com/v1/assembly/compose \
-H "Authorization: Bearer roasos_sk_live_..." \
-H "Idempotency-Key: kitchen-ad-final-001" \
-H "Content-Type: application/json" \
-d '{
"quote_only": true,
"tracks": [
{
"id": "picture",
"type": "video",
"keyframes": [
{ "timestamp": 0, "duration": 10000, "url": "https://cdn.roasos.com/workspaces/ws_01/videos/shot-1.mp4" },
{ "timestamp": 10000, "duration": 10000, "url": "https://cdn.roasos.com/workspaces/ws_01/videos/shot-2.mp4" },
{ "timestamp": 20000, "duration": 10000, "url": "https://cdn.roasos.com/workspaces/ws_01/videos/shot-3.mp4" }
]
},
{
"id": "voice",
"type": "audio",
"keyframes": [
{ "timestamp": 0, "duration": 30000, "url": "https://cdn.roasos.com/workspaces/ws_01/uploads/voice-over.mp3" }
]
},
{
"id": "logo",
"type": "image",
"keyframes": [
{ "timestamp": 25000, "duration": 5000, "url": "https://cdn.roasos.com/workspaces/ws_01/uploads/logo.png" }
]
}
]
}'{
"data": {
"kind": "quote",
"quote_id": "q_7Hk...",
"credits": 1,
"expires_at": "2026-08-21T11:12:19.000Z",
"confirmation": { "model": "compose" }
}
}Send the same body again with "quote_id": "q_7Hk..." and the same idempotency key to run it. Then poll GET /v1/jobs/{id}/status and read GET /v1/jobs/{id}/result.
The keyframe durations here sum to 65,000 ms and the timeline ends at 30,000 ms. Both are within the cap. Note that the sum counts each track: a three-minute timeline with three full-length tracks does not fit, because the durations alone reach 540,000 ms.
Recipe: swap the hook of a proven ad
An ad that works usually works because of its body. The first three to five seconds are what you test. Assembly lets you change only that part.
- Analyze the winning ad and read its first scene.
- Regenerate scene 1 alone, as one short clip with the new hook.
- Call
POST /v1/assembly/merge-videosover[new scene 1, scene 2, ... scene N].
You pay for one short clip and one flat credit. You do not pay to render the body again, and the body stays exactly as it was approved.
This works while the body is still separate clips. A flattened ad cannot be hook-swapped: once the scenes are merged into one file, there is no scene 1 to replace, and merge-videos joins whole clips only. Keep the scene files, or extract the body with a fresh render.
The same shape works for an end card: keep scenes 1 to N-1 and merge a new last clip.
The rest of the finishing set
Assembly is one of four families that ship together. All four are live, with one exception named below the table.
| Family | What it does | Price shape |
|---|---|---|
Assembly, /v1/assembly/{op} | cut, join, mux | 1 credit flat |
Captions, /v1/captions/{model} | burn subtitles into a clip | per minute of the source, rounded up, one minute minimum |
Reframe, /v1/reframe/{model} | re-crop to another shape and fill the new edges | per source second for video, per image for a still |
Music, /v1/music/{model} | write a soundtrack | flat per track, or per second of audio |
One endpoint in that set stays dark. POST /v1/music/sonilo-v1.1 answers 404, and the catalog does not list it.
POST /v1/reframe/ideogram-v3 answers a real job. It stayed dark through the first release of this family. The image lane first needed its own completion-intent admission, because a completed reframe raised after the charge. That admission landed, and the endpoint opened with it.
Captions and reframe are priced from measured media, not from what you declare. roasOS measures the source before it quotes, and it refuses the request when it cannot measure it. It never assumes the cheap tier. A caption source must be 480 seconds or shorter; an LTX reframe source must be 60 seconds or shorter.
roasOS states no rights position on the music that the music family generates. Read the terms of the model and of the provider before you publish a track.
Each model of these four families has its own page under /models. Each one also has a plain-text contract at https://docs.roasos.com/models/<family>/<model>/llms.txt. Coming endpoints names a model that roasOS prices and does not serve yet.
A finished ad without a second request
POST /v1/workflows/remix and POST /v1/agent/completions both take finished_ad. It is false by default.
Send "finished_ad": true and roasOS starts one follow-up run after every generation of the first run reaches a terminal status. The follow-up captions the parts, writes a music bed and stitches them into one deliverable. It spends what the first run left of generation_spend_cap_credits; when nothing is left, it opens an approval card instead of spending, and the card is listed on GET /v1/agent-runs/{id} under pending_approvals. Refer to Runs.
Raise generation_spend_cap_credits on the first request to give the follow-up room to work.
Not built yet
One thing that this family will make possible is designed and not implemented. Nothing below is callable, and no shape below is final.
- An extend chain as a public route. Continue a clip past one generation's cap: extract the last frame, generate the next segment from it, repeat, then merge the segments into one deliverable. Each segment quotes and reserves on its own, so a failure part-way keeps the segments you already have. There is no
POST /v1/workflows/extendroute today.
Watch Coming endpoints and the OpenAPI document for the routes that actually exist.