From f5aa3490f0bb28b80e786873301126b5a4a72186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 15 May 2026 12:59:51 -0700 Subject: [PATCH 1/4] feat(studio): add per-composition render button in compositions tab 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) --- packages/core/src/studio-api/routes/render.ts | 6 +++ packages/core/src/studio-api/types.ts | 2 + .../src/components/StudioLeftSidebar.tsx | 14 +++++- .../src/components/StudioRightPanel.tsx | 12 +++++- .../src/components/renders/useRenderQueue.ts | 12 +++++- .../components/sidebar/CompositionsTab.tsx | 43 ++++++++++++++++++- .../src/components/sidebar/LeftSidebar.tsx | 6 +++ packages/studio/vite.adapter.ts | 1 + 8 files changed, 91 insertions(+), 5 deletions(-) diff --git a/packages/core/src/studio-api/routes/render.ts b/packages/core/src/studio-api/routes/render.ts index fe5dc14e5..96391577c 100644 --- a/packages/core/src/studio-api/routes/render.ts +++ b/packages/core/src/studio-api/routes/render.ts @@ -59,6 +59,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void quality?: string; format?: string; resolution?: string; + composition?: string; }; const VALID_FORMATS = new Set(["mp4", "webm", "mov"]); const FORMAT_EXT: Record = { mp4: ".mp4", webm: ".webm", mov: ".mov" }; @@ -76,6 +77,10 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void const outputResolution = VALID_RESOLUTIONS.has(body.resolution ?? "") ? (body.resolution as CanvasResolution) : undefined; + const composition = + typeof body.composition === "string" && body.composition.length > 0 + ? body.composition + : undefined; const now = new Date(); const datePart = now.toISOString().slice(0, 10); @@ -94,6 +99,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void quality, jobId, outputResolution, + composition, }); (jobState as RenderJobState & { createdAt: number }).createdAt = Date.now(); renderJobs.set(jobId, jobState as RenderJobState & { createdAt: number }); diff --git a/packages/core/src/studio-api/types.ts b/packages/core/src/studio-api/types.ts index 40e1c4460..93aa9dc4a 100644 --- a/packages/core/src/studio-api/types.ts +++ b/packages/core/src/studio-api/types.ts @@ -88,6 +88,8 @@ export interface StudioApiAdapter { * the producer for the integer-scale + aspect + HDR constraints. */ outputResolution?: CanvasResolution; + /** Entry file relative to projectDir (e.g. "compositions/intro.html"). Defaults to index.html. */ + composition?: string; }): RenderJobState; /** Optional: generate a JPEG thumbnail via Puppeteer or similar. */ diff --git a/packages/studio/src/components/StudioLeftSidebar.tsx b/packages/studio/src/components/StudioLeftSidebar.tsx index 7fef17319..38cafff03 100644 --- a/packages/studio/src/components/StudioLeftSidebar.tsx +++ b/packages/studio/src/components/StudioLeftSidebar.tsx @@ -1,4 +1,4 @@ -import type { RefObject } from "react"; +import { useCallback, type RefObject } from "react"; import { SourceEditor } from "./editor/SourceEditor"; import { LeftSidebar, type LeftSidebarHandle } from "./sidebar/LeftSidebar"; import { MediaPreview } from "./MediaPreview"; @@ -28,7 +28,7 @@ export function StudioLeftSidebar({ handlePanelResizeMove, handlePanelResizeEnd, } = usePanelLayoutContext(); - const { projectId } = useStudioContext(); + const { projectId, renderQueue, waitForPendingDomEditSaves } = useStudioContext(); const { compositions, assets, @@ -45,6 +45,14 @@ export function StudioLeftSidebar({ handleContentChange, } = useFileManagerContext(); + const handleRenderComposition = useCallback( + async (comp: string) => { + await waitForPendingDomEditSaves(); + await renderQueue.startRender({ composition: comp }); + }, + [renderQueue, waitForPendingDomEditSaves], + ); + if (leftCollapsed) { return (
@@ -107,6 +115,8 @@ export function StudioLeftSidebar({ ) ) : undefined } + onRenderComposition={handleRenderComposition} + isRendering={renderQueue.isRendering} onLint={onLint} linting={linting} onToggleCollapse={toggleLeftSidebar} diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 64a169ac8..f58262ebe 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -195,7 +195,17 @@ export function StudioRightPanel({ onClearCompleted={renderQueue.clearCompleted} onStartRender={async (format, quality, resolution, fps) => { await waitForPendingDomEditSaves(); - await renderQueue.startRender({ fps, quality, format, resolution }); + const composition = + activeCompPath && activeCompPath !== "index.html" + ? activeCompPath + : undefined; + await renderQueue.startRender({ + fps, + quality, + format, + resolution, + composition, + }); }} compositionDimensions={compositionDimensions} isRendering={renderQueue.isRendering} diff --git a/packages/studio/src/components/renders/useRenderQueue.ts b/packages/studio/src/components/renders/useRenderQueue.ts index 4a65b20b8..7531f2862 100644 --- a/packages/studio/src/components/renders/useRenderQueue.ts +++ b/packages/studio/src/components/renders/useRenderQueue.ts @@ -29,6 +29,8 @@ export interface StartRenderOptions { format?: "mp4" | "webm" | "mov"; /** `"auto"` (default) renders at the composition's authored dimensions. */ resolution?: ResolutionPreset | "auto"; + /** Render a specific composition file instead of index.html. */ + composition?: string; } export function useRenderQueue(projectId: string | null) { @@ -86,17 +88,25 @@ export function useRenderQueue(projectId: string | null) { const quality = opts.quality ?? "standard"; const format = opts.format ?? "mp4"; const resolution = opts.resolution; + const composition = opts.composition; const startTime = Date.now(); // "auto" / undefined means "render at the composition's authored size". // Omit the field entirely — sending "auto" would trip the route's // enum validation set. - const body: { fps: number; quality: string; format: string; resolution?: string } = { + const body: { + fps: number; + quality: string; + format: string; + resolution?: string; + composition?: string; + } = { fps, quality, format, }; if (resolution && resolution !== "auto") body.resolution = resolution; + if (composition) body.composition = composition; let res: Response; try { res = await fetch(`/api/projects/${projectId}/render`, { diff --git a/packages/studio/src/components/sidebar/CompositionsTab.tsx b/packages/studio/src/components/sidebar/CompositionsTab.tsx index 5e1639894..04b892051 100644 --- a/packages/studio/src/components/sidebar/CompositionsTab.tsx +++ b/packages/studio/src/components/sidebar/CompositionsTab.tsx @@ -5,6 +5,8 @@ interface CompositionsTabProps { compositions: string[]; activeComposition: string | null; onSelect: (comp: string) => void; + onRenderComposition?: (comp: string) => void; + isRendering?: boolean; } const DEFAULT_PREVIEW_STAGE = { width: 1920, height: 1080 }; @@ -94,11 +96,15 @@ function CompCard({ comp, isActive, onSelect, + onRender, + isRendering, }: { projectId: string; comp: string; isActive: boolean; onSelect: () => void; + onRender?: () => void; + isRendering?: boolean; }) { const [hovered, setHovered] = useState(false); const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE); @@ -158,7 +164,7 @@ function CompCard({ onClick={onSelect} onPointerEnter={handleEnter} onPointerLeave={handleLeave} - className={`w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${ + className={`group/card w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${ isActive ? "bg-studio-accent/10 border-l-2 border-studio-accent" : "border-l-2 border-transparent hover:bg-neutral-800/50" @@ -200,6 +206,37 @@ function CompCard({ {name} {comp}
+ {onRender && ( + + )} ); } @@ -209,6 +246,8 @@ export const CompositionsTab = memo(function CompositionsTab({ compositions, activeComposition, onSelect, + onRenderComposition, + isRendering, }: CompositionsTabProps) { if (compositions.length === 0) { return ( @@ -227,6 +266,8 @@ export const CompositionsTab = memo(function CompositionsTab({ comp={comp} isActive={activeComposition === comp} onSelect={() => onSelect(comp)} + onRender={onRenderComposition ? () => onRenderComposition(comp) : undefined} + isRendering={isRendering} /> ))} diff --git a/packages/studio/src/components/sidebar/LeftSidebar.tsx b/packages/studio/src/components/sidebar/LeftSidebar.tsx index 4aa7ada2f..6a882babb 100644 --- a/packages/studio/src/components/sidebar/LeftSidebar.tsx +++ b/packages/studio/src/components/sidebar/LeftSidebar.tsx @@ -43,6 +43,8 @@ interface LeftSidebarProps { onDuplicateFile?: (path: string) => void; onMoveFile?: (oldPath: string, newPath: string) => void; codeChildren?: ReactNode; + onRenderComposition?: (comp: string) => void; + isRendering?: boolean; onLint?: () => void; linting?: boolean; onToggleCollapse?: () => void; @@ -69,6 +71,8 @@ export const LeftSidebar = memo( onDuplicateFile, onMoveFile, codeChildren, + onRenderComposition, + isRendering, onLint, linting, onToggleCollapse, @@ -169,6 +173,8 @@ export const LeftSidebar = memo( compositions={compositions} activeComposition={activeComposition} onSelect={onSelectComposition} + onRenderComposition={onRenderComposition} + isRendering={isRendering} /> )} {tab === "assets" && ( diff --git a/packages/studio/vite.adapter.ts b/packages/studio/vite.adapter.ts index ffedf227b..c61e3cf05 100644 --- a/packages/studio/vite.adapter.ts +++ b/packages/studio/vite.adapter.ts @@ -201,6 +201,7 @@ export function createViteAdapter(dataDir: string, server: ViteDevServer): Studi format: opts.format, ...(renderBodyScripts.length > 0 ? { renderBodyScripts } : {}), outputResolution: opts.outputResolution, + ...(opts.composition ? { entryFile: opts.composition } : {}), }); const onProgress = (j: { progress: number; currentStage?: string }) => { state.progress = j.progress; From 5a4646bbc0454cf0cf0d9d056f1e148bd56f3b97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 15 May 2026 13:16:35 -0700 Subject: [PATCH 2/4] fix(studio): make composition render buttons always visible The hover-only opacity made them undiscoverable. --- packages/studio/src/components/sidebar/CompositionsTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/studio/src/components/sidebar/CompositionsTab.tsx b/packages/studio/src/components/sidebar/CompositionsTab.tsx index 04b892051..4c69164df 100644 --- a/packages/studio/src/components/sidebar/CompositionsTab.tsx +++ b/packages/studio/src/components/sidebar/CompositionsTab.tsx @@ -218,7 +218,7 @@ function CompCard({ className={`flex-shrink-0 p-1 rounded transition-colors ${ isRendering ? "text-neutral-600 cursor-not-allowed" - : "text-neutral-500 hover:text-studio-accent hover:bg-neutral-800 opacity-0 group-hover/card:opacity-100" + : "text-neutral-600 hover:text-studio-accent hover:bg-neutral-800" }`} > Date: Fri, 15 May 2026 13:40:11 -0700 Subject: [PATCH 3/4] =?UTF-8?q?fix(studio):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20CLI=20adapter,=20path=20guard,=20a11y,=20tests,=20s?= =?UTF-8?q?ettings=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- packages/cli/src/server/studioServer.ts | 1 + .../core/src/studio-api/routes/render.test.ts | 77 +++++++++++++++++++ packages/core/src/studio-api/routes/render.ts | 14 ++-- .../src/components/StudioLeftSidebar.tsx | 4 +- .../src/components/renders/RenderQueue.tsx | 62 +++++++++++++-- .../components/sidebar/CompositionsTab.tsx | 1 + 6 files changed, 146 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index b246d3525..0f76b62dd 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -263,6 +263,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { format: opts.format, outputResolution: opts.outputResolution, ...(manualEditsRenderScript ? { renderBodyScripts: [manualEditsRenderScript] } : {}), + ...(opts.composition ? { entryFile: opts.composition } : {}), }); const startTime = Date.now(); const onProgress = (j: { progress: number; currentStage?: string }) => { diff --git a/packages/core/src/studio-api/routes/render.test.ts b/packages/core/src/studio-api/routes/render.test.ts index 7efe03267..b7a168747 100644 --- a/packages/core/src/studio-api/routes/render.test.ts +++ b/packages/core/src/studio-api/routes/render.test.ts @@ -117,6 +117,83 @@ describe("POST /projects/:id/render — outputResolution forwarding", () => { }); }); +describe("POST /projects/:id/render — composition forwarding", () => { + it("forwards a valid composition path to the adapter", async () => { + const spy = vi.fn(); + const { app, cleanup } = buildApp(spy); + try { + const res = await app.request("http://localhost/projects/demo/render", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + fps: 30, + quality: "standard", + format: "mp4", + composition: "compositions/intro.html", + }), + }); + expect(res.status).toBe(200); + expect(spy).toHaveBeenCalledOnce(); + expect(spy.mock.calls[0][0].composition).toBe("compositions/intro.html"); + } finally { + cleanup(); + } + }); + + it("omits composition when not specified", async () => { + const spy = vi.fn(); + const { app, cleanup } = buildApp(spy); + try { + const res = await app.request("http://localhost/projects/demo/render", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4" }), + }); + expect(res.status).toBe(200); + expect(spy.mock.calls[0][0].composition).toBeUndefined(); + } finally { + cleanup(); + } + }); + + it("omits composition when empty string", async () => { + const spy = vi.fn(); + const { app, cleanup } = buildApp(spy); + try { + const res = await app.request("http://localhost/projects/demo/render", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4", composition: "" }), + }); + expect(res.status).toBe(200); + expect(spy.mock.calls[0][0].composition).toBeUndefined(); + } finally { + cleanup(); + } + }); + + it("rejects path-traversal attempts with 400", async () => { + const spy = vi.fn(); + const { app, cleanup } = buildApp(spy); + try { + const res = await app.request("http://localhost/projects/demo/render", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + fps: 30, + quality: "standard", + format: "mp4", + composition: "../../../etc/passwd", + }), + }); + expect(res.status).toBe(400); + expect(spy).not.toHaveBeenCalled(); + } finally { + cleanup(); + } + }); +}); + describe("POST /projects/:id/render — fps wire format", () => { // The fps fraction-syntax feature accepts JSON `number` (integer fps) and // JSON `string` (ffmpeg-style rational) on the wire, normalizing both to diff --git a/packages/core/src/studio-api/routes/render.ts b/packages/core/src/studio-api/routes/render.ts index 96391577c..ac3253f44 100644 --- a/packages/core/src/studio-api/routes/render.ts +++ b/packages/core/src/studio-api/routes/render.ts @@ -1,7 +1,7 @@ import type { Hono } from "hono"; import { streamSSE } from "hono/streaming"; import { existsSync, readFileSync, mkdirSync, unlinkSync, readdirSync, statSync } from "node:fs"; -import { join } from "node:path"; +import { join, resolve, sep } from "node:path"; import type { StudioApiAdapter, RenderJobState } from "../types.js"; import { VALID_CANVAS_RESOLUTIONS, parseFps, type CanvasResolution } from "../../core.types.js"; @@ -77,10 +77,14 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void const outputResolution = VALID_RESOLUTIONS.has(body.resolution ?? "") ? (body.resolution as CanvasResolution) : undefined; - const composition = - typeof body.composition === "string" && body.composition.length > 0 - ? body.composition - : undefined; + let composition: string | undefined; + if (typeof body.composition === "string" && body.composition.length > 0) { + 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); + } + composition = body.composition; + } const now = new Date(); const datePart = now.toISOString().slice(0, 10); diff --git a/packages/studio/src/components/StudioLeftSidebar.tsx b/packages/studio/src/components/StudioLeftSidebar.tsx index 38cafff03..a99f002e2 100644 --- a/packages/studio/src/components/StudioLeftSidebar.tsx +++ b/packages/studio/src/components/StudioLeftSidebar.tsx @@ -6,6 +6,7 @@ import { isMediaFile } from "../utils/mediaTypes"; import { usePanelLayoutContext } from "../contexts/PanelLayoutContext"; import { useStudioContext } from "../contexts/StudioContext"; import { useFileManagerContext } from "../contexts/FileManagerContext"; +import { getPersistedRenderSettings } from "./renders/RenderQueue"; export interface StudioLeftSidebarProps { leftSidebarRef: RefObject; @@ -48,7 +49,8 @@ export function StudioLeftSidebar({ const handleRenderComposition = useCallback( async (comp: string) => { await waitForPendingDomEditSaves(); - await renderQueue.startRender({ composition: comp }); + const { format, quality, fps } = getPersistedRenderSettings(); + await renderQueue.startRender({ composition: comp, format, quality, fps }); }, [renderQueue, waitForPendingDomEditSaves], ); diff --git a/packages/studio/src/components/renders/RenderQueue.tsx b/packages/studio/src/components/renders/RenderQueue.tsx index 4565560dc..8b41c6163 100644 --- a/packages/studio/src/components/renders/RenderQueue.tsx +++ b/packages/studio/src/components/renders/RenderQueue.tsx @@ -1,7 +1,34 @@ -import { memo, useState, useRef, useEffect } from "react"; +import { memo, useState, useRef, useEffect, useCallback } from "react"; import { RenderQueueItem } from "./RenderQueueItem"; import type { RenderJob, ResolutionPreset } from "./useRenderQueue"; +const RENDER_SETTINGS_KEY = "hf-studio-render-settings"; + +interface PersistedRenderSettings { + format: "mp4" | "webm" | "mov"; + quality: "draft" | "standard" | "high"; + fps: 24 | 30 | 60; +} + +export function getPersistedRenderSettings(): PersistedRenderSettings { + try { + const raw = localStorage.getItem(RENDER_SETTINGS_KEY); + if (raw) { + const parsed = JSON.parse(raw); + return { + format: ["mp4", "webm", "mov"].includes(parsed.format) ? parsed.format : "mp4", + quality: ["draft", "standard", "high"].includes(parsed.quality) + ? parsed.quality + : "standard", + fps: [24, 30, 60].includes(parsed.fps) ? parsed.fps : 30, + }; + } + } catch { + /* ignore */ + } + return { format: "mp4", quality: "standard", fps: 30 }; +} + export interface CompositionDimensions { width: number; height: number; @@ -198,10 +225,19 @@ function FormatExportButton({ isRendering: boolean; compositionDimensions?: CompositionDimensions | null; }) { - const [format, setFormat] = useState<"mp4" | "webm" | "mov">("mp4"); - const [quality, setQuality] = useState<"draft" | "standard" | "high">("standard"); + const persisted = getPersistedRenderSettings(); + const [format, setFormat] = useState<"mp4" | "webm" | "mov">(persisted.format); + const [quality, setQuality] = useState<"draft" | "standard" | "high">(persisted.quality); const [resolution, setResolution] = useState("auto"); - const [fps, setFps] = useState<24 | 30 | 60>(30); + const [fps, setFps] = useState<24 | 30 | 60>(persisted.fps); + + const persistSettings = useCallback((f: typeof format, q: typeof quality, fp: typeof fps) => { + try { + localStorage.setItem(RENDER_SETTINGS_KEY, JSON.stringify({ format: f, quality: q, fps: fp })); + } catch { + /* ignore */ + } + }, []); // MOV (ProRes) is a fixed-quality codec — quality selector has no effect. const showQuality = format !== "mov"; @@ -228,7 +264,11 @@ function FormatExportButton({ {showQuality && (