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

# Render node preview contract

# render-node contract: preview slice + contact sheet

**Audience:** render-node (`main`, C++). **Owner of the emit side:** framelane-api.

This is the contract framelane-api's preview feature depends on. It covers two
render-node deliverables:

1. **`outputSlice` hardening** (framelane-api task #4) — render a sub-window
   faithfully.
2. **The `contactSheet` directive** (task #10) — render N sampled frames and tile
   them into one grid PNG.

All pixel work lives on render-node. framelane-api only translates the timeline
and emits declarative directives; it has no codec and does no frame extraction.

## Ground rule: preview == export

A preview is the *same* engine on a sub-window, downscaled. A preview frame at
timeline time `t` must be pixel-identical (modulo output resolution) to frame `t`
of the equivalent full render. Nothing in the preview path may take a different
decode, color, or compositing route. This is what lets an agent trust a cheap
preview instead of paying for a full render, so treat any divergence as a bug.

## Where these appear in the payload

Preview jobs are ordinary renderer JSON **v4** project jobs
(`params.renderType == "project"`, `params.version == "v4"`) delivered on the
existing Redis job queue. framelane-api adds preview-only keys under `params`;
everything else (elements, transitions, background, `exportSettings`) is identical
to a full render.

```jsonc theme={null}
"params": {
  "uuid": "render_01J...",
  "renderType": "project",
  "version": "v4",
  "outputWidth": 480,          // already downscaled for a frame/window preview
  "outputHeight": 270,
  "outputDuration": 30.0,      // full timeline length (unchanged)
  "outputFormat": "mp4",
  "exportSettings": { "fps": 30, "fpsLimit": 30, ... },  // previews pin `fps`: engine renders at EXACTLY this

  // preview-only, injected by framelane-api:
  "outputSliceStart": 4.0,     // seconds, absolute timeline (optional)
  "outputSliceEnd":   4.0333,  // seconds, absolute timeline (optional)
  "contactSheet": { ... }      // present only for a contact-sheet preview
}
```

A job with neither `outputSlice*` nor `contactSheet` is a normal full render.

***

## Part A — `outputSlice` (task #4)

`outputSliceStart` / `outputSliceEnd` are absolute timeline seconds. When present,
render only the frames whose timeline time falls in `[start, end)`. Absent means
the full `[0, outputDuration]`. framelane-api uses this for:

* a **single frame**: `[t, t + 1/fps]`, with `t` snapped to the frame grid;
* a **window clip**: `[t0, t1]`, audio trimmed and muxed as today.

By the time a job reaches the queue, framelane-api guarantees `end > start` and both
values inside `[0, outputDuration]` — but the two bounds get there by different
means (confirm against current `main`), and render-node must still be defensive:

* `end > start` and `start >= 0` are enforced at request-parse time (a schema
  validator on the window field, before any preview logic runs).
* A window whose `start` is at/after the timeline is explicitly **rejected** with a
  422 before dispatch (`_guard_window` in `api/routers/preview.py`).
* A window's `end` (or a single-frame `at`) that overshoots the timeline is never
  rejected — it is silently **clamped** down to `outputDuration`
  (`window_slice`/`frame_slice` in `api/services/preview_service.py`). So the slice
  render-node receives is always well-formed, but may be narrower than what an agent
  originally asked for.

1. **Zero-width guard.** Reject `end <= start` with a clear error instead of a
   divide-by-zero in the frame counter (per the earlier `main` review,
   `src/RenderNodeLib/frame_counter.cpp`; confirm against current `main`). A
   preview must never crash the worker on a degenerate slice.
2. **Non-zero start / decode offset.** A slice with `start > 0` must seek or adjust
   the source streams so mid-timeline frames decode correctly
   (`IgnoreOrAdjustStreamForSlice` path). No production caller exercises `start > 0`
   today, so this needs an explicit e2e test.
3. **Fidelity.** A frame produced from a slice must be pixel-identical to the same
   frame of the full render (see "preview == export").

The slice maps to the `FrameCounter` window (per the earlier review,
`graph.cpp:149-156`).

### Part A acceptance tests

* Slice `[t, t + 1/fps]` produces exactly one frame; `[t0, t1]` produces only that
  window with audio.
* `end <= start` is rejected with an error, not a crash.
* A slice with `start > 0` decodes the correct frames (not an offset/garbage frame).
* A sliced frame is byte-identical to the same frame extracted from a full render
  at the same timestamp and resolution.

***

## Part B — `contactSheet` directive (task #10)

A contact sheet is one PNG: a grid of composition-aware frames sampled across the
timeline, so an agent perceives a whole edit in a single vision call. framelane-api
computes *which* frames and the grid shape; render-node renders and tiles them.

```jsonc theme={null}
"contactSheet": {
  "frames":     [0.0, 2.2333, 4.0, 6.0, 9.9667],  // absolute timeline seconds, ascending, frame-snapped
  "columns":    4,
  "rows":       2,
  "cellWidth":  480,        // even; already downscaled from the canvas, aspect-correct
  "cellHeight": 270,        // even
  "labels":     ["0:00 first_frame", "0:02 uniform", "0:04 element_start", "0:06 element_start", "0:09 last_frame"]
}
```

Field semantics:

* **`frames`** is authoritative for which frames appear and in what order. Each is
  an absolute timeline second, already snapped to the frame grid, so `round(t*fps)`
  is exact. Render each faithfully (same engine, "preview == export").
* **`columns` / `rows`** give the grid. `len(frames) <= columns*rows`; fill
  **row-major** from index 0, leaving trailing cells blank if the last row is short.
* **`cellWidth` / `cellHeight`** are the per-cell pixel size (even, aspect-correct
  vs the canvas). Downscale each rendered frame to exactly this. Note: on a
  contact-sheet job `outputWidth`/`outputHeight` stay the full canvas (unlike a
  frame/window preview); `cellWidth`/`cellHeight` govern each cell, and the final
  PNG is `columns*cellWidth` by `rows*cellHeight`.
* **`labels`** is parallel to `frames` (same length, same order): a short
  `"m:ss reason"` caption. Burning it into each cell (small, legible, corner) is
  recommended so the agent maps a cell to a timeline moment and knows *why* it was
  sampled. If you cannot burn text, ignore it (framelane-api also returns the labels
  in the API response).

`outputSliceStart/End` are also set, bounding `[frames[0], frames[-1] + 1/fps]`. You
may use it to limit decoding, but `frames` is authoritative. Rendering only the
listed frames (seek per frame) is preferred over rendering the whole span; if you
render a span instead, the pinned `exportSettings.fps` (see below) is the render fps.

**framelane-api pins `exportSettings.fps`.** On every preview payload (single frame,
window, and contact sheet) framelane-api sets `exportSettings.fps` to the exact fps
it snapped `frames` / the slice to. The engine must honour it as the output fps
(`EncodeQuality.FPS`, bypassing the source-deduced `[fpsLimitMin, min(fpsLimit, 60)]`
clamp), so the engine's frame grid equals the planner's. This is what makes each
listed frame land on an exact engine index -- without it, a source-deduced fps that
differs from the planner's makes distinct frames collide on one index (black cells)
or a single-frame slice round to 0/2 frames. framelane-api caps the pinned fps at 60
(the engine ceiling), so planner and engine agree even for higher `frame_rate`s.

### Output

* The artifact is a **single PNG**, `columns*cellWidth` by `rows*cellHeight` (plus
  any label gutter you add). Emit it regardless of `params.outputFormat`; the engine
  emits mp4/webm today, so the grid PNG is a new still/grid output path.
* Report the completed artifact path (ending `.png`) through the existing
  render-events callback exactly like a normal render. framelane-api stores it as
  the job output and serves it via the signed-download endpoint; no new callback
  fields are needed. The job is `is_preview` (framelane-api excludes it from billing
  and the renders list); render-node treats it like any render.

### Do not drop or reorder cells (positional sync)

Render **exactly** `len(frames)` cells, one per `frames[i]`, in order, row-major.
Do **not** deduplicate, collapse, reorder, or skip cells, even if two rendered
frames look identical. The image is positionally 1:1 with framelane-api's `cells`
and `labels` arrays returned in the API response: image cell `i` must be
`frames[i]` / `labels[i]`. Any pixel-based dedup here would silently desync the
grid from those arrays (cell `i` would no longer be label `i`), and render-node has
no way to update them. "Skip these two, they're identical" is a decision for the
planner that owns the frame metadata (framelane-api `contact_sheet.py`), not the
pixel renderer, so this side just renders what it is given.

### Forward-compatibility (important)

Until this lands, **do not hard-fail on an unknown `contactSheet` key.** Ignoring
it and rendering the bounded `outputSlice` as an mp4 is an acceptable interim
(the agent still gets a windowed preview). A strict param parser that rejects the
unknown key would break contact-sheet previews before the feature ships, so make
unknown top-level `params` keys non-fatal.

### Part B acceptance tests

* A directive with N frames yields one PNG with N cells laid out `columns`x`rows`,
  row-major, in `frames` order, each `cellWidth`x`cellHeight`.
* Each cell equals the same-timestamp frame of a full render downscaled to the cell
  size (preview == export, per cell).
* Labels (if burned) match `labels[i]` for cell `i`.
* An absent `contactSheet` key renders normally; an unknown key does not crash.

***

## Delivery + references (confirm against current `main`)

Preview jobs (frame, window, and contact sheet) arrive as ordinary `is_preview`
render jobs on the existing render queue and run through render-node's normal render
path -- there is no separate synchronous preview pool, and framelane-api polls the
job via `GET /v1/renders/{id}`. Pointers to confirm against current `main`:

* The zero-width slice guard (Part A) lives in JSON parsing, not
  `frame_counter.cpp` as an earlier draft of this doc said — confirm current
  location against `main` before relying on either pointer.
* `graph.cpp:149-156` — `outputSlice` to `FrameCounter` window (Part A).
* The grid PNG is a new still/grid output path (the engine emits mp4/webm today); it
  belongs wherever render-node composes and encodes output, not a fixed file cited here.

## Emit side (framelane-api, already implemented)

Everything below is framelane-api-internal (render-node only sees the `params`
payload documented above); kept here so it's the one place that maps the wire
contract to the code that produces it.

### HTTP surface

Two endpoints funnel into the same code (`run_preview` in `api/routers/preview.py`),
so render-node sees an identical payload no matter which one was used:

* **`POST /v1/preview`** — the composition is inline, as `render_request` in the
  body.
* **`POST /v1/projects/{project_id}/preview`** — no `render_request`; the
  composition is the stored project's current head
  (`ProjectService.head_request`, `api/services/project_service.py`). 404s if the
  project doesn't exist in the workspace.

  Note: this "project" (`api/models/project.py`, a stored/versioned composition
  with its own CRUD + patch-ops API, new alongside preview) is an unrelated concept
  to the payload's `params.renderType == "project"` discriminator described at the
  top of this doc — same word, different thing. The stored-project CRUD surface is
  otherwise out of scope here.

Request body (`PreviewRequest` / `PreviewOptions`, `api/schemas/preview.py`):

```jsonc theme={null}
{
  "render_request": { /* RenderRequest — only on POST /v1/preview */ },
  "at": 2.0,                              // seconds; single-frame preview
  "window": { "from": 4.0, "to": 6.0 },   // OR at, not both
  "contact_sheet": { "detail": "balanced", "columns": 4, "max_cells": 16 }, // OR at
  "width": 480,                           // 16-3840; downscale target, height derives from aspect
  "dry_run": false,                       // true = validate only, no job
  "target_duration_sec": 30.0,
  "target_tolerance_sec": 0.5
}
```

`window` and `contact_sheet` may be combined — the window becomes the sheet's span.
`at` is mutually exclusive with both `window` and `contact_sheet`.

Response (`PreviewResponse`):

```jsonc theme={null}
{
  "kind": "frame",       // "validation" (dry-run) | "frame" | "window" | "contact_sheet"
  "ok": true,             // false iff any error-severity violation
  "violations": [ { "code": "DANGLING_TRANSITION", "message": "...", "severity": "error", "path": "transitions[0]" } ],
  "job": { "id": "render_...", "is_preview": true, "status": "queued", "..." : "..." }, // null on dry_run
  "sheet": { "columns": 4, "rows": 1, "width": 480, "window": { "from": 0, "to": 10 }, "cells": [ { "index": 0, "at": 0.0, "reason": "first_frame" } ] } // present iff contact_sheet requested (including dry_run)
}
```

Status codes: `200` for a dry run (no job dispatched, `job: null`); `202` once a
preview render is actually queued; `401` missing/invalid credential; `403`
unverified-email workspace; `404` project-not-found (project variant only); `422`
malformed request or a rejected business rule.

`violations` (`api/schemas/validation.py` — `Violation{code, message, severity,
path}`) comes from the same free linter used for `dry_run`
(`check_invariants`, `api/services/invariants.py`): duplicate/dangling/self-loop
transitions, an empty timeline, an element or caption word starting past the
composition, a missed `target_duration_sec`, and any translator warning folded in
(translation runs `strict=False` for preview, so most unsupported-feature issues
become advisory violations instead of a 422). `ok` only reflects `severity ==
"error"` violations — a missed target duration, for example, never flips it false.

### Validation & error shapes

Two distinct 422 envelopes exist (both wrapped as `{"error": {...}}` by
`api/app.py`'s exception handlers) — useful when debugging a preview that never got
dispatched, though render-node never sees either, it only ever sees a job that
either exists or doesn't:

* **Schema-level** (`window.to <= window.from`, `at`+`window` both set, a field out
  of range): `{"code": "invalid_request", "message": "Request validation failed.",
  "details": {"errors": [...]}}`.
* **Application-level** (`raise_validation`, `api/routers/_helpers.py` — the
  window-past-duration guard, unresolved media, a translator/preflight failure):
  `{"code": <ErrorCode>, "message": <specific message>, "details": {...}}`.

On a non-dry-run request, a video/audio element referencing an unregistered or
still-processing media asset also 422s (`INVALID_SOURCE` / `ASSET_NOT_READY`,
`api/services/job_service.py`); on `dry_run=true` that failure is swallowed and the
raw request is linted instead, so a dry run tolerates media that hasn't finished
uploading yet.

### `is_preview`, billing, and listing

`Job.is_preview` (`api/models/job.py`, added by
`alembic/versions/0008_jobs_is_preview.py`: `BOOLEAN NOT NULL server_default false`

* btree index `ix_jobs_is_preview`) is the *only* discriminator between a preview
  and a billable render — both share `kind = RENDER` and the `render_` id prefix, and
  run through the identical translate → preflight → CDN-rewrite → queue pipeline
  (`create_preview_render` calls the same payload builder as a full render, then
  narrows it with `apply_preview_slice`/`apply_contact_sheet` before publishing).
  Three places branch on it:

- `JobService.list(..., exclude_preview=True)` (the default) filters
  `Job.is_preview.is_(False)`; `GET /v1/renders` never overrides it, so previews
  never appear in the list.
- `JobService.get()` does **not** filter on `is_preview` — `GET /v1/renders/{id}`
  can still poll a preview job, which is what makes "polls via
  `GET /v1/renders/{id}`" below true.
- `GET /v1/workspace/usage` (`api/routers/workspace.py`) filters
  `Job.is_preview.is_(False)` on both the `render_minutes` and `egress_bytes`
  aggregates, so previews are excluded from billed usage too. `output_path` is
  always populated on completion; `output_duration`/`output_size_bytes` are only
  as good as whatever `renderData` the completion callback carries (see
  `_apply_complete`, `api/workers/event_drainer.py`) — for a video/window preview
  that should match a real render, but a contact-sheet (PNG) completion likely
  omits `duration` (and maybe `sizeBytes`) if render-node's metadata probe is
  video-only; confirm against current `main` before assuming those fields are set
  for a contact-sheet job.

A preview request never takes an `Idempotency-Key` — every call creates a new job,
there's no dedup.

### Contact-sheet sampling (`api/services/contact_sheet.py`, `plan_contact_sheet`)

Deterministic and composition-aware — no pixels, no DB. Derives `duration` from the
same `Translator._derive_duration` the translator itself uses (so the plan agrees
with the real render), snaps the window to the frame grid, then:

1. **Budget** — `max(1, min(round(window_seconds), tier_cap, max_cells,
   max_frames_in_window))`, floored at `MIN_CELLS = 4`. Tier caps by `detail`:
   `overview = 9`, `balanced = 16` (default), `detailed = 36`; the request's
   `max_cells` (1–64) can only lower it further.
2. **Anchors** — always `first_frame` (window start) and `last_frame` (last
   renderable frame in-window); plus every visible element's start
   (`element_start`), each transition's entering-element boundary (`transition`),
   and each caption's first-word start on a text/countdown element (`caption`).
3. **Dedup** — anchors landing on the same frame keep the highest-priority reason:
   `first_frame` > `{element_start, transition, caption}` > `last_frame` >
   `uniform`.
4. **Reconciliation** — too many anchors → evenly subsampled (always keeps the
   first and last); too few → uniform-filled (`reason: "uniform"`) up to budget.
5. **Grid** — `columns = request.columns or ceil(sqrt(n))`, `rows = ceil(n /
   columns)`.

`plan.cells[i].at` is what becomes `contactSheet.frames[i]`; the label is
`_mmss(at)` (`"m:ss"`) followed by the reason string, e.g. `"0:04 element_start"`.

### File map

* `api/routers/preview.py` — `POST /v1/preview`; `run_preview` (shared
  orchestration: media resolution → `_guard_window` → dry-run/dispatch branch →
  contact-sheet planning), `_guard_window`, `_resolve_media`, `_run_contact_sheet`.
* `api/routers/projects.py` — `POST /v1/projects/{project_id}/preview`, delegating
  to the same `run_preview`.
* `api/schemas/preview.py` — the wire contract: `PreviewRequest`, `PreviewOptions`,
  `PreviewWindow`, `ContactSheetOptions`, `ContactSheetPlan`, `ContactSheetCell`,
  `SampleReason`, `PreviewResponse`.
* `api/schemas/validation.py` — `Violation`, `Severity`, `ValidationResult`.
* `api/services/preview_service.py` — `frame_slice`, `window_slice`, `scaled_dims`
  (pure slice/scale math), `apply_preview_slice`, `apply_contact_sheet` (payload
  mutators), `PreviewService.validate` / `build_preview_payload`.
* `api/services/contact_sheet.py` — `plan_contact_sheet`, the sampling planner.
* `api/services/invariants.py` — `check_invariants`, the free linter behind
  `dry_run` / `violations`.
* `api/services/job_service.py` — `create_preview_render`; `JobService.list`'s
  `exclude_preview` filter.
* `api/models/job.py`, `alembic/versions/0008_jobs_is_preview.py` — `Job.is_preview`.
* `api/routers/workspace.py` — usage-endpoint `is_preview` exclusion.
