feat(studio,studio-server,cli): render cancel end-to-end + renders/nle/storyboard UX#1963
feat(studio,studio-server,cli): render cancel end-to-end + renders/nle/storyboard UX#1963vanceingalls wants to merge 1 commit into
Conversation
|
Warning This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
This stack of pull requests is managed by Graphite. Learn more about stacking. |
959c3ee to
fb9494d
Compare
2f9fbd8 to
55b613b
Compare
miga-heygen
left a comment
There was a problem hiding this comment.
PR Review: feat(studio,studio-server,cli): render cancel end-to-end + renders/nle/storyboard UX
VERDICT: APPROVE (soft) — strong work across a wide surface. Three items to resolve, one architectural suggestion.
Summary
This is the plumbing + UX half of render cancel: server route, adapter abort hooks, SSE terminal-status handling, partial-output cleanup, and a healthy pile of render queue / NLE / storyboard polish. The cancel lifecycle is well-designed — AbortController at the adapter layer, RenderCancelledError propagation through the producer, optimistic UI in the client, and cleanup of partial files so cancelled jobs don't haunt the history. The NLE and storyboard changes are largely a11y completions (APG tabs, role="separator", role="progressbar", beforeunload guard, blur autosave) and correctness fixes (composition-size-aware drop coordinates, zoom HUD per-frame updates, initial pan clamping).
Ponytail Lens
The cancel lifecycle has a clean separation of concerns: the route marks the job cancelled and invokes the hook, the adapter aborts the controller, the producer throws RenderCancelledError at the next checkpoint, and the catch block cleans up partial output. The double-status-set (route sets "cancelled", catch block re-sets it) is redundant but harmless since they're the same object reference. The executeRenderJob function already has thorough abort support — checkpoint polling via assertNotAborted() at every stage boundary, signal forwarding to parallel capture workers, and typed RenderCancelledError with resource cleanup. This PR correctly plugs into that existing infrastructure rather than reinventing it.
The hidden-ids mechanism for "Hide finished" is appropriately scoped (per-project localStorage key, capped at 200 entries to prevent unbounded growth, defensive JSON parsing). The deleteRender change from fire-and-forget to error-aware is a meaningful reliability improvement — previously a failed delete silently removed the UI row while the file stayed on disk.
Findings
[medium] Side effect inside setState updater — NLEPreview.tsx:183
setCompositionSize((prev) => {
if (prev?.width === next?.width && prev?.height === next?.height) return prev;
onCompositionSizeChangeRef.current?.(next); // <-- side effect in updater
return next;
});State updater functions should be pure — React may call them multiple times in Strict Mode (dev) and during concurrent rendering. The onCompositionSizeChange callback sets parent state (setPreviewCompositionSize), which is idempotent, so this won't cause visible bugs in production. But it's a React anti-pattern that could confuse future maintainers or break under stricter concurrent features.
Suggestion: Extract the side effect to a useEffect that watches compositionSize:
const updateCompositionSizeFromPreview = useCallback(() => {
const next = readPreviewCompositionSize(previewIframeRef.current);
setCompositionSize((prev) =>
prev?.width === next?.width && prev?.height === next?.height ? prev : next,
);
}, []);
useEffect(() => {
onCompositionSizeChange?.(compositionSize);
}, [compositionSize, onCompositionSizeChange]);[low] New RenderQueue props unwired in consumer
The props onCancel, loadError, onRetryLoad, actionError, onDismissActionError are all defined as optional in RenderQueueProps and destructured in the component, but StudioRightPanel.tsx (the only consumer) doesn't pass them. The cancelRender, loadError, actionError, dismissActionError, and reloadRenders values from useRenderQueue flow into StudioContext but never reach <RenderQueue>. This means: the cancel button renders but does nothing (calls onCancel?.() which is undefined); load errors and action errors are silently swallowed in the UI.
I assume this wiring lands in a later PR in the 7-PR stack — just flagging to make sure it doesn't get lost.
[low] Missing aria-valuemax on TimelineResizeDivider
The separator declares aria-valuenow and aria-valuemin={MIN_TIMELINE_H} but omits aria-valuemax. Assistive tech can't communicate the full range without it. The max is dynamic (containerHeight - MIN_PREVIEW_H), so it would need computing at render time — an easy addition:
aria-valuemax={Math.round(
(containerRef.current?.getBoundingClientRect().height ?? 600) - MIN_PREVIEW_H
)}[nit] Redundant setIsDragOver(true) in handleDragOver
With the new handleDragEnter setting setIsDragOver(true), the same call in handleDragOver (line ~72 in usePreviewBlockDrop.ts) is now redundant. It doesn't cause bugs (browsers fire dragenter before dragover), but removing it would make the intent clearer — dragenter/dragleave own the flag, dragover only handles preventDefault.
Diff Stats
| Metric | Value |
|---|---|
| Files changed | 19 |
| Additions | +823 |
| Deletions | -204 |
| New files | 1 (TimelineResizeDivider.tsx) |
| New tests | 3 (cancel route) |
| Test suites touched | 1 |
Review by Miga
🤖 Generated with Claude Code
55b613b to
b143798
Compare
fb9494d to
679bf1b
Compare
miguel-heygen
left a comment
There was a problem hiding this comment.
Stack review for PR 2/7.
Audited: packages/studio-server/src/routes/render.ts, render.test.ts, packages/studio/src/components/renders/useRenderQueue.ts, RenderQueue.tsx, RenderQueueItem.tsx, NLEPreview.tsx, and TimelineResizeDivider.tsx at head b143798.
The server-side cancel path is narrow and test-backed: it marks rendering jobs terminal, invokes the adapter cancel hook, and terminates progress streaming on any non-rendering state. Client-side deletion/cancel error surfacing is materially better than the prior silent removal path, and the render history “hide, not delete” persistence is scoped per project and capped.
One stack-boundary caveat: at this PR boundary, RenderQueue’s onCancel/error props are introduced before StudioRightPanel wires them, so the cancel button is only truly end-to-end once #1964 lands. I verified #1964 performs that wiring. I’m approving this as part of the seven-PR Graphite stack, not as a standalone release boundary. Miga’s note on the side effect inside the NLEPreview state updater is also real but non-blocking here.
Verdict: APPROVE
Reasoning: The cancel/server/client primitives are sound, and the only functional incompleteness is resolved by the immediately-upstack #1964 wiring that is part of this requested stack.
— Magi
b143798 to
be749f6
Compare
679bf1b to
34dfb3d
Compare
be749f6 to
18e87ac
Compare
34dfb3d to
13bc5d2
Compare
|
Addressed at head 🤖 Generated with Claude Code |

Summary
Render cancel, end to end — plus the renders/NLE/storyboard findings from the studio UX review. Before this PR a multi-minute render could not be aborted: the
cancelledstatus existed in the job model but nothing could ever set it, and deleting a job never closed its EventSource.Changes
Render cancel (critical):
POST /render/:jobId/cancelroute in@hyperframes/studio-server;RenderJobStategains"cancelled"+ acancel?()hook; SSE/TTL treat any non-rendering status as terminal.cli/studioServer.ts,studio/vite.adapter.ts) create anAbortControllerand pass its signal throughexecuteRenderJob(the producer already accepted one). Aborted renders delete partial output so cancelled jobs don't resurrect in history, and skip error telemetry.Render queue UX:
role="progressbar"; "Last render took 2m 10s" ETA hint from storeddurationMs; disabled resolution options explain why ("not an integer scale of 1280×720"); format info is a focusable disclosure; Export uses ui Button with loading + double-fire guard.NLE:
role="separator"; initial pan clamped to viewport; zoom HUD updates every frame (was empty/stale); drag-indicator flicker fixed with a depth counter; loading overlay labeled.Storyboard:
beforeunloadguard on dirty voiceover (matched the sibling markdown editor); blur autosave unifies the panel's mixed save paradigms; Save shows in-progress state; error state wires the in-scopereloadas Retry; SubViewToggle completes the APG tabs contract.Verification
Stack
PR 2/7 of the studio UX-review fixes (stacks on the ui-primitives PR).
🤖 Generated with Claude Code