Video Editing
Video processing API: transcode, merge clips, change speed, stabilize, burn in subtitles you provide, apply colour effects, add a text watermark, upscale, and plan or remix edits with the AI Director — all through one REST API.
Each operation takes a public https:// video URL, processes it synchronously and answers with the URL of the finished file. The request waits until the file is ready (up to 5 minutes for most operations, 10 for merge and subtitles, 15 for stabilize, upscale, AI Director and effects with stabilize: true), so use a long client timeout.
Endpoints
| Endpoint | Description | Billing |
|---|---|---|
POST /v1/video/transcode | Change container, codec, encode quality or resolution | flat per call |
POST /v1/video/merge | Join 2-20 clips, with optional transitions | flat per call |
POST /v1/video/speed | Slow motion or speed-up | flat per call |
POST /v1/video/stabilize | Remove camera shake | flat per call |
POST /v1/video/subtitles | Burn subtitles you provide (SRT) into the video | flat per call |
POST /v1/video/effects | Colour adjustments, looks, sharpen, blur, denoise | flat per call |
POST /v1/video/watermark | Burn in a text watermark | flat per call |
POST /v1/video/upscale | Upscale 2x-4x; quality: "pro" starts an Upscale Pro job | flat per call (pro: per second, see Upscale Pro) |
POST /v1/video/ai-director | Shot plan, remix plan, or a rendered remix of your clips | flat per call |
POST /v1/video/remove-object | Remove Object Pro: remove an object from a video window (async job) | per started 5 s, see quote |
POST /v1/video/remove-object/quote | Free price, limits and ETA for a removal | free |
GET /v1/video/remove-object/jobs | Your recent removals | free |
GET /v1/video/remove-object/jobs/{job_id} | Status and result of one removal | free |
POST /v1/video/remove-object/jobs/{job_id}/cancel | Cancel a queued or running removal; refunded at once | free |
Authentication: Bearer token (API key)
Base URL: https://apis.fotohub.app
Field names are checked, unknown fields are not
Every request body below is validated: a wrong type, a value outside the listed range or a value that is not in the listed set answers 422 before anything is charged. A field that the operation does not know, however, is silently ignored — a misspelled output_format (for example format) is not an error, the default is used instead. Send only the fields in each Parameters table.
Response format
The operations from transcode to AI Director (except upscale with quality: "pro") answer 200 with the same shape:
{
"operation": "transcode",
"cost_usd": 0.053591,
"currency": "USD",
"billing": {
"cost_usd": 0.053591,
"balance_usd": 18.42,
"currency": "USD",
"method": "wallet",
"model": "prepaid"
},
"success": true,
"output_url": "https://s1.fotohub.app/storage/v1/object/public/videos/...",
"output_size": 15728640,
"duration": 42.5,
"metadata": null
}| Field | Description |
|---|---|
operation | The operation name: transcode, merge, speed, stabilize, subtitles, effects, watermark, upscale, ai-director |
cost_usd, currency, billing | What this call charged your wallet |
output_url | The finished file |
output_size | File size in bytes |
duration | Length of the output in seconds (not returned by every operation) |
metadata | Operation-specific details, listed in each section; null when there are none |
The AI Director adds mode, and its plan and remix modes return a plan instead of a file (see AI Director).
Calling the endpoints
The examples on this page use these small helpers. The SDKs have no dedicated methods for these routes (see SDK Reference).
import requests
API = "https://apis.fotohub.app/v1/video"
H = {"Authorization": "Bearer fh_live_your_api_key"}
def video_op(op, **body):
r = requests.post(f"{API}/{op}", headers=H, json=body, timeout=960)
r.raise_for_status()
return r.json()const API = "https://apis.fotohub.app/v1/video";
const H = { Authorization: "Bearer fh_live_your_api_key", "Content-Type": "application/json" };
async function videoOp(op: string, body: Record<string, unknown>): Promise<any> {
const res = await fetch(`${API}/${op}`, { method: "POST", headers: H, body: JSON.stringify(body) });
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
return res.json();
}package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
var client = &http.Client{Timeout: 16 * time.Minute}
func videoOp(op string, body map[string]interface{}) (map[string]interface{}, error) {
payload, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "https://apis.fotohub.app/v1/video/"+op, bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer fh_live_your_api_key")
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("%d %s", resp.StatusCode, raw)
}
var result map[string]interface{}
err = json.Unmarshal(raw, &result)
return result, err
}
func main() {
result, err := videoOp("transcode", map[string]interface{}{
"video_url": "https://example.com/raw-footage.mov",
"output_format": "mp4",
"codec": "h265",
"resolution": "1080p",
})
if err != nil {
panic(err)
}
fmt.Println(result["output_url"])
}Transcode
POST /v1/video/transcodeConvert a video to another container or codec, change the encode quality, or scale it down to a standard resolution.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
video_url | string | Yes | — | Public https:// URL of the source video (otherwise 400 url_blocked). |
output_format | string | No | mp4 | mp4, mov, webm, gif or prores (ProRes in a .mov file). |
codec | string | No | H.264 | h264, h265, vp9 or prores. Omit for H.264. |
quality | string | No | high | Encode quality: draft (fastest, lowest quality), standard, high, ultra (slowest, best). |
resolution | string | No | source | 480p, 720p, 1080p, 1440p or 4K (capital K). The video is fitted inside that size with its aspect ratio kept and is never enlarged. Omit to keep the source resolution. |
Notes:
- Audio is re-encoded to AAC, 48 kHz stereo.
gifis always 15 fps and 480 px wide, without audio;codec,qualityandresolutiondo not apply.prores(asoutput_formatorcodec) encodes ProRes;qualitydoes not apply. Use it withoutput_formatmovorprores.webmneedscodec: "vp9", and currently succeeds only for a source without an audio track (the AAC audio track is not allowed in WebM). A failed call is refunded.
Response: the common shape, metadata is null.
Code Examples
result = video_op("transcode",
video_url="https://example.com/raw-footage.mov",
output_format="mp4",
codec="h265",
quality="high",
resolution="1080p",
)
print(f"Transcoded: {result['output_url']}")
print(f"File size: {result['output_size'] / 1024 / 1024:.1f} MB")const result = await videoOp("transcode", {
video_url: "https://example.com/raw-footage.mov",
output_format: "mp4",
codec: "h265",
quality: "high",
resolution: "1080p",
});
console.log(`Transcoded: ${result.output_url}`);
console.log(`File size: ${(result.output_size / 1024 / 1024).toFixed(1)} MB`);curl -X POST "https://apis.fotohub.app/v1/video/transcode" \
-H "Authorization: Bearer fh_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"video_url": "https://example.com/raw-footage.mov",
"output_format": "mp4",
"codec": "h265",
"quality": "high",
"resolution": "1080p"
}'Merge Videos
POST /v1/video/mergeJoin clips into one video, in the order given, with an optional transition between consecutive clips. When some clips have audio and others do not, the silent ones get a silent track so the sound stays in sync.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
video_urls | array of strings | Yes | — | 2 to 20 public https:// URLs, in playback order. A blocked URL answers 400 url_blocked with field such as video_urls[2]. |
transition | string | No | none | none (hard cut), dissolve, fade, crossfade, wipeleft, wiperight, slidedown or slideup. |
transition_duration | number | No | 0.5 | Transition length in seconds, 0.1-3.0. Used only when transition is not none. |
output_format | string | No | mp4 | mp4 or mov. webm is accepted but currently fails (the call is refunded). |
Response: the common shape, metadata is null.
Code Examples
result = video_op("merge",
video_urls=[
"https://example.com/intro.mp4",
"https://example.com/main-content.mp4",
"https://example.com/outro.mp4",
],
transition="crossfade",
transition_duration=1.0,
)
print(f"Merged video: {result['output_url']}")
print(f"Total duration: {result['duration']}s")const result = await videoOp("merge", {
video_urls: [
"https://example.com/intro.mp4",
"https://example.com/main-content.mp4",
"https://example.com/outro.mp4",
],
transition: "crossfade",
transition_duration: 1.0,
});
console.log(`Merged video: ${result.output_url}`);
console.log(`Total duration: ${result.duration}s`);curl -X POST "https://apis.fotohub.app/v1/video/merge" \
-H "Authorization: Bearer fh_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"video_urls": [
"https://example.com/intro.mp4",
"https://example.com/main-content.mp4",
"https://example.com/outro.mp4"
],
"transition": "crossfade",
"transition_duration": 1.0
}'Change Speed
POST /v1/video/speedSlow a video down or speed it up. The audio is time-stretched to the new speed with its pitch kept. The output is MP4.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
video_url | string | Yes | — | Public https:// URL of the source video. |
speed | number | Yes | — | Speed multiplier, 0.1-10.0: 0.25 = 4x slower, 2.0 = 2x faster. |
interpolation | string | No | blend | How new frames are made when slowing down (speed below 1; ignored otherwise): none (repeat frames, choppy at strong slow motion), blend (blend neighbouring frames, output 60 fps), mci (motion-compensated, output 60 fps, smoothest and slowest). |
Response: the common shape, metadata is null.
Code Examples
# Slow motion (4x slower), motion-compensated
result = video_op("speed",
video_url="https://example.com/action-clip.mp4",
speed=0.25,
interpolation="mci",
)
print(f"Slow-mo: {result['output_url']} ({result['duration']}s)")
# Speed up (2x faster)
result = video_op("speed",
video_url="https://example.com/long-tutorial.mp4",
speed=2.0,
)
print(f"Sped up: {result['output_url']}")// Slow motion (4x slower), motion-compensated
const slow = await videoOp("speed", {
video_url: "https://example.com/action-clip.mp4",
speed: 0.25,
interpolation: "mci",
});
console.log(`Slow-mo: ${slow.output_url} (${slow.duration}s)`);
// Speed up (2x faster)
const fast = await videoOp("speed", {
video_url: "https://example.com/long-tutorial.mp4",
speed: 2.0,
});
console.log(`Sped up: ${fast.output_url}`);curl -X POST "https://apis.fotohub.app/v1/video/speed" \
-H "Authorization: Bearer fh_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"video_url": "https://example.com/action-clip.mp4",
"speed": 0.25,
"interpolation": "mci"
}'Stabilize
POST /v1/video/stabilizeRemove camera shake and jitter from handheld or action footage. Two passes (motion analysis, then correction), so this is the slowest operation on the page: allow up to 15 minutes.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
video_url | string | Yes | — | Public https:// URL of the source video. |
preset | string | No | smooth | Strength: smooth, very_smooth, cinematic or action. |
Response: the common shape, with metadata: {"preset": "smooth"}.
Code Examples
result = video_op("stabilize",
video_url="https://example.com/shaky-handheld.mp4",
preset="cinematic",
)
print(f"Stabilized: {result['output_url']}")
print(f"Preset: {result['metadata']['preset']}")const result = await videoOp("stabilize", {
video_url: "https://example.com/shaky-handheld.mp4",
preset: "cinematic",
});
console.log(`Stabilized: ${result.output_url}`);
console.log(`Preset: ${result.metadata.preset}`);curl -X POST "https://apis.fotohub.app/v1/video/stabilize" \
-H "Authorization: Bearer fh_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"video_url": "https://example.com/shaky-handheld.mp4",
"preset": "cinematic"
}'Burn In Subtitles
POST /v1/video/subtitlesBurn subtitles you provide into the video. This route does not transcribe speech and does not translate: send the subtitles as SRT text in srt_content. The text is white.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
video_url | string | Yes | — | Public https:// URL of the source video. |
srt_content | string | Yes | — | The subtitles in SRT format (not empty). Content that is not subtitles (for example a playlist) is refused with 422 and the call is refunded. |
font | string | No | Arial | Font family name, up to 64 characters: letters, digits, space, _ and - only. |
font_size | integer | No | 24 | Font size in points, 10-120. |
position | string | No | bottom | bottom or top (both horizontally centred). center is accepted but currently renders the subtitles at the top left, not in the middle of the frame — use top or bottom. |
Response: the common shape, metadata is null.
Code Examples
srt = """1
00:00:00,000 --> 00:00:02,500
Welcome to the product tour.
2
00:00:02,500 --> 00:00:05,000
Let's start with the dashboard.
"""
result = video_op("subtitles",
video_url="https://example.com/product-tour.mp4",
srt_content=srt,
font_size=32,
position="bottom",
)
print(f"Subtitled video: {result['output_url']}")const srt = `1
00:00:00,000 --> 00:00:02,500
Welcome to the product tour.
2
00:00:02,500 --> 00:00:05,000
Let's start with the dashboard.
`;
const result = await videoOp("subtitles", {
video_url: "https://example.com/product-tour.mp4",
srt_content: srt,
font_size: 32,
position: "bottom",
});
console.log(`Subtitled video: ${result.output_url}`);curl -X POST "https://apis.fotohub.app/v1/video/subtitles" \
-H "Authorization: Bearer fh_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"video_url": "https://example.com/product-tour.mp4",
"srt_content": "1\n00:00:00,000 --> 00:00:02,500\nWelcome to the product tour.\n\n2\n00:00:02,500 --> 00:00:05,000\nLet'\''s start with the dashboard.\n",
"font_size": 32,
"position": "bottom"
}'Apply Video Effects
POST /v1/video/effectsColour-grade and filter a video in one pass: either pick a preset look or set individual adjustments. The two do not combine: when preset is set, the preset's own values replace the whole adjustment set, so every other adjustment you send — including sharpen, blur, denoise and stabilize — is silently ignored (no error). To grade and stabilize, call stabilize separately, or use manual adjustments with stabilize: true. Every adjustment is a top-level field; leave a field out to keep it unchanged. The audio is copied unchanged.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
video_url | string | Yes | — | Public https:// URL of the source video. |
preset | string | No | none | vintage, noir, warm, cool, cinematic or hdr. When set, all fields below except output_format and quality are ignored. |
brightness | number | No | 0.0 | -1.0-1.0; 0 = unchanged. |
contrast | number | No | 1.0 | Multiplier, 0.0-3.0; 1.0 = unchanged. |
saturation | number | No | 1.0 | Multiplier, 0.0-3.0; 0 = greyscale, 1.0 = unchanged. |
hue | number | No | 0.0 | Hue rotation in degrees, -180-180. |
gamma | number | No | 1.0 | 0.1-10.0; 1.0 = unchanged. |
sharpen | number | No | 0.0 | Sharpening strength. The effective range is 0-5: values up to 10 pass validation, but a value above 5 currently fails the render (502, the call is refunded). |
blur | number | No | 0.0 | Blur strength, 0-20. |
denoise | boolean | No | false | Reduce noise. |
stabilize | boolean | No | false | Also stabilize (two passes; the call can take up to 15 minutes). Ignored when preset is set. |
output_format | string | No | mp4 | mp4 or mov. webm is accepted but currently fails (the call is refunded). |
quality | string | No | standard | Encode quality: draft, standard, high or ultra. |
Response: the common shape, metadata is null.
Code Examples
# Manual adjustments (no preset)
result = video_op("effects",
video_url="https://example.com/raw-footage.mp4",
contrast=1.15,
saturation=0.9,
sharpen=0.5,
denoise=True,
quality="high",
)
print(f"Processed: {result['output_url']}")
# A preset look: send the preset alone, other adjustments would be ignored
look = video_op("effects",
video_url="https://example.com/raw-footage.mp4",
preset="cinematic",
quality="high",
)// Manual adjustments (no preset)
const result = await videoOp("effects", {
video_url: "https://example.com/raw-footage.mp4",
contrast: 1.15,
saturation: 0.9,
sharpen: 0.5,
denoise: true,
quality: "high",
});
console.log(`Processed: ${result.output_url}`);
// A preset look: send the preset alone, other adjustments would be ignored
const look = await videoOp("effects", {
video_url: "https://example.com/raw-footage.mp4",
preset: "cinematic",
quality: "high",
});curl -X POST "https://apis.fotohub.app/v1/video/effects" \
-H "Authorization: Bearer fh_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"video_url": "https://example.com/raw-footage.mp4",
"contrast": 1.15,
"saturation": 0.9,
"sharpen": 0.5,
"denoise": true,
"quality": "high"
}'Add Watermark
POST /v1/video/watermarkBurn a text watermark into the video. Image (logo) watermarks are not supported by this route.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
video_url | string | Yes | — | Public https:// URL of the source video. |
text | string | No | FOTOhub | Watermark text, 1-200 characters. |
position | string | No | bottom_right | top_left, top_right, bottom_left, bottom_right (20 px in from the edges), center, or tiled: two copies that move across the frame, harder to crop out. |
opacity | number | No | 0.3 | 0.05-1.0. |
font_size | integer | No | 24 | Font size in points, 10-120. |
color | string | No | white | white, black, red, yellow or gray. |
Response: the common shape, with metadata: {"text": "...", "position": "..."}.
Code Examples
result = video_op("watermark",
video_url="https://example.com/preview.mp4",
text="© 2026 Acme Studios",
position="bottom_right",
opacity=0.5,
font_size=28,
)
print(f"Watermarked: {result['output_url']}")
# Tiled, for previews that should be hard to reuse
result = video_op("watermark",
video_url="https://example.com/preview.mp4",
text="PREVIEW",
position="tiled",
opacity=0.25,
font_size=48,
color="gray",
)const result = await videoOp("watermark", {
video_url: "https://example.com/preview.mp4",
text: "© 2026 Acme Studios",
position: "bottom_right",
opacity: 0.5,
font_size: 28,
});
console.log(`Watermarked: ${result.output_url}`);
// Tiled, for previews that should be hard to reuse
const tiled = await videoOp("watermark", {
video_url: "https://example.com/preview.mp4",
text: "PREVIEW",
position: "tiled",
opacity: 0.25,
font_size: 48,
color: "gray",
});curl -X POST "https://apis.fotohub.app/v1/video/watermark" \
-H "Authorization: Bearer fh_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"video_url": "https://example.com/preview.mp4",
"text": "© 2026 Acme Studios",
"position": "bottom_right",
"opacity": 0.5,
"font_size": 28
}'Upscale
POST /v1/video/upscaleEnlarge a video 2x, 3x or 4x. The default standard quality resamples every frame (Lanczos) and sharpens it, encoding H.264 with the audio copied unchanged: fast and predictable, but it does not invent new detail. For a generative upscale that restores detail, use quality: "pro" (Upscale Pro).
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
video_url | string | Yes | — | URL of the video to upscale. Must be a public https URL (otherwise 400 url_blocked). |
scale | integer | No | 2 | Upscale factor: 2, 3 or 4. |
quality | string | No | standard | standard (this section) or pro: FOTOhub Upscale Pro 4K, an asynchronous job on the Professional plan and above. See Upscale Pro (quality pro). |
start_seconds | number | No | 0 | quality: "pro" only: where the span starts. |
max_seconds | number | No | to the end | quality: "pro" only: span length. |
quote_credits | number | No | — | quality: "pro" only: the credits of POST /v1/upscale/pro/quote you confirmed. Above it the start is refused with 409 price_changed and nothing is charged. Ignored by standard. |
Any other field is ignored.
Response (standard): the common shape, with metadata: {"scale": 2, "method": "lanczos"}. The output is scale times the source width and height.
Code Examples
# 480p footage to 960p (2x)
result = video_op("upscale",
video_url="https://example.com/old-video-480p.mp4",
scale=2,
)
print(f"Upscaled: {result['output_url']}")
print(f"Scale: {result['metadata']['scale']}x")// 480p footage to 960p (2x)
const result = await videoOp("upscale", {
video_url: "https://example.com/old-video-480p.mp4",
scale: 2,
});
console.log(`Upscaled: ${result.output_url}`);
console.log(`Scale: ${result.metadata.scale}x`);curl -X POST "https://apis.fotohub.app/v1/video/upscale" \
-H "Authorization: Bearer fh_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"video_url": "https://example.com/old-video-480p.mp4",
"scale": 2
}'Upscale Pro (quality pro)
"quality": "pro" starts a FOTOhub Upscale Pro 4K job instead: a generative upscale of one span of up to 30 s and 240 frames, output up to 1920 x 1080. It is the same job as POST /v1/upscale/pro/video, with the same plan gate (Professional and above, 403 plan_required otherwise), limits, price and errors. The answer is 202 with a job to poll at poll_url (/v1/upscale/pro/jobs/{job_id}), not a finished file, and it carries the charge fields (cost_usd, currency, billing).
The price is per second of the span, rounded up to 10-second blocks; get it first from the free POST /v1/upscale/pro/quote and send its credits as quote_credits to be protected against a longer or larger file than you quoted (409 price_changed, nothing charged).
curl -X POST "https://apis.fotohub.app/v1/video/upscale" \
-H "Authorization: Bearer fh_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"video_url": "https://example.com/clip-540p.mp4",
"scale": 2,
"quality": "pro",
"start_seconds": 4,
"max_seconds": 6
}'Full reference: Upscale Pro.
AI Director
POST /v1/video/ai-directorThe AI Director has three modes, chosen with mode, which take different inputs:
mode | Input | Returns |
|---|---|---|
plan (default) | A text prompt. No video is read. | plan: a shot plan for a video you are going to make. No file. |
remix | Your clip_urls (and optional music_url) | remix: an edit plan for those clips. No file. |
advanced-remix | Your clip_urls (and optional music_url) | A rendered cut: output_url, output_size, duration, plus the plan it followed. |
Every mode costs the same (see Pricing). A missing prompt (plan) or clip_urls (remix modes), or a style from another mode's list, is refused with 400 before anything is charged.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
mode | string | No | plan | plan, remix or advanced-remix. |
prompt | string | plan | — | What to plan, up to 4,000 characters. Used by plan only. |
target_duration | integer | No | plan: 30; remix modes: chosen by the director | Target length in seconds, 1-3600. |
clip_urls | array of strings | remix modes | — | Up to 50 public https:// URLs of your clips. |
music_url | string | No | — | Public https:// URL of a music track to cut to (remix modes). |
style | string | No | remix: dynamic; advanced-remix: tiktok | plan: free text. remix: fast, slow, dynamic or chill. advanced-remix: tiktok, cinematic, highlight_reel, music_video, storyteller, glitch, vhs_retro, neon, hyperpop, drill or lofi. |
intensity | number | No | 0.7 | Strength of speed ramps and zooms, 0.0-1.0 (advanced-remix only). |
existing_media | array of strings | No | — | Up to 50 items the plan should build around (plan only). |
Response
advanced-remix:
{
"operation": "ai-director",
"cost_usd": 0.267953,
"currency": "USD",
"billing": {"cost_usd": 0.267953, "balance_usd": 18.15, "currency": "USD", "method": "wallet", "model": "prepaid"},
"mode": "advanced-remix",
"success": true,
"output_url": "https://s1.fotohub.app/storage/v1/object/public/videos/...",
"output_size": 48234112,
"duration": 58.4,
"plan": { "...": "the edit the director followed" }
}plan and remix return the same charge fields, mode and success, plus plan or remix respectively, and no output_url.
Code Examples
# Plan a shoot from a prompt (no video needed)
plan = video_op("ai-director",
mode="plan",
prompt="A 30-second launch teaser for a minimalist desk lamp, warm evening mood",
target_duration=30,
)
print(plan["plan"])
# Render a cut from your clips, synced to music
cut = video_op("ai-director",
mode="advanced-remix",
clip_urls=[
"https://example.com/clip-1.mp4",
"https://example.com/clip-2.mp4",
"https://example.com/clip-3.mp4",
],
music_url="https://example.com/background-track.mp3",
style="cinematic",
target_duration=45,
intensity=0.5,
)
print(f"AI edit: {cut['output_url']} ({cut['duration']}s)")// Plan a shoot from a prompt (no video needed)
const plan = await videoOp("ai-director", {
mode: "plan",
prompt: "A 30-second launch teaser for a minimalist desk lamp, warm evening mood",
target_duration: 30,
});
console.log(plan.plan);
// Render a cut from your clips, synced to music
const cut = await videoOp("ai-director", {
mode: "advanced-remix",
clip_urls: [
"https://example.com/clip-1.mp4",
"https://example.com/clip-2.mp4",
"https://example.com/clip-3.mp4",
],
music_url: "https://example.com/background-track.mp3",
style: "cinematic",
target_duration: 45,
intensity: 0.5,
});
console.log(`AI edit: ${cut.output_url} (${cut.duration}s)`);curl -X POST "https://apis.fotohub.app/v1/video/ai-director" \
-H "Authorization: Bearer fh_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"mode": "advanced-remix",
"clip_urls": [
"https://example.com/clip-1.mp4",
"https://example.com/clip-2.mp4",
"https://example.com/clip-3.mp4"
],
"music_url": "https://example.com/background-track.mp3",
"style": "cinematic",
"target_duration": 45,
"intensity": 0.5
}'Remove Object Pro
POST /v1/video/remove-objectFOTOhub Remove Object Pro removes an object you mark with masks from a window of a video and fills the background in, frame by frame. It is an asynchronous job: the start answers 202 and you poll the job. The job is charged once when accepted and refunded automatically if it fails or is cancelled.
Plan and availability
Remove Object Pro is part of the Professional plan and above: a lower plan answers 403 plan_required (required_plan, upgrade_url, feature, message) and nothing is charged. While it is not released to your account, starts answer 403 {"detail": {"error": "feature_disabled", "flag": "<feature id>"}} and the quote answers {"available": false, "reason": "feature_disabled", "flag_on": false}.
Authentication: API key (or a dashboard session) on every route of this section.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
video_url | string | Yes | — | https:// URL of the source video. At most 1 GB and 30 minutes long. |
masks_url | string | One of the two | — | https:// URL of a ZIP (at most 64 MB) with one PNG mask per frame of the window, in name order. White = remove. |
static_mask_url | string | One of the two | — | One PNG mask (at most 16 MB) used for every frame: https:// URL or data:image/png;base64,.... White = remove. |
start_frame | integer | No | 0 | First frame of the window, counting from 0. |
end_frame | integer | No | see below | The frame after the window (>= 1). Default: as many frames as the ZIP has masks, or (static mask) the longest window one job takes from start_frame. |
dilation | integer | No | 8 | Grow the mask by this many pixels before the removal, 0-24. |
client_ref | object | No | — | Your own context, echoed back in the job. At most 2 KB as JSON. |
Send exactly one of masks_url and static_mask_url (both or neither is a 422). Masks are scaled to the video's size. A ZIP must hold exactly one mask per window frame (422 bad_masks says how many it has and how many the window needs); masks that mark nothing are 422 mask_empty.
Limits per job: a window of at most 5 seconds and at most 97 frames, whichever is shorter at the clip's frame rate (97 frames is 4.04 s at 24 fps); the video's long side at most 3840 px and each side at least 32 px. A longer window is refused with the exact limit for that frame rate (422 pro_limit, code: "too_long", limits.max_frames, limits.max_seconds) rather than split, because separate jobs would leave a visible seam. These are the default limits; quote returns the current ones as limits.
Engines and where they run
The job runs on the Pro engine, processed in the EU (processing_region: "eu"). When the Pro engine is unavailable, the Standard remover may run the job instead, on FOTOhub-operated GPU servers in the United States: the job then says engine: "standard", fallback: true, fallback_reason and processing_region: "us". If a job charged at the Pro price is finished by the Standard remover, the price difference is refunded and the job shows fallback_refunded_credits, fallback_refunded_usd and fallback_note.
Pricing
The Pro engine is charged per started 5 seconds of the window (billed_seconds is a multiple of 5). A job that starts on the Standard remover costs nothing on a plan that includes processing on FOTOhub GPUs, and the Standard rate otherwise. The start response's price object says which applied: tier (pro or standard), region, included_in_plan, credits, and for Pro also units (5-second blocks); the current per-block USD rate is listed by GET /v1/pricing. The USD charge itself is in cost_usd and billing.
Response
202 Accepted:
{
"job_id": "7c1e9a52-0d4b-4f3e-8a61-5b2c3d4e5f60",
"status": "queued", // queued | running | succeeded | failed | cancelled
"engine": "pro", // "pro" | "standard"
"fallback": false,
"fallback_reason": null,
"processing_region": "eu",
"start_frame": 48,
"end_frame": 120,
"frames": 72,
"seconds": 3.0,
"fps": 24.0,
"billed_seconds": 5,
"estimate_s": 95,
"eta_s": 95,
"progress": null,
"queue_position": null,
"created_at": "2026-10-09T10:00:00+00:00",
"started_at": null,
"finished_at": null,
"client_ref": null,
"poll_url": "/v1/video/remove-object/jobs/7c1e9a52-0d4b-4f3e-8a61-5b2c3d4e5f60",
"price": {"tier": "pro", "region": "eu", "included_in_plan": false, "credits": 15.0,
"price_key": "<key in GET /v1/pricing>", "units": 1}
// + cost_usd, currency, billing
}GET /v1/video/remove-object/jobs/{job_id} returns the same job (without price and the charge fields). Once status is succeeded it has result: the whole video with the object removed, as url (a signed link valid for 7 days, expires_in), storage_path, bucket, mime_type: "video/mp4", width, height, fps, frames, duration_s, size_bytes and engine. A failed or cancelled job has error_code (engine_busy, engine_failed, engine_rejected, timeout, lost, bad_result, storage, interrupted, cancelled), error and refunded. A webhook event video.remove_object.completed or video.remove_object.failed is sent when the job ends.
Code Examples
curl -X POST "https://apis.fotohub.app/v1/video/remove-object" \
-H "Authorization: Bearer fh_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"video_url": "https://example.com/street.mp4",
"static_mask_url": "https://example.com/street-mask.png",
"start_frame": 48,
"end_frame": 120
}'import os, time, requests
API = "https://apis.fotohub.app"
H = {"Authorization": f"Bearer {os.environ['FOTOHUB_API_KEY']}"}
job = requests.post(f"{API}/v1/video/remove-object", headers=H, json={
"video_url": "https://example.com/street.mp4",
"static_mask_url": "https://example.com/street-mask.png",
"start_frame": 48, "end_frame": 120,
}, timeout=300).json()
print(job["engine"], job["processing_region"], job["cost_usd"])
while job["status"] in ("queued", "running"):
time.sleep(10)
job = requests.get(f"{API}{job['poll_url']}", headers=H, timeout=30).json()
print(job["status"], job.get("result", {}).get("url"))const API = "https://apis.fotohub.app";
const H = { Authorization: `Bearer ${process.env.FOTOHUB_API_KEY}`, "Content-Type": "application/json" };
let job: any = await (await fetch(`${API}/v1/video/remove-object`, {
method: "POST", headers: H,
body: JSON.stringify({
video_url: "https://example.com/street.mp4",
static_mask_url: "https://example.com/street-mask.png",
start_frame: 48, end_frame: 120,
}),
})).json();
while (job.status === "queued" || job.status === "running") {
await new Promise((r) => setTimeout(r, 10000));
job = await (await fetch(`${API}${job.poll_url}`, { headers: H })).json();
}
console.log(job.status, job.result?.url);Remove Object Pro: quote
POST /v1/video/remove-object/quoteFree. Nothing is fetched or charged.
| Parameter | Type | Required | Description |
|---|---|---|---|
width, height | integer | Yes | Video size in pixels, 1-100000 |
fps | number | Yes | Frame rate, > 0 and <= 1000 |
frames | integer | No | Window length in frames (default: the longest window one job takes) |
source_frames | integer | No | Frames of the whole source (it affects the ETA) |
The answer: flag_on, plan_ok, required_plan, upgrade_url, price_key, max_frames and max_seconds (the longest window at this frame rate), frames, limits (max_frames, max_long_side), engine (which engine would run it now) and processing_region, eligible, available and, when not available, reason (plan_required or pro_limit). An eligible window adds seconds, units (5-second blocks), billed_seconds, estimate_s ({"pro": ..., "standard": ...}), eta_s for the engine that would run it, price (as in the start response) and cold_start: true when the Pro model has to load first. An ineligible one adds refusal: {code, message, limits}.
Jobs and cancel
GET /v1/video/remove-object/jobsreturns{"jobs": [...]}, newest first;active=truelists unfinished jobs only,limitis 1-50 (default 20). The list does not sign result links: read one job for itsurl.POST /v1/video/remove-object/jobs/{job_id}/cancelstops aqueuedorrunningjob and refunds it at once. A finished job answers409 {"detail": {"error": "not_cancellable", "status": "...", "message": "This removal has already finished or is being delivered."}}.- An unknown id, or a job of another account, answers
404 {"detail": "Job not found"}.
Errors
Refusals before the charge end their message with "Your wallet was not charged for this request.".
| Status | error | When |
|---|---|---|
| 400 | bad_input, unreadable, bad_masks | A URL is not https or could not be fetched; the video or a mask cannot be read; the ZIP is not a ZIP, is damaged, has no PNG or a non-PNG entry |
| 400 | url_blocked | A URL is not a public https URL (see URL refused) |
| 402 | insufficient_funds | The wallet cannot cover the charge |
| 403 | feature_disabled, plan_required | See above |
| 409 | host_capacity | The window needs more capacity than is available right now (code, limits, charged: false); try later or select a shorter stretch |
| 409 | not_cancellable | Cancel of a finished job |
| 413 | too_large, too_long_source, bad_masks | Video over 1 GB, source over 30 minutes, masks archive over 64 MB, static mask over 16 MB, a mask or the unpacked archive too large |
| 422 | pro_limit | code: too_small, too_large (long side over 3840 px), too_long, bad_window (start_frame / end_frame outside the video, or end_frame <= start_frame), unreadable; with message and limits |
| 422 | bad_masks, mask_empty | Mask count does not match the window; masks mark nothing |
| 429 | engine_busy | The remover is busy: retry_after_s and a Retry-After header |
| 503 | engine_unavailable | The remover is temporarily unavailable |
Pricing
The editing operations are billed flat per call from your prepaid USD wallet: the price depends on the operation, not on the length or resolution of the video. There are no API credits — see Billing & Pricing.
| Operation | Billing | Description |
|---|---|---|
| Transcode | flat per call | Container, codec, quality, resolution |
| Merge | flat per call | Concatenate multiple video clips |
| Speed | flat per call | Speed change, 0.1x-10x |
| Stabilize | flat per call | Camera-shake removal |
| Subtitles | flat per call | Burn in your SRT subtitles |
| Effects | flat per call | Colour adjustments and looks |
| Watermark | flat per call | Text watermark |
Upscale (standard) | flat per call | 2x-4x resampling; pro is priced per second, see Upscale Pro |
| AI Director | flat per call | Any mode: plan, remix or rendered remix |
Billing notes:
- The current USD price of each operation is listed by
GET /v1/pricing, the source of truth; the amounts in the response examples on this page are illustrative. - Every successful response carries what it charged:
cost_usd,currencyand thebillingobject (withbalance_usdafter the charge). See ThebillingObject. - A call that fails after the charge is refunded; one refused by validation (
422) is never charged.
Rate Limits
Rate limits depend on your subscription tier:
Per-Tier Limits
| Endpoint | Free | Pay-as-you-go | Growth | Business | Enterprise |
|---|---|---|---|---|---|
transcode | 5/min | 15/min | 40/min | 120/min | Custom |
merge | 3/min | 10/min | 30/min | 100/min | Custom |
speed | 5/min | 15/min | 40/min | 120/min | Custom |
stabilize | 3/min | 10/min | 30/min | 80/min | Custom |
subtitles | 3/min | 10/min | 30/min | 80/min | Custom |
effects | 5/min | 15/min | 40/min | 120/min | Custom |
watermark | 5/min | 15/min | 40/min | 120/min | Custom |
upscale | 2/min | 5/min | 15/min | 40/min | Custom |
ai-director | 2/min | 5/min | 15/min | 40/min | Custom |
Burst Limits
All tiers have a burst limit of 3x the per-minute rate for up to 10 seconds. Example: the Growth tier can burst to 120 req/min on transcode for 10s before throttling to 40/min.
Rate Limit Headers
Every response includes:
X-RateLimit-Limit: 40
X-RateLimit-Remaining: 38
X-RateLimit-Reset: 1719936000Exceeding the limit returns HTTP 429 with a Retry-After header (seconds until reset).
Supported Formats
Input: any common video file at a public https:// URL (MP4, MOV, WebM, MKV and the like). See URL refused (400) for what a URL must look like.
Output:
| Operation | Formats |
|---|---|
| Transcode | mp4, mov, gif, prores (.mov); webm with codec: "vp9" for a source without audio |
| Merge, Effects | mp4, mov |
| Speed, Stabilize, Subtitles, Watermark, Upscale, AI Director | MP4 |
Error Responses
| Status | When | Charged? |
|---|---|---|
400 | url_blocked: a URL is not a public https:// URL (field names it, e.g. video_urls[1]). AI Director: missing prompt / clip_urls, or a style not valid for the mode. | No |
402 | insufficient_funds: the wallet cannot pay for the call. See 402 Payment Required. | No |
422 | Request validation: a wrong type, a value out of range or not in the listed set. | No |
400, 402, 413, 422 | The video could not be processed as asked (for example unreadable input, or subtitles that are not SRT). The message starts with Video processing failed:. | Refunded |
502 | The processing service could not be reached, failed, or produced no file. | Refunded |
When a call is refunded, the message ends with "Your wallet was not charged for this request."
{
"detail": "Video processing failed: ... Your wallet was not charged for this request."
}{
"detail": [
{
"type": "less_than_equal",
"loc": ["body", "speed"],
"msg": "Input should be less than or equal to 10",
"input": 16,
"ctx": {"le": 10.0}
}
]
}SDK Reference
The Python and TypeScript SDKs (pip install fotohub, npm install fotohub) have no dedicated methods for the editing routes on this page (transcode, merge, speed, stabilize, subtitles, effects, watermark, upscale, AI director). Call them over HTTP as in the examples above: POST https://apis.fotohub.app/v1/video/<operation> with your API key as a Bearer token and the snake_case JSON body from each Parameters table.
The SDKs do cover Upscale Pro (the quality: "pro" path of /v1/video/upscale runs the same job):
| Python | TypeScript | Endpoint |
|---|---|---|
client.upscale_pro.quote("video", width=..., height=..., fps=..., seconds=...) | client.upscalePro.quote({ kind: "video", ... }) | POST /v1/upscale/pro/quote |
client.upscale_pro.video(video_url, quote_credits=...) | client.upscalePro.video({ videoUrl, quoteCredits }) | POST /v1/upscale/pro/video |
client.upscale_pro.wait_for_job(job_id) | client.upscalePro.waitForJob(jobId) | GET /v1/upscale/pro/jobs/ |
See Upscale Pro for the full contract.
Combined Workflow Examples
Each operation takes a URL and returns a URL, so the output_url of one step is the video_url of the next. The examples use the helpers from Calling the endpoints.
Full Post-Production Pipeline
Stabilize, grade, subtitle and brand raw footage.
srt = open("captions.srt", encoding="utf-8").read()
# Step 1: Stabilize shaky footage
step1 = video_op("stabilize",
video_url="https://example.com/raw-handheld.mp4",
preset="smooth",
)
# Step 2: Cinematic look
step2 = video_op("effects",
video_url=step1["output_url"],
preset="cinematic",
)
# Step 3: Burn in your subtitles
step3 = video_op("subtitles",
video_url=step2["output_url"],
srt_content=srt,
font_size=28,
)
# Step 4: Text watermark
final = video_op("watermark",
video_url=step3["output_url"],
text="© Acme Studios",
position="bottom_right",
opacity=0.4,
)
print(f"Final: {final['output_url']}")
# Billed: 4 flat-rate calls; each response's cost_usd is what it chargedimport { readFileSync } from "node:fs";
const srt = readFileSync("captions.srt", "utf-8");
const step1 = await videoOp("stabilize", { video_url: "https://example.com/raw-handheld.mp4", preset: "smooth" });
const step2 = await videoOp("effects", { video_url: step1.output_url, preset: "cinematic" });
const step3 = await videoOp("subtitles", { video_url: step2.output_url, srt_content: srt, font_size: 28 });
const final = await videoOp("watermark", {
video_url: step3.output_url,
text: "© Acme Studios",
position: "bottom_right",
opacity: 0.4,
});
console.log(`Final: ${final.output_url}`);
// Billed: 4 flat-rate calls; each response's cost_usd is what it chargedBatch Transcode for Multi-Platform Delivery
source = "https://example.com/master-4k.mov"
targets = [
{"name": "4K archive (H.265)", "output_format": "mp4", "codec": "h265", "quality": "ultra"},
{"name": "1080p web (H.264)", "output_format": "mp4", "resolution": "1080p", "quality": "high"},
{"name": "720p mobile", "output_format": "mp4", "resolution": "720p", "quality": "standard"},
{"name": "Edit master (ProRes)", "output_format": "prores"},
{"name": "GIF preview", "output_format": "gif"},
]
for t in targets:
name = t.pop("name")
r = video_op("transcode", video_url=source, **t)
print(f"{name}: {r['output_url']} ({r['output_size'] / 1024 / 1024:.1f} MB)")
# Billed: 5 flat-rate transcode callsconst source = "https://example.com/master-4k.mov";
const targets = [
{ name: "4K archive (H.265)", output_format: "mp4", codec: "h265", quality: "ultra" },
{ name: "1080p web (H.264)", output_format: "mp4", resolution: "1080p", quality: "high" },
{ name: "720p mobile", output_format: "mp4", resolution: "720p", quality: "standard" },
{ name: "Edit master (ProRes)", output_format: "prores" },
{ name: "GIF preview", output_format: "gif" },
];
for (const { name, ...body } of targets) {
const r = await videoOp("transcode", { video_url: source, ...body });
console.log(`${name}: ${r.output_url} (${(r.output_size / 1024 / 1024).toFixed(1)} MB)`);
}
// Billed: 5 flat-rate transcode callsHighlight Reel with AI Director + Upscale
Render a cut from your clips, then enlarge it.
reel = video_op("ai-director",
mode="advanced-remix",
clip_urls=[
"https://example.com/event-1.mp4",
"https://example.com/event-2.mp4",
"https://example.com/event-3.mp4",
],
music_url="https://example.com/upbeat-track.mp3",
style="highlight_reel",
target_duration=60,
)
print(f"Highlight reel: {reel['output_url']}")
big = video_op("upscale", video_url=reel["output_url"], scale=2)
print(f"2x version: {big['output_url']}")
# Billed: 2 flat-rate calls (AI Director + upscale)const reel = await videoOp("ai-director", {
mode: "advanced-remix",
clip_urls: [
"https://example.com/event-1.mp4",
"https://example.com/event-2.mp4",
"https://example.com/event-3.mp4",
],
music_url: "https://example.com/upbeat-track.mp3",
style: "highlight_reel",
target_duration: 60,
});
console.log(`Highlight reel: ${reel.output_url}`);
const big = await videoOp("upscale", { video_url: reel.output_url, scale: 2 });
console.log(`2x version: ${big.output_url}`);
// Billed: 2 flat-rate calls (AI Director + upscale)Performance Tips
- Use a long client timeout. Every operation answers only when the file is ready: up to 15 minutes for stabilize, upscale, AI Director and effects with
stabilize: true. draftquality for intermediate steps.transcodeandeffectstakequality; encode the final step athighorultra.- One
effectscall, many adjustments. Colour, sharpen, blur, denoise and even stabilize go into a single flat-rate call. Apresetreplaces all of them, so send either a preset or manual adjustments. - One
mergecall for all clips. Up to 20 clips in one flat-rate call instead of merging pairs. - Skip redundant transcodes. The other operations read any common video format directly.
Common Workflow Patterns
| Workflow | Operations | Billed calls |
|---|---|---|
| Quick social clip | Speed + Effects + Subtitles | 3 |
| Professional edit | Stabilize + Effects + Subtitles + Watermark | 4 |
| AI highlight reel | AI Director (advanced-remix) + Upscale | 2 |
| Multi-platform delivery | 4x Transcode | 4 |
| Event recap | Merge + Speed + Effects + Subtitles | 4 |
Use Cases
Content Creators
- Turn your clips into a music-synced cut with the AI Director (
advanced-remix) - Burn in captions you already have (SRT) for silent autoplay
- Apply a consistent colour look across all content with one
effectspreset - Produce several delivery formats from one master with
transcode
Film & Video Production
- Stabilize handheld footage
- Plan a shoot from a brief with the AI Director (
plan) - Enlarge archival footage, or restore detail with Upscale Pro
- Join multi-part edits with transitions
E-Commerce & Marketing
- Add a text watermark to preview videos
- Speed up product demos, or slow down the hero moment
- Build highlight reels from event recordings
Education & Corporate
- Burn in subtitles for accessibility
- Merge lecture segments with transitions
- Stabilize presentation recordings
Enterprise
For higher limits, SLA guarantees, dedicated processing capacity, and priority queues:
- Email: [email protected]
- Custom rate limits up to 1000 req/min
- Guaranteed processing times (P95 SLA)
- Dedicated GPU instances for upscale and AI Director
- Volume discounts starting at 10,000 operations/month
- On-premise deployment available

