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:
outputSlicehardening (framelane-api task #4) — render a sub-window faithfully.- The
contactSheetdirective (task #10) — render N sampled frames and tile them into one grid PNG.
Ground rule: preview == export
A preview is the same engine on a sub-window, downscaled. A preview frame at timeline timet 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.
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], withtsnapped to the frame grid; - a window clip:
[t0, t1], audio trimmed and muxed as today.
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 > startandstart >= 0are enforced at request-parse time (a schema validator on the window field, before any preview logic runs).- A window whose
startis at/after the timeline is explicitly rejected with a 422 before dispatch (_guard_windowinapi/routers/preview.py). - A window’s
end(or a single-frameat) that overshoots the timeline is never rejected — it is silently clamped down tooutputDuration(window_slice/frame_sliceinapi/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.
- Zero-width guard. Reject
end <= startwith a clear error instead of a divide-by-zero in the frame counter (per the earliermainreview,src/RenderNodeLib/frame_counter.cpp; confirm against currentmain). A preview must never crash the worker on a degenerate slice. - Non-zero start / decode offset. A slice with
start > 0must seek or adjust the source streams so mid-timeline frames decode correctly (IgnoreOrAdjustStreamForSlicepath). No production caller exercisesstart > 0today, so this needs an explicit e2e test. - Fidelity. A frame produced from a slice must be pixel-identical to the same frame of the full render (see “preview == export”).
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 <= startis rejected with an error, not a crash.- A slice with
start > 0decodes 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.
framesis authoritative for which frames appear and in what order. Each is an absolute timeline second, already snapped to the frame grid, soround(t*fps)is exact. Render each faithfully (same engine, “preview == export”).columns/rowsgive 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 practicelen(frames) == columns*rows— but keep this an inequality: a caller can still pin acolumnswider 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/cellHeightare the per-cell pixel size (even, aspect-correct vs the canvas). Downscale each rendered frame to exactly this. Note: on a contact-sheet joboutputWidth/outputHeightstay the full canvas (unlike a frame/window preview);cellWidth/cellHeightgovern each cell, and the final PNG iscolumns*cellWidthbyrows*cellHeight.labelsis parallel toframes(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):
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*cellWidthbyrows*cellHeight(plus any label gutter you add). Emit it regardless ofparams.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 isis_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 exactlylen(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 unknowncontactSheet 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, inframesorder, eachcellWidthxcellHeight. - 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 celli, and no lit glyph pixel falls withinglyphH / 2of 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
contactSheetkey 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.cppas an earlier draft of this doc said — confirm current location againstmainbefore relying on either pointer. graph.cpp:149-156—outputSlicetoFrameCounterwindow (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 theparams
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, asrender_requestin the body. -
POST /v1/projects/{project_id}/preview— norender_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’sparams.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.
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):
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+windowboth 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": {...}}.
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 sharekind = RENDERand therender_id prefix, and run through the identical translate → preflight → CDN-rewrite → queue pipeline (create_preview_rendercalls the same payload builder as a full render, then narrows it withapply_preview_slice/apply_contact_sheetbefore publishing). Three places branch on it:
JobService.list(..., exclude_preview=True)(the default) filtersJob.is_preview.is_(False);GET /v1/rendersnever overrides it, so previews never appear in the list.JobService.get()does not filter onis_preview—GET /v1/renders/{id}can still poll a preview job, which is what makes “polls viaGET /v1/renders/{id}” below true.GET /v1/workspace/usage(api/routers/workspace.py) filtersJob.is_preview.is_(False)on both therender_minutesandegress_bytesaggregates, so previews are excluded from billed usage too.output_pathis always populated on completion;output_duration/output_size_bytesare only as good as whateverrenderDatathe 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 omitsduration(and maybesizeBytes) if render-node’s metadata probe is video-only; confirm against currentmainbefore assuming those fields are set for a contact-sheet job.
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:
- Budget —
max(1, min(round(window_seconds), tier_cap, max_cells, max_frames_in_window)), floored atMIN_CELLS = 4. Tier caps bydetail:overview = 9,balanced = 16(default),detailed = 36; the request’smax_cells(1–64) can only lower it further.max_frames_in_windowis 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. - Anchors — always
first_frame(window start) andlast_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). - Dedup — anchors landing on the same frame keep the highest-priority reason:
first_frame>{element_start, transition, caption}>last_frame>uniform. - Reconciliation — too many anchors → evenly subsampled (always keeps the
first and last); too few → uniform-filled (
reason: "uniform") up to budget. - Grid —
columns = 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 of1, 2, 4, 6, 9, 12, 16, 20, 25, 30, 36, 42, 49, 56, 64, or a multiple of a pinnedcolumns—len(frames) == columns*rowsand the sheet has no blank cells. Rounding up is free:ceil(sqrt(n))andceil(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 areuniformsamples 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 pinnedcolumnswider than any count the budget ceiling admits (e.g.columns: 12withdetail: "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.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 samerun_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 behinddry_run/violations.api/services/job_service.py—create_preview_render;JobService.list’sexclude_previewfilter.api/models/job.py,alembic/versions/0008_jobs_is_preview.py—Job.is_preview.api/routers/workspace.py— usage-endpointis_previewexclusion.

