Video Editing and Trim
Apply targeted AI edits to an existing video, and cut clips with the frame-accurate trim job.
Two different things live on this page:
POST /api/v1/edit/video— targeted AI edits of an existing video (upscale, reframe, extend, lipsync, inpaint, and more). Async like every other generation: you get ageneration_idand poll/v1/generate/{id}/status.POST /api/v1/video/trim— a plain server-side cut by start/end time. Not an AI generation: it runs as its own job with its own polling endpoint.
POST /api/v1/edit/videoaccepts an optionalproject_idthat routes the result into a specific project — see Projects.POST /api/v1/video/trimalso accepts aproject_idfield, but the trim job never reads it: it does no project routing, and the trimmed file lands wherever the source lived.
Edit an Existing Video
Endpoint
POST /api/v1/edit/videoRate limited to 10 requests per minute per API key. Returns 403 with code INSUFFICIENT_CREDITS before any work starts when the account cannot cover the edit.
Request Body
video_url and operation are always required. Every other field is read only by the operations listed in its description — a field sent for the wrong operation is ignored.
| Field | Type | Required | Description |
|---|---|---|---|
video_url | string | Yes | URL of the source video. Missing → 400 video_url is required. |
operation | string | Yes | One of upscale, reframe, generate_audio, remove_watermark, face_swap, extend, magic_edit, lipsync, remove_background, inpaint, retake. Anything else → 400 listing the valid values. |
model | string | No | Model identifier override. Omit to use the platform default for the operation. Must be a string. |
scale | number | No | Accepted but inert — see the callout below. Use resolution to control upscale output. |
resolution | string | No | upscale, reframe. Target output resolution tier, e.g. "4k", "2k", "1080p". On upscale this is the field that actually drives the output size; omit it and 1080p is used. |
target_fps | number | No | upscale. Target frame rate; setting it enables frame interpolation. Must be between 16 and 60 — outside that range the job fails with Invalid target_fps. |
aspect_ratio | string | No | reframe — required. Target aspect ratio, e.g. "16:9", "9:16", "1:1". Missing → 400 aspect_ratio is required for reframe. |
grid_position_x | number | No | reframe. Horizontal placement (0.0–1.0) of the original content inside the reframed canvas. Omit to let the model centre it. A value of exactly 0 is treated as unset. |
grid_position_y | number | No | reframe. Vertical placement (0.0–1.0) of the original content inside the reframed canvas. Omit to let the model centre it. A value of exactly 0 is treated as unset. |
prompt | string | No | magic_edit — required. Also read by generate_audio, extend, inpaint, and retake. Missing on magic_edit → 400 prompt is required for magic_edit. |
sound_effect_prompt | string | No | generate_audio. Separate prompt for the sound-effects layer. |
background_music_prompt | string | No | generate_audio. Separate prompt for the background-music layer. |
original_sound | boolean | No | generate_audio. Keep the original audio and mix the generated audio in on top. |
cfg_strength | number | No | generate_audio. Guidance strength — higher follows the prompt more strictly. |
audio_format | string | No | generate_audio, Sonilo models only (identifier contains sonilo). Validated against that model's output_audio_formats from GET /api/v1/models; when that list is empty the check falls back to wav, mp3, aac, flac. An unlisted value fails the job with Invalid audio_format "…". Omit and the model's default_output_audio_format is used, falling back to aac. Only the Sonilo sound-effects model forwards the value to the provider — the Sonilo scoring model validates it and then ignores it. |
segments | array | No | generate_audio, Sonilo sound-effects models only. Per-segment audio instructions, forwarded verbatim to the provider. Dropped unless it is a non-empty array. |
image_url | string | No | face_swap — required. URL of the reference face image. Missing → 400 image_url (reference face) is required for face_swap. |
duration | number | No | extend, retake. Seconds of content to generate. On extend the default is 5, clamped into the chosen model's own min/max extension length (falling back to 1–20 when the model declares none). On retake the default and the floor are 2 seconds, and the segment is clamped so start_time + duration stays inside the source. |
mode | string | No | extend — "start" or "end", default "end". retake — "replace_audio", "replace_video", or "replace_audio_and_video"; any other value falls back to "replace_audio_and_video". |
context | string | No | extend. Extra context guiding what gets generated in the extended segment. |
audio_url | string | No | lipsync. URL of the audio track to sync to. Required unless text_prompt is given. |
text_prompt | string | No | lipsync. Text to synthesize and sync to, as an alternative to audio_url. With neither → 400 audio_url or text_prompt is required for lipsync. |
refine_edges | boolean | No | remove_background. Apply edge refinement for a cleaner cutout. |
subject_is_person | boolean | No | remove_background. Optimize for a human subject. |
mask_video_url | string | No | inpaint. URL of a black-and-white mask video; white marks the area to fill. |
object_prompt | string | No | inpaint. Description of the object being replaced. |
video_strength | number | No | inpaint. How closely to follow the original video, 0.0–1.0 (higher stays closer to the source). |
start_time | number | No | retake. Start of the segment to regenerate, in seconds. Default 0; negative values are floored to 0 and a value later than source_duration − 2s is pulled back so a full 2-second segment still fits. |
project_id | string | No | Project to file the edit into. See Projects. |
scale does nothing on upscale. The request accepts the field and records it, but the upscale pipeline never reads it — the factor is derived from resolution (defaulting to 1080p) and the source size. Drive the output with resolution, not scale.
generate_audio needs some audio instruction: prompt, or sound_effect_prompt, or background_music_prompt. Models that auto-caption the video (identifiers containing sonilo) are exempt and accept the request with none of them. Otherwise you get 400.
remove_watermark takes no operation-specific fields — send only video_url and operation. extend accepts a duration but does not require one; omit it and 5 seconds is used, clamped to the model's own limits.
extend and retake reject long sources. Both cap the source video at 30 seconds — anything longer fails the job with a Source video is too long … error naming the 30-second maximum. retake additionally needs at least 3 seconds of source, and bills by the length of the whole input video, not the segment you asked for — so trim the clip down with POST /api/v1/video/trim before retaking a long file. extend bills by the generated extension plus about a second of context frames, capped at 21 billed seconds.
Examples
Reframe a landscape clip to vertical:
curl -X POST https://api.kolbo.ai/api/v1/edit/video \
-H "X-API-Key: kolbo_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"video_url": "https://cdn.kolbo.ai/videos/source.mp4",
"operation": "reframe",
"aspect_ratio": "9:16",
"grid_position_x": 0.5,
"grid_position_y": 0.4
}'Add a sound-effects and music bed:
curl -X POST https://api.kolbo.ai/api/v1/edit/video \
-H "X-API-Key: kolbo_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"video_url": "https://cdn.kolbo.ai/videos/source.mp4",
"operation": "generate_audio",
"sound_effect_prompt": "Footsteps on gravel, distant traffic",
"background_music_prompt": "Sparse ambient pads, slow tempo",
"original_sound": false
}'Lipsync to an audio track:
curl -X POST https://api.kolbo.ai/api/v1/edit/video \
-H "X-API-Key: kolbo_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"video_url": "https://cdn.kolbo.ai/videos/speaker.mp4",
"operation": "lipsync",
"audio_url": "https://cdn.kolbo.ai/audio/voiceover.mp3"
}'Poll for the result (JavaScript):
const API_KEY = "kolbo_live_YOUR_API_KEY";
const start = await fetch("https://api.kolbo.ai/api/v1/edit/video", {
method: "POST",
headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
video_url: "https://cdn.kolbo.ai/videos/source.mp4",
operation: "upscale",
resolution: "4k",
target_fps: 60
})
}).then((r) => r.json());
while (true) {
await new Promise((r) => setTimeout(r, start.poll_interval_hint * 1000));
const status = await fetch(
`https://api.kolbo.ai/api/v1/generate/${start.generation_id}/status`,
{ headers: { "X-API-Key": API_KEY } }
).then((r) => r.json());
if (status.state === "completed") {
console.log("Edited video:", status.result.urls[0]);
break;
}
if (status.state === "failed") {
console.error(status.error);
break;
}
}Response
Edit started:
{
"success": true,
"generation_id": "68f2c1a9b4e5d6f7a8b9c0d1",
"type": "global_video_edit",
"model": "your-model-identifier",
"credits_charged": 40,
"poll_url": "/v1/generate/68f2c1a9b4e5d6f7a8b9c0d1/status",
"poll_interval_hint": 8,
"session_id": "…",
"project_id": "…"
}credits_charged is null when the pipeline has not settled the cost at submit time — the authoritative figure is credits_used on the completed status. The model field echoes exactly what you sent, so it is null when you omitted model; the default the platform picked for the operation shows up as result.model once the edit completes.
Completed status:
{
"success": true,
"generation_id": "68f2c1a9b4e5d6f7a8b9c0d1",
"type": "global_video_edit",
"state": "completed",
"progress": 100,
"credits_used": 40,
"result": {
"urls": ["https://cdn.kolbo.ai/videos/..."],
"download_url": "https://cdn.kolbo.ai/videos/...",
"edit_type": "reframe",
"duration": 12,
"model": "the-model-that-actually-ran",
"created_at": "2026-04-12T10:00:00.000Z"
}
}edit_type is the internal name of the operation, which is not always identical to the operation you sent — for example generate_audio reports audio_generation, remove_watermark reports watermark_removal, face_swap reports video_face_swap, and remove_background reports background_removal.
Trim a Video
A frame-accurate server-side cut. This is not an AI generation: it does not go through the generation tracker, it is not pollable via /v1/generate/{id}/status, and no SDK credit gate is applied to it.
Endpoint
POST /api/v1/video/trim
GET /api/v1/video/trim/{jobId}The POST is rate limited to 10 requests per minute per API key; the status GET to 120 per minute.
Completion contract
Polling is the only completion mechanism. No webhook, no callback_url field on the trim request body, no server-sent events. Submit with POST /v1/video/trim, then loop against GET /api/v1/video/trim/{jobId} until status is completed or failed.
Read Polling & Cancellation for the shared concepts — poll intervals, transient-error tolerance, back-off. A trim diverges from that generic contract in almost every field name:
| Generic generation contract | Video Trim |
|---|---|
Poll GET /v1/generate/{id}/status | Poll GET /v1/video/trim/{jobId}. A trim job lives in its own store and is never registered in the SDK generation tracker. Its id is not an ObjectId either, so the generic route rejects it outright with 400 Invalid generation ID format. |
Submit returns 200 | Submit returns 202 Accepted. Do not test for 200 alone. |
| Ids are Mongo ObjectIds | The id is a prefixed string like video-trim_1753612345678_k3f9d2p1a. Pass it verbatim. |
poll_url (no /api prefix) | pollingUrl, and it points at the in-app route (/api/video-trim/{jobId}/progress), not the public API. Ignore it — see the callout below. |
state: pending, processing, completed, failed, cancelled | status, lowercased server-side, with a wider vocabulary: initializing, downloading, analyzing, processing, uploading, completed, failed. Not every value appears on every job — downloading only shows up when the clip is processed locally rather than offloaded. Treat anything that is not completed or failed as still running rather than enumerating them. |
result.urls[] | Four sibling top-level fields — url, videoUrl, fileUrl, publicUrl — all carrying the same string. Read url. |
credits_used | Not returned. A trim is a utility operation, not a billed generation. |
error string plus error_code | A single error string, null while the job is healthy. |
POST /v1/generate/{id}/cancel | No public cancel route. cancelled exists in the status vocabulary but nothing in the public API can put a job into it. |
| Timestamps are ISO 8601 strings | updatedAt is a Unix timestamp in milliseconds. |
Submit returns poll_interval_hint | Not returned. Nothing in the trim submit response suggests an interval — choose your own. Trims usually finish in seconds, so 2–4 s is reasonable; the examples on this page use 4. |
| The server force-fails a generation past its own hard deadline | No server deadline is published for trim jobs, and there is no cancel route. Set your own client-side give-up window and treat expiry as a display decision only: the job keeps running, and a trim is not billed as a generation so nothing is at stake if you stop watching. |
Because a trim is not registered as an SDK generation, the WebSocket suppression that silences every API-key generation does not apply to it: the trim service still emits its in-app generation:progress events (type: "video-trim") into the owner's user room. Those events are an internal implementation detail of the web app, not part of the public API contract — they are not documented, not versioned, and can be removed. Poll.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
video_url | string | Yes | URL of the source video. Missing → 400 video_url is required. The URL is validated against internal and private network addresses and rejected if it points at one. |
start_time | number | Yes | Trim start in seconds. Must be a JSON number, not a string. A value at or beyond the source video's duration fails the job. |
end_time | number | Yes | Trim end in seconds. Must be a JSON number. A value beyond the source video's duration is clamped down to the real duration rather than failing. |
project_id | string | No | Accepted by the request but not used — the trim job does no project routing. |
Sending start_time or end_time as anything other than a number returns 400 start_time and end_time (seconds, numbers) are required.
The original video is never deleted by this endpoint — the trim always produces a new file.
Submit Response (202)
{
"success": true,
"jobId": "video-trim_1753612345678_k3f9d2p1a",
"generationId": "video-trim_1753612345678_k3f9d2p1a",
"status": "processing",
"message": "Video trim job started",
"pollingUrl": "/api/video-trim/video-trim_1753612345678_k3f9d2p1a/progress"
}Ignore pollingUrl. It points at the in-app route, not the public API. Poll GET /api/v1/video/trim/{jobId} instead, using the jobId from this response. generationId is the same value under a legacy field name.
Status Response
{
"success": true,
"jobId": "video-trim_1753612345678_k3f9d2p1a",
"generationId": "video-trim_1753612345678_k3f9d2p1a",
"status": "completed",
"progress": 100,
"message": "…",
"error": null,
"updatedAt": 1753612389421,
"url": "https://cdn.kolbo.ai/videos/trimmed.mp4",
"videoUrl": "https://cdn.kolbo.ai/videos/trimmed.mp4",
"fileUrl": "https://cdn.kolbo.ai/videos/trimmed.mp4",
"publicUrl": "https://cdn.kolbo.ai/videos/trimmed.mp4"
}status is normalized to lowercase. The four URL fields carry the same value and appear only once the trimmed file exists — read url. progress climbs 0→100 but is not monotonic across the two execution paths (a heavy clip offloaded to the hosted trimmer reports a different curve than a locally processed one), so drive your logic off status, not progress.
| Field | Type | When | Description |
|---|---|---|---|
success | boolean | always | true on any readable job — it is not the job state |
jobId | string | always | The id you polled with |
generationId | string | always | The same value under a legacy field name |
status | string | always | Lowercased phase — see the vocabulary above |
progress | number | always | 0-100 |
message | string | always | Human-readable step, e.g. "Trimming video... 42%" |
error | string | null | always | null unless status is failed |
updatedAt | number | always | Unix milliseconds |
url / videoUrl / fileUrl / publicUrl | string | once the file exists | Identical values. Read url. |
Failure shape
A failed trim is a successful poll — HTTP 200, success: true, with the failure in the payload:
{
"success": true,
"jobId": "video-trim_1753612345678_k3f9d2p1a",
"generationId": "video-trim_1753612345678_k3f9d2p1a",
"status": "failed",
"progress": 20,
"message": "Trimming video...",
"error": "ffmpeg exited with code 1: …",
"updatedAt": 1753612389421
}progress freezes wherever the job died and the URL fields are absent. The error string is the raw failure reason — there is no error code on this route.
Transport-level errors are different: an unknown or expired jobId returns 404 { "success": false, "error": "Trim job not found" }, and a job belonging to another account returns 403 { "success": false, "error": "Access denied to this trim job" }.
There is no public endpoint to cancel a trim job. Once submitted, a trim runs to completed or failed.
Example
# Submit
curl -X POST https://api.kolbo.ai/api/v1/video/trim \
-H "X-API-Key: kolbo_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"video_url": "https://cdn.kolbo.ai/videos/source.mp4",
"start_time": 12.5,
"end_time": 41
}'
# Poll (jobId from the submit response)
curl https://api.kolbo.ai/api/v1/video/trim/video-trim_1753612345678_k3f9d2p1a \
-H "X-API-Key: kolbo_live_YOUR_API_KEY"import requests, time
API_KEY = "kolbo_live_YOUR_API_KEY"
HEADERS = {"X-API-Key": API_KEY, "Content-Type": "application/json"}
job = requests.post(
"https://api.kolbo.ai/api/v1/video/trim",
headers=HEADERS,
json={
"video_url": "https://cdn.kolbo.ai/videos/source.mp4",
"start_time": 12.5,
"end_time": 41,
},
).json()
job_id = job["jobId"]
while True:
time.sleep(4)
status = requests.get(
f"https://api.kolbo.ai/api/v1/video/trim/{job_id}",
headers={"X-API-Key": API_KEY},
).json()
if status["status"] == "completed":
print("Trimmed video:", status["url"])
break
if status["status"] == "failed":
print("Error:", status.get("error"))
breakTrims usually finish in seconds. Longer or heavier clips are offloaded to a hosted trimming service instead of being processed locally, which is transparent to the caller but can add time.
Related
Polling & Cancellation
The shared async contract — and why polling is the only completion mechanism
Video Generation
Text-to-video and image-to-video
Video-to-Video
Restyle, motion-transfer, or subtitle an existing video
Lipsync
The dedicated lipsync generation endpoint
Media Library
Upload source videos and retrieve the resulting files