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. framelane-api now rounds its cell budget to a count that tiles the grid exactly, so in practice len(frames) == columns*rows — but keep this an inequality: a caller can still pin a columns wider than the tier budget can fill, and that is the one case that leaves a short last row. Blank cells should be painted with the sheet background, not left at the zeroed buffer: an opaque-black slot is indistinguishable from a legitimately black frame.
  • 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). Inset the caption from the cell edge — today it is flush, and the glyph tops read as clipped (see below).
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.

Label inset (defect on current main)

Captions are drawn hard against the cell corner, so the top row of every glyph sits on the cell boundary and the caption reads as clipped rather than placed. In ContactSheetTiler::DrawCellLabel (src/RenderNodeLib/contact_sheet.cpp:90 on main):
On the default cell framelane-api emits — 480 px wide (DEFAULT_PREVIEW_WIDTH = 480, api/services/preview_service.py:32) — scale is 2, so glyphH is 16 px and pad is 2 px. The dark bar starts on the boundary itself, and the first lit glyph row lands 2 px inside it. kFont8x8Basic lights row 0 for every digit (0-9 all have a non-zero first byte), so the numerals in "0:00 first_frame" are 2 px from the cell edge — and for every row after the first, 2 px from the bottom edge of the cell above, with no visual break between them. Required: at least half a glyph height of clear inset before the first lit glyph row — pad >= glyphH / 2, i.e. 8 px at the 480 px default cell and 4 px at scale == 1. pad drives both penX and penY, so one change fixes the top and left edges together; inset the contrast bar’s origin by the same amount instead of filling from (x0, y0), and keep barH/barW clamped to the cell as they are today. Preferred alternative, if you would rather not paint over the frame at all: reserve a real label gutter of glyphH + 2 * pad per grid row, draw the caption in the gutter below (or above) each cell, and emit a PNG of columns*cellWidth by rows*(cellHeight + gutter). The Output section already permits the taller sheet: framelane-api never reads the PNG’s pixel dimensions (api/mcp/media.py checks byte size against the inline cap and nothing else), only cell order matters (see positional sync), and a caption band of dead pixels compresses to almost nothing. Either fix is acceptable; a 2 px margin is not.

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, and no lit glyph pixel falls within glyphH / 2 of the cell’s top or left edge (8 px at the 480 px default cell) — or the caption sits in a dedicated gutter outside the frame image.
  • 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. max_frames_in_window is the count of frames in [window_start, last_renderable_frame] — the frames the anchors and the uniform fill actually sample from. The budget is then snapped to a count that tiles a whole grid (see step 5), rounding up when a full count fits under the ceiling and down otherwise.
  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). Because the budget in step 1 was snapped to a count this formula tiles exactly — one of 1, 2, 4, 6, 9, 12, 16, 20, 25, 30, 36, 42, 49, 56, 64, or a multiple of a pinned columnslen(frames) == columns*rows and the sheet has no blank cells. Rounding up is free: ceil(sqrt(n)) and ceil(n/columns) are unchanged by the snap, so the grid shape, the PNG’s pixel size and the sampled span are the same as before; the added cells are uniform samples filling slots the grid already had (a 5s clip yields 6 cells in the same 3x2, not 5 with a hole). The one exception is a pinned columns wider than any count the budget ceiling admits (e.g. columns: 12 with detail: "overview"), which is honoured as asked and leaves a short last row.
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.