spicyapiDocs
Main content

Subject swap

One toolkit, two shapes. Single-call models replace a face or a head in one request; two-call models analyse a clip first so you can choose which person changes.

Some models replace a person instead of generating a new scene: a face carried from one photo onto another, a whole head, or a whole figure in a video clip. The catalog groups them under one label, toolkit: "subject-swap", and they come in two shapes — one call, or two calls with a choice in between.

Nothing else about them is special. They are ordinary asynchronous tasks, their parameters come from their own inputSchema, and their results arrive in output.assets[].

toolkit tells you the model is one of these

toolkit is a classification label on a catalog item, present in both the authenticated GET /api/v1/models response and the public catalog. Today it has one value, subject-swap, and the field is absent on models that belong to no toolkit — so test for the value, never for the key.

It is informational only. It does not affect which key may call a model, how the request is priced, or what the model accepts. Treat an unfamiliar value the same way you treat an unfamiliar badges entry: ignore it.

curl -s "https://api.spicyapi.ai/api/v1/models?modality=video" \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  | jq -r '.data.items[]
      | select(.toolkit == "subject-swap" and .enabled and .available)
      | [.model, (.tasks | join(",")), .family] | @tsv'

Every line is one callable endpoint. The tasks column is what decides the rest of this page: a family that has a video-analyze endpoint is a two-call family, and its companion video-edit endpoint cannot be called on its own.

Which shape am I looking at?

One callTwo calls
EndpointsA single editing endpointvideo-analyze, then video-edit, in the same family
Who gets replacedWhatever the model finds. The request has no field that names a personThe targets you picked, by position
What you sendThe target image or clip plus your reference image, in one requestA clip on the first call; the positions you chose, each with its own reference images, on the second
What you payOne taskTwo tasks, priced separately
Good forOne obvious subject in frameSeveral people in frame, or when replacing the wrong one is expensive

Both shapes are async: true: you create a task, then collect the result by polling or webhook.

One call

Nothing here differs from any other editing model. Read the endpoint's inputSchema, fill it in, create the task:

const created = await fetch('https://api.spicyapi.ai/api/v1/jobs/createTask', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.SPICY_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'order-8814-swap-1',
  },
  body: JSON.stringify({
    model: swap.model, // one item from the catalog query above
    input: {
      image_url: 'spicy://f/fil_01k3m8x9q2z4v7n5p6r8s0t1w3',
      reference_image_url: 'spicy://f/fil_01k3m8xb2c3d4e5f6g7h8j9k0m',
    },
  }),
});

Field names differ between endpoints — some take a prompt, a seed or an output format, some take nothing but the two pictures. The schema is the list; do not carry field names over from one endpoint to another. References can be a public HTTPS URL, a small inline Base64 image or an uploaded spicy:// URI; see Media.

Two calls

The first call looks at your clip and reports what it found, as an ordered set of preview stills. You decide which of those to replace. The second call does the work.

your clip ──▶ video-analyze ──▶ output.assets[0], [1], [2] …   (one still per target)
                                          │
                        you pick positions │ 0 and 2
                                          ▼
            video-edit (analysis_task_id + swap_targets) ──▶ the finished clip

Why not one call? Because in a scene with four people, one call would have to guess which one you meant, and a wrong guess is a finished, billed video of the wrong person being replaced. Splitting the job makes the choice explicit and reviewable.

Analyse the clip

Send the clip to the video-analyze endpoint. Set resolution explicitly if the endpoint has one — the second call has to run at the same value, so choose the resolution you want the finished clip to have. On these endpoints the analysis price does not depend on it.

const analysis = await createTask({
  model: analyze.model, // the item whose tasks include "video-analyze"
  input: {
    video_url: 'spicy://f/fil_01k3m8x9q2z4v7n5p6r8s0t1w3',
    resolution: '720p',
  },
});
const analysisTaskId = analysis.taskId;

Read what it found

Wait for the task the usual way. A succeeded analysis returns one preview still per target it tracked, in output.assets[]:

{
  "state": "succeeded",
  "output": {
    "assets": [
      { "url": "https://…", "expiresAt": "2026-09-20T09:32:11Z", "mime": "image/jpeg" },
      { "url": "https://…", "expiresAt": "2026-09-20T09:32:11Z", "mime": "image/jpeg" }
    ]
  },
  "settled": true
}

The order of that array is the contract. The index of an asset is the object_index you send back on the second call: the first still is 0, the second is 1, and so on. Show the stills to whoever is choosing, or match them yourself; either way, send back positions from this same array.

An analysis that finds nothing to track does not succeed with an empty array: it ends failed with errorCode: "generation_failed", and the hold is released in full. So a succeeded analysis always has at least one still, and the only thing your code has to handle is choosing among them.

Swap the targets you chose

Send the analysis task ID and one swap_targets entry per target you want replaced. Targets you leave out are untouched.

const swapped = await createTask({
  model: edit.model, // the "video-edit" item in the same family
  input: {
    analysis_task_id: analysisTaskId,
    resolution: '720p', // the same value the analysis ran at
    swap_targets: [
      { object_index: 0, image_urls: ['spicy://f/fil_01k3m8xb2c3d4e5f6g7h8j9k0m'] },
    ],
  },
});

The result is one clip in output.assets[], like any other video task.

What the second call checks before it accepts

These checks run at creation, so a rejected request creates nothing and holds nothing.

RuleWhy
The analysis must belong to your account and must have succeededA queued or failed analysis has nothing to point at
Use the same API key for both callsAn analysis can only be read back with the key that created it, so anything else leaves you unable to see the stills you are choosing from
Every object_index must be within range and must appear onceOut of range would silently replace the wrong person; a duplicate would replace the same one twice. Both would finish and bill normally
Price-affecting fields such as resolution must match the analysisInheriting them would hide from you which price tier you are being charged at
The source clip must be the same oneLeave video_url out and the analysed clip is used; send it and it must be identical
The analysis is usable for 24 hours after it finishesAfter that the tracking data behind it is gone, and so, often, is the source clip

Set `resolution` on both calls, or read the default carefully

Omitted fields take the value of their schema default before the two requests are compared. So an analysis left at the default and a swap left at the default do match — but an analysis you set to one value and a swap you left blank do not, and the mismatch is rejected rather than silently corrected. Sending the value explicitly on both calls removes the question.

When the second call is rejected

All of these are HTTP 400 with business code: 400. The msg is one of:

msgWhat happenedWhat to do
The referenced analysis task was not found.No such task on this account, or analysis_task_id is not a task IDUse the taskId returned by your own analysis. A task belonging to another account is reported the same way, on purpose
The referenced task belongs to a different model.The ID points at some other task — often the swap task, or another family's analysisPoint it at the video-analyze task of the same family
The referenced analysis did not complete successfully.The analysis is still running, or ended failed / expiredWait for it, or run it again
That analysis has no reusable result. Run the analysis step again.The analysis succeeded but left nothing the swap can useRun the analysis again on the same clip
That analysis result has expired. Run the analysis step again.More than 24 hours have passedRun the analysis again
"resolution" must match the analysis step.The two calls disagree on a price-affecting fieldSend the analysis's value, or run a new analysis at the value you want
"video_url" must match the analysis step.You sent a different clipSend the same URI, or leave the field out
That analysis found N target(s); "object_index" is out of range.A position is beyond what the analysis returnedPositions are zero-based and stop at assets.length - 1
"object_index" is listed more than once.Two entries name the same targetGive each target one entry, with all of its reference images in that entry's image_urls

What the two calls cost

The analysis and the swap are two separate tasks with two separate prices, each held and settled on its own. A rejected second call costs nothing; a successful analysis is charged even if you then decide not to swap anything.

  • The analysis is usually priced per_request — one charge per call, whatever the clip's length and however many targets it finds.
  • The swap is usually priced per_second, in whole blocks, and the length comes from the clip rather than from a field you set. So the amount quoted up front is the longest clip the endpoint accepts: the quoted amount is the amount held, the final charge is computed from the rounded duration and never exceeds the hold, and the difference returns to your balance when the task settles. Seeing a hold larger than the final charge is expected here. Read Length billed in whole blocks before you budget for it.

Read both prices from the catalog rather than assuming, and quote each call if the exact number matters.

Next steps

On this page