Skip to content

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 ​

EndpointDescriptionBilling
POST /v1/video/transcodeChange container, codec, encode quality or resolutionflat per call
POST /v1/video/mergeJoin 2-20 clips, with optional transitionsflat per call
POST /v1/video/speedSlow motion or speed-upflat per call
POST /v1/video/stabilizeRemove camera shakeflat per call
POST /v1/video/subtitlesBurn subtitles you provide (SRT) into the videoflat per call
POST /v1/video/effectsColour adjustments, looks, sharpen, blur, denoiseflat per call
POST /v1/video/watermarkBurn in a text watermarkflat per call
POST /v1/video/upscaleUpscale 2x-4x; quality: "pro" starts an Upscale Pro jobflat per call (pro: per second, see Upscale Pro)
POST /v1/video/ai-directorShot plan, remix plan, or a rendered remix of your clipsflat per call
POST /v1/video/remove-objectRemove Object Pro: remove an object from a video window (async job)per started 5 s, see quote
POST /v1/video/remove-object/quoteFree price, limits and ETA for a removalfree
GET /v1/video/remove-object/jobsYour recent removalsfree
GET /v1/video/remove-object/jobs/{job_id}Status and result of one removalfree
POST /v1/video/remove-object/jobs/{job_id}/cancelCancel a queued or running removal; refunded at oncefree

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:

json
{
  "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
}
FieldDescription
operationThe operation name: transcode, merge, speed, stabilize, subtitles, effects, watermark, upscale, ai-director
cost_usd, currency, billingWhat this call charged your wallet
output_urlThe finished file
output_sizeFile size in bytes
durationLength of the output in seconds (not returned by every operation)
metadataOperation-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).

python
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()
typescript
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();
}
go
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/transcode

Convert a video to another container or codec, change the encode quality, or scale it down to a standard resolution.

Parameters ​

ParameterTypeRequiredDefaultDescription
video_urlstringYes—Public https:// URL of the source video (otherwise 400 url_blocked).
output_formatstringNomp4mp4, mov, webm, gif or prores (ProRes in a .mov file).
codecstringNoH.264h264, h265, vp9 or prores. Omit for H.264.
qualitystringNohighEncode quality: draft (fastest, lowest quality), standard, high, ultra (slowest, best).
resolutionstringNosource480p, 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.
  • gif is always 15 fps and 480 px wide, without audio; codec, quality and resolution do not apply.
  • prores (as output_format or codec) encodes ProRes; quality does not apply. Use it with output_format mov or prores.
  • webm needs codec: "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 ​

python
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")
typescript
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`);
bash
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/merge

Join 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 ​

ParameterTypeRequiredDefaultDescription
video_urlsarray of stringsYes—2 to 20 public https:// URLs, in playback order. A blocked URL answers 400 url_blocked with field such as video_urls[2].
transitionstringNononenone (hard cut), dissolve, fade, crossfade, wipeleft, wiperight, slidedown or slideup.
transition_durationnumberNo0.5Transition length in seconds, 0.1-3.0. Used only when transition is not none.
output_formatstringNomp4mp4 or mov. webm is accepted but currently fails (the call is refunded).

Response: the common shape, metadata is null.

Code Examples ​

python
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")
typescript
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`);
bash
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/speed

Slow 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 ​

ParameterTypeRequiredDefaultDescription
video_urlstringYes—Public https:// URL of the source video.
speednumberYes—Speed multiplier, 0.1-10.0: 0.25 = 4x slower, 2.0 = 2x faster.
interpolationstringNoblendHow 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 ​

python
# 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']}")
typescript
// 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}`);
bash
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/stabilize

Remove 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 ​

ParameterTypeRequiredDefaultDescription
video_urlstringYes—Public https:// URL of the source video.
presetstringNosmoothStrength: smooth, very_smooth, cinematic or action.

Response: the common shape, with metadata: {"preset": "smooth"}.

Code Examples ​

python
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']}")
typescript
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}`);
bash
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/subtitles

Burn 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 ​

ParameterTypeRequiredDefaultDescription
video_urlstringYes—Public https:// URL of the source video.
srt_contentstringYes—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.
fontstringNoArialFont family name, up to 64 characters: letters, digits, space, _ and - only.
font_sizeintegerNo24Font size in points, 10-120.
positionstringNobottombottom 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 ​

python
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']}")
typescript
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}`);
bash
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/effects

Colour-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 ​

ParameterTypeRequiredDefaultDescription
video_urlstringYes—Public https:// URL of the source video.
presetstringNononevintage, noir, warm, cool, cinematic or hdr. When set, all fields below except output_format and quality are ignored.
brightnessnumberNo0.0-1.0-1.0; 0 = unchanged.
contrastnumberNo1.0Multiplier, 0.0-3.0; 1.0 = unchanged.
saturationnumberNo1.0Multiplier, 0.0-3.0; 0 = greyscale, 1.0 = unchanged.
huenumberNo0.0Hue rotation in degrees, -180-180.
gammanumberNo1.00.1-10.0; 1.0 = unchanged.
sharpennumberNo0.0Sharpening 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).
blurnumberNo0.0Blur strength, 0-20.
denoisebooleanNofalseReduce noise.
stabilizebooleanNofalseAlso stabilize (two passes; the call can take up to 15 minutes). Ignored when preset is set.
output_formatstringNomp4mp4 or mov. webm is accepted but currently fails (the call is refunded).
qualitystringNostandardEncode quality: draft, standard, high or ultra.

Response: the common shape, metadata is null.

Code Examples ​

python
# 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",
)
typescript
// 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",
});
bash
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/watermark

Burn a text watermark into the video. Image (logo) watermarks are not supported by this route.

Parameters ​

ParameterTypeRequiredDefaultDescription
video_urlstringYes—Public https:// URL of the source video.
textstringNoFOTOhubWatermark text, 1-200 characters.
positionstringNobottom_righttop_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.
opacitynumberNo0.30.05-1.0.
font_sizeintegerNo24Font size in points, 10-120.
colorstringNowhitewhite, black, red, yellow or gray.

Response: the common shape, with metadata: {"text": "...", "position": "..."}.

Code Examples ​

python
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",
)
typescript
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",
});
bash
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/upscale

Enlarge 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 ​

ParameterTypeRequiredDefaultDescription
video_urlstringYes—URL of the video to upscale. Must be a public https URL (otherwise 400 url_blocked).
scaleintegerNo2Upscale factor: 2, 3 or 4.
qualitystringNostandardstandard (this section) or pro: FOTOhub Upscale Pro 4K, an asynchronous job on the Professional plan and above. See Upscale Pro (quality pro).
start_secondsnumberNo0quality: "pro" only: where the span starts.
max_secondsnumberNoto the endquality: "pro" only: span length.
quote_creditsnumberNo—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 ​

python
# 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")
typescript
// 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`);
bash
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).

bash
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-director

The AI Director has three modes, chosen with mode, which take different inputs:

modeInputReturns
plan (default)A text prompt. No video is read.plan: a shot plan for a video you are going to make. No file.
remixYour clip_urls (and optional music_url)remix: an edit plan for those clips. No file.
advanced-remixYour 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 ​

ParameterTypeRequiredDefaultDescription
modestringNoplanplan, remix or advanced-remix.
promptstringplan—What to plan, up to 4,000 characters. Used by plan only.
target_durationintegerNoplan: 30; remix modes: chosen by the directorTarget length in seconds, 1-3600.
clip_urlsarray of stringsremix modes—Up to 50 public https:// URLs of your clips.
music_urlstringNo—Public https:// URL of a music track to cut to (remix modes).
stylestringNoremix: dynamic; advanced-remix: tiktokplan: 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.
intensitynumberNo0.7Strength of speed ramps and zooms, 0.0-1.0 (advanced-remix only).
existing_mediaarray of stringsNo—Up to 50 items the plan should build around (plan only).

Response ​

advanced-remix:

json
{
  "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 ​

python
# 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)")
typescript
// 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)`);
bash
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-object

FOTOhub 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 ​

ParameterTypeRequiredDefaultDescription
video_urlstringYes—https:// URL of the source video. At most 1 GB and 30 minutes long.
masks_urlstringOne 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_urlstringOne of the two—One PNG mask (at most 16 MB) used for every frame: https:// URL or data:image/png;base64,.... White = remove.
start_frameintegerNo0First frame of the window, counting from 0.
end_frameintegerNosee belowThe 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.
dilationintegerNo8Grow the mask by this many pixels before the removal, 0-24.
client_refobjectNo—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:

jsonc
{
  "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 ​

bash
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
  }'
python
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"))
typescript
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/quote

Free. Nothing is fetched or charged.

ParameterTypeRequiredDescription
width, heightintegerYesVideo size in pixels, 1-100000
fpsnumberYesFrame rate, > 0 and <= 1000
framesintegerNoWindow length in frames (default: the longest window one job takes)
source_framesintegerNoFrames 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/jobs returns {"jobs": [...]}, newest first; active=true lists unfinished jobs only, limit is 1-50 (default 20). The list does not sign result links: read one job for its url.
  • POST /v1/video/remove-object/jobs/{job_id}/cancel stops a queued or running job and refunds it at once. A finished job answers 409 {"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.".

StatuserrorWhen
400bad_input, unreadable, bad_masksA 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
400url_blockedA URL is not a public https URL (see URL refused)
402insufficient_fundsThe wallet cannot cover the charge
403feature_disabled, plan_requiredSee above
409host_capacityThe window needs more capacity than is available right now (code, limits, charged: false); try later or select a shorter stretch
409not_cancellableCancel of a finished job
413too_large, too_long_source, bad_masksVideo 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
422pro_limitcode: 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
422bad_masks, mask_emptyMask count does not match the window; masks mark nothing
429engine_busyThe remover is busy: retry_after_s and a Retry-After header
503engine_unavailableThe 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.

OperationBillingDescription
Transcodeflat per callContainer, codec, quality, resolution
Mergeflat per callConcatenate multiple video clips
Speedflat per callSpeed change, 0.1x-10x
Stabilizeflat per callCamera-shake removal
Subtitlesflat per callBurn in your SRT subtitles
Effectsflat per callColour adjustments and looks
Watermarkflat per callText watermark
Upscale (standard)flat per call2x-4x resampling; pro is priced per second, see Upscale Pro
AI Directorflat per callAny 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, currency and the billing object (with balance_usd after the charge). See The billing Object.
  • 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 ​

EndpointFreePay-as-you-goGrowthBusinessEnterprise
transcode5/min15/min40/min120/minCustom
merge3/min10/min30/min100/minCustom
speed5/min15/min40/min120/minCustom
stabilize3/min10/min30/min80/minCustom
subtitles3/min10/min30/min80/minCustom
effects5/min15/min40/min120/minCustom
watermark5/min15/min40/min120/minCustom
upscale2/min5/min15/min40/minCustom
ai-director2/min5/min15/min40/minCustom

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: 1719936000

Exceeding 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:

OperationFormats
Transcodemp4, mov, gif, prores (.mov); webm with codec: "vp9" for a source without audio
Merge, Effectsmp4, mov
Speed, Stabilize, Subtitles, Watermark, Upscale, AI DirectorMP4

Error Responses ​

StatusWhenCharged?
400url_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
402insufficient_funds: the wallet cannot pay for the call. See 402 Payment Required.No
422Request validation: a wrong type, a value out of range or not in the listed set.No
400, 402, 413, 422The 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
502The 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."

json
{
  "detail": "Video processing failed: ... Your wallet was not charged for this request."
}
json
{
  "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):

PythonTypeScriptEndpoint
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.

python
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 charged
typescript
import { 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 charged

Batch Transcode for Multi-Platform Delivery ​

python
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 calls
typescript
const 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 calls

Highlight Reel with AI Director + Upscale ​

Render a cut from your clips, then enlarge it.

python
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)
typescript
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.
  • draft quality for intermediate steps. transcode and effects take quality; encode the final step at high or ultra.
  • One effects call, many adjustments. Colour, sharpen, blur, denoise and even stabilize go into a single flat-rate call. A preset replaces all of them, so send either a preset or manual adjustments.
  • One merge call 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 ​

WorkflowOperationsBilled calls
Quick social clipSpeed + Effects + Subtitles3
Professional editStabilize + Effects + Subtitles + Watermark4
AI highlight reelAI Director (advanced-remix) + Upscale2
Multi-platform delivery4x Transcode4
Event recapMerge + Speed + Effects + Subtitles4

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 effects preset
  • 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