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.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).
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*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. - 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. - 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).
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.

