Skip to content

feat(studio): per-composition render button in compositions tab#874

Merged
miguel-heygen merged 4 commits into
mainfrom
worktree-feat+composition-render-button
May 15, 2026
Merged

feat(studio): per-composition render button in compositions tab#874
miguel-heygen merged 4 commits into
mainfrom
worktree-feat+composition-render-button

Conversation

@miguel-heygen
Copy link
Copy Markdown
Collaborator

@miguel-heygen miguel-heygen commented May 15, 2026

Summary

  • Adds a download/render icon button on each composition card in the Comps sidebar tab
  • Threads composition path through the full render pipeline: POST body → API route → adapter → producer entryFile
  • The Export button in the Renders panel is now composition-aware — renders the active composition instead of always index.html
  • Comp card buttons read format/quality/fps from the Renders panel settings (persisted via localStorage)

Changes

Layer File What
API types core/src/studio-api/types.ts composition?: string on startRender opts
API route core/src/studio-api/routes/render.ts Accept + validate + path-jail composition
Vite adapter studio/vite.adapter.ts Map compositionentryFile for producer
CLI adapter cli/src/server/studioServer.ts Map compositionentryFile for producer
Settings studio/src/components/renders/renderSettings.ts Persist/read render settings via localStorage
Hook studio/src/components/renders/useRenderQueue.ts composition in StartRenderOptions
UI studio/src/components/sidebar/CompositionsTab.tsx Download icon button per comp card
Wiring LeftSidebar.tsx, StudioLeftSidebar.tsx, StudioRightPanel.tsx Prop threading

Test plan

  • Open studio with a multi-composition project, go to Comps tab
  • Comp card shows download icon — click it, render starts in Renders tab
  • Rendered MP4 contains only that composition
  • Click Export while viewing a sub-composition — renders only that composition
  • Click Export on index.html view — renders the full project (no regression)
  • Path traversal ../../../etc/passwd → rejected with 400
  • Tests pass (518 total — 514 studio + 4 new render route)

Thread composition path through the full render pipeline so individual
compositions can be rendered independently from the studio UI.

- Add download icon button on each comp card (visible on hover)
- Accept `composition` field in POST /projects/:id/render
- Pass composition as `entryFile` to the producer's createRenderJob
- Make the Export button in the Renders panel composition-aware
  (renders the active composition instead of always index.html)
The hover-only opacity made them undiscoverable.
Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

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

LGTM — clean threading of an optional composition field end-to-end.

Audited — chain integrity

  • CompositionsTab.tsx — per-card download button, e.stopPropagation() on click so it doesn't double-fire the parent onSelect, disabled={isRendering} gate ✓
  • LeftSidebar.tsx / StudioLeftSidebar.tsx — prop-passthrough plumbing, handleRenderComposition calls waitForPendingDomEditSaves() BEFORE startRender so in-flight studio edits flush before the producer reads HTML from disk ✓
  • StudioRightPanel.tsx Export button — derives composition = activeCompPath && activeCompPath !== "index.html" ? activeCompPath : undefined. Clean handling of the "viewing index.html" case (no regression) and the "viewing sub-composition" case (overrides) ✓
  • useRenderQueue.tsStartRenderOptions.composition? added; conditional if (composition) body.composition = composition; only includes the field when present ✓
  • core/src/studio-api/routes/render.ts — validates typeof === "string" && length > 0 before forwarding; defaults to undefined otherwise ✓
  • core/src/studio-api/types.ts — adapter contract widened with composition?: string
  • studio/vite.adapter.ts...(opts.composition ? { entryFile: opts.composition } : {}) conditional spread maps composition → entryFile only when present, so producer's index.html default is preserved ✓
  • Pure-additive end-to-end: every layer is a no-op when composition is undefined, so existing call sites (full-project renders) are byte-identical to before ✓

Body claim verification

Two claims to spot-check:

  • "composition-aware Export button" — verified, StudioRightPanel.tsx:198-208
  • "visible on hover"doesn't quite match: CompositionsTab.tsx:240-242 renders the button unconditionally when onRender is provided, no opacity-0/group-hover/card:opacity-100 gating. The parent gets group/card Tailwind class added (the named-group hover infra), but no child class actually uses it. Either add opacity-0 group-hover/card:opacity-100 transition-opacity on the button to match the body, or drop "visible on hover" from the description.

Non-blocking notes

  1. Comp-card render uses defaults, not user's selected settings — clicking the download icon on a card calls renderQueue.startRender({ composition }) without passing fps / quality / format / resolution, so those fall through to the useRenderQueue defaults (fps=30, quality="standard", format="mp4"). If the user has dialed in 60fps WebM in the Renders panel, the card button silently ignores that. Probably intentional ("quick render with defaults") but worth either documenting in a tooltip or passing through the current panel settings. The Export button in StudioRightPanel does pass them — the asymmetry is real.

  2. Path-jailing for compositionrender.ts:80-83 validates the string is non-empty but doesn't path-jail. composition: "../../../etc/passwd" would flow through to the producer's entryFile resolution. The trust boundary is bounded (the studio user owns the project they're viewing, so they already have arbitrary read/write to their own project dir via the file editor), so practical impact is low — defense-in-depth would still be worth a path.relative(projectDir, resolve(projectDir, composition)).startsWith("..") rejection at the route layer. Same defensive shape vite.adapter.ts already implicitly relies on.

  3. Test plan all-unchecked — the 7-row test plan is entirely unverified per the body. Worth at least exercising the no-regression case (Export on index.html view → full project) before merge to confirm the new conditional doesn't accidentally rewire the default.

CI all green on required checks (Test, Typecheck, Lint, Build, CodeQL, Test: runtime contract, CLI smoke (required), Tests on windows-latest, Preview parity, Smoke: global install, Perf:*); Render on windows-latest + regression-shards still in-progress. mergeable_state: blocked is reviewer-gate.

Review by Rames Jusso (pr-review)

Copy link
Copy Markdown
Collaborator

@vanceingalls vanceingalls left a comment

Choose a reason for hiding this comment

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

Per-comp render is a clean, useful surface and the wiring is tight — but the contract was widened across the studio API surface and only one of the two adapter implementations was updated, plus the new server-side input lacks the path-sanitization that the rest of the route applies to every other field.

Strengths

  • packages/studio/src/components/StudioRightPanel.tsx:198-208 — the Export button gracefully degrades to "render the active composition vs. full project" without a separate code path. Nice reuse of activeCompPath.
  • packages/producer/src/services/renderOrchestrator.ts:1917-1939 already handles <template>-wrapped sub-compositions standalone-extraction — this PR threads to a producer pipeline that was already designed for it. Good leverage.
  • useRenderQueue.ts:99 consistent with the prior pattern: omit the field when falsy rather than sending an enum-failing value.

Blockers

  • blocker — Rule 2 — second adapter not updated. The contract composition?: string was added to StudioApiAdapter.startRender in packages/core/src/studio-api/types.ts:91, and the vite adapter at packages/studio/vite.adapter.ts:204 honors it. But the CLI studio server at packages/cli/src/server/studioServer.ts:227-260 — the embedded server used by hyperframes preview outside the monorepo (see packages/cli/src/commands/preview.ts:321) — silently drops opts.composition and always renders index.html. Users running hyperframes preview will see the per-comp button in the UI, click it, and get the full project rendered instead. Add ...(opts.composition ? { entryFile: opts.composition } : {}) to the createRenderJob call there, parallel to the vite adapter change.

Important

  • important — input validation gap on the route. packages/core/src/studio-api/routes/render.ts:80-83 accepts composition as any non-empty string and forwards it straight to the producer, which does join(projectDir, entryFile) + readFileSync(...) at packages/producer/src/services/renderOrchestrator.ts:1907-1916. The route validates format, quality, resolution, fps against enums, but composition gets only a typeof === "string" && length > 0 check. A value like "../../../../etc/hosts" resolves outside projectDir and gets readFileSync'd. The studio API is normally localhost-only, but hyperframes preview exposes a port (see packages/cli/src/commands/preview.ts), so this is a real arbitrary-file-read primitive when that command is used. Defense-in-depth: reject paths containing .. segments, leading /, leading ~, or backslashes; or path.resolve() and assert the result is startsWith(resolvedProjectDir + sep). Same pattern as resolveRenderPaths already uses.
  • important — isRendering is a global busy-flag, not per-job. useRenderQueue.ts:227 is jobs.some(...), and that single boolean is what disables every comp-card button (CompositionsTab.tsx:208, disabled={isRendering}). Click comp A → every other comp's render button is disabled until A finishes. That contradicts a "queue" semantic and is a UX trap for the obvious "render all my compositions" workflow. Either name it isAnyRendering and document the intent, or track per-composition busy state so multiple renders can queue.
  • important — no tests for the new server-side composition forwarding. packages/core/src/studio-api/routes/render.test.ts has end-to-end coverage for resolution, fps, format, quality forwarding (lines 41-115) but adds nothing for composition. The forwarding/sanitization is exactly the kind of thing that silently regresses on the next route refactor. Two pinning tests: (a) composition: "intro.html" reaches the adapter as composition: "intro.html"; (b) missing or empty composition reaches the adapter as undefined. If you take the sanitization finding above, add (c) composition: "../etc/passwd" is rejected at the route boundary.
  • important — accessibility: icon-only button has no accessible name. CompositionsTab.tsx:210-237 — the new render <button> has only title="..."; the inner <svg> has no aria-label, no visible text, and no aria-labelledby. Screen readers will announce "button" with no label. Add aria-label={isRendering ? "Rendering..." : + "Render ${name}}" + to the button (thetitle` is for sighted hover, not for AT). For consistency with other studio buttons, check whether there's a shared icon-button pattern.

Nits

  • nit — SSE event-source ref is single-slotted. useRenderQueue.ts:36, 147eventSourceRef.current is overwritten on every startRender. Today the isRendering flag mostly prevents concurrent calls so the prior subscriber would already be torn down — but if you fix the per-comp queue gating above, you'll trip this immediately. Pre-existing issue, but the new UI is what makes it reachable.
  • nit — jobId is second-resolution. routes/render.ts:81-83 builds the jobId from YYYY-MM-DD_HH-MM-SS + project ID. Two renders started in the same wall-second collide on jobId → second one's outputPath and SSE channel overwrite the first's. Per-comp button makes this trivially reachable (click comp A and comp B inside one second). Append crypto.randomUUID().slice(0,8) or a nano counter. Also pre-existing; new surface raises the priority.

Verdict: REQUEST CHANGES
Reasoning: The CLI-adapter omission (blocker) makes the new UI silently misbehave for one of the two ways users launch studio, and the unsanitized composition body field is a clear defense-in-depth gap on the same route that already validates every other input.

— Vai

Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

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

Self-correction: deferring to Vai's REQUEST_CHANGES — my APPROVE was too soft on the CLI adapter blocker.

Vai caught the path-enum gap I missed: I traced the chain through studio/vite.adapter.ts (the dev path) and stopped, but there's a second consumer at packages/cli/src/server/studioServer.ts:227 that silently drops the composition field. So when users run via the bundled CLI's hyperframes preview (not vite), the per-comp render button silently renders the full project — UI says "rendering composition X," filesystem gets full-project output. Wrong-output failure mode is worse than no-feature.

Per my own path-enum-when-body-promises-global discipline: the body's "Threads composition path through the full render pipeline" is a global contract claim. Missing the CLI adapter is exactly a Rule-3 (body-vs-diff) blocker, not a non-blocking note. My APPROVE underweighted this.

I also undersold the path-traversal finding as non-blocking ("trust boundary bounded — user owns project"). Vai's escalation is right: hyperframes preview binds a network port, and an unsanitized composition field bypassing the enum-validation pattern that protects every other body field on this route is a real-arbitrary-read vector if that port is reachable. Validate against the project's known composition list before joining the path.

Vai's other findings (icon-only button a11y, isRendering queue semantics, zero tests for forwarding) also stand — the test-coverage gap is the kind of thing that catches CLI-vs-vite parity bugs like this one.

Treating my prior APPROVE as superseded by Vai's REQUEST_CHANGES. Happy to re-stamp once:

  1. CLI adapter (studioServer.ts) is wired to honor composition the same way vite.adapter.ts does
  2. Path-traversal guard added at the route layer
  3. Test coverage for the forwarding chain

Self-correction by Rames Jusso (pr-review)

…, settings sync

- Wire `composition` → `entryFile` in CLI studio adapter (studioServer.ts)
  so `hyperframes preview` renders the correct composition, not always index.html
- Add path-traversal guard: reject composition paths that resolve outside projectDir
- Add `aria-label` to the icon-only render button for screen readers
- Add 4 tests: forwarding, empty/missing → undefined, path-traversal → 400
- Persist render settings (format/quality/fps) to localStorage so comp card
  buttons use the same settings as the Export panel
@miguel-heygen
Copy link
Copy Markdown
Collaborator Author

Addressed all review findings in 8b790a1:

Blockers resolved:

  • CLI adapterstudioServer.ts:265 now passes ...(opts.composition ? { entryFile: opts.composition } : {}), matching the vite adapter pattern. hyperframes preview will correctly render individual compositions.

Important items resolved:

  • Path-traversal guardrender.ts:80-86 now resolve()s the composition path against projectDir and rejects with 400 if it escapes. Tested: "../../../etc/passwd"{"error":"composition path must be within the project directory"}.
  • Accessibilityaria-label added to the icon-only render button, matching the title text.
  • Tests — 4 new test cases in render.test.ts: valid composition forwarding, empty → undefined, missing → undefined, path-traversal → 400.
  • Render settings sync — Comp card buttons now read format/quality/fps from localStorage, which the Export panel persists on every change. If you set 60fps WebM in the Renders panel, the comp card buttons will use those settings too.

Acknowledged but deferred (pre-existing):

  • isRendering global flag / jobId second-resolution / SSE single-slot — all pre-existing; the global flag is the documented behavior for now ("one render at a time"), the other two are tracked for a follow-up.

Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

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

Re-approving on 8b790a18. All four items from the prior round are properly resolved.

Audited

  • CLI adapter wired (cli/src/server/studioServer.ts:266) — ...(opts.composition ? { entryFile: opts.composition } : {}) mirrors the exact conditional-spread shape in vite.adapter.ts. Bundled-CLI hyperframes preview now honors the new field the same way the vite dev adapter does. Vai's wrong-output blocker fixed. ✓
  • Path-jailing at the route layer (core/src/studio-api/routes/render.ts:80-87):
    const resolved = resolve(project.dir, body.composition);
    if (!resolved.startsWith(resolve(project.dir) + sep)) {
      return c.json({ error: "composition path must be within the project directory" }, 400);
    }
    Right shape — path.resolve normalizes ../ traversal, and the + sep trailing separator avoids the classic /foo/bar falsely matching /foo/barEvil long-sibling-prefix bug. 400 response is appropriate. ✓
  • Four-case test matrix (render.test.ts:117-194): forwards valid path ✓, omits when not specified ✓, omits when empty ✓, rejects ../../../etc/passwd with 400 ✓ — that last one pins the security boundary against future regressions. ✓
  • aria-label on the icon button (CompositionsTab.tsx:213) — mirrors the existing title, so screen reader users get the same "Render {name}" / "Rendering..." labels. Cleans Vai's a11y nit. ✓
  • Persisted render settings synced across both surfaces (RenderQueue.tsx + StudioLeftSidebar.tsx):
    • New getPersistedRenderSettings() reads format/quality/fps from localStorage["hf-studio-render-settings"] with enum-clamped fallbacks
    • FormatExportButton initializes its useState from persisted values, and each onChange writes back via persistSettings(...)
    • handleRenderComposition now reads getPersistedRenderSettings() and passes format/quality/fps through to renderQueue.startRender({ composition, format, quality, fps })
    • Both surfaces read+write the same key, so settings stay in sync. This is the right architecture — the comp-card download now actually picks up what the user dialed in on the Renders panel, which is what Miguel originally claimed in Slack and is now true. ✓

Body claim verification

  • "Threads composition path through the full render pipeline" — verified across BOTH adapters now (vite + CLI). ✓
  • "composition-aware Export button" — unchanged from prior round, still verified. ✓
  • "visible on hover" — body still says this, but the button is still rendered unconditionally when onRender is provided. Stale text; no opacity gating actually applies. Either delete "(visible on hover)" from the body or add opacity-0 group-hover/card:opacity-100 transition-opacity to the button. Cosmetic.

Non-blocking — the only theoretical edge case left

path.resolve doesn't follow symlinks, so if compositions/foo.html inside the project dir is a symbolic link to /etc/passwd, the path-startsWith check passes but the producer's readFileSync walks the symlink out of the project. Bypassable only by a user who already has filesystem write access to plant the symlink inside their own project dir — which is the same trust boundary as the file editor in the studio gives them anyway, so practical exposure is nil. Defense-in-depth would be fs.realpathSync before the startsWith comparison, but the cost-benefit isn't there. Mentioning for completeness.

CI

All required green on this commit: Test, Typecheck, Lint, Build, CodeQL, Test: runtime contract, CLI smoke (required), Tests on windows-latest, Render on windows-latest, Preview parity, preview-regression, Smoke: global install, Perf:drift/load/fps/parity/scrub, player-perf, Analyze (python), Analyze (actions). Regression-shards still in-progress; mergeable_state: blocked is reviewer-gate.

Withdrawing the self-correction COMMENT. Both consumer adapters updated, path-jail enforced + tested, a11y addressed, settings synced. Clean to merge once regression shards land green.

Re-review by Rames Jusso (pr-review)

Copy link
Copy Markdown
Collaborator

@vanceingalls vanceingalls left a comment

Choose a reason for hiding this comment

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

Clean follow-up — blocker dead, security tight, tests cover the new contract. The remaining queue-disabling-everyone item is fine to land as a follow-up rather than block again.

Re-review status of prior findings

  • blocker — CLI adapter not threading compositionADDRESSED. packages/cli/src/server/studioServer.ts:266 now spreads ...(opts.composition ? { entryFile: opts.composition } : {}) into createRenderJob, matching the vite adapter pattern exactly. hyperframes preview users will now get the correct comp.
  • important — path-traversal on compositionADDRESSED. packages/core/src/studio-api/routes/render.ts:80-87 resolves against project.dir and asserts startsWith(resolve(project.dir) + sep). Verified against the attack matrix: ../../etc/passwd, leading /, embedded .., and the prefix-match case (../proj-evil/foo against projectDir=/home/user/proj) all correctly reject. The trailing sep is exactly what closes the prefix-match hole.
  • important — isRendering is a global busy-flagSTILL OPEN. useRenderQueue.ts:isRendering is unchanged (jobs.some(j => j.status === 'rendering')), and StudioLeftSidebar.tsx:121 passes that single boolean to every card via CompositionsTab. Clicking comp A still disables every other comp's render button until A finishes. Not a release-blocker — the queue still functions — but the per-button UI keeps implying parallelism it doesn't have. Reasonable follow-up: track a Set<string> of in-flight comp IDs in useRenderQueue and drive each card's disabled from membership in that set.
  • important — no tests for composition forwardingADDRESSED. render.test.ts:120-196 adds four pinning tests: valid path forwards through, missing → undefined, empty string → undefined, ../../../etc/passwd → 400 with the adapter spy unreached. Exactly the shape requested.
  • important — icon-only button has no accessible nameADDRESSED. CompositionsTab.tsx:212 adds aria-label={isRendering ? "Rendering..." : + "Render ${name}}" + `. Title stays for sighted hover. Good split.

Fresh findings on the new code

  • nit — getPersistedRenderSettings() exported from RenderQueue.tsx. Module-level helper exported from a JSX file is slightly awkward — if you ever code-split this component, the helper drags the JSX module along. Inlining or moving to renders/settings.ts would be cleaner, but no need to block on it.
  • nit — comp-card render reads localStorage at click time, not from React state (StudioLeftSidebar.tsx:50-57). If a user changes settings in another browser tab, the comp-card render picks up the cross-tab value while the visible Export panel still shows the in-state value. Unlikely to bite in practice and worse fixes than the current code, leave as-is.
  • nit — pre-existing jobId second-resolution collision still present at render.ts:92. With per-comp buttons now in the UI, two clicks inside the same wall-second still collide. Same recommendation as last round (append crypto.randomUUID().slice(0,8) or a nano counter), still pre-existing, can ride a follow-up.

Verdict: APPROVE
Reasoning: Blocker resolved, security finding closed with a correct ancestor check (verified against attack vectors), tests pin the new contract end-to-end, a11y addressed. The remaining queue-disabling-everyone behavior is a UX nit that's safe to ship as a follow-up rather than gate this PR again.

Review by Vai (re-review)

Move getPersistedRenderSettings/persistRenderSettings out of
RenderQueue.tsx into renderSettings.ts so code-splitting the
component doesn't drag along the helper.
@miguel-heygen miguel-heygen merged commit 4fd9520 into main May 15, 2026
38 checks passed
@miguel-heygen miguel-heygen deleted the worktree-feat+composition-render-button branch May 15, 2026 20:55
miguel-heygen added a commit that referenced this pull request May 15, 2026
…g, duration heuristic

Based on testing the skill with subagents on HyperFrames PR #874 and
Pacific PR #27684:

- Emphasize that manifest schema must match build.mjs exactly (agents
  were inventing their own slide structures)
- Add duration estimation formula: ~2.5 words/second
- Specify which design.md tokens to extract for branding
- Add GitHub org avatar as fallback logo source
- Adjust narration length guidance for small vs large PRs
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