Skip to content

feat(studio,studio-server,cli): render cancel end-to-end + renders/nle/storyboard UX#1963

Open
vanceingalls wants to merge 1 commit into
studio-ux-1-ui-primitivesfrom
studio-ux-2-render-cancel-nle
Open

feat(studio,studio-server,cli): render cancel end-to-end + renders/nle/storyboard UX#1963
vanceingalls wants to merge 1 commit into
studio-ux-1-ui-primitivesfrom
studio-ux-2-render-cancel-nle

Conversation

@vanceingalls

@vanceingalls vanceingalls commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

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 cancelled status 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/cancel route in @hyperframes/studio-server; RenderJobState gains "cancelled" + a cancel?() hook; SSE/TTL treat any non-rendering status as terminal.
  • Both adapters (cli/studioServer.ts, studio/vite.adapter.ts) create an AbortController and pass its signal through executeRenderJob (the producer already accepted one). Aborted renders delete partial output so cancelled jobs don't resurrect in history, and skip error telemetry.
  • Client: Cancel button on rendering rows, SSE closed on cancel/delete.

Render queue UX:

  • Inline "Delete?/Keep" confirm (ui Button danger) — was a one-click permanent file delete on a 20px target.
  • History load failure shows an error + Retry instead of a false "No renders yet"; delete no longer removes the row when the API fails.
  • "Clear" → "Hide" with per-project persisted hidden ids (previously local-only state whose rows silently resurrected on reload while the per-row X deleted server-side — same surface, opposite permanence).
  • Completed rows keyboard-openable; ≥24px action targets; role="progressbar"; "Last render took 2m 10s" ETA hint from stored durationMs; 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:

  • Block drops map through the measured composition size — was hard-coded 1080/1920, so drops landed at the wrong coordinates on any other comp size (critical intent/result gap).
  • Timeline height persisted alongside zoom/pan; divider is a keyboard-resizable 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:

  • beforeunload guard 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-scope reload as Retry; SubViewToggle completes the APG tabs contract.

Verification

  • render route tests 25/25 (3 new cancel tests); nle + storyboard + vite adapter suites 45/45
  • studio-server + cli build/typecheck clean

Stack

PR 2/7 of the studio UX-review fixes (stacks on the ui-primitives PR).

🤖 Generated with Claude Code

@miga-heygen miga-heygen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@vanceingalls vanceingalls force-pushed the studio-ux-2-render-cancel-nle branch from 55b613b to b143798 Compare July 6, 2026 05:24
@vanceingalls vanceingalls force-pushed the studio-ux-1-ui-primitives branch from fb9494d to 679bf1b Compare July 6, 2026 05:24

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@vanceingalls vanceingalls force-pushed the studio-ux-2-render-cancel-nle branch from b143798 to be749f6 Compare July 6, 2026 06:23
@vanceingalls vanceingalls force-pushed the studio-ux-1-ui-primitives branch from 679bf1b to 34dfb3d Compare July 6, 2026 06:23
@vanceingalls vanceingalls force-pushed the studio-ux-2-render-cancel-nle branch from be749f6 to 18e87ac Compare July 6, 2026 06:35
@vanceingalls vanceingalls force-pushed the studio-ux-1-ui-primitives branch from 34dfb3d to 13bc5d2 Compare July 6, 2026 06:35
@vanceingalls

Copy link
Copy Markdown
Collaborator Author

Addressed at head 18e87acee: the NLEPreview side effect moved out of the setState updater into an effect (pure updater now), TimelineResizeDivider declares aria-valuemax, and the redundant setIsDragOver(true) in handleDragOver is gone (dragenter/dragleave own the flag). The unwired RenderQueue props are confirmed wired in #1964 as you both noted. A later amend on this branch also fixed the cancel↔completion races found in a follow-up review: both adapters check signal.aborted on resolve (no resurrecting cancelled renders), and the client reconciles with the cancel route's {status} body (no rows stuck on optimistic "Cancelled").

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants