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

# Preview

> Validate a composition for free, or render a cheap single frame, window clip, or whole-timeline contact sheet before spending a full render.

<Tip>
  Building with an AI agent? `POST /v1/preview` is the fast inner loop: validate for
  free, glance at a frame or a whole-timeline contact sheet, then only call
  `POST /v1/renders` once it looks right.
</Tip>

## How preview works

Preview reuses the exact same render pipeline and renderer as `POST /v1/renders` —
same engine, same JSON schema — just restricted to a slice of the timeline and
downscaled. What you see in a preview is exactly what the full render produces at
that timestamp.

1. **Submit** `POST /v1/preview` with a `render_request` composition.
2. **Choose what you want**:
   * `dry_run: true` — validate only, no render, returns instantly.
   * `at: <seconds>` — a single frame.
   * `window: { from, to }` — a short clip, with audio.
   * `contact_sheet: { ... }` — a grid of frames sampled across the timeline.
3. **Get a response immediately** — `200` for a dry run, no job (a contact-sheet
   dry run also includes the sampling plan, still with no job). Any non-dry-run
   preview — frame, window, or contact sheet — returns `202` with a job queued.
4. **Poll like any render** — `job.id` (prefix `render_`) works with
   `GET /v1/renders/{id}`. Preview jobs are free: excluded from your usage totals
   and from `GET /v1/renders`.

## Free validation (`dry_run`)

Set `dry_run: true` to run Framelane's linter without spending a render: duplicate
element IDs, dangling or self-referencing transitions, an empty timeline, an
element or caption that starts after the composition ends, and — if you pass
`target_duration_sec` — a timeline that misses your target length.

```json theme={null}
{
  "render_request": { "...": "..." },
  "dry_run": true
}
```

```json theme={null}
{
  "kind": "validation",
  "ok": false,
  "violations": [
    {
      "code": "DANGLING_TRANSITION",
      "message": "transitions[0].from_id references an unknown element",
      "severity": "error",
      "path": "transitions[0]"
    }
  ],
  "job": null
}
```

`ok` is `false` only when a violation is `severity: "error"` — a missed
`target_duration_sec`, for example, is a `"warning"` and never blocks you.

## Frame and window previews

Pass `at` for a single frame, or `window: { from, to }` for a short clip (audio
trimmed and muxed for free). Both render at a small draft width by default (`480`
px; raise `width` up to `3840` for a sharper look) and dispatch a real, cheap render
job.

```json theme={null}
{
  "render_request": { "...": "..." },
  "at": 4.0,
  "width": 480
}
```

```json theme={null}
{
  "kind": "frame",
  "ok": true,
  "violations": [],
  "job": { "id": "render_01J...", "is_preview": true, "status": "queued" }
}
```

Poll `job.id` via `GET /v1/renders/{id}` exactly like a full render.

<Note>
  `at` and `window` are mutually exclusive. A `window.to` past the end of the
  composition is clamped down to the composition's duration rather than rejected —
  you may get a shorter clip than requested, never an error. A `window.from` at or
  past the composition's duration *is* rejected, with `422 invalid_request`.
</Note>

## Contact sheets: see the whole edit in one image

Set `contact_sheet` to get back a single grid PNG sampling frames across the
timeline — enough to check an entire edit in one glance (or one vision call),
without downloading and scrubbing a video.

```json theme={null}
{
  "render_request": { "...": "..." },
  "contact_sheet": { "detail": "balanced", "max_cells": 4 }
}
```

Framelane samples at meaningful moments — the first and last frame, where each
element starts, each transition, and each caption's first word — then fills any
remaining budget uniformly (bare `{ "detail": "balanced" }` on a 10-second
composition budgets \~10 cells; `max_cells` caps that, shown here at 4 to keep the
example short). The `sheet` field describes the plan immediately (even on a
`dry_run`, before any render happens):

```json theme={null}
{
  "kind": "contact_sheet",
  "ok": true,
  "sheet": {
    "columns": 2,
    "rows": 2,
    "width": 480,
    "window": { "from": 0, "to": 10 },
    "cells": [
      { "index": 0, "at": 0.0, "reason": "first_frame" },
      { "index": 1, "at": 4.0, "reason": "element_start" },
      { "index": 2, "at": 6.5, "reason": "caption" },
      { "index": 3, "at": 9.97, "reason": "last_frame" }
    ]
  },
  "job": { "id": "render_01J...", "is_preview": true, "status": "queued" }
}
```

| Option      | Type                                         | Description                                                                              |
| ----------- | -------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `detail`    | `"overview"` \| `"balanced"` \| `"detailed"` | Cell budget: \~9 / \~16 / \~36 cells, capped down for short clips. Default `"balanced"`. |
| `columns`   | integer 1–12                                 | Grid columns. Defaults to a near-square layout.                                          |
| `max_cells` | integer 1–64                                 | Hard cap on cell count, below the `detail` tier's budget.                                |

Combine with `window` to sample only part of the timeline instead of the whole
thing. Once the render completes, `job.output.url` is a single PNG —
`columns × cellWidth` pixels wide, `rows × cellHeight` tall — with each cell's
frame and burned-in label (`"0:04 element_start"`) matching `sheet.cells[i]` in
order.

## Request reference

| Field                  | Type            | Description                                                                                 |
| ---------------------- | --------------- | ------------------------------------------------------------------------------------------- |
| `render_request`       | object          | The composition to preview.                                                                 |
| `at`                   | number ≥ 0      | Single-frame preview, in timeline seconds. Exclusive with `window` and `contact_sheet`.     |
| `window`               | `{ from, to }`  | Clip preview range, in timeline seconds. `to` must be greater than `from`.                  |
| `contact_sheet`        | object          | See table above. May be combined with `window` to bound its span.                           |
| `width`                | integer 16–3840 | Preview width in px; height derives from the composition's aspect ratio. Default `480`.     |
| `dry_run`              | boolean         | Validate only, no render. Default `false`.                                                  |
| `target_duration_sec`  | number > 0      | Optional target timeline length; a mismatch surfaces as a `TARGET_DURATION_MISSED` warning. |
| `target_tolerance_sec` | number ≥ 0      | Tolerance around `target_duration_sec`. Default `0.5`.                                      |

## Endpoints

| Method | Path          | Description                       |
| ------ | ------------- | --------------------------------- |
| `POST` | `/v1/preview` | Validate or preview a composition |

## Good to know

* Preview jobs cost nothing and are excluded from `GET /v1/renders` and
  `GET /v1/workspace/usage` — build as many as you need while iterating.
* A preview is pixel-identical to the equivalent full render at the same
  timestamp, modulo resolution — the renderer takes no different code path for a
  preview, so what you approve (once scaled up) is what you'll get.
* Preview requests never accept `Idempotency-Key` — every call creates a new job.
* If an element references media you haven't finished uploading yet, a real
  preview (`dry_run: false`) returns `422 asset_not_ready`; a `dry_run: true`
  validation tolerates it and lints the rest of the composition anyway.
