# Editing the video

> How to edit a video through the Slicer API — word ids in the video text, one operation catalog, elements by time, revisions, captions from speech, AI at cost.

Source: https://docs.slicer.media/en/api/editing/

A finished video or the draft of a [project without the AI director](https://docs.slicer.media/en/api/montage/#manual) can be edited just like in the app's editor: cut words and pauses, split, trim, reorder and duplicate fragments, add a freeze frame, fix captions, add a hook, plaques, titles, emoji, zooms and markers, change speed (and the speed curve), sound, transitions, effects and colour, framing, mask, chroma key and keyframes, set music and voice, put your own photos and videos over the video — full frame or in a window, change the cover and the frame format. Edits are free and show up in the editor right away; the video file is updated by [export](https://docs.slicer.media/en/api/export/). Only Slicer's AI features are paid: captions from speech, the AI assistant and caption translation, all at cost. Your agent can also edit the whole video itself, for free: it writes the plan, and Slicer applies the rules of its AI director to it — Your own director.

## How edits work

### 1. Get the video as text

`GET /v1/projects/{id}/transcript` returns the video as text: fragments in order, words with `id` inside them, marks for cut and highlighted words, and elements — placed on words or by video time.

Response (shortened):

```json
{
  "rev": 7,
  "durationSec": 31.4,
  "text": "…",
  "blocks": [
    {
      "id": "b1",
      "position": 0,
      "sourceIndex": 0,
      "kind": "words",
      "words": [
        { "id": 12, "text": "Today", "cut": false, "accent": false },
        { "id": 13, "text": "I'll", "cut": false, "accent": false },
        { "id": 14, "text": "um", "cut": true, "accent": false },
        { "id": 15, "text": "show", "cut": false, "accent": true }
      ]
    },
    { "id": "b2", "position": 1, "sourceIndex": 2, "kind": "range", "words": [] }
  ],
  "elements": {
    "hook": "A reel in 3 minutes",
    "plaques": [],
    "cards": [],
    "emoji": [{ "id": "e1", "emoji": "🚀", "wordId": 15, "start": 1.2, "end": 2.4 }],
    "zooms": [{ "id": "z1", "fromWordId": 12, "toWordId": 15, "start": 0, "end": 2.4 }],
    "titles": [
      {
        "id": "t1", "text": "Step 1", "font": "punch", "size": 96, "color": "#FFFFFF", "background": "pill",
        "align": "center", "x": 540, "y": 420, "fromWordId": null, "toWordId": null,
        "start": 8, "end": 11, "anchor": { "blockId": "b2", "at": 3.5, "duration": 3 }
      }
    ],
    "markers": [{ "id": "m1", "time": 12.5, "label": "Music here", "color": "yellow" }]
  }
}
```

-   `kind: "words"` is a speech fragment with words; `kind: "range"` is a fragment by source time: b-roll, a photo or a clip without recognized speech. It may have no words.
-   `start` and `end` of an element are where it sits in the video, in seconds. No `start` — the element isn't visible right now (its words are cut).
-   An element placed by time rather than on words comes with `fromWordId: null` and an `anchor` — the fragment, the second of its source and the duration (Elements by time).
-   `markers` are the author's notes on the timeline; they don't appear in the video.

The `text` field is the same video as a single string in a form convenient for a language model: you can pass it to your own AI agent so it can suggest edits. Fragments without speech are shown in it with their time in the video.

### 2. Send operations

`POST /v1/projects/{id}/edits` with the `rev` from step 1 and a list of operations (up to 80 at a time):

Request body:

```json
{
  "rev": 7,
  "operations": [
    { "op": "cut_fillers" },
    { "op": "set_hook", "text": "A reel in 3 minutes" },
    { "op": "set_accent", "wordId": 18 },
    { "op": "set_emoji", "wordId": 18, "emoji": "🔥" },
    { "op": "add_plaque", "fromWordId": 20, "toWordId": 26, "kind": "list", "text": "Three steps", "caption": "Upload\nPick a style\nExport" },
    { "op": "add_title", "start": 8, "duration": 3, "text": "Step 1", "titleBackground": "pill", "titleIn": "pop" },
    { "op": "tune_block", "id": "b3", "transition": { "kind": "dissolve", "duration": 0.4 }, "animIn": { "kind": "zoomIn", "duration": 0.5 } }
  ]
}
```

Response:

```json
{ "ok": true, "data": { "rev": 8, "applied": 7, "skipped": [], "durationSec": 29.8 } }
```

### 3. Export when you're done

Edits are saved immediately. To get an updated file, run an [export](https://docs.slicer.media/en/api/export/) — it's free.

## Operations

Slicer has a single operation catalog: the same operations are used by manual edits in the editor, the AI assistant, the director in the Chat, the API and the `edit_project` MCP tool. So the result matches what you'd get in the editor.

Words are addressed by their `id` from the video text, fragments and elements by their own `id`. A word range is `fromWordId`…`toWordId`, inclusive; fragment positions start at 0.

### Speech and fragments

| Operation | Fields | What it does |
| --- | --- | --- |
| `cut_words` | `fromWordId`, `toWordId` | cut words out of the video |
| `restore_words` | `fromWordId`, `toWordId` | bring back cut or unused source words |
| `cut_fillers` | —   | cut filler words |
| `cut_silences` | `minPause`, `blockIds` | remove pauses by silence, free and without speech recognition: splits fragments by time (`kind: "range"`) at pauses longer than `minPause` — `0.3`, `0.5` (default) or `1` second; without `blockIds` — in all such fragments |
| `add_source` | `sourceIndex`, `position`, `srcStart`, `srcEnd` | put a project source into the video as a new fragment: without `srcStart`/`srcEnd` — its speech by words or the whole source; without `position` — at the end |
| `remove_block` | `id` | delete a fragment together with the elements placed on it by time |
| `move_block` | `id`, `position` | move a fragment |
| `slip_block` | `id`, `delta` | show another piece of the same source in a b-roll fragment: `delta` in seconds, `+` — later footage; the fragment's length and place stay the same |
| `split_block` | `id`, `at` | split a fragment in two at the video moment `at` (s) inside it; both parts keep its settings |
| `trim_block` | `id`, `edge`, `delta` | trim or extend a fragment: edge `start` or `end`, `delta` — how far to move the edge, in seconds of the video (`+` — later, `−` — earlier), −60…60. `start` `+1` cuts the first second, `end` `+1` adds a second of the source |
| `duplicate_block` | `id` | a copy of the fragment right after it |
| `freeze_frame` | `id`, `at`, `duration` | a freeze frame: a new silent fragment with a held frame. `at` — a video moment inside the fragment (default — its last frame), `duration` — 0.3–10 s (default 2) |
| `set_hold` | `id`, `duration` | the length of a freeze frame, 0.3–10 s |
| `replace_source` | `id`, `sourceIndex`, `srcStart` | show another source in a fragment from second `srcStart` (default 0): in b-roll the picture is replaced in place, the length stays; in a fragment with speech the speech stays and the new picture goes over it as an insert |

### Fragment and video settings

| Operation | Fields | What it does |
| --- | --- | --- |
| `tune_block` | `id`, `speed`, `volume`, `fadeIn`, `fadeOut`, `effect`, `transition`, `filter`, `adjust`, `lut`, `animIn`, `animOut`, `background` | speed, volume, sound fade-in and fade-out, effect, transition into this fragment, color — filter, Adjust and LUT, entrance and exit animations, the background behind a fitted or masked picture — `blur` (a blurred frame) or a colour `#RRGGBB`; `null` resets a field |
| `set_process` | `id`, `process` | free fragment processing — a list of `reverse`, `stabilize`, `denoise` (noise reduction), `enhance` (audio enhancement); replaces these four, an empty list — no processing. The server prepares the result in the background, usually within a minute |
| `apply_keyframe_preset` | `id`, `preset` | a smooth camera move across the whole fragment by keyframes: `zoomIn`, `zoomOut`, `panLeft`, `panRight`, `panUp`, `panDown`; replaces the fragment's keyframes |
| `apply_look` | `id`, `blockIds`, `parts` | copy the look of fragment `id` to others: without `blockIds` — to all other fragments; `parts` — what to copy: `speed`, `audio`, `color`, `effect`, `transition`, `animations`, `mask`, `background`, `framing` (default — everything except `framing`) |
| `set_all_transitions` | `transition` | one transition on every cut (`null` — remove all) |
| `set_all_effects` | `effect` | one effect on every fragment (`null` — remove all) |
| `set_all_color` | `filter`, `adjust`, `lut` | one color on every fragment: filter, Adjust and LUT; whatever the request doesn't include is removed (`filter: null` alone removes all color) |
| `set_format` | `format` | frame format: `9:16`, `4:5`, `1:1`, `16:9` |
| `set_cover` | `start`, `coverTitle`, `coverStyle`, `coverIntro` | the cover: the frame (a moment of the video, s), a title on it up to 60 characters (`null` — no title), the title look `hook` or `plaque`, and whether to show the cover as the first frame |

### Frame, mask, speed and sound over time

Since API 1.5.0.

| Operation | Fields | What it does |
| --- | --- | --- |
| `set_framing` | `id`, `mode`, `cx`, `cy`, `zoom` | fragment framing: `auto` — automatic, `crop` — fill the frame (window centre `cx`, `cy` — shares of the source, 0–1), `fit` — the whole picture with a background, `track` — the frame follows the face; `zoom` — 1–3. `track` works only if faces in this part were already found — by the AI montage or by “Follow the face” in the editor; otherwise the operation is skipped |
| `set_keyframe` | `id`, `t`, `x`, `y`, `scale`, `rotate`, `opacity`, `ease` | a keyframe `t` seconds from the fragment start: shift `x`, `y` (shares of the frame, −1…1), scale 0.2–4, clockwise rotation −180…180°, opacity 0–1 (below 1 — fades to black), easing to the next keyframe `smooth` (default), `linear`, `in` (speeds up), `out` (slows down). A keyframe at that moment is changed; fields not in the request take the frame's look at `t`. Up to 24 per fragment; a move needs two keyframes |
| `remove_keyframe` | `id`, `t` | remove the keyframe at `t` |
| `set_mask` | `id`, `shape`, `size`, `feather`, `cx`, `cy`, `rotate`, `invert`, `maskKeys` | the mask: shape `circle`, `rounded`, `heart`, `stripe` (`none` — remove), size 0.2–1, feather 0–1, centre shift `cx`, `cy` — −0.5…0.5 shares of the frame from the centre, rotation in degrees, `invert` — video outside the shape, background inside; `maskKeys` — animation `[{ "t", "cx", "cy", "size", "rotate" }]`, up to 24 points (`null` — no animation). Fields not in the request stay as they were |
| `set_chromakey` | `id`, `keyColor`, `similarity`, `blend`, `spill`, `keyBackground`, `keyImage` | chroma key, free: the screen colour `#rrggbb` (`null` — off), similarity 0.05–0.5 (0.15 by default), edge softness 0–0.3 (0.1), `spill` — remove the coloured fringe (`true` by default); behind — the colour `keyBackground` or a project photo `keyImage` (an image upload id, `null` — colour) |
| `set_speed_curve` | `id`, `preset` or `speedPoints` | a speed curve of a footage fragment without speech: a ready one — `montage`, `hero`, `bullet`, `flash`, `rampUp`, `rampDown` — or your own `[{ "t", "speed" }]`: 2–8 points, `t` — share of the fragment 0–1, speed 0.1–8, linear in between. The fragment's sound is muted on a curve; `preset: null` — normal speed |
| `set_smooth` | `id`, `on` | smooth slow motion of a footage fragment slower than 1× (a source piece up to 10 s): the server interpolates frames in the background, free |
| `set_volume_keys` | `id`, `volumeKeys` | fragment volume by points `[{ "t", "v" }]`: `t` — seconds from the fragment start, `v` — 0–1, linear in between, up to 24 points; replaces the constant volume, `null` — constant again |

A speed ramp with smooth slow motion and a smooth frame turn:

```json
[
  { "op": "set_speed_curve", "id": "b4", "preset": "bullet" },
  { "op": "set_smooth", "id": "b4", "on": true },
  { "op": "set_keyframe", "id": "b5", "t": 0, "rotate": 0 },
  { "op": "set_keyframe", "id": "b5", "t": 2.5, "rotate": 12, "scale": 1.2 }
]
```

### Captions

| Operation | Fields | What it does |
| --- | --- | --- |
| `set_text` | `wordId`, `text` | fix a caption word (up to 200 characters) |
| `set_accent`, `clear_accent` | `wordId` | highlight a word with the accent color or remove the highlight |
| `set_caption_position` | `placement` | caption position: `top`, `center`, `bottom` — like a manual move in the editor: every phrase goes to this height, and smart Auto doesn't move them (style) |
| `set_phrase_text` | `fromWordId`, `toWordId`, `text` | rewrite the caption text of a whole phrase — typos, names, numbers; the speech doesn't change |
| `reset_phrase_text` | `fromWordId`, `toWordId` | the caption text of these words — back to the recognized speech |

### Elements

Elements are placed on words (`fromWordId`, `toWordId` or `wordId`) or — where there is no speech — by video time (`start`, `duration`).

| Operation | Fields | What it does |
| --- | --- | --- |
| `set_hook`, `remove_hook` | `text` (up to 120 characters) | a catchy title for the first seconds |
| `add_plaque`, `remove_plaque` | words or time, `text`, `caption`, `kind` / `id` | a plaque: a title up to 60 characters and lines separated by `\n`; kind — `title`, `number`, `list`, `quote`, `name` |
| `add_title`, `update_title`, `remove_title` | words or time, `text` and look / `id` | a title — a free text layer, up to 3 lines separated by `\n` (up to 200 characters); its look is described below |
| `set_emoji`, `remove_emoji` | `wordId` or `start` + `duration`, `emoji` / `id` or `wordId` | an emoji over the video; only from Slicer's set (`GET /v1/styles` → `emoji`) |
| `add_zoom`, `remove_zoom` | words or time, `scale`, `motion` / `id` | a camera zoom: scale 1.05–1.6 (default 1.3), motion `push` — a smooth push-in (default), `pull` — a pull-out, `punch` — a sharp jump |
| `add_card`, `remove_card` | words or time, `text`, `caption` / `id` | a media card: `text` — an image description in English, `caption` — a label |
| `add_marker`, `update_marker`, `remove_marker` | `wordId` or `start`, `label`, `color` / `id` | a marker — a note for the author on the timeline, up to 40 characters, not shown in the video; color — `cyan`, `yellow`, `red`, `green`, `violet` |
| `add_sound`, `update_sound`, `remove_sound` | `wordId` or `start`, `kind` / `id`, `kind` / `id` | a sound at a moment of the video: `whoosh`, `pop`, `ding`, `click`, `swipe`, `rise`, `boom`, `chime`; it moves with its fragment and plays even with sound effects turned off; up to 60 sounds per video. To move a sound — `move_element` with `element: "sound"` |
| `update_plaque` | `id`, `text`, `caption`, `kind` | change a plaque's title, lines or kind in place |
| `update_card` | `id`, `caption`, `uploadId` | a media card: the label (`""` — none) and your own photo from the project media instead of the picture from a description, free (`null` — back to the description) |
| `update_zoom` | `id`, `scale`, `motion` | change a zoom's scale and motion in place |
| `move_element` | `element`, `id`, `start`, `duration` | move an element to another moment of the video: `element` — `plaque`, `card`, `zoom`, `emoji`, `insert`, `title`, `sound` or `marker`; over speech it sticks to the words there |
| `set_element_position` | `element`, `id`, `x`, `y` | move an emoji, card or plaque (`element` — `emoji`, `card`, `plaque`) around the frame: its centre in the 1080×1920 frame; `null` — back to the default place |

### Music and voice

Since API 1.7.0. A track or a voice recording is a project source (`sourceIndex`) or a file from the project media (`uploadId` — your own files added to the project in the editor). Voice recording `id`s are in the edit document, the `voices` list.

| Operation | Fields | What it does |
| --- | --- | --- |
| `set_music` | `sourceIndex` or `uploadId`, `volume`, `sourceVolume`, `ducking`, `offset`, `fadeIn`, `fadeOut` | background music: an audio source or audio from the project media; without `sourceIndex` and `uploadId` — change the current music. Music volume 0–1 (usually 0.2–0.35), `sourceVolume` — fragment sound under the music 0–1, `ducking` — music gets quieter under speech, `offset` — the track second to start from, fade-in and fade-out — 0–5 s |
| `remove_music` | —   | remove the background music |
| `fit_music` | `on` | `true` — fit the track to the video length: it ends together with the video on a bar line; `false` — play it as is |
| `fit_to_beats` | `every` | snap the cuts of footage fragments to the music beats: every beat (`1`, default), every 2nd or 4th |
| `add_voice` | `sourceIndex` or `uploadId`, `start`, `volume` | a voice recording or an audio source on the voice track from video second `start`; volume 0–2 (default 1). Fragment sound and music get quieter under the voice |
| `move_voice` | `id`, `start` | move a recording to another moment of the video |
| `trim_voice` | `id`, `edge`, `at` | trim a recording: edge `start` or `end` and its new time in the video `at` (s) |
| `set_voice_volume` | `id`, `volume` | recording volume, 0–2 |
| `remove_voice` | `id` | remove a recording from the track |

A quieter track from the sources, fitted to the length, cuts on every second beat:

```json
[
  { "op": "set_music", "sourceIndex": 3, "volume": 0.25, "ducking": true, "fadeOut": 1.5 },
  { "op": "fit_music", "on": true },
  { "op": "fit_to_beats", "every": 2 }
]
```

### Your photos and videos over the video

Since API 1.7.0. An insert covers the picture with a photo or video while the speech goes on — for example, to show what the author is talking about. It is placed on words or by time, like elements; insert `id`s are in the edit document, the `inserts` list.

| Operation | Fields | What it does |
| --- | --- | --- |
| `add_insert` | `sourceIndex` or `uploadId`, `fromWordId` + `toWordId` or `start` + `duration`, `fit`, `srcStart`, `mode` | a photo or video from the sources or project media over the video: `fit` — `cover` (fills the frame) or `contain` (the whole picture), `srcStart` — for a video, from this second, `mode` — `full` (full frame, default) or `pip` (in a window; the window look — `set_pip`) |
| `update_insert` | `id`, `fit`, `srcStart` | the insert's fit and the start of its video |
| `remove_insert` | `id` | remove an insert |

A photo of the result in a window over a phrase:

```json
{ "op": "add_insert", "sourceIndex": 2, "fromWordId": 40, "toWordId": 52, "mode": "pip" }
```

### Picture in picture

Since API 1.6.0. An insert — your own photo or video over the video (its `id` is in the edit document, the `inserts` list) — can cover the whole frame or sit in a window.

| Operation | Fields | What it does |
| --- | --- | --- |
| `set_pip` | `id`, `mode`, `x`, `y`, `scale`, `rotate`, `opacity`, `shape`, `radius`, `borderColor`, `borderWidth`, `volume` | `mode: "pip"` (default) — the insert as a window over the video, `"full"` — back to full frame. Window centre `x`, `y` — in the 1080×1920 frame (may be partly past the edge), width `scale` — share of the frame width 0.1–1, clockwise rotation −180…180°, opacity 0–1, shape `rect` or `circle`, corner rounding `radius` — share of the shorter side 0–0.5, border — colour `#rrggbb` (`null` — no border) and width 0–0.1, `volume` — sound of the video in the window 0–1 (default 0). Fields not in the request stay as they were; for a window with keyframes the position is changed in the first keyframe. Up to 10 windows per video: an 11th is skipped |

An insert as a round window with a white border, bottom right:

```json
{ "op": "set_pip", "id": "i4k2m9q", "x": 800, "y": 1500, "scale": 0.4, "shape": "circle", "borderColor": "#ffffff", "borderWidth": 0.03 }
```

Window keyframes are set in the editor; in the edit document they are `inserts[].pip.keyframes`.

### Values

-   `speed` — 0.25 to 4; `volume` — 0 to 1; `fadeIn`, `fadeOut` — 0 to 3 seconds;
-   `effect` — `shake`, `pulse`, `flash`, `cinema`, `vignette`, `grain`, `blur`, `glitch`, `vhs`;
-   `transition` — `{ "kind", "duration" }`, duration — 0.2 to 1.2 seconds; kind — `fade`, `flash`, `zoom`, `dissolve`, `blur`, slides `slide`, `slideRight`, `slideUp`, `slideDown`, wipes `wipe`, `wipeRight`, `wipeUp`, `wipeDown`, circle `circleOpen` and `circleClose`, `zoomIn`, `pixelize`, `radial`, `scatter`;
-   `filter` — `{ "id", "strength" }`, filter — `bw`, `warm`, `cool`, `vintage`, `sepia`, `vivid`, `pastel`, `noir`, `cinema`, strength — 0 to 1;
-   `adjust` — Adjust: `exposure`, `brightness`, `contrast`, `saturation`, `temperature`, `tint`, each from −1 to 1 (0.15 is a noticeable step); replaces all of the fragment's sliders at once;
-   `lut` — `{ "preset", "strength" }` or `{ "uploadId", "strength" }`: a built-in LUT — `teal-orange`, `film`, `matte`, `bleach`, `golden`, `moody`, `nordic`, `cross`, `mono-film`, `vibrant`; `uploadId` — your own `.cube` from My LUTs (its id is in the edit document on a fragment that uses it); strength — 0 to 1;
-   `animIn`, `animOut` — `{ "kind", "duration" }`, kind — `fade`, `zoomIn`, `zoomOut`, `slideLeft`, `slideRight`, `slideUp`, `slideDown`, duration — 0.2 to 2 seconds.

A title's look uses fields with the `title` prefix; in `update_title`, `null` resets a field:

| Field | Value |
| --- | --- |
| `titleFont` | a font from `GET /v1/styles` → `fonts` |
| `titleSize` | font size, 24–200 px on a 1080×1920 frame |
| `titleColor`, `titleBackgroundColor` | text and background colour, `#RRGGBB` |
| `titleBackground` | background: `none`, `box` (per line), `pill` (rounded) |
| `titleOutline`, `titleShadow` | letter outline and shadow, `true` / `false` |
| `titleAlign` | alignment: `left`, `center`, `right` |
| `titleX`, `titleY` | position on a 1080×1920 frame: the horizontal anchor (edge or centre, by alignment) and the vertical centre |
| `titleIn`, `titleOut` | entrance `fade`, `slide`, `pop`, `typewriter` (letter by letter) and exit `fade`, `slide`, `pop` |

The full operation schema:

`EditOperation`

| Field | Type | Description |
| --- | --- | --- |

## Elements by time

A video without speech — b-roll, photos, a draft without recognized speech — has no words, so plaques, titles, emoji, zooms, cards and markers are placed by time: `start` is a second of the video, `duration` is the length in seconds of the video (0.3–60).

A title and a zoom by time:

```json
{ "op": "add_title", "start": 4, "duration": 2.5, "text": "Morning at the coffee shop", "titleIn": "typewriter" }
```

```json
{ "op": "add_zoom", "start": 9, "duration": 2, "scale": 1.2, "motion": "pull" }
```

An element holds on to the fragment it sits over, like a connected clip: move the fragment — the element moves with it; delete the fragment — the element goes too. Over speech, prefer placing elements on words: that placement survives text edits. If an operation has both words and time, the words win.

## Skipped operations

Operations are applied in order. If an operation changes nothing — the word isn't found, the fragment doesn't exist, the emoji isn't in the set — it's skipped and the rest are applied. Skipped operations are listed in `skipped`:

Response with skips:

```json
{ "rev": 9, "applied": 2, "skipped": [{ "index": 1, "op": "remove_plaque", "reason": "element_not_found" }], "durationSec": 29.8 }
```

| `reason` | Why |
| --- | --- |
| `word_not_found_or_nothing_to_change` | no such word, or the edit is already applied |
| `words_or_start_required` | the element has neither words nor `start`, there are no such words, or there is no fragment at that moment of the video |
| `block_not_found` | no fragment with this `id` |
| `source_not_found` | no source with this index |
| `element_not_found` | no element or fragment with this `id` |
| `emoji_not_allowed_or_word_not_found` | the emoji isn't in Slicer's set, or no such word |
| `bad_arguments` | the operation's fields failed validation |
| `unknown_operation` | there is no such operation |
| `block_not_footage` | a speed curve and smooth slow motion work only on a time-based footage fragment, without speech and not a freeze frame |
| `keyframe_not_found` | there is no keyframe at that moment of the fragment |
| `block_not_found_or_no_face_data` | no fragment with this `id`, or for `track` no faces have been found in this part yet |
| `block_or_source_not_found` | no fragment with this `id`, or no suitable source |
| `audio_not_found` | no such audio in the sources or project media |
| `no_music_or_beats` | the video has no music, or the track's beats haven't been found yet |
| `recording_not_found` | no such voice recording or audio |
| `media_or_place_not_found` | no such photo or video, or no place for the insert: no such words, or no fragment at that moment of the video |
| `word_not_found` | no such word |
| `nothing_to_change` | the operation changes nothing |

## Revisions

A video has a revision number `rev`; every saved edit increases it. An edit with an outdated revision is rejected with `409 stale_document` — the video was changed in the editor or by another request. Re-read the video text and repeat the edit with the new `rev`. This way edits from the API and the app never overwrite each other.

## Captions from speech

In a [project without the AI director](https://docs.slicer.media/en/api/montage/#manual) the speech isn't recognised, so there are no captions, and the fragments in the video text are by time. `POST /v1/projects/{id}/transcribe` recognises the speech in the sources — like **Captions from speech** in the editor ([Captions from speech](https://docs.slicer.media/en/guide/editor/speech/#speech)). It also recognises sources added to a project after the montage.

Terminal:

```bash
curl https://api.slicer.media/v1/projects/318/transcribe \
  -H "Authorization: Bearer $SLICER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"language":"auto"}'
```

| Field | What it sets |
| --- | --- |
| `sources` | source indexes; by default — every source with sound whose speech isn't recognised yet |
| `language` | the speech language: a two-letter code or `auto` — the language from the project settings, or, if none is set, Slicer detects it |
| `confirmCost` | the price you agree to; without it the response is a `Quote` and nothing starts |

Recognition is priced **at cost** per minute of sound of the chosen sources: without `confirmCost` you get a `Quote` with `estimate` (the expected price) and `cost` (the maximum), `atCost: true` — as with the AI assistant. Repeat the request with `"confirmCost"` equal to `cost`: the tokens are reserved, and the actual cost is charged, never more than `cost`. Requires the `montage:run` scope.

Recognition runs in the background. The response to the start is a job `{ id, status, sources, createdAt }`; follow it with `GET /v1/projects/{id}/transcribe`, the project's job list:

| `status` | Meaning |
| --- | --- |
| `processing` | the speech is being recognised |
| `ready` | done: the words are in the video text, the captions follow the speech |
| `failed` | it didn't work, `error` has a readable message; the reserved tokens came back |

When the job is ready, re-read `GET /v1/projects/{id}/transcript`: the fragments now have words with `id`s, you can place elements on them and edit captions (`set_text`, `set_accent`, `set_phrase_text`). The edit document itself doesn't change. `POST /v1/estimate` with `kind: "transcribe"` and `projectId` names the price in advance.

| Response | Meaning |
| --- | --- |
| `400 nothing_to_transcribe` | the chosen sources have no sound or their speech is already recognised |
| `409 busy` | speech in the project is already being recognised — wait until it's done |
| `409 not_ready` | the project isn't ready for edits yet |
| `409 sources_expired` | the sources were deleted after the retention period |
| `503 ai_unavailable` | speech recognition is temporarily unavailable |

## AI assistant

A command in plain words — the assistant picks the operations from the same catalog itself:

Terminal:

```bash
curl https://api.slicer.media/v1/projects/318/assistant \
  -H "Authorization: Bearer $SLICER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"command":"Make the video more dynamic and add emoji on the key words","rev":9}'
```

The assistant is priced **at cost**: a command costs exactly what its AI calls cost, with no markup. Without `confirmCost` you get the price:

Response without confirmCost:

```json
{ "ok": true, "data": { "status": "quote", "cost": 2, "free": false, "estimate": 0.4, "atCost": true, "available": 240, "enough": true, "spendLimitLeft": 176 } }
```

-   `atCost: true` — the operation is priced at cost;
-   `estimate` — the expected price in tokens, fractional;
-   `cost` — the maximum price: repeat the request with `"confirmCost"` equal to it. The actual cost is charged, never more than `cost`.

With `confirmCost` the response is `{ rev, summary, applied, charged, costTokens, durationSec }`: `summary` describes what was done, `costTokens` is the actual cost of the command (fractional), `charged` is how many whole tokens were taken from the balance now. The fractional remainder accumulates with the project and is charged once it adds up to a whole token. Tokens are charged only if the assistant changed something. Requires the `montage:run` scope.

## Caption translation

`POST /v1/projects/{id}/translate` with `language` — `en`, `es`, `pt`, `de`, `fr`, `it`, `pl`, `tr`, `id`, `uk`, `ru` — and `rev`. The translation is laid out word by word, so accents and emoji stay in place. Translation is priced at cost too: the price (`Quote` with `estimate` and `cost`) and confirmation work as for the assistant. The response is `{ rev, phrases, charged, costTokens }`; you're charged only if the translation succeeded.

## Auto color

`POST /v1/projects/{id}/auto-color` balances color without AI and for free — from 10 frames of the fragment, like the **Auto** and **Match all to this** buttons in the editor. The body has `rev`, `blockId` and `mode`:

-   `auto` — correct fragment `blockId`: exposure, contrast, saturation and white balance;
-   `match` — make the other fragments look like `blockId`: they get its filter and LUT, and each gets its own Adjust values.

Body of POST /v1/projects/318/auto-color:

```json
{ "rev": 7, "blockId": "b2", "mode": "match" }
```

The result is saved right away. The response has the new revision, Adjust values by fragment id (`adjust`), how many fragments changed (`applied`) and the video's duration.

## Your own director

Your AI agent can edit the video itself — on its side, for free in Slicer: it looks at the frames and writes the plan, and Slicer applies the rules of its AI director to the plan on the server and answers with fixes and issues. The rules themselves are not passed to the agent. How it works step by step — [Your own director](https://docs.slicer.media/en/mcp/own-director/) in the MCP section: the `get_director_brief`, `get_frames` and `submit_director_plan` tools are built on these routes.

| Route | What it does | Scope |
| --- | --- | --- |
| `POST /v1/projects/{id}/director/brief` | the material — speech by word ids, pauses, retakes, wishes (`material`), the sources (`sources`), general tips (`guide`), the plan schema (`planSchema`), the schema of observations from the frames (`scenesSchema`) and `rev` | `projects:write` |
| `GET /v1/projects/{id}/sources/{index}/frames` | frames of a source: JPEG in base64, 512 px on the longer side, up to 16 per request | `projects:read` |
| `POST /v1/projects/{id}/director/plan` | a plan (`plan`) and observations from the frames (`scenes`) → the video, a new revision; the answer has fixes (`fixes`) and issues (`issues`) | `projects:write` |

All three are free. If the video has speech, recognise it first — Captions from speech (at cost): without words the director has nothing to cut.

### Brief

The body is optional: director settings — `mode` (`auto`, `talking`, `story`, `product`, `aesthetic`), `pace` (`calm`, `balanced`, `dynamic`), `effects` (`off`, `light`, `bold`), `targetDurationSec` (10–180, `null` — the director decides, up to 90 seconds), `hook`, `plaques`, `emoji`, `cameraMotion`. Fields you pass are saved to the project: Slicer applies the rules to the plan by them.

Terminal:

```bash
curl https://api.slicer.media/v1/projects/318/director/brief \
  -H "Authorization: Bearer $SLICER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"pace":"dynamic","targetDurationSec":30}'
```

| Response field | What it holds |
| --- | --- |
| `projectId`, `rev` | the project and the video revision — `rev` is needed for the plan |
| `settings` | the project's director settings |
| `sources` | the sources: number (`index`), kind (`kind`), duration, file name and how many recognised words it has |
| `speechRecognized` | whether the speech is recognised |
| `material` | the material as text: the sources, speech by word ids `w<ID>`, pauses, `[RETAKE …]` takes, the author's wishes |
| `guide` | a few general tips — the brief has no rules of the Slicer AI director |
| `planSchema` | the JSON schema of the plan |
| `scenesSchema` | the JSON schema of observations from the frames |
| `next` | what to do next |

### Frames

`times` — seconds of the source, comma-separated (up to 16), or evenly: `from`, `to`, `count` (8 frames over the whole source by default). Seconds outside the source are clamped to its edge. A photo returns one image; audio has no frames (`400 no_picture`), a missing source — `404 source_not_found`.

Terminal:

```bash
curl "https://api.slicer.media/v1/projects/318/sources/0/frames?times=2.5,7,12.4" \
  -H "Authorization: Bearer $SLICER_TOKEN"
```

Response:

```json
{ "ok": true, "data": { "sourceIndex": 0, "durationSec": 48.2, "frames": [{ "time": 2.5, "mimeType": "image/jpeg", "data": "/9j/4AAQ…" }] } }
```

### Plan

The body has `rev` (from the brief or from the previous answer), `plan` in `planSchema` and, optionally, `scenes` — up to 400 observations in `scenesSchema`: `sourceIndex`, `start` and `end` in seconds of the source, `kind` (`talking_head`, `demo`, `people`, `screen`, `broll`, `unusable`), `interest` from 1 to 5 and `description`. What the kinds mean and how Slicer places footage by them — [Observations from the frames](https://docs.slicer.media/en/mcp/own-director/#scenes).

Body of POST /v1/projects/318/director/plan (abridged):

```json
{
  "rev": 3,
  "plan": {
    "concept": "Three steps from milk to pattern",
    "segments": [
      { "kind": "speech", "role": "hook", "fromWordId": 41, "toWordId": 47, "why": "A strong opening" },
      { "kind": "footage", "role": "body", "sourceIndex": 0, "fromSec": 14.5, "toSec": 19, "why": "Showing the pattern" }
    ],
    "hook": "Latte art in a minute"
  },
  "scenes": [
    { "sourceIndex": 0, "start": 0, "end": 14.5, "kind": "talking_head", "interest": 3, "description": "The barista talks at the counter" },
    { "sourceIndex": 0, "start": 14.5, "end": 21, "kind": "demo", "interest": 5, "description": "Draws a heart on a latte" }
  ]
}
```

On the server the rules of the Slicer director are applied to the plan: speech by words, footage by the observations, restrained editing, length. The plan becomes the video right away — a new revision, even if it has issues. Media cards from a description are not accepted — their picture is paid; the `update_card` operation sets your own photo. Fragments are framed in the centre; fix the framing with the `set_framing` operation.

| Response | Meaning |
| --- | --- |
| `200` | `{ rev, durationSec, planSegments, fragments, fixes, issues, video }` — the video is saved: `fixes` — what Slicer corrected itself, `issues` — plan issues, `video` — the video as text to check |
| `400 bad_plan` | the plan doesn't match the schema; `message` lists the first errors |
| `400 empty_plan` | the plan has no fragments |
| `409 stale_document` | the video changed after the brief or the previous plan — re-read it and send the plan with the new `rev` |
| `409 busy` / `409 not_ready` / `409 sources_expired` | the project is rendering, isn't ready for edits yet, or its sources were deleted |

### Plan issues

An issue is `{ code, segment, message }`: a code, the plan fragment number from zero (only if the issue is about a fragment) and what to fix — in English, for the model to read. Fix the plan and send the whole plan again with the new `rev`; when `issues` is empty, the plan is fine.

| Code | What's wrong |
| --- | --- |
| `cuts_thought` | the fragment stops in the middle of a sentence |
| `starts_mid_thought` | the fragment starts in the middle of a sentence |
| `single_word` | the fragment is a single word — make it a complete thought or remove it |
| `two_takes` | the video has two takes of the same thought — keep the best one |
| `weak_ending` | the video ends mid-phrase rather than on a complete sentence |
| `too_long` | the video is noticeably longer than the target length or, without one, longer than 90 seconds |
| `too_short` | the video is noticeably shorter than the target length |
| `unused_source` | a source without speech — a video or a photo — isn't used in the plan: show it where it belongs in the story |
| `footage_is_talking_head` | the footage shows a silent talking head instead of an action, a product or a result (by the observations) |
| `unusable_footage` | the footage comes from a part marked `unusable` in the observations |
| `nothing_shown` | the observations have demo moments or b-roll, but the plan has neither footage nor cutaways |
| `hook_long` | the hook is longer than 8 seconds |
| `hook_title_long` | the hook title is longer than 6 words |

## Style

`PATCH /v1/projects/{id}/style` changes the template, font, accent color, caption look, sound effects and intro zoom — for free. The change is partial: only the fields you pass change, everything else — including the other fields of `subtitles` and `params` — stays as it was.

Body of PATCH /v1/projects/318/style:

```json
{ "templateId": "clean", "fontId": "editorial", "colorId": "mint", "subtitles": { "design": "karaoke", "size": "large" } }
```

The new style shows in the editor right away and in the file after export. To have the AI rebuild the video with different montage settings, use a [rebuild](https://docs.slicer.media/en/api/montage/).

### Caption look

The `subtitles` fields are the same as in the editor's **Caption style** sheet ([Captions](https://docs.slicer.media/en/guide/editor/captions/#style)). Word highlight, entrance, words on screen, letter case and colour by speaker — since API 1.8.0.

| Field | Values |
| --- | --- |
| `enabled` | show captions: `true` or `false` |
| `design` | the design: `dynamic`, `karaoke`, `classic`, `minimal`, `pop` — Pop, `box` — Box, `word` — One word |
| `size` | the size: `small`, `medium`, `large` |
| `position` | the position: `auto` — each phrase where the frame has the most free space: off the face, hook, plaques, titles and picture-in-picture windows; `top`, `center`, `bottom` — every phrase in one place |
| `speakerColors` | each speaker's words in their own color: `true` or `false` |
| `highlight` | the spoken-word highlight: `color` — the accent color, `pop` — the word grows while the others make room, `box` — an accent box, `underline` — an underline, `none` — no highlight |
| `animation` | the phrase entrance: `fade`, `pop` — a springy pop, `slide` — from below, `words` — words as they are spoken, `none` — at once |
| `words` | words on screen: `one`, `few` — 2–3, `line` — a line of up to six words, `phrase` — a phrase of up to two lines |
| `letterCase` | letter case: `upper` — capitals, `normal` — as spoken |

`highlight`, `animation`, `words` and `letterCase` also take `auto`, the default: the way the design intends (for `letterCase` — the font). The templates are `bold`, `clean` and `pop`.

The spoken word pops — the rest of the style stays the same:

```json
{ "subtitles": { "highlight": "pop" } }
```

How a `subtitles` change behaves:

-   only the fields you pass change, the others stay as they were. Before API 1.8.0 a partial `subtitles` change reset the other fields to their defaults;
-   a new `design` without `highlight`, `animation` and `words` resets them to `auto` — like picking a design in the editor: the design looks the way it is meant to right away. Size, position, letter case and colour by speaker stay. To change the design and set the highlight at once, pass both fields:

Karaoke where the spoken word grows:

```json
{ "subtitles": { "design": "karaoke", "highlight": "pop" } }
```

Names and descriptions of the values in both languages are in the `GET /v1/styles` catalog: designs — `subtitleDesigns`, positions — `subtitlePositions`, word highlight, entrance, words on screen and letter case — `subtitleOptions`:

GET /v1/styles → subtitleOptions (abridged):

```json
[
  {
    "id": "highlight",
    "title": { "ru": "Выделение слова", "en": "Word highlight" },
    "help": { "ru": "Как выделяется слово, которое сейчас звучит.", "en": "How the word being spoken is highlighted." },
    "values": [
      { "id": "auto", "title": { "ru": "Как в оформлении", "en": "As in the style" }, "description": { "ru": "Так, как задумано выбранным оформлением.", "en": "As the chosen style intends." } },
      { "id": "pop", "title": { "ru": "Увеличение", "en": "Pop" }, "description": { "ru": "Слово, которое звучит, увеличивается и подсвечивается, соседние слова плавно расступаются.", "en": "The spoken word grows and lights up, the others smoothly make room." } }
    ]
  }
]
```

The same fields are accepted by `settings.subtitles` when you create a project ([Video settings](https://docs.slicer.media/en/api/montage/)) and by the `set_style` MCP tool.

## The edit document (advanced)

`GET /v1/projects/{id}/document` returns the whole edit document — the complete description of the video that the preview and the render are built from — with its revision. A `PUT` to the same path with `{ rev, document }` replaces it entirely. The document goes through the same checks as edits from the editor; unknown fields are dropped.

> **Caution.** The document is the internal model of the video. Within v1 fields are only added to it, but if operations are enough for you, use them: they're simpler and don't depend on how the document is structured.

## Example: remove fillers and add a hook

**JavaScript**

polish.mjs:

```js
// node polish.mjs 318
const API = 'https://api.slicer.media/v1';
const headers = { Authorization: `Bearer ${process.env.SLICER_TOKEN}`, 'Content-Type': 'application/json' };
const id = process.argv[2];

async function call(method, path, body) {
  const response = await fetch(`${API}${path}`, { method, headers, body: body && JSON.stringify(body) });
  const json = await response.json();
  if (!json.ok) throw Object.assign(new Error(json.error.message), { code: json.error.code });
  return json.data;
}

for (let attempt = 0; attempt < 3; attempt += 1) {
  const { rev, blocks } = await call('GET', `/projects/${id}/transcript`);
  const words = blocks.flatMap((block) => block.words).filter((word) => !word.cut);
  try {
    const result = await call('POST', `/projects/${id}/edits`, {
      rev,
      operations: [
        { op: 'cut_fillers' },
        { op: 'set_hook', text: 'A reel in 3 minutes' },
        { op: 'set_accent', wordId: words[0].id },
      ],
    });
    console.log(`Applied: ${result.applied}, length: ${result.durationSec} s`);
    break;
  } catch (error) {
    if (error.code !== 'stale_document') throw error; // the video changed — re-read and retry
  }
}
```

**Python**

polish.py:

```python
# python polish.py 318
import os
import sys

import requests

API = "https://api.slicer.media/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['SLICER_TOKEN']}"
project_id = sys.argv[1]

class ApiError(Exception):
    def __init__(self, error):
        super().__init__(error["message"])
        self.code = error["code"]

def call(method, path, json=None):
    body = session.request(method, API + path, json=json, timeout=60).json()
    if not body["ok"]:
        raise ApiError(body["error"])
    return body["data"]

for attempt in range(3):
    transcript = call("GET", f"/projects/{project_id}/transcript")
    words = [w for block in transcript["blocks"] for w in block["words"] if not w["cut"]]
    try:
        result = call("POST", f"/projects/{project_id}/edits", {
            "rev": transcript["rev"],
            "operations": [
                {"op": "cut_fillers"},
                {"op": "set_hook", "text": "A reel in 3 minutes"},
                {"op": "set_accent", "wordId": words[0]["id"]},
            ],
        })
        print(f"Applied: {result['applied']}, length: {result['durationSec']} s")
        break
    except ApiError as error:
        if error.code != "stale_document":  # the video changed — re-read and retry
            raise
```

## What's next

[Export and publishingGet the file with your edits and publish it.](https://docs.slicer.media/en/api/export/)[Reference: editingEndpoints for the video text, edits, assistant and style.](https://docs.slicer.media/en/api/reference/editing/)[AI assistantWhich commands the assistant understands.](https://docs.slicer.media/en/guide/ai/assistant/)
