Pre-Gen Validation — frontend integration handoff¶
For the engineer building the final "Review all inputs before generation" screen.
A complete working reference implementation exists on studio-frontend branch f-t2ibenchmarking-ui — modal, API wiring, conflict banner with expandable rows, severity chips, dismiss flow, reference/textual cards. Use it, restyle it, or rewrite it; the contract below is what the backend guarantees. Backend PR: Scenarix/studio-backend#677.
1. The two endpoints¶
POST /workbench/validateShotForGeneration¶
{ "storyId": "...", "sceneId": "...", "shotId": "...", "force": false, "sketchImageUrl": "https://…" }
- Call it when the review screen opens (not on the Generate confirm — the check takes time; let it run while the user reads).
sketchImageUrl(optional) — send it whenever generation will use a specific sketch rather than the shot's selected one. This is the "Regenerate from sketch" path in the full-preview modal, which passessketchImageUrltogenerateFromSketch. If you omit it there, validation checks the selected sketch while generation renders a different one — wrong verdict, and the cache never hits (different input hash). The rule is simple: validate the same sketch you will generate from. Omit it on the normal Generate path (backend falls back toisSelected). A URL that isn't one of the shot's sketches returns an error.- Latency: first validation of a shot ≈ 1.5–3 min (LLM stages). Unchanged inputs → instant (cached by input hash — any edit to the shot's fields/sketch/references invalidates automatically). Set a request timeout ≥ 5 min (the reference impl uses 300 000 ms — the default 60 s axios timeout WILL abort first runs).
- Backend-side timeouts exist (120s per stage, 240s final review): a hung LLM call returns
status: validation_failedrather than hanging the request forever — so a well-formed response always arrives within ~6 min worst case. force: truebypasses the cache (debug only).
Response: { isSuccess, data: { validation: PreGenValidationResult } } — full type in app/_shared/sharedTypes.ts (already on the branch).
POST /workbench/dismissShotConflict¶
{
"storyId": "...",
"sceneId": "...",
"shotId": "...",
"issueId": "...",
"reason": "not_actually_a_conflict",
"note": "..."
}
reason is required and must be one of: not_actually_a_conflict, minor_can_ignore, dont_understand, other. note is optional.
- Instant (no LLM). Returns the recomputed
{ status, conflicts }— re-render from it. - Returns an error for non-dismissible conflicts (blockers) — don't show the button for those (
dismissible: false). - Dismissals persist: same conflict on unchanged data stays dismissed; editing an involved field re-raises it.
2. What's in the response — field-by-field, with UI purpose¶
Top level¶
| Field | Type | Show in UI? | Purpose |
|---|---|---|---|
status |
pass \| pass_with_warnings \| review_required \| blocked \| validation_failed |
drives states | blocked only for SKETCH_ANALYSIS_MISSING. Other blockers surface as review_required. validation_failed → the CHECKER broke, not the data: show a distinct "check could not complete" state + Retry (never render as "no conflicts") |
canGenerate |
boolean | drives Generate button | false only when an active SKETCH_ANALYSIS_MISSING conflict is present — all other findings are advisory for now |
conflicts |
array | yes — the banner | see below |
selectedSketchMoment |
start \| middle \| end |
optional, nice | "Your sketch shows the END of this shot" |
sketchAlignmentConfidence |
0–1 | optional | confidence of the above |
sketchMomentScores |
{start,middle,end} |
probably not | debug/analytics |
frameCandidates |
{start,middle,end} strings |
yes — right column, labelled as derived | The frame descriptions the checker derived, not the creator's initialFrameInstructions/etc. Stage 3 compares the sketch against frameCandidates[selectedSketchMoment], and conflict text quotes it ("the frame description says…") — so if you don't show it, the user can't find what the conflict is talking about. See the box below. Present on cached responses too, but absent on results validated before 2026-08-04 — guard for undefined |
resolvedInputs |
{sketchUrl, references[]} |
yes — Visual references cards | The sketch + character/environment/prop images the checker validated. Each reference is exactly {type, name, imageUrl, assetId} — no variantId, so you can't label which view of a location it is. sketchUrl is the raw sketch; the annotated (C1/C2-labelled) version Stage 3 actually looks at is not exposed. Both are backend additions if design needs them — ask, don't work around it |
validatedAt, validatorVersion, inputHash |
— | no | telemetry |
Each conflict¶
| Field | Values | Show in UI as |
|---|---|---|
shortMessage |
fixed sentence per code | banner row text — never model-written, stable wording |
severity |
blocker / risky / minor / info |
chip + ordering. info → do not render (analytics only). Blockers sort first. Read it off the conflict — never infer it from code. The model picks severity per finding; the catalog default is only a fallback. Measured: SKETCH_POSE_ACTION_MISMATCH came back blocker 6× / risky 10× / minor 3× |
dismissible |
boolean | show/hide "Not a conflict". Blockers are false; risky and minor are true (held in all 118 measured conflicts) |
description |
model prose | expanded view — "What we found" |
suggestion |
model prose, optional | expanded view — "Proposed fix". Absent on 46 of 118 measured conflicts (39%) — the deterministic ones and most text ones. Render the row without it, don't hide the row |
fieldPaths |
e.g. ["sketch"], ["dialogue","actingInstructions"], ["FRAME DESCRIPTION","STAGE 2 SKETCH OBSERVATION"] |
highlight matching input cards; target for "Go back and resolve". Two naming styles: Stage 1 + deterministic checks use real camelCase field names, Stage 3 uses uppercase section labels from its prompt. STAGE 2 SKETCH OBSERVATION is an internal artefact with no user-visible input. Match case-insensitively, treat unknown paths as "general", never drop a conflict because its path didn't map |
code |
stable ID — but only for Stage 2/3 conflicts | not user-visible; use for analytics events. Stage-1 text conflicts have no code enum: the model invents a string (14 distinct codes in 14 findings when measured), so they all fall back to the one-liner "Shot properties contradict each other." Don't build per-code analytics on text conflicts until that's fixed backend-side. Full catalog: docs/pregen-validation-output-example.md §4 |
issueId |
uuid | pass to the dismiss endpoint |
confidence |
0–1 | not shown in V0 |
proposedFix |
{path, oldValue, newValue, ...}, optional |
nothing yet — reserved for a future "Fix with AIDA" one-click apply |
The derived-frame trap — read this before designing the right column¶
Stage 1 rewrites the shot's text into three static frame descriptions (frameCandidates). Stage 3 compares the sketch against frameCandidates[selectedSketchMoment] — not against the creator's initialFrameInstructions / middleFrameInstructions / finalFrameInstructions (selectedFrameAlignmentStage.ts:61).
Conflict description text then quotes the derived version: "the frame description says the phone screen should fill the frame." If the screen only shows the creator's own fields, the user reads that, searches their text, finds nothing, and reports the checker as broken. Our PM did exactly this on the first pass.
Real example from a measured shot — creator's initialFrameInstructions:
"Becca stands frame-left and Tim stands frame-right beside the Bubble Car at center-right, both waist-up with the driveway and street behind them."
frameCandidates.start, which the sketch was actually compared to:
"Medium eye-level static 2-shot, framed head-to-waist. Becca stands frame-left at roughly 30% of the frame, body angled slightly three-quarter right, shoulders square and chin level, eyes fixed off-frame right. Tim stands frame-right at roughly 65% of the frame…"
Same intent, much more specific — and the added specifics are what conflicts get raised against. So: render frameCandidates[selectedSketchMoment] on the review screen with a label that says it's derived (e.g. "What the checker compared your sketch to"), separate from the creator's own fields.
Also note middleFrameInstructions is empty in practice — nothing in the product writes it (not in ALLOWED_METADATA_FIELDS, so SketchAIDA can't), and it was empty on 16/16 shots of the scene measured. Don't design a layout that assumes three filled creator frame fields.
3. Product rules already agreed¶
- The review screen opens on every Generate click (pre-flight step, not an error popup). Conflict banner is its warning state.
- Confirm waits for the verdict — Generate button disabled until the response arrives.
- Which severities are shown is an open PM decision — see
docs/pregen-validation-pm-report.md. The API always returns everything; filtering is frontend-side. - Validation runs only on the normal Generate paths. Regenerate-with-feedback and chat-tweak paths skip the review screen (agreed scope).
4. Gotchas learned building the reference implementation¶
- Timeout (again, because it will bite): default 60s axios timeout aborts real first-run validations. Use ≥5 min for this one call.
- The verdict can arrive after the user closed the modal — guard against stale responses (reference impl keys responses by request and drops mismatches).
- A shot whose sketch was never analyzed always gets a non-dismissible
SKETCH_ANALYSIS_MISSINGblocker (deterministic, instant). The design shows this as the red banner tier. Expect this to dominate your test data: it fired on 31/31 shots across the two scenes measured, which alone forcesblockedeverywhere. If you want to see thepass/review_requiredstates while building, you need shots that already have sketch analysis — otherwise every shot you open isblocked. resolvedInputsis also present on cached responses (rebuilt live from the shot each call).- Cached responses carry the display fields too.
selectedSketchMoment,sketchMomentScores,sketchAlignmentConfidenceandframeCandidatesare stored on the shot, so a cache hit renders the same screen as a fresh run. Two things are not stored and only appear on a fresh run:timings(empty array when cached — a reliable way to tell you got a cached verdict) andtraces(never sent to the UI at all). Results validated before 2026-08-04 predate this and lack the display fields — treat all four as optional. - Sketch mismatch = permanent cache miss. Validating sketch A while generating from sketch B doesn't just give a wrong verdict — the input hash differs from what any later run produces, so every open of the screen pays the full 1.5–3 min again. Always pass
sketchImageUrlon the regenerate-from-preview path. - How many rows to expect in the banner. Measured over 31 real shots: 3.8 conflicts per shot including
SKETCH_ANALYSIS_MISSING, 2.8 without it. Worst case seen was 9 on one shot. Design for ~4 rows normal, 9 possible — blockers expanded, the rest behind an "N more warnings" toggle is the current recommendation. - Latency in practice: ~100–110 s average on a fresh run, 194 s worst case measured. Not a theoretical bound — that's what real shots took.
- Live example payload to develop against:
docs/pregen-validation-output-example.json(real production shot output — note it was captured beforeframeCandidates/resolvedInputswere added to the stored summary, so it lacks both;docs/pregen-validation-output-example.md§5 lists what a current response carries).