> ## Documentation Index
> Fetch the complete documentation index at: https://docs.eachlabs.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Capabilities Reference (Beta)

> Every capability the eachlabs Video API accepts — parameters, bounds, copy-pasteable request bodies, and response shapes.

<Note>
  **Beta.** The Video API is in beta. The surface below is validated server-side with `additionalProperties: false` — a request is either exactly this shape or it is rejected before any compute runs, at \$0. Contracts here are stable for the beta (additions may land; nothing is removed without notice).
</Note>

<Note>
  Using these from an AI agent? The [MCP server](/video/mcp/quickstart) exposes every capability as a native tool — its page has the install and connect steps.
</Note>

Every capability job is submitted the same way: `POST https://api.eachlabs.ai/v1/prediction` with header `Authorization: Bearer YOUR_API_KEY`, body `{"model": "eachlabs-video-api", "input": {"capability": "<name>", "input_url": "...", "params": {...}}}`, then poll `GET /v1/prediction/{id}` until a terminal status. A request needs only `capability`, `input_url` (or `input_urls`), and `params`. Analysis capabilities (`probe`, `scene_detect`, `silence_detect`, `audio_analysis`) return structured JSON in `output`; everything else returns hosted artifact URL(s) — the exact JSON per output class is in [Response shapes](#response-shapes). See [Quickstart](/video/quickstart) for the input URL requirements and [Billing & Limits](/video/billing-limits) for pricing mechanics and the error catalog. For long-tail cases the named capabilities don't cover, a raw-ffmpeg mode with a different top-level contract is documented at [run\_ffmpeg](#run_ffmpeg), and multi-step patterns are collected under [Recipes](#recipes).

The 44 capabilities by category (sections below are alphabetical; `keep_ranges` and `silence_remove` are one capability under two accepted names, so the API accepts 45 capability names):

| Category               | Capabilities                                                                                                                                                 |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Transform & geometry   | `crop`, `pad`, `reframe`, `reframe_track`, `rotate`, `scale`                                                                                                 |
| Encode & convert       | `gif`, `transcode`                                                                                                                                           |
| Time & speed           | `fade`, `keep_ranges` (alias `silence_remove`), `reverse`, `segment`, `silence_split`, `speed`, `trim`                                                       |
| Audio                  | `audio_duck`, `audio_master`, `audio_replace`, `extract_audio`, `loudnorm`, `volume`                                                                         |
| Composition & branding | `captions`, `color`, `concat`, `concat_copy`, `crossfade`, `lut3d`, `overlay`, `slideshow`, `sticker_overlay`, `subtitle_compose`, `title_card`, `watermark` |
| Packaging              | `hls_ladder`, `package_abr`, `package_abr_ladder`                                                                                                            |
| Stills & sprites       | `poster`, `stills`, `storyboard_sprites`, `thumbnails`                                                                                                       |
| Analysis               | `audio_analysis`, `probe`, `scene_detect`, `silence_detect`                                                                                                  |

## Encode defaults

Re-encoding capabilities that expose no codec knobs (`crop`, `scale`, `pad`, `rotate`, `overlay`, `watermark`, `speed`, `reverse`, `fade`, `color`, `reframe`, `reframe_track`, `concat`, `captions`, `lut3d`, `keep_ranges`/`silence_remove`, `slideshow`, `crossfade`, `sticker_overlay`, `title_card`) share one fixed encode contract, keyed on the `container` parameter:

| Container | Video codec | Audio codec | Other        |
| --------- | ----------- | ----------- | ------------ |
| `mp4`     | libx264     | aac         | `+faststart` |
| `mkv`     | libx264     | aac         |              |
| `webm`    | libvpx-vp9  | libopus     |              |

* CRF, audio bitrate, and sample rate are left at ffmpeg's defaults (effectively CRF 23 for x264 and 128k AAC) unless the capability exposes a `crf` knob (`transcode`, `poster`, `segment` and `silence_split` with `reencode: true`).
* Deviations: `volume` and `loudnorm` stream-copy the video (mp4/mkv only) and touch only audio; `slideshow` encodes at fixed quality 23; `concat` resamples audio to 44.1 kHz stereo; `audio_master` always resamples to 44.1 kHz with a 320k default bitrate.

## What the probe gate checks

Before compute starts, staged inputs pass through an ffprobe gate. The gate's checks are a **closed list** — nothing else is probed:

1. **Source duration** — over 3600 s rejects (`input_rejected`, reason `duration_over_cap`).
2. **Stream prerequisites** — the capabilities below declare which streams each input must carry; a missing stream rejects at \$0. Capabilities not listed declare none:

| Capability                                | Requires a video stream | Requires an audio stream       |
| ----------------------------------------- | ----------------------- | ------------------------------ |
| `audio_analysis`                          | —                       | input                          |
| `audio_master`                            | —                       | input                          |
| `loudnorm`                                | —                       | input                          |
| `silence_split`                           | —                       | input                          |
| `audio_duck`                              | —                       | both inputs                    |
| `segment` (only when `first_frame: true`) | input                   | —                              |
| `audio_replace` (`mode: replace`)         | base (`input_urls[0]`)  | audio source (`input_urls[1]`) |
| `audio_replace` (`mode: mix`)             | base                    | base **and** audio source      |
| `title_card`                              | input                   | —                              |
| `watermark`                               | both inputs             | —                              |
| `overlay`                                 | both inputs             | —                              |
| `sticker_overlay`                         | both inputs             | —                              |
| `hls_ladder`                              | input                   | input                          |
| `package_abr_ladder`                      | input                   | input                          |
| `crossfade`                               | both inputs             | both inputs                    |
| `concat`                                  | every input             | every input                    |
| `slideshow`                               | every input             | —                              |

(An image counts as a video stream — still-image watermarks and overlays pass.)
3\. **Empty-output parameters** — the per-capability zero-bill rejects noted in the tables below (`trim` start at/past EOF, `fade_out_start` at/past EOF, `stills`/`poster` timestamps at/past EOF, `keep_ranges` windows entirely past EOF or under one frame, `title_card`/`sticker_overlay` show windows outside the probed duration, `crossfade` `offset`/`duration` exceeding the first input's probed duration).
4\. **Encoder constraints** — a codec the requested container cannot mux, such as `segment`/`silence_split` with `reencode: true` targeting `webm`, is checked at submit. Each is noted per capability.

**Geometry is not probed.** A `crop` window larger than the source frame, or a `pad` `x`/`y` offset that pushes the picture outside the canvas, passes the gate and fails at execution as `exec_error` (verified against production) — probe the source first when its dimensions are uncertain. If the probe itself cannot read the source, the gates that need probe data fail open and the job proceeds.

## audio\_analysis

Measure the audio track — integrated loudness, loudness range, true peak, sample peak, and clipping indicators — returned as structured JSON via astats+ebur128. A metadata analysis, not a media artifact.

**Inputs:** `input_url` (1) · **Output:** analysis JSON — `{"audio_analysis": {...}}` in `output`; no uploaded artifact, zero egress

No parameters — an empty `params` object is valid.

| Result field       | Unit  | Meaning                                                    |
| ------------------ | ----- | ---------------------------------------------------------- |
| `integrated_lufs`  | LUFS  | integrated loudness (EBU R128)                             |
| `lra`              | LU    | loudness range                                             |
| `true_peak_dbtp`   | dBTP  | true peak                                                  |
| `sample_peak_dbfs` | dBFS  | sample peak                                                |
| `flat_factor`      | dB    | flatness of consecutive equal samples (clipping indicator) |
| `peak_count`       | count | occurrences of the maximum sample (clipping indicator)     |

<Note>
  * The input must carry an audio stream — a video-only input is rejected at \$0 before billing.
  * Any field that cannot be measured is **omitted from the result rather than reported as zero**: a digitally silent input returns no `true_peak_dbtp` and no `sample_peak_dbfs` (silence has no finite dB peak), and any statistic ffmpeg reports as `-inf`/`nan` is likewise omitted. Treat a missing field as not-measurable, never as `0`.
</Note>

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "audio_analysis",
    "input_url": "YOUR_INPUT_URL",
    "params": {}
  }
}
```

A real production result:

```json theme={"dark"}
{
  "audio_analysis": {
    "integrated_lufs": -11.697,
    "lra": 1.76,
    "true_peak_dbtp": -4.307654147342493,
    "sample_peak_dbfs": -4.307654147342493,
    "flat_factor": 0,
    "peak_count": 2
  }
}
```

## audio\_duck

Duck a music/program bed under a voice track: the voice drives a sidechain compressor that lowers the music while the voice is above threshold, and the same voice is then mixed on top — output carries the voice over the ducked bed, audio-only.

**Inputs:** `input_urls` (2) — `input_urls[0]` is the music/program bed, `input_urls[1]` is the voice · **Output:** media — single audio file (mp3/wav/m4a)

| Parameter    | Type   | Constraints             | Default | Required | Notes                                                                                                                                                              |
| ------------ | ------ | ----------------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `threshold`  | number | min 0.000976563, max 1  | `0.05`  | no       | voice level that triggers the duck, as **linear** amplitude where 1 is full scale (0 dBFS); the default 0.05 is about -26 dBFS. Lower values duck on quieter voice |
| `ratio`      | number | min 1, max 20           | `8`     | no       | how hard the music is reduced above threshold: 1 leaves it untouched, 20 flattens it; 8 is a firm broadcast-style duck                                             |
| `attack_ms`  | number | min 0.01, max 2000      | `20`    | no       | milliseconds for the duck to fully engage after the voice crosses threshold                                                                                        |
| `release_ms` | number | min 0.01, max 9000      | `250`   | no       | milliseconds for the music to recover to full level after the voice falls below threshold                                                                          |
| `container`  | string | `mp3` \| `wav` \| `m4a` | `"mp3"` | no       | mp3 encodes with libmp3lame, m4a with AAC; wav writes lossless PCM                                                                                                 |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "audio_duck",
    "input_urls": [
      "YOUR_INPUT_URL_1",
      "YOUR_INPUT_URL_2"
    ],
    "params": {
      "threshold": 0.05,
      "ratio": 8,
      "container": "mp3"
    }
  }
}
```

<Note>
  * The output runs **exactly as long as `input_urls[0]`'s audio**: a voice longer than the music is truncated at the music's end; a shorter voice leaves the remaining music playing at full level.
  * The output is always audio-only and inherits `input_urls[0]`'s sample rate and channel count — a video `input_urls[0]` contributes only its audio track, its video stream is discarded. Sample-rate or channel-layout mismatches never reject: the voice is silently resampled and up/down-mixed to the music's format.
  * Levels are never auto-normalized — the voice and the ducked music keep their source loudness, which can clip if both are hot.
  * Both inputs must carry an audio stream — an input without one is rejected at \$0 before billing.
</Note>

## audio\_master

Master the audio track to a distribution loudness target — compressor → EBU R128 loudness normalization (single-pass) → true-peak limiter — written audio-only as MP3, WAV, or M4A. The first-class path for audio-in/audio-out mastering (e.g. MP3 → −14 LUFS MP3).

**Inputs:** `input_url` (1) · **Output:** media — single audio file (mp3/wav/m4a)

| Parameter      | Type   | Constraints                             | Default  | Required | Notes                                                                                                                                               |
| -------------- | ------ | --------------------------------------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `target_lufs`  | number | min -30, max -8                         | `-14`    | no       | integrated loudness target in LUFS: -14 is the common streaming target, -16 podcast, -23 EBU R128 broadcast                                         |
| `ceiling_dbtp` | number | min -9, max 0                           | `-1`     | no       | true-peak ceiling in dBTP; the final limiter enforces it                                                                                            |
| `bitrate`      | string | `128k` \| `192k` \| `256k` \| `320k`    | `"320k"` | no       | lossy containers (mp3/m4a) only — rejected for wav (lossless)                                                                                       |
| `preset`       | string | `broadcast` \| `podcast` \| `streaming` | —        | no       | named target pair: streaming = -14 LUFS / -1 dBTP, podcast = -16 / -1.5, broadcast = -23 / -2. Mutually exclusive with `target_lufs`/`ceiling_dbtp` |
| `container`    | string | `mp3` \| `wav` \| `m4a`                 | `"mp3"`  | no       |                                                                                                                                                     |

<Note>
  * The input must carry an audio stream — a video-only input is rejected at \$0 before billing.
  * A fixed gentle compressor (2:1 ratio, -18 dB threshold, 30 ms attack) is **always applied before normalization and cannot be disabled** — for loudness normalization without compression use [loudnorm](#loudnorm) (video containers) or [run\_ffmpeg](#run_ffmpeg).
  * Normalization is single-pass, so the output's measured integrated loudness can deviate from `target_lufs` on short or highly dynamic inputs; `ceiling_dbtp` is still enforced by the final limiter. For mastering-grade accuracy use the two-pass pattern under [run\_ffmpeg](#run_ffmpeg).
  * The output is always resampled to 44100 Hz regardless of the source sample rate; there is no way to keep the source rate.
</Note>

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "audio_master",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "target_lufs": -14,
      "ceiling_dbtp": -1,
      "container": "mp3"
    }
  }
}
```

## audio\_replace

Swap or mix a video's audio track using a second input as the audio source; video always stream-copied.

**Inputs:** `input_urls` (2) — `input_urls[0]` is the base video, `input_urls[1]` is the audio source · **Output:** media — single file

| Parameter   | Type    | Constraints        | Default     | Required | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| ----------- | ------- | ------------------ | ----------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mode`      | string  | `replace` \| `mix` | `"replace"` | no       | replace: -shortest (output runs to the shorter stream); mix: amix ending with the shorter audio over the full video. A base video with **no audio stream** is accepted for `replace` — the new track is simply added; `mix` requires the base to carry audio (rejected at \$0 otherwise)                                                                                                                                                                                                        |
| `container` | string  | `mp4` \| `mkv`     | `"mp4"`     | no       |                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `normalize` | boolean |                    | `true`      | no       | mix only: true halves each track (no-clip-safe); false preserves source levels                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `offset_ms` | integer | min 0, max 3600000 | `0`         | no       | milliseconds of silence prepended to `input_urls[1]`'s audio (adelay, all channels equally) before the replace/mix — start narration at t>0 without client-side pre-padding. In replace mode `-shortest` still ends the output at min(video, offset+audio): an offset pushing the narration past the video end truncates it, and an offset at/past the video duration yields a full-length video whose audio is entirely silent. In mix mode the delayed audio mixes over the full copied video |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "audio_replace",
    "input_urls": [
      "YOUR_INPUT_URL_1",
      "YOUR_INPUT_URL_2"
    ],
    "params": {
      "mode": "replace",
      "container": "mp4",
      "normalize": true
    }
  }
}
```

<Note>
  `offset_ms` replaces the old client-side pre-padding recipe (`adelay` before upload): the server pads the decoded stream, so the \~46ms MP3 encoder-delay skew that made local pre-padding frame-inaccurate does not apply.
</Note>

## captions

Burn a subtitle track (SRT/VTT/ASS) into the video via libass, with optional style overrides. No subtitle file yet? Generate one with a speech-to-text model first (e.g. `elevenlabs-speech-to-text-scribe-v2`), or use [subtitle\_compose](#subtitle_compose) to author animated captions directly from word timings.

**Inputs:** `input_urls` (2) — `input_urls[0]` is the video, `input_urls[1]` is the subtitle file (SRT/VTT/ASS) · **Output:** media — single file

| Parameter    | Type    | Constraints              | Default | Required | Notes                                                                                   |
| ------------ | ------- | ------------------------ | ------- | -------- | --------------------------------------------------------------------------------------- |
| `font_size`  | integer | min 8, max 200           | —       | no       | omit for libass/source default                                                          |
| `text_color` | string  |                          | —       | no       | named (white,black,gray/grey,red,green,blue,yellow) or #RRGGBB; omit for source default |
| `position`   | string  | `bottom` \| `top`        | —       | no       | omit to keep the subtitle file's own alignment                                          |
| `container`  | string  | `mp4` \| `webm` \| `mkv` | `"mp4"` | no       |                                                                                         |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "captions",
    "input_urls": [
      "YOUR_INPUT_URL_1",
      "YOUR_INPUT_URL_2"
    ],
    "params": {
      "container": "mp4"
    }
  }
}
```

## color

Color-grade via ffmpeg eq: brightness/contrast/saturation/gamma. All-defaults is the identity grade (still re-encodes).

**Inputs:** `input_url` (1) · **Output:** media — single file

| Parameter    | Type   | Constraints              | Default | Required | Notes |
| ------------ | ------ | ------------------------ | ------- | -------- | ----- |
| `brightness` | number | min -1, max 1            | `0`     | no       |       |
| `contrast`   | number | min 0, max 2             | `1`     | no       |       |
| `saturation` | number | min 0, max 3             | `1`     | no       |       |
| `gamma`      | number | min 0.1, max 10          | `1`     | no       |       |
| `container`  | string | `mp4` \| `webm` \| `mkv` | `"mp4"` | no       |       |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "color",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "brightness": 0,
      "contrast": 1,
      "saturation": 1
    }
  }
}
```

## concat

Join 2-10 inputs end-to-end, normalizing each to a common WxH canvas and 44.1kHz stereo (accepts mixed resolutions/formats).

**Inputs:** `input_urls` (2-10, must equal the `inputs` param; joined in array order) · **Output:** media — single file

| Parameter   | Type    | Constraints              | Default | Required | Notes                                |
| ----------- | ------- | ------------------------ | ------- | -------- | ------------------------------------ |
| `inputs`    | integer | min 2, max 10            | `2`     | no       | supply exactly this many input\_urls |
| `width`     | integer | min 16, no schema max    | `1280`  | no       | no upper bound in the schema         |
| `height`    | integer | min 16, no schema max    | `720`   | no       | same bounds as `width`               |
| `container` | string  | `mp4` \| `webm` \| `mkv` | `"mp4"` | no       |                                      |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "concat",
    "input_urls": [
      "YOUR_INPUT_URL_1",
      "YOUR_INPUT_URL_2"
    ],
    "params": {
      "inputs": 2,
      "width": 1280,
      "height": 720
    }
  }
}
```

## concat\_copy

Losslessly rejoin 2-50 already-encoded, **same-codec** chunks via the concat demuxer with -c copy (no re-encode).

**Inputs:** `input_urls` (2-50, must equal the `inputs` param; joined in array order) · **Output:** media — single file

<Warning>
  Chunks produced by stream-copy `trim` carry discard-flagged pre-roll back to the previous keyframe, which becomes **visible** after `concat_copy` (measured +1.35s on a 30s target) — use [keep\_ranges](#keep_ranges) for frame-accurate multi-range editing.
</Warning>

| Parameter   | Type    | Constraints            | Default | Required | Notes                                                   |
| ----------- | ------- | ---------------------- | ------- | -------- | ------------------------------------------------------- |
| `inputs`    | integer | min 2, max 50          | `2`     | no       |                                                         |
| `container` | string  | `mp4` \| `mkv` \| `ts` | `"mp4"` | no       | must be able to carry the chunks' codecs (no re-encode) |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "concat_copy",
    "input_urls": [
      "YOUR_INPUT_URL_1",
      "YOUR_INPUT_URL_2"
    ],
    "params": {
      "inputs": 2,
      "container": "mp4"
    }
  }
}
```

## crop

Cut a width x height rectangle out of the frame (centered unless x/y given).

**Inputs:** `input_url` (1) · **Output:** media — single file

| Parameter   | Type    | Constraints              | Default | Required | Notes          |
| ----------- | ------- | ------------------------ | ------- | -------- | -------------- |
| `width`     | integer | min 1, max 8192          | `640`   | no       |                |
| `height`    | integer | min 1, max 8192          | `480`   | no       |                |
| `x`         | integer | min 0, max 8192          | —       | no       | omit to center |
| `y`         | integer | min 0, max 8192          | —       | no       | omit to center |
| `container` | string  | `mp4` \| `webm` \| `mkv` | `"mp4"` | no       |                |

<Note>
  The window is **not checked against the source frame**: a `width`/`height` (or `x`/`y` offset) that exceeds the source dimensions passes the [\$0 probe gate](#what-the-probe-gate-checks) and fails at execution as `exec_error` (verified against production). [Probe](#probe) the source first when its dimensions are uncertain.
</Note>

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "crop",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "width": 640,
      "height": 480,
      "container": "mp4"
    }
  }
}
```

## crossfade

Crossfade two clips into one: the video transition starts `offset` seconds into the first clip and blends into the second over `duration` seconds (xfade); the audio is always crossfaded at the junction of the two audio tracks over the same `duration` (acrossfade ignores `offset`).

**Inputs:** `input_urls` (2) — joined in order, first into second · **Output:** media — single file

| Parameter    | Type   | Constraints                                                                                                                                                                                                                     | Default  | Required | Notes                                                                                                                                                                                                                                                                |
| ------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `transition` | string | `circleclose` \| `circleopen` \| `dissolve` \| `fade` \| `fadeblack` \| `fadewhite` \| `pixelize` \| `radial` \| `slidedown` \| `slideleft` \| `slideright` \| `slideup` \| `wipedown` \| `wipeleft` \| `wiperight` \| `wipeup` | `"fade"` | no       | which xfade wipe/blend the video transition uses; the audio always uses a plain acrossfade regardless                                                                                                                                                                |
| `duration`   | number | min 0.1, max 10                                                                                                                                                                                                                 | `1`      | no       | transition length in seconds, shared by the video xfade and the audio acrossfade; must not exceed the first input's probed duration (rejected at \$0; unchecked when the duration is unknown)                                                                        |
| `offset`     | number | min 0, max 86400                                                                                                                                                                                                                | —        | no       | seconds into the first input where the video transition starts; omit for the junction (first input duration − `duration`, derived from the probe). `offset` + `duration` must not exceed the first input's probed duration (rejected at \$0; unchecked when unknown) |
| `container`  | string | `mp4` \| `webm` \| `mkv`                                                                                                                                                                                                        | `"mp4"`  | no       |                                                                                                                                                                                                                                                                      |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "crossfade",
    "input_urls": [
      "YOUR_INPUT_URL_1",
      "YOUR_INPUT_URL_2"
    ],
    "params": {
      "transition": "fade",
      "duration": 1
    }
  }
}
```

<Note>
  * With `offset` omitted, video and audio cross at the same junction and the output runs first + second − `duration` seconds. When the first input's duration cannot be probed (direct bindings), an omitted `offset` is rejected at \$0 — supply it explicitly. An `offset` earlier than the junction switches the video to the second clip early while the audio still crosses at the junction, so the audio track outlasts the video track.
  * Both inputs must contain a video **and** an audio stream — an input missing either is rejected at \$0 before billing.
  * The second input's video is scaled (stretched, square pixels) to the probed size of the first input; when that size is unknown the input sizes must already match or the run fails at execution. Both inputs must share a frame rate — mismatched rates fail at execution; container timebases are normalized in-graph, so inputs with identical frame rates but different encoder timescales are safe.
  * A second input shorter than `duration` truncates the transition. Video and audio are always re-encoded (audio becomes AAC for mp4/mkv or Opus for webm); nothing is stream-copied.
</Note>

## extract\_audio

Drop the video stream and write the audio track in the requested format (STT-ready via sample\_rate/mono).

**Inputs:** `input_url` (1) · **Output:** media — single audio file (mp3/wav/m4a)

| Parameter     | Type    | Constraints             | Default | Required | Notes                                                                                                                                            |
| ------------- | ------- | ----------------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `format`      | string  | `mp3` \| `wav` \| `m4a` | `"mp3"` | no       |                                                                                                                                                  |
| `sample_rate` | integer | min 8000, max 48000     | —       | no       | resample in Hz (e.g. 16000 for STT); omit keeps the source rate — mp3 caps at 48 kHz, so a source above that is resampled down even when omitted |
| `mono`        | boolean |                         | `false` | no       | downmix to one channel                                                                                                                           |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "extract_audio",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "format": "mp3",
      "mono": false
    }
  }
}
```

## fade

Video fade-in and/or fade-out with matching audio afade.

**Inputs:** `input_url` (1) · **Output:** media — single file

| Parameter           | Type   | Constraints              | Default | Required                    | Notes                                                              |
| ------------------- | ------ | ------------------------ | ------- | --------------------------- | ------------------------------------------------------------------ |
| `type`              | string | `in` \| `out` \| `both`  | `"in"`  | no                          |                                                                    |
| `fade_in_duration`  | number | max 3600, > 0            | `1.0`   | no                          | must be > 0                                                        |
| `fade_out_duration` | number | max 3600, > 0            | `1.0`   | no                          | must be > 0                                                        |
| `fade_out_start`    | number | min 0, max 86400         | —       | when `type` is `out`/`both` | zero-bill reject when >= probed source duration (fade never fires) |
| `container`         | string | `mp4` \| `webm` \| `mkv` | `"mp4"` | no                          |                                                                    |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "fade",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "type": "both",
      "fade_in_duration": 1.0,
      "fade_out_duration": 1.0,
      "fade_out_start": 10
    }
  }
}
```

## gif

Convert a clip to an animated GIF at a given fps and width (lanczos, single pass).

**Inputs:** `input_url` (1) · **Output:** media — single output.gif

| Parameter | Type    | Constraints          | Default | Required | Notes                    |
| --------- | ------- | -------------------- | ------- | -------- | ------------------------ |
| `fps`     | integer | min 1, max 50        | `10`    | no       |                          |
| `width`   | integer | min 1, no schema max | `480`   | no       | height keeps aspect (-1) |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "gif",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "fps": 10,
      "width": 480
    }
  }
}
```

## hls\_ladder

Encode a multi-rendition adaptive-bitrate HLS ladder (master.m3u8 + N variant playlists/segments) in one pass. Re-encodes each rung; upscaling is **not** rejected.

**Inputs:** `input_url` (1) · **Output:** bundle `{"primary": master.m3u8, "artifacts": [...]}` in `output` — master.m3u8 + stream\_N.m3u8 variants + shared TS segments — see [Response shapes](#response-shapes)

| Parameter          | Type    | Constraints                                                                                                                                     | Default | Required | Notes                                                                                                                                                                                            |
| ------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `renditions`       | array   | 1-6 items; each item requires `height` (`144` \| `240` \| `360` \| `480` \| `720` \| `1080` \| `1440` \| `2160`) and `bitrate_kbps` (100-50000) | —       | no       | mutually exclusive with ladder; duplicate heights rejected                                                                                                                                       |
| `ladder`           | string  | `240p` \| `360p` \| `480p` \| `720p` \| `1080p`                                                                                                 | —       | no       | exact rungs (height\@kbps): 240p = 240\@600; 360p = 360\@1000, 240\@600; 480p = 480\@1500, 360\@1000, 240\@600; 720p = 720\@3000, 480\@1500, 360\@1000; 1080p = 1080\@5000, 720\@3000, 480\@1500 |
| `segment_duration` | integer | min 1, max 30                                                                                                                                   | `4`     | no       |                                                                                                                                                                                                  |

<Note>
  Omitting both `renditions` and `ladder` applies the default `720p` preset (720\@3000, 480\@1500, 360\@1000). Supplying both is rejected at \$0 — they are mutually exclusive.
</Note>

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "hls_ladder",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "renditions": [
        { "height": 720, "bitrate_kbps": 2800 },
        { "height": 360, "bitrate_kbps": 800 }
      ],
      "segment_duration": 4
    }
  }
}
```

## keep\_ranges

Keep only the supplied time ranges and close the gaps into one continuous, frame-accurate output (re-encodes).

**Two accepted names, one capability.** `keep_ranges` and `silence_remove` are the same capability under two wire names with byte-identical contracts — submit either. `silence_remove` is the original name (the 'act' half of the silence\_detect → act chain); `keep_ranges` says what it does. Everything below applies to both.

**Inputs:** `input_url` (1) · **Output:** media — single file

| Parameter   | Type   | Constraints                                                                                        | Default | Required | Notes                                                                                                                                                                       |
| ----------- | ------ | -------------------------------------------------------------------------------------------------- | ------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ranges`    | array  | 1-100 items; each item requires `start` and `end` (seconds, min 0, no schema max; `end` > `start`) | —       | yes      | ranges to **keep**, sorted ascending, non-overlapping; unsorted/overlapping/empty rejected; zero-bill reject when all ranges start past EOF or total kept window \< 1 frame |
| `container` | string | `mp4` \| `webm` \| `mkv`                                                                           | `"mp4"` | no       |                                                                                                                                                                             |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "keep_ranges",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "ranges": [
        { "start": 0.0, "end": 4.2 },
        { "start": 9.7, "end": 15.0 }
      ],
      "container": "mp4"
    }
  }
}
```

## loudnorm

Normalize audio loudness to an EBU R128 target (single-pass); video stream-copied.

**Inputs:** `input_url` (1) · **Output:** media — single file

| Parameter   | Type   | Constraints     | Default | Required | Notes                              |
| ----------- | ------ | --------------- | ------- | -------- | ---------------------------------- |
| `target_i`  | number | min -70, max -5 | `-14`   | no       | LUFS; -14 streaming, -23 broadcast |
| `true_peak` | number | min -9, max 0   | `-1`    | no       | dBTP                               |
| `lra`       | number | min 1, max 50   | `11`    | no       | loudness range in LU               |
| `container` | string | `mp4` \| `mkv`  | `"mp4"` | no       |                                    |

<Note>
  * The input must carry an audio stream — a video-only input is rejected at \$0 before billing. An **audio-only input** (e.g. an `.mp3`) **is accepted**: the `-c:v copy` is a no-op with no video stream present, and the output is an audio-only mp4/mkv.
  * The audio track is re-encoded to AAC regardless of its source codec, and the output sample rate is **not preserved**: loudnorm internally resamples to 192 kHz and no rate reset is applied, so the AAC output lands at 96 kHz (the highest rate AAC accepts), not the source rate.
  * Normalization is single-pass, so the output's measured integrated loudness can deviate from `target_i` — especially on short or highly dynamic inputs. For mastering-grade accuracy use the two-pass pattern under [run\_ffmpeg](#run_ffmpeg).
  * loudnorm outputs mp4/mkv video containers only — it never emits `.mp3`. For audio-in/audio-out mastering (e.g. MP3 to a -14 LUFS MP3) use [audio\_master](#audio_master), the first-class MP3/WAV/M4A path (note it always applies a fixed compressor); [run\_ffmpeg](#run_ffmpeg) remains the escape hatch for custom chains and two-pass accuracy.
</Note>

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "loudnorm",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "target_i": -14,
      "true_peak": -1,
      "lra": 11
    }
  }
}
```

## lut3d

Color-grade through a caller-supplied .cube 3D LUT (lut3d filter).

**Inputs:** `input_urls` (2) — `input_urls[0]` is the video, `input_urls[1]` is the `.cube` LUT file · **Output:** media — single file

| Parameter   | Type   | Constraints                               | Default | Required | Notes                                 |
| ----------- | ------ | ----------------------------------------- | ------- | -------- | ------------------------------------- |
| `interp`    | string | `nearest` \| `trilinear` \| `tetrahedral` | —       | no       | omit for ffmpeg default (tetrahedral) |
| `container` | string | `mp4` \| `webm` \| `mkv`                  | `"mp4"` | no       |                                       |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "lut3d",
    "input_urls": [
      "YOUR_INPUT_URL_1",
      "YOUR_INPUT_URL_2"
    ],
    "params": {
      "container": "mp4"
    }
  }
}
```

## overlay

Composite a second video/image over a base at a named corner/center anchor, with optional margin and pre-scale.

**Inputs:** `input_urls` (2) — `input_urls[0]` is the base video, `input_urls[1]` is the overlay (video or image) · **Output:** media — single file

| Parameter   | Type   | Constraints                                                              | Default          | Required | Notes                                                     |
| ----------- | ------ | ------------------------------------------------------------------------ | ---------------- | -------- | --------------------------------------------------------- |
| `position`  | string | `top_left` \| `top_right` \| `bottom_left` \| `bottom_right` \| `center` | `"bottom_right"` | no       |                                                           |
| `margin`    | number | min 0, max 1024                                                          | `0`              | no       | ignored for center                                        |
| `scale`     | number | min 0.01, max 10                                                         | —                | no       | resize overlay before compositing; omit keeps native size |
| `container` | string | `mp4` \| `webm` \| `mkv`                                                 | `"mp4"`          | no       |                                                           |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "overlay",
    "input_urls": [
      "YOUR_INPUT_URL_1",
      "YOUR_INPUT_URL_2"
    ],
    "params": {
      "position": "bottom_right",
      "margin": 0,
      "container": "mp4"
    }
  }
}
```

## package\_abr

Package an input into a single-rendition CMAF HLS + DASH adaptive-bitrate bundle (clear, no DRM). Stream-copies — never re-encodes.

**Inputs:** `input_url` (1) · **Output:** bundle `{"primary": master.m3u8, "artifacts": [...]}` in `output` — manifest.mpd + master.m3u8 + fmp4 segments — see [Response shapes](#response-shapes)

| Parameter          | Type    | Constraints   | Default | Required | Notes |
| ------------------ | ------- | ------------- | ------- | -------- | ----- |
| `segment_duration` | integer | min 1, max 30 | `4`     | no       |       |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "package_abr",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "segment_duration": 4
    }
  }
}
```

## package\_abr\_ladder

Encode a **true** multi-rendition CMAF HLS + DASH ladder (re-encodes every rung, any source codec). When the source height is probeable, upscaling is rejected and the default ladder is clipped to source height; when the source cannot be probed, the ladder is **not** clipped (fail-open, may upscale).

**Inputs:** `input_url` (1) · **Output:** bundle `{"primary": master.m3u8, "artifacts": [...]}` in `output` — master.m3u8 + manifest.mpd over shared fmp4 segments — see [Response shapes](#response-shapes)

| Parameter          | Type    | Constraints                                                                                                                   | Default | Required | Notes                                                                                                                                                                                                                                                                                                                                                                                |
| ------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `renditions`       | array   | 1-6 items; each item requires `height` (`360` \| `480` \| `720` \| `1080` \| `1440` \| `2160`) and `bitrate_kbps` (100-50000) | —       | no       | mutually exclusive with ladder; duplicate heights rejected; height above source rejected (no upscaling) when the source is probeable — unprobeable source skips the check                                                                                                                                                                                                            |
| `ladder`           | string  | `360p` \| `480p` \| `720p` \| `1080p` \| `1440p` \| `2160p`                                                                   | —       | no       | preset rung set, clipped to source height when probeable (unclipped if the probe fails). Exact rungs (height\@kbps): 360p = 360\@1000; 480p = 480\@1500, 360\@1000; 720p = 720\@3000, 480\@1500, 360\@1000; 1080p = 1080\@5000, 720\@3000, 480\@1500; 1440p = 1440\@8000, 1080\@5000, 720\@3000; 2160p = 2160\@16000, 1080\@5000, 720\@3000 (2160p does **not** include a 1440 rung) |
| `segment_duration` | integer | min 1, max 30                                                                                                                 | `4`     | no       |                                                                                                                                                                                                                                                                                                                                                                                      |

<Note>
  Omitting both `renditions` and `ladder` applies the default ladder — 1080\@5000, 720\@2800, 480\@1200 — clipped to the source height when probeable. Supplying both is rejected at \$0 — they are mutually exclusive.
</Note>

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "package_abr_ladder",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "renditions": [
        { "height": 1080, "bitrate_kbps": 5000 },
        { "height": 480, "bitrate_kbps": 1200 }
      ],
      "segment_duration": 4
    }
  }
}
```

## pad

Frame the picture inside a width x height canvas filled with a background color.

**Inputs:** `input_url` (1) · **Output:** media — single file

| Parameter   | Type    | Constraints              | Default   | Required | Notes                                                                                        |
| ----------- | ------- | ------------------------ | --------- | -------- | -------------------------------------------------------------------------------------------- |
| `width`     | integer | min 1, max 8192          | `1280`    | no       |                                                                                              |
| `height`    | integer | min 1, max 8192          | `720`     | no       |                                                                                              |
| `x`         | integer | min 0, max 8192          | —         | no       | omit to center                                                                               |
| `y`         | integer | min 0, max 8192          | —         | no       | omit to center                                                                               |
| `color`     | string  |                          | `"black"` | no       | named color (black,white,gray,grey,red,green,blue,yellow) or #RRGGBB; invalid value rejected |
| `container` | string  | `mp4` \| `webm` \| `mkv` | `"mp4"`   | no       |                                                                                              |

<Note>
  `x`/`y` are **not checked against the canvas**: an offset that pushes the picture outside `width`×`height` passes the [\$0 probe gate](#what-the-probe-gate-checks) and fails at execution as `exec_error`.
</Note>

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "pad",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "width": 1280,
      "height": 720,
      "color": "black"
    }
  }
}
```

## poster

One input -> two named outputs in one pass: a transcoded H.264 MP4 ('video') and a still-frame JPEG ('thumbnail').

**Inputs:** `input_url` (1) · **Output:** named-outputs object `{"video": ..., "thumbnail": ...}` in `output` — see [Response shapes](#response-shapes)

| Parameter          | Type    | Constraints      | Default | Required | Notes                                           |
| ------------------ | ------- | ---------------- | ------- | -------- | ----------------------------------------------- |
| `crf`              | integer | min 0, max 51    | `23`    | no       |                                                 |
| `thumbnail_second` | integer | min 0, max 36000 | `0`     | no       | zero-bill reject when >= probed source duration |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "poster",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "crf": 23,
      "thumbnail_second": 0
    }
  }
}
```

## probe

Inspect an input with ffprobe and return the JSON metadata (format + streams) as the structured result — no media artifact, no egress.

**Inputs:** `input_url` (1) · **Output:** analysis JSON — the ffprobe document (`format` + `streams`) returned directly in `output`; no uploaded artifact

No parameters — an empty `params` object is valid.

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "probe",
    "input_url": "YOUR_INPUT_URL",
    "params": {}
  }
}
```

## reframe

Convert a clip to a social aspect ratio via scale+crop (fill) or scale+pad (fit).

**Inputs:** `input_url` (1) · **Output:** media — single file at the preset resolution

| Parameter   | Type   | Constraints                        | Default   | Required | Notes                                                        |
| ----------- | ------ | ---------------------------------- | --------- | -------- | ------------------------------------------------------------ |
| `aspect`    | string | `9:16` \| `1:1` \| `4:5` \| `16:9` | `"9:16"`  | no       | 9:16=1080x1920, 1:1=1080x1080, 4:5=1080x1350, 16:9=1920x1080 |
| `mode`      | string | `crop` \| `pad`                    | `"crop"`  | no       |                                                              |
| `pad_color` | string | `black` \| `white`                 | `"black"` | no       | pad mode only                                                |
| `container` | string | `mp4` \| `webm` \| `mkv`           | `"mp4"`   | no       |                                                              |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "reframe",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "aspect": "9:16",
      "mode": "crop",
      "pad_color": "black"
    }
  }
}
```

## reframe\_track

Pan a fixed-size crop window across the frame following a subject, driven by a crop-track sidecar (sendcmd script).

**Inputs:** `input_urls` (2) — `input_urls[0]` is the source video, `input_urls[1]` is the crop-track sidecar (sendcmd script) · **Output:** media — single file

<Warning>**Experimental.** `reframe_track` ships only the execution leg today: the auto-tracking model that would generate crop tracks for you is not yet available, so you must supply your own sendcmd tracking sidecar as `input_urls[1]` (format below). If you cannot produce a crop track yourself, use `reframe` (static crop/pad) instead. The sidecar contract on this page is stable and will not change when auto-tracking lands.</Warning>

**The sidecar.** `input_urls[1]` is a raw ffmpeg [sendcmd](https://ffmpeg.org/ffmpeg-filters.html#sendcmd_002c-asendcmd) script the engine splices in as `sendcmd=f=<sidecar>,crop=W:H`. One command per line:

```text theme={"dark"}
0.0 crop x 16, crop y 16;
2.5 crop x 240, crop y 16;
5.0 crop x 480, crop y 120;
```

* `<seconds> crop x <px>, crop y <px>;` — the timestamp in seconds, then the pixel coordinates of the crop window's **top-left corner** relative to the source frame.
* The window is `width`x`height` (fixed by params), starts centered, and values **step-hold** between timestamps — there is no interpolation.
* The script is **not parsed or validated server-side**: a malformed sidecar fails at execution as a metered `exec_error` (settles \$0 in beta), not a pre-run reject. Validate it locally before submitting.

| Parameter   | Type    | Constraints              | Default | Required | Notes                    |
| ----------- | ------- | ------------------------ | ------- | -------- | ------------------------ |
| `width`     | integer | min 1, max 8192          | `640`   | no       | crop window width in px  |
| `height`    | integer | min 1, max 8192          | `360`   | no       | crop window height in px |
| `container` | string  | `mp4` \| `webm` \| `mkv` | `"mp4"` | no       |                          |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "reframe_track",
    "input_urls": [
      "YOUR_INPUT_URL_1",
      "YOUR_INPUT_URL_2"
    ],
    "params": {
      "width": 640,
      "height": 360,
      "container": "mp4"
    }
  }
}
```

## reverse

Play the clip backwards (video reverse + audio areverse). Buffers the whole clip — suits short inputs.

**Inputs:** `input_url` (1) · **Output:** media — single file

| Parameter   | Type   | Constraints              | Default | Required | Notes |
| ----------- | ------ | ------------------------ | ------- | -------- | ----- |
| `container` | string | `mp4` \| `webm` \| `mkv` | `"mp4"` | no       |       |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "reverse",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "container": "mp4"
    }
  }
}
```

## rotate

Rotate the picture in fixed 90-degree clockwise steps (transpose, no interpolation).

**Inputs:** `input_url` (1) · **Output:** media — single file

| Parameter   | Type    | Constraints              | Default | Required | Notes |
| ----------- | ------- | ------------------------ | ------- | -------- | ----- |
| `degrees`   | integer | `90` \| `180` \| `270`   | `90`    | no       |       |
| `container` | string  | `mp4` \| `webm` \| `mkv` | `"mp4"` | no       |       |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "rotate",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "degrees": 90,
      "container": "mp4"
    }
  }
}
```

## scale

Resize video to a target width/height, keeping audio.

**Inputs:** `input_url` (1) · **Output:** media — single file

| Parameter   | Type    | Constraints              | Default | Required | Notes                                                                                                                                  |
| ----------- | ------- | ------------------------ | ------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `width`     | integer | min 1, max 8192          | `1280`  | no       |                                                                                                                                        |
| `height`    | integer |                          | `-2`    | no       | valid set \{-2,-1} ∪ \[1,8192]: -2 keeps aspect rounded even (h264-safe), -1 keeps aspect, positive = explicit; anything else rejected |
| `container` | string  | `mp4` \| `webm` \| `mkv` | `"mp4"` | no       |                                                                                                                                        |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "scale",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "width": 1280,
      "height": -2,
      "container": "mp4"
    }
  }
}
```

## scene\_detect

Detect hard cuts and return the structured scene list as the result — a metadata analysis, not a media artifact.

**Inputs:** `input_url` (1) · **Output:** analysis JSON — `{"scenes": [{time, score}]}` in `output` (`time` in seconds, `score` in 0-1); no uploaded artifact, zero egress

| Parameter   | Type   | Constraints  | Default | Required | Notes                 |
| ----------- | ------ | ------------ | ------- | -------- | --------------------- |
| `threshold` | number | min 0, max 1 | `0.3`   | no       | lower finds more cuts |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "scene_detect",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "threshold": 0.3
    }
  }
}
```

## segment

Split input into fixed-duration chunks (seg\_000, seg\_001, ...) — multi-output. With `first_frame: true` it also delivers each clip's first frame as a companion still in the same call.

**Inputs:** `input_url` (1) · **Output:** media — URL array in `output`, one entry per chunk, index-ordered (`seg_000`, `seg_001`, …); with `first_frame: true` the stills are interleaved with their clips (clip, still, clip, still, … in index order) — see [Response shapes](#response-shapes)

<Note>`reencode: true` with `container: "webm"` is rejected — H.264 cannot mux into webm.</Note>

| Parameter         | Type    | Constraints              | Default | Required | Notes                                                                                                                                                                                                                                                                                                                                        |
| ----------------- | ------- | ------------------------ | ------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `segment_seconds` | integer | min 1, no schema max     | `10`    | no       |                                                                                                                                                                                                                                                                                                                                              |
| `container`       | string  | `mp4` \| `webm` \| `mkv` | `"mp4"` | no       |                                                                                                                                                                                                                                                                                                                                              |
| `reencode`        | boolean |                          | `false` | no       | false: stream-copy (cuts only on keyframes, may yield fewer/longer segments); true: cuts accurate to within one frame via forced keyframes, re-encodes H.264                                                                                                                                                                                 |
| `crf`             | integer | min 0, max 51            | `23`    | no       | reencode=true only                                                                                                                                                                                                                                                                                                                           |
| `first_frame`     | boolean |                          | `false` | no       | true also delivers each clip's first frame as a companion still (`seg_N_first.jpg`\|`png`), interleaved with the clips in `output`. Requires a video stream on the input (audio-only rejected at \$0 when set). With stream copy the clip boundaries drift to keyframes, so each first frame is boundary-accurate only with `reencode: true` |
| `frame_format`    | string  | `jpg` \| `png`           | `"jpg"` | no       | companion-still format; validated whenever supplied, applied only when `first_frame` is true                                                                                                                                                                                                                                                 |

<Note>
  A `first_frame` split that produces **more than 999 clips** fails the whole job after the split has run (the numbered names stop pairing with their stills) — surfaced as `platform_drift` at \$0. Raise `segment_seconds` so the clip count stays at or under 999.
</Note>

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "segment",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "segment_seconds": 30,
      "reencode": true,
      "first_frame": true,
      "frame_format": "jpg"
    }
  }
}
```

## silence\_detect

Detect silent spans in the audio and return the structured list as the result. No silence found = valid empty result.

**Inputs:** `input_url` (1) · **Output:** analysis JSON — `{"silences": [{start, end, duration}]}` in `output` (all fields in seconds); no artifact, zero egress

| Parameter             | Type    | Constraints    | Default | Required | Notes                  |
| --------------------- | ------- | -------------- | ------- | -------- | ---------------------- |
| `noise_db`            | integer | min -80, max 0 | `-30`   | no       | dBFS silence threshold |
| `min_silence_seconds` | number  | max 600, > 0   | `0.5`   | no       |                        |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "silence_detect",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "noise_db": -30,
      "min_silence_seconds": 0.5
    }
  }
}
```

## silence\_split

Detect silences and split the file at them in **one job** — `silence_detect` plus the split, with hard caps, replacing the detect → compute-cuts-client-side → one-trim-per-segment chain (see [Recipes](#recipes)).

**Inputs:** `input_url` (1) · **Output:** media — URL array in `output`, one entry per segment, index-ordered (`seg_000`, `seg_001`, …) — see [Response shapes](#response-shapes)

| Parameter             | Type    | Constraints                                  | Default | Required | Notes                                                                                                                                                                                                                                                                                                   |
| --------------------- | ------- | -------------------------------------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `noise_db`            | integer | min -80, max 0                               | `-30`   | no       | dBFS silence threshold: samples quieter than this count as silence                                                                                                                                                                                                                                      |
| `min_silence_seconds` | number  | max 600, > 0                                 | `0.5`   | no       | shortest span treated as a silence                                                                                                                                                                                                                                                                      |
| `max_segment_seconds` | number  | min 1, max 86400                             | —       | **yes**  | hard cap on segment length: when the next usable silence is further away, cuts are forced at exactly this spacing                                                                                                                                                                                       |
| `min_segment_seconds` | number  | min 0, must not exceed `max_segment_seconds` | `0`     | no       | a silence cut that would close a segment shorter than this is skipped — the segment keeps growing until the next usable silence or the forced cap cut. The default 0 allows arbitrarily short segments when silences cluster                                                                            |
| `container`           | string  | `mp4` \| `webm` \| `mkv`                     | `"mp4"` | no       |                                                                                                                                                                                                                                                                                                         |
| `reencode`            | boolean |                                              | `false` | no       | false: stream-copies the split (near-instant, lossless) but cuts snap to the keyframe **at or after** each computed time — a segment can exceed the cap by up to one keyframe interval; true: forces a keyframe at every cut for exact boundaries, re-encodes H.264 (webm cannot hold H.264 — rejected) |
| `crf`                 | integer | min 0, max 51                                | `23`    | no       | reencode=true only; ignored for stream copy                                                                                                                                                                                                                                                             |

<Note>
  * Each cut lands at the **end** of a detected silence: the silence stays attached to the preceding segment and each new segment starts where sound resumes. A source with no qualifying silence and no forced cap cut comes back as a single file.
  * The input must carry an audio stream — rejected at \$0 otherwise.
  * When the source duration cannot be probed, the span after the last detected silence is never force-cut (fail-open): the final segment — or the whole file, when no silence qualifies — can come back longer than the cap.
  * Verified in production: a 90 s source with silences ending at 13 / 22.5 / 58 / 62 / 70.5 s and `max_segment_seconds: 30` returns exactly 7 segments — the same cuts as the manual recipe below, in one billed job instead of eight.
</Note>

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "silence_split",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "max_segment_seconds": 30,
      "min_segment_seconds": 2
    }
  }
}
```

## slideshow

Turn 2-50 still images into one silent video (each looped image\_duration, normalized to a common canvas, optional Ken Burns zoom, concatenated).

**Inputs:** `input_urls` (2-50, must equal the `images` param; shown in array order) · **Output:** media — single silent video

| Parameter        | Type    | Constraints              | Default  | Required | Notes                                |
| ---------------- | ------- | ------------------------ | -------- | -------- | ------------------------------------ |
| `images`         | integer | min 2, max 50            | `3`      | no       | supply exactly this many input\_urls |
| `image_duration` | number  | min 0.5, max 30          | `3`      | no       |                                      |
| `fps`            | integer | min 1, max 60            | `30`     | no       |                                      |
| `effect`         | string  | `none` \| `kenburns`     | `"none"` | no       |                                      |
| `container`      | string  | `mp4` \| `webm` \| `mkv` | `"mp4"`  | no       |                                      |
| `width`          | integer | min 16, max 8192         | `1280`   | no       |                                      |
| `height`         | integer | min 16, max 8192         | `720`    | no       |                                      |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "slideshow",
    "input_urls": [
      "YOUR_INPUT_URL_1",
      "YOUR_INPUT_URL_2"
    ],
    "params": {
      "images": 2,
      "image_duration": 3,
      "fps": 30
    }
  }
}
```

## speed

Change playback speed by a factor (video setpts + pitch-preserving atempo chain).

**Inputs:** `input_url` (1) · **Output:** media — single file

| Parameter   | Type   | Constraints              | Default | Required | Notes                 |
| ----------- | ------ | ------------------------ | ------- | -------- | --------------------- |
| `factor`    | number | min 0.25, max 4.0        | `2.0`   | no       | >1 faster, \<1 slower |
| `container` | string | `mp4` \| `webm` \| `mkv` | `"mp4"` | no       |                       |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "speed",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "factor": 2.0,
      "container": "mp4"
    }
  }
}
```

## sticker\_overlay

Composite the second input (typically a still image: a sticker, logo, or watermark) over the base video, visible only inside a timed window — [overlay](#overlay)'s anchors plus `start`/`end` visibility.

**Inputs:** `input_urls` (2) — `input_urls[0]` is the base video, `input_urls[1]` is the sticker (image or video) · **Output:** media — single file

| Parameter   | Type   | Constraints                                                              | Default          | Required | Notes                                                                                                                                                                                                                                |
| ----------- | ------ | ------------------------------------------------------------------------ | ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `start`     | number | min 0, max 3600                                                          | —                | **yes**  | second of the base video the sticker appears (inclusive); must be \< `end` when `end` is supplied. Zero-bill reject when at/past the probed source duration (the sticker would never appear)                                         |
| `end`       | number | min 0, max 3600                                                          | —                | no       | second the sticker disappears; omit to keep it visible until the end of the source. Must be > `start`; a value past the probed source duration is rejected at \$0 — **never silently clamped** (omit `end` to run to the source end) |
| `position`  | string | `top_left` \| `top_right` \| `bottom_left` \| `bottom_right` \| `center` | `"bottom_right"` | no       | corners offset by margin; center ignores margin                                                                                                                                                                                      |
| `margin`    | number | min 0, max 1024                                                          | `0`              | no       | inset in pixels from the anchored edge(s); ignored for center                                                                                                                                                                        |
| `scale`     | number | min 0.05, max 4                                                          | `1`              | no       | resize the sticker before compositing (bicubic, aspect preserved); the default 1 composites at native size with no resample pass                                                                                                     |
| `container` | string | `mp4` \| `webm` \| `mkv`                                                 | `"mp4"`          | no       |                                                                                                                                                                                                                                      |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "sticker_overlay",
    "input_urls": [
      "YOUR_INPUT_URL_1",
      "YOUR_INPUT_URL_2"
    ],
    "params": {
      "start": 2,
      "end": 8,
      "position": "bottom_right",
      "margin": 24,
      "scale": 0.5
    }
  }
}
```

<Note>
  * The window only shows or hides the sticker — an **animated** sticker plays on the base's timeline from t=0, not from `start`: an animation shorter than `start` shows only its frozen last frame inside the window; one that ends before `end` stays frozen on its last frame for the remainder; one that runs longer has its extra tail dropped. A still image is unaffected.
  * No opacity control — the sticker's own alpha channel (e.g. a transparent PNG) is honored, and fully opaque pixels stay fully opaque. A sticker larger than the frame (natively or after `scale`) is composited with the out-of-frame overflow cropped; the output always keeps the base's resolution and duration.
  * Only the base's audio is kept (a silent base still succeeds); the sticker input's audio is discarded. Both inputs must contain a video stream (a still image counts) — an input without one is rejected at \$0 before billing.
  * The window checks compare against the source's **video stream** duration when it disagrees with the container duration; when the source cannot be probed the window is range-checked only. Video and audio are always re-encoded (audio becomes AAC for mp4/mkv or Opus for webm); nothing is stream-copied.
</Note>

## stills

Extract a single frame as JPEG or PNG (first, last, or at an exact timestamp).

**Inputs:** `input_url` (1) · **Output:** media — single image

| Parameter   | Type    | Constraints               | Default   | Required                | Notes                                           |
| ----------- | ------- | ------------------------- | --------- | ----------------------- | ----------------------------------------------- |
| `position`  | string  | `first` \| `last` \| `at` | `"first"` | no                      |                                                 |
| `timestamp` | number  | min 0, max 86400          | —         | when `position` is `at` | zero-bill reject when >= probed source duration |
| `format`    | string  | `jpg` \| `png`            | `"jpg"`   | no                      |                                                 |
| `quality`   | integer | min 2, max 31             | —         | no                      | JPEG only (2 best, 31 worst); ignored for png   |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "stills",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "position": "at",
      "timestamp": 12.5,
      "format": "jpg"
    }
  }
}
```

## storyboard\_sprites

Sample one frame per interval, pack into **one** tiled sprite sheet plus a server-computed WebVTT thumbnail index.

**Inputs:** `input_url` (1) · **Output:** named-outputs object `{"sheet": ..., "vtt": ...}` in `output` — see [Response shapes](#response-shapes)

| Parameter          | Type    | Constraints            | Default | Required | Notes |
| ------------------ | ------- | ---------------------- | ------- | -------- | ----- |
| `interval_seconds` | number  | min 0.1, no schema max | `2`     | no       |       |
| `tile_width`       | integer | min 16, max 8192       | `160`   | no       |       |
| `tile_height`      | integer | min 16, max 8192       | `90`    | no       |       |
| `columns`          | integer | min 1, max 20          | `5`     | no       |       |
| `rows`             | integer | min 1, max 20          | `5`     | no       |       |
| `format`           | string  | `jpg` \| `png`         | `"jpg"` | no       |       |

<Note>
  **Overflow truncates silently.** The sheet holds at most `columns` × `rows` frames: any part of the video past `columns × rows × interval_seconds` is omitted from both the sheet and the VTT index — no error, no second sheet, no interval stretch. The defaults (5 × 5 at 2 s) cover the first 50 s. Grid cells beyond the sampled frames are left blank. Size the grid or interval to the source duration ([probe](#probe) it first when unknown).
</Note>

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "storyboard_sprites",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "interval_seconds": 2,
      "tile_width": 160,
      "tile_height": 90
    }
  }
}
```

## subtitle\_compose

Word-timed, animated captions in the style popularized by TikTok and Shorts editors: you supply the transcript as individual words with millisecond timings, pick a style, and the engine authors the subtitle document server-side and burns it into the video. An optional **hook** — a short headline shown near the top of the frame — can run alongside the captions. No subtitle file is ever uploaded or fetched.

Word timings typically come from a speech-to-text model with word-level timestamps (for example `elevenlabs-speech-to-text-scribe-v2` on this platform); feed its word list straight into `words`.

**Inputs:** `input_url` (1) · **Output:** media — single file

| Parameter              | Type    | Constraints                                                                                                                                                                                                                                                                                                                                                                                              | Default             | Required | Notes                                                                                                                                                         |
| ---------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `words`                | array   | 1+ items, no schema max item count; each item requires `text` (string), `start_ms` and `end_ms` (integers 0-86400000)                                                                                                                                                                                                                                                                                    | —                   | yes      | the word-level timing track the captions animate over                                                                                                         |
| `hook`                 | object  | requires `text`, `start_ms`, `end_ms` (same bounds as words items)                                                                                                                                                                                                                                                                                                                                       | —                   | no       | optional headline shown above the captions                                                                                                                    |
| `style.font`           | string  | `Liberation Sans` \| `Liberation Serif` \| `Liberation Mono` \| `DejaVu Sans` \| `DejaVu Serif` \| `DejaVu Sans Mono` \| `Anton` \| `Archivo Black` \| `Bangers` \| `Inter` \| `Montserrat` \| `Montserrat Black` \| `Poppins` \| `TikTok Sans` \| `TikTok Sans Medium` \| `TikTok Display` \| `TikTok Display Medium` \| `TikTok Text` \| `TikTok Text Medium` \| `Public Sans` \| `Public Sans Medium` | `"Liberation Sans"` | no       |                                                                                                                                                               |
| `style.font_size_pct`  | integer | min 3, max 15                                                                                                                                                                                                                                                                                                                                                                                            | `5`                 | no       |                                                                                                                                                               |
| `style.hook_font`      | string  | `Liberation Sans` \| `Liberation Serif` \| `Liberation Mono` \| `DejaVu Sans` \| `DejaVu Serif` \| `DejaVu Sans Mono` \| `Anton` \| `Archivo Black` \| `Bangers` \| `Inter` \| `Montserrat` \| `Montserrat Black` \| `Poppins` \| `TikTok Sans` \| `TikTok Sans Medium` \| `TikTok Display` \| `TikTok Display Medium` \| `TikTok Text` \| `TikTok Text Medium` \| `Public Sans` \| `Public Sans Medium` | `"Liberation Sans"` | no       |                                                                                                                                                               |
| `style.hook_size_pct`  | integer | min 4, max 16                                                                                                                                                                                                                                                                                                                                                                                            | `6`                 | no       |                                                                                                                                                               |
| `style.text_color`     | string  |                                                                                                                                                                                                                                                                                                                                                                                                          | `"white"`           | no       | named palette (white,black,yellow,gold,lime,green,cyan,blue,magenta,pink,red,orange,purple) or #RRGGBB                                                        |
| `style.active_color`   | string  |                                                                                                                                                                                                                                                                                                                                                                                                          | `"gold"`            | no       |                                                                                                                                                               |
| `style.hook_color`     | string  |                                                                                                                                                                                                                                                                                                                                                                                                          | `"yellow"`          | no       |                                                                                                                                                               |
| `style.outline_color`  | string  |                                                                                                                                                                                                                                                                                                                                                                                                          | `"black"`           | no       |                                                                                                                                                               |
| `style.outline_width`  | integer | min 0, max 20                                                                                                                                                                                                                                                                                                                                                                                            | `7`                 | no       |                                                                                                                                                               |
| `style.shadow_depth`   | integer | min 0, max 10                                                                                                                                                                                                                                                                                                                                                                                            | `3`                 | no       |                                                                                                                                                               |
| `style.margin_v_pct`   | integer | min 5, max 40                                                                                                                                                                                                                                                                                                                                                                                            | `20`                | no       |                                                                                                                                                               |
| `style.animation`      | string  | `highlight` \| `fill`                                                                                                                                                                                                                                                                                                                                                                                    | `"highlight"`       | no       |                                                                                                                                                               |
| `style.hook_animation` | string  | `pop` \| `fade` \| `none`                                                                                                                                                                                                                                                                                                                                                                                | `"pop"`             | no       | hook entrance: `pop` = fade + scale pop (a long hook that wraps can re-wrap mid-animation); `fade` = fade-in only (never re-wraps); `none` = appears directly |
| `style.max_words`      | integer | min 1, max 6                                                                                                                                                                                                                                                                                                                                                                                             | `4`                 | no       |                                                                                                                                                               |
| `style.combine_ms`     | integer | min 0, max 2000                                                                                                                                                                                                                                                                                                                                                                                          | `450`               | no       |                                                                                                                                                               |
| `style.uppercase`      | boolean |                                                                                                                                                                                                                                                                                                                                                                                                          | `true`              | no       |                                                                                                                                                               |
| `container`            | string  | `mp4` \| `webm` \| `mkv`                                                                                                                                                                                                                                                                                                                                                                                 | `"mp4"`             | no       |                                                                                                                                                               |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "subtitle_compose",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "words": [
        { "text": "never", "start_ms": 0, "end_ms": 420 },
        { "text": "skip", "start_ms": 420, "end_ms": 800 },
        { "text": "leg", "start_ms": 800, "end_ms": 1100 },
        { "text": "day", "start_ms": 1100, "end_ms": 1600 }
      ],
      "hook": { "text": "GYM RULES", "start_ms": 0, "end_ms": 1600 },
      "style": {
        "font": "Montserrat Black",
        "hook_font": "Archivo Black",
        "hook_animation": "none",
        "active_color": "yellow",
        "animation": "highlight",
        "max_words": 3
      }
    }
  }
}
```

### Choosing a font

`Anton`, `Archivo Black`, `Bangers`, and `Montserrat Black` are heavy display faces built for short, loud, uppercase captions — the viral-video look. `Inter`, `Montserrat`, and `Poppins` are text faces that stay readable in longer sentences and calmer edits; the Liberation and DejaVu families are neutral defaults. For TikTok-style captions, `TikTok Sans` is the platform's own typeface in its 36pt display cut — `TikTok Display` is an alias of it (identical rendering) — and `TikTok Text` is the 12pt cut, built for small caption sizes; the `Medium` variants are the same cuts at medium weight. `Public Sans` is a neutral UI-style face. The same list is available for `style.font`, `style.hook_font`, and `title_card`'s `font`.

### Choosing a hook entrance

`pop` (the default) fades the hook in with a short scale pop. Scaling re-computes line wrapping every frame, so a hook long enough to wrap onto multiple lines can visibly re-wrap mid-animation — for long hooks prefer `fade` (fade-in, never re-wraps) or `none` (appears instantly).

### Style recipes

Ready-to-paste `style` objects for the three most-requested caption looks. Everything else (timing, paging, colors) can be tuned per the parameter table above.

**Bold viral caps** — heavy uppercase captions, active word flips to yellow:

```json theme={"dark"}
{
  "font": "Archivo Black",
  "hook_font": "Archivo Black",
  "hook_animation": "fade",
  "text_color": "white",
  "active_color": "yellow",
  "outline_width": 9,
  "shadow_depth": 4,
  "animation": "highlight",
  "max_words": 3,
  "uppercase": true
}
```

**Comic burst** — comic-style caps in short two-word bursts, active word in green:

```json theme={"dark"}
{
  "font": "Bangers",
  "hook_font": "Bangers",
  "hook_animation": "pop",
  "text_color": "white",
  "active_color": "lime",
  "outline_width": 10,
  "shadow_depth": 3,
  "animation": "highlight",
  "max_words": 2,
  "uppercase": true
}
```

**Clean karaoke** — sentence-case captions with a left-to-right color sweep, for podcasts and talking-head edits:

```json theme={"dark"}
{
  "font": "Poppins",
  "hook_font": "Poppins",
  "hook_animation": "none",
  "text_color": "white",
  "active_color": "gold",
  "outline_width": 5,
  "shadow_depth": 2,
  "animation": "fill",
  "max_words": 5,
  "uppercase": false
}
```

## thumbnails

Extract one image frame every interval\_seconds — multi-output.

**Inputs:** `input_url` (1) · **Output:** media — URL array in `output`, one entry per frame, index-ordered (`thumb_001`, `thumb_002`, …) — see [Response shapes](#response-shapes)

| Parameter          | Type   | Constraints            | Default | Required | Notes |
| ------------------ | ------ | ---------------------- | ------- | -------- | ----- |
| `interval_seconds` | number | min 0.1, no schema max | `2`     | no       |       |
| `format`           | string | `jpg` \| `png`         | `"jpg"` | no       |       |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "thumbnails",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "interval_seconds": 2,
      "format": "jpg"
    }
  }
}
```

## title\_card

Draw a text title over the single input video (drawtext) with an optional show window — the text is rendered from a staged file, never interpreted.

**Inputs:** `input_url` (1) · **Output:** media — single file

| Parameter    | Type    | Constraints                                                                                                                                                                                                                                                                                                                                                                                              | Default             | Required | Notes                                                                                                                                                                                                                                                                               |
| ------------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `text`       | string  | 1–200 chars after normalization                                                                                                                                                                                                                                                                                                                                                                          | —                   | **yes**  | drawn verbatim; newlines render as line breaks (multi-line titles supported). Normalization before rendering: CRLF/CR → LF, tabs → single spaces, other control characters removed, leading/trailing whitespace trimmed. Empty or over 200 chars after normalization rejects at \$0 |
| `font`       | string  | `Liberation Sans` \| `Liberation Serif` \| `Liberation Mono` \| `DejaVu Sans` \| `DejaVu Serif` \| `DejaVu Sans Mono` \| `Anton` \| `Archivo Black` \| `Bangers` \| `Inter` \| `Montserrat` \| `Montserrat Black` \| `Poppins` \| `TikTok Sans` \| `TikTok Sans Medium` \| `TikTok Display` \| `TikTok Display Medium` \| `TikTok Text` \| `TikTok Text Medium` \| `Public Sans` \| `Public Sans Medium` | `"Liberation Sans"` | no       | rendered from the value's baked font file bundled in the runtime image — never a fontconfig fallback                                                                                                                                                                                |
| `font_size`  | integer | min 12, max 288                                                                                                                                                                                                                                                                                                                                                                                          | `64`                | no       | text size in pixels                                                                                                                                                                                                                                                                 |
| `font_color` | string  |                                                                                                                                                                                                                                                                                                                                                                                                          | `"white"`           | no       | named (black, white, gray/grey, red, green, blue, yellow) or #RRGGBB hex                                                                                                                                                                                                            |
| `position`   | string  | `center` \| `top` \| `bottom` \| `top_left` \| `top_right` \| `bottom_left` \| `bottom_right`                                                                                                                                                                                                                                                                                                            | `"center"`          | no       | anchor for the text block; non-center anchors are inset from the frame edge by 5% of the frame dimension                                                                                                                                                                            |
| `start`      | number  | min 0, max 86400                                                                                                                                                                                                                                                                                                                                                                                         | —                   | no       | seconds when the title becomes visible; supplied alone it shows the title from `start` to the end of the video. Zero-bill reject when at/past the probed source duration (the title would never show)                                                                               |
| `end`        | number  | > 0, max 86400                                                                                                                                                                                                                                                                                                                                                                                           | —                   | no       | seconds when the title stops being visible; supplied alone it shows the title from the beginning until `end`. Must be > `start` when both are given; an `end` past the probed source duration rejects at \$0                                                                        |
| `container`  | string  | `mp4` \| `webm` \| `mkv`                                                                                                                                                                                                                                                                                                                                                                                 | `"mp4"`             | no       |                                                                                                                                                                                                                                                                                     |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "title_card",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "text": "Chapter One",
      "font_size": 96,
      "position": "center",
      "start": 0,
      "end": 4
    }
  }
}
```

<Note>
  * **Injection-safe by construction:** the text is staged as a server-generated text file and rendered with expansion disabled, so it is drawn verbatim — quotes, colons, backslashes, and ffmpeg `%{...}` expansion sequences have no effect on the command.
  * Omit both `start` and `end` to show the title for the whole video. When the source duration cannot be probed, the window is range-checked only.
  * The input must contain a video stream — rejected at \$0 before billing otherwise. Video and audio are always re-encoded (audio becomes AAC for mp4/mkv or Opus for webm); nothing is stream-copied.
</Note>

## transcode

Container/codec transcode with server-side normalization and defaults.

**Inputs:** `input_url` (1) · **Output:** media — single file, container-chosen extension

| Parameter   | Type    | Constraints                        | Default     | Required | Notes                                                      |
| ----------- | ------- | ---------------------------------- | ----------- | -------- | ---------------------------------------------------------- |
| `container` | string  | `mp4` \| `webm` \| `mkv`           | `"mp4"`     | no       |                                                            |
| `vcodec`    | string  | `h264` \| `vp9` \| `hevc` \| `av1` | `"h264"`    | no       | h264/hevc invalid in webm (rejected)                       |
| `acodec`    | string  | `aac` \| `opus`                    | —           | no       | omit keeps ffmpeg's container default; aac invalid in webm |
| `crf`       | integer | min 0, max 51                      | `23`        | no       |                                                            |
| `pix_fmt`   | string  | `yuv420p` \| `yuv444p`             | `"yuv420p"` | no       |                                                            |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "transcode",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "container": "mp4",
      "vcodec": "h264",
      "crf": 23
    }
  }
}
```

## trim

Cut a clip of roughly `duration` seconds via stream copy — no re-encode. The cut snaps to the keyframe at or before `start` (playback start is trimmed via an edit list); the end can drift by up to packet granularity. For frame-accurate cuts use [keep\_ranges](#keep_ranges) with a single `ranges` entry.

**Inputs:** `input_url` (1) · **Output:** media — single file

<Warning>
  Stream-copy trim outputs carry discard-flagged pre-roll back to the previous keyframe — players skip it, but it becomes **visible** after `concat_copy` (measured +1.35s on a 30s target). Use [keep\_ranges](#keep_ranges) for frame-accurate multi-range editing.
</Warning>

| Parameter  | Type   | Constraints | Default | Required | Notes                                                            |
| ---------- | ------ | ----------- | ------- | -------- | ---------------------------------------------------------------- |
| `start`    | number | min 0       | `0`     | no       | zero-bill reject when >= probed source duration (empty output)   |
| `duration` | number | min 0       | `5`     | no       | zero-bill reject when kept window \< 1 video frame at source fps |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "trim",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "start": 0,
      "duration": 5
    }
  }
}
```

## volume

Scale audio loudness by a linear factor; video is stream-copied untouched.

**Inputs:** `input_url` (1) · **Output:** media — single file

| Parameter   | Type   | Constraints    | Default | Required | Notes                                    |
| ----------- | ------ | -------------- | ------- | -------- | ---------------------------------------- |
| `factor`    | number | min 0, max 10  | `2.0`   | no       | 0 mutes, 1 unchanged, 2 doubles          |
| `container` | string | `mp4` \| `mkv` | `"mp4"` | no       | webm excluded — cannot hold copied H.264 |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "volume",
    "input_url": "YOUR_INPUT_URL",
    "params": {
      "factor": 2.0,
      "container": "mp4"
    }
  }
}
```

## watermark

Overlay a second input (image or video) onto the primary video at a pixel offset.

**Inputs:** `input_urls` (2) — `input_urls[0]` is the base video, `input_urls[1]` is the watermark (image or video) · **Output:** media — single file

| Parameter   | Type    | Constraints              | Default | Required | Notes |
| ----------- | ------- | ------------------------ | ------- | -------- | ----- |
| `x`         | integer | min 0, no schema max     | `10`    | no       |       |
| `y`         | integer | min 0, no schema max     | `10`    | no       |       |
| `container` | string  | `mp4` \| `webm` \| `mkv` | `"mp4"` | no       |       |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "capability": "watermark",
    "input_urls": [
      "YOUR_INPUT_URL_1",
      "YOUR_INPUT_URL_2"
    ],
    "params": {
      "x": 10,
      "y": 10,
      "container": "mp4"
    }
  }
}
```

## Response shapes

Every result arrives in the polled prediction's `output` field. There are exactly six shapes — five for success, determined by the capability's output class, and one for error; the sibling fields (`status`, `metrics`, `urls`, `input`, `logs`) are identical across all of them (the examples below show the result-bearing fields). On success, `metrics` carries the billed quantities directly — `billed_seconds`, `output_gb`, `tier` — alongside `cost` and `predict_time`; failed predictions carry only `cost` and `predict_time` (see [Billing & Limits](/video/billing-limits#the-formula)).

**1. URL array** — every single-file media capability. Single-output jobs return a one-element array:

```json theme={"dark"}
{
  "id": "1f0fdf4d-...",
  "status": "success",
  "output": ["https://cdn-us.eachlabs.ai/uploads/9b967c93-....mp4"],
  "metrics": { "predict_time": 14.2, "cost": 0.015, "tier": "cpu", "billed_seconds": 10, "output_gb": 0.0016 }
}
```

**2. Multi-output URL array** — `segment`, `silence_split`, `thumbnails`. Same array, one URL per produced file, index-ordered (`seg_000`, `seg_001`, …). `segment` with `first_frame: true` interleaves each clip with its companion still, in index order (verified in production):

```json theme={"dark"}
{
  "id": "dd6bc3ec-...",
  "status": "success",
  "output": [
    "https://cdn-us.eachlabs.ai/uploads/9b06c633-....mp4",
    "https://cdn-us.eachlabs.ai/uploads/37d85e14-....jpg",
    "https://cdn-us.eachlabs.ai/uploads/733a985a-....mp4",
    "https://cdn-us.eachlabs.ai/uploads/cc2a0a1c-....jpg",
    "https://cdn-us.eachlabs.ai/uploads/1d899cd7-....mp4",
    "https://cdn-us.eachlabs.ai/uploads/ee35a903-....jpg"
  ],
  "metrics": { "predict_time": 19.67, "cost": 0.0225, "tier": "cpu", "billed_seconds": 15, "output_gb": 0.0027 }
}
```

A plain multi-output job (no stills) is the same array without the interleave:

```json theme={"dark"}
{
  "id": "7a2c91be-...",
  "status": "success",
  "output": [
    "https://cdn-us.eachlabs.ai/uploads/....-seg_000.mp4",
    "https://cdn-us.eachlabs.ai/uploads/....-seg_001.mp4",
    "https://cdn-us.eachlabs.ai/uploads/....-seg_002.mp4"
  ],
  "metrics": { "predict_time": 9.8, "cost": 0.0105, "tier": "cpu", "billed_seconds": 7, "output_gb": 0.0021 }
}
```

**3. Named-outputs object** — `poster` (`video`, `thumbnail`) and `storyboard_sprites` (`sheet`, `vtt`). `output` maps output name → URL:

```json theme={"dark"}
{
  "id": "c04d55e1-...",
  "status": "success",
  "output": {
    "video": "https://cdn-us.eachlabs.ai/uploads/....mp4",
    "thumbnail": "https://cdn-us.eachlabs.ai/uploads/....jpg"
  },
  "metrics": { "predict_time": 8.3, "cost": 0.009, "tier": "cpu", "billed_seconds": 6, "output_gb": 0.0019 }
}
```

**4. Bundle** — `hls_ladder`, `package_abr`, `package_abr_ladder`. `output` is `{primary, artifacts}`: `primary` is the entrypoint a player opens (master.m3u8), `artifacts` lists every produced file in deterministic (name-sorted) order with `role` ∈ `playlist` | `init` | `segment` | `media`:

```json theme={"dark"}
{
  "id": "e81b3f09-...",
  "status": "success",
  "output": {
    "primary": "https://cdn-us.eachlabs.ai/uploads/....-master.m3u8",
    "artifacts": [
      { "name": "master.m3u8", "url": "https://cdn-us.eachlabs.ai/uploads/....-master.m3u8", "role": "playlist" },
      { "name": "seg_000.ts", "url": "https://cdn-us.eachlabs.ai/uploads/....-seg_000.ts", "role": "segment" },
      { "name": "stream_0.m3u8", "url": "https://cdn-us.eachlabs.ai/uploads/....-stream_0.m3u8", "role": "playlist" }
    ]
  },
  "metrics": { "predict_time": 42.7, "cost": 0.057, "tier": "cpu", "billed_seconds": 38, "output_gb": 0.0024 }
}
```

**5. Analysis JSON** — `probe`, `scene_detect`, `silence_detect`, `audio_analysis` (and `run_ffmpeg` with `ffprobe`). `output` is the JSON document itself; no artifact is uploaded. `scene_detect` and `silence_detect` arrays are ordered ascending by time:

```json theme={"dark"}
{
  "id": "281e4953-...",
  "status": "success",
  "output": { "silences": [ { "start": 12.46, "end": 14.02, "duration": 1.56 } ] },
  "metrics": { "predict_time": 4.1, "cost": 0.0075, "tier": "cpu", "billed_seconds": 5, "output_gb": 0 }
}
```

`audio_analysis` wraps its measurements under an `audio_analysis` key (fields in [audio\_analysis](#audio_analysis)):

```json theme={"dark"}
{
  "id": "899cb7f0-...",
  "status": "success",
  "output": { "audio_analysis": { "integrated_lufs": -11.697, "lra": 1.76, "true_peak_dbtp": -4.31, "sample_peak_dbfs": -4.31, "flat_factor": 0, "peak_count": 2 } },
  "metrics": { "predict_time": 3.71, "cost": 0.0075, "tier": "cpu", "billed_seconds": 5, "output_gb": 0 }
}
```

**6. Error** — any prediction that ends in `status: "error"`. `output` is the error envelope: `error_code` carries the public code, `error_message` the specific reason, `retryable` and `classification.category` the handling hints, and `metrics.cost` what it billed. The field-by-field contract and two real captured examples (a \$0 reject and an execution failure) are in the [error catalog](/video/billing-limits#error-catalog):

```json theme={"dark"}
{
  "id": "f2762a7e-...",
  "status": "error",
  "output": {
    "classification": { "category": "PROVIDER" },
    "error": "Execution failed",
    "error_code": "PROVIDER_FAILED",
    "error_message": "exec_error",
    "retryable": false,
    "success": false
  },
  "metrics": { "predict_time": 2.69, "cost": 0 }
}
```

## run\_ffmpeg

Raw ffmpeg/ffprobe for long-tail cases the named capabilities don't cover. **Available to beta organizations today**; access may move to per-key grants later in the beta. This section is the canonical statement of the `run_ffmpeg` contract — other pages link here.

**Different contract.** `run_ffmpeg` is a top-level `mode`, not a capability — the input carries `mode`, `argv`, `input_url` (or `input_urls`), and `output_format` (no `capability`, no `params`):

| Field           | Type             | Constraints                                                                                                         | Default | Required                          | Notes                                                                                                     |
| --------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------- | ------- | --------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `mode`          | string           | `run_ffmpeg`                                                                                                        | —       | yes                               | selects raw mode                                                                                          |
| `argv`          | array of strings | `argv[0]` must be `ffmpeg` or `ffprobe`; denied tokens below                                                        | —       | yes                               | use placeholders, never literal media paths or URLs                                                       |
| `input_url`     | string           | https media URL or `s3://` URI                                                                                      | —       | one of `input_url` / `input_urls` | binds `{input}`                                                                                           |
| `input_urls`    | array of strings | ordered                                                                                                             | —       | one of `input_url` / `input_urls` | one URL binds `{input}`; N URLs bind `{input0}`, `{input1}`, … in array order                             |
| `output_format` | string           | `mp4` \| `webm` \| `mkv` \| `mov` \| `gif` \| `mp3` \| `wav` \| `m4a` \| `jpg` \| `png` \| `vtt` \| `hls` \| `dash` | `mp4`   | no                                | sets `{output}`'s extension; `hls` → `.m3u8`, `dash` → `.mpd`; unknown or empty values fall back to `mp4` |

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "mode": "run_ffmpeg",
    "input_url": "YOUR_INPUT_URL",
    "output_format": "mp3",
    "argv": ["ffmpeg", "-hide_banner", "-nostats", "-i", "{input}", "-af", "acompressor=threshold=-18dB:ratio=2:attack=30,loudnorm=I=-14:TP=-1.0:LRA=11,alimiter=limit=0.891:level=false", "-ar", "44100", "-c:a", "libmp3lame", "-b:a", "320k", "{output}"]
  }
}
```

The example above is a working audio-mastering command (compress → normalize to -14 LUFS → true-peak limit → 320k MP3) — [audio\_master](#audio_master) now does exactly this as a named capability; keep this argv for custom filter chains or two-pass accuracy. Two gotchas it encodes:

* `alimiter` needs `level=false` — the default (`level=true`) re-normalizes after limiting and **silently defeats the true-peak ceiling**.
* The explicit `-ar 44100` is required — `loudnorm` internally upsamples to 192kHz, and without an explicit rate the output stays at 192kHz.

**Placeholders.** Never write media paths or URLs into `argv` — staged placeholder paths are the only way to reach your media, and the only literal path a job needs is the `/dev/null` sink shown below. The server substitutes `{input}` (the staged input), `{input0}`, `{input1}`, … (indexed inputs), and `{output}` (the artifact path, whose extension comes from `output_format`).

**Multiple inputs.** Pass `input_urls`; each URL binds its index placeholder in array order. Example — replace a video's audio track with `-c copy`:

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "mode": "run_ffmpeg",
    "input_urls": ["YOUR_INPUT_URL_1", "YOUR_INPUT_URL_2"],
    "output_format": "mp4",
    "argv": ["ffmpeg", "-i", "{input0}", "-i", "{input1}", "-map", "0:v", "-map", "1:a", "-c", "copy", "-shortest", "{output}"]
  }
}
```

**The enforced safety boundary.** Commands are validated server-side, and ffmpeg runs with an injected `-protocol_whitelist file` — commands can read local files only, never the network. Every `run_ffmpeg` job is billed like any capability job. The argv restrictions — this is the **complete** list; anything not listed passes validation (and, if ffmpeg itself rejects it, fails at execution as `exec_error`):

* `argv[0]` must be `ffmpeg` or `ffprobe`.
* **URL schemes are an allow-list of one** (`protocol_denied`): any token carrying a `scheme:` prefix is denied unless the scheme is `expr:` (ffmpeg's expression prefix). Nested prefixes are unwrapped recursively — `async:file:...`-style chains are caught.
* **Denied tokens** (`flag_denied`): `-protocol_whitelist` (the sandbox injects its own), a bare `-` (stdin/stdout), `movie=` / `amovie=` filter sources (an arbitrary-file-read vector), and `-safe` with any value other than the literal `1`.
* **Placeholder-assembled paths** reject `..` traversal; raw literal path tokens are permitted in `run_ffmpeg` (it compiles in escalated mode — `/dev/null` and plain local paths pass validation, though only staged placeholder paths resolve to your media).
* **`-var_stream_map`** values are checked against a strict grammar; a nonconforming value rejects as `invalid_command`.
* **Placeholder bindings**: a bound value may not begin with `-` (`flag_denied`), may not contain `..` traversal, and, when spliced inside a filtergraph argument, may not contain the filtergraph metacharacters `, : ; ' [ ] \`.

**ffprobe.** With `argv[0]: "ffprobe"` the JSON is returned inline in the prediction `output` — no media artifact.

**Analysis-to-file pattern.** Filters that print metadata can write their report to the artifact instead of producing media — point the filter's `file=` at `{output}` and satisfy ffmpeg's output requirement with the `-f null /dev/null` sink:

```json theme={"dark"}
{
  "model": "eachlabs-video-api",
  "input": {
    "mode": "run_ffmpeg",
    "input_url": "YOUR_INPUT_URL",
    "output_format": "vtt",
    "argv": ["ffmpeg", "-i", "{input}", "-af", "ametadata=mode=print:file={output}", "-f", "null", "/dev/null"]
  }
}
```

<Note>
  **Two-pass loudnorm.** Single-pass `loudnorm` is dynamic and can land off-target. For mastering-grade accuracy run a first pass with `loudnorm=...:print_format=json` through the `-f null /dev/null` sink to measure, then a second pass feeding back `measured_I`, `measured_LRA`, `measured_TP` with `linear=true` — this lands within 0.5 LU of target.
</Note>

## Recipes

Patterns verified end-to-end against production. Two of these are now single-call capabilities — the manual chains remain documented as alternatives.

### Clip list + first frames: `segment` with `first_frame: true` (one call)

One prediction returns the clips **and** each clip's first frame, interleaved in index order — see [segment](#segment). Set `reencode: true` for boundary-accurate frames. Verified in production: a 90 s source with `{"segment_seconds": 30, "reencode": true, "first_frame": true}` returned 3 clips + 3 JPGs in one job billed \$0.0225 — the same output the manual chain produced across four billed predictions.

**Manual alternative** (`segment` → `stills`) — when you need frames at positions other than the first (`position: "at"` / `"last"`): split with `segment`, then run `stills` on each chunk — segment output URLs are directly usable as the next job's `input_url`. For boundary-accurate frames set `reencode: true` on the segment job: stream-copy segmentation cuts only on keyframes, and measured chunk durations drifted to 33.36s / 33.33s / 23.33s against a requested 30/30/30 split.

### Split on silence with a max-duration cap: `silence_split` (one call)

One prediction detects the silences, applies your hard cap and minimum, and delivers the segments — see [silence\_split](#silence_split). Verified in production: the worked example below returns the same 7 segments as the manual chain, in one billed job instead of eight.

**Manual alternative** (`silence_detect` → `trim` per segment) — when you need custom cut logic the caps don't express:

1. Run `silence_detect` on the source.
2. Compute capped cut points client-side: cut at each silence's `end`; hard-cut when a segment would exceed your max duration; skip segments shorter than your min; cap the tail.
3. Submit one `trim` per computed segment.

Worked example (verified in production): silences ending at **13 / 22.5 / 58 / 62 / 70.5 s** on a **90 s** source with a **30 s cap** —

* cut at 13 (silence end), cut at 22.5 (silence end); the next silence end (58) is more than 30 s away, so hard-cut at 22.5 + 30 = **52.5**;
* cut at 58, 62, 70.5 (silence ends, each within the cap); the tail 70.5 → 90 fits the cap;
* segments: 0–13, 13–22.5, 22.5–52.5, 52.5–58, 58–62, 62–70.5, 70.5–90 → **7 `trim` jobs**.

<Note>
  Stream-copy `trim` carries +0.00-0.17s of end drift per segment. When exact boundaries matter, use [keep\_ranges](#keep_ranges) (re-encodes) instead of per-segment trims — or `silence_split` with `reencode: true`.
</Note>
