Skip to main content

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

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 columnsxrows, row-major, in frames order, each cellWidthxcellHeight.
  • 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-156outputSlice 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):
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):
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.pyViolation{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_previewGET /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. Budgetmax(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. Gridcolumns = 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.pyPOST /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.pyPOST /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.pyViolation, Severity, ValidationResult.
  • api/services/preview_service.pyframe_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.pyplan_contact_sheet, the sampling planner.
  • api/services/invariants.pycheck_invariants, the free linter behind dry_run / violations.
  • api/services/job_service.pycreate_preview_render; JobService.list’s exclude_preview filter.
  • api/models/job.py, alembic/versions/0008_jobs_is_preview.pyJob.is_preview.
  • api/routers/workspace.py — usage-endpoint is_preview exclusion.