---
title: "Assembly"
description: "Five mechanical operations that cut, join and mux media your workspace already owns."
url: https://docs.roasos.com/concepts/assembly
---

# Assembly

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.

<Callout type="info">
  The assembly family is live. Every operation on this page answers a real job.
</Callout>

## 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](/concepts/jobs) and [Quotes and credits](/concepts/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.

1. The quote is exact, always. Assembly never reconciles and never settles: `quote_only: true` answers `credits: 1` before you spend an idempotency key.
2. 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: &#x2A;*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](/concepts/reference-media).

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 |

<Callout type="warn">
  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](/concepts/media-rights).
</Callout>

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"`.

```json
{
  "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.

```bash
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" }
        ]
      }
    ]
  }'
```

```json
{
  "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.

1. Analyze the winning ad and read its first scene.
2. Regenerate scene 1 alone, as one short clip with the new hook.
3. Call `POST /v1/assembly/merge-videos` over `[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.

<Callout type="warn">
  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.
</Callout>

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](/models/coming-endpoints) names a model that roasOS prices and does not serve yet.

## A finished ad without a second request

[`POST /v1/workflows/remix`](/workflows/remix-an-ad) 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](/agent/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/extend` route today.

Watch [Coming endpoints](/models/coming-endpoints) and the OpenAPI document for the routes that actually exist.
