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 call | Two calls | |
|---|---|---|
| Endpoints | A single editing endpoint | video-analyze, then video-edit, in the same family |
| Who gets replaced | Whatever the model finds. The request has no field that names a person | The targets you picked, by position |
| What you send | The target image or clip plus your reference image, in one request | A clip on the first call; the positions you chose, each with its own reference images, on the second |
| What you pay | One task | Two tasks, priced separately |
| Good for | One obvious subject in frame | Several 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 clipWhy 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.
| Rule | Why |
|---|---|
| The analysis must belong to your account and must have succeeded | A queued or failed analysis has nothing to point at |
| Use the same API key for both calls | An 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 once | Out 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 analysis | Inheriting them would hide from you which price tier you are being charged at |
| The source clip must be the same one | Leave 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 finishes | After 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:
msg | What happened | What to do |
|---|---|---|
The referenced analysis task was not found. | No such task on this account, or analysis_task_id is not a task ID | Use 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 analysis | Point 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 / expired | Wait 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 use | Run the analysis again on the same clip |
That analysis result has expired. Run the analysis step again. | More than 24 hours have passed | Run the analysis again |
"resolution" must match the analysis step. | The two calls disagree on a price-affecting field | Send 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 clip | Send 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 returned | Positions are zero-based and stop at assets.length - 1 |
"object_index" is listed more than once. | Two entries name the same target | Give 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.

