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 fe5dc14e5..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"; @@ -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,14 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void const outputResolution = VALID_RESOLUTIONS.has(body.resolution ?? "") ? (body.resolution as CanvasResolution) : 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); @@ -94,6 +103,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..dd422d861 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"; @@ -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/renderSettings"; export interface StudioLeftSidebarProps { leftSidebarRef: RefObject; @@ -28,7 +29,7 @@ export function StudioLeftSidebar({ handlePanelResizeMove, handlePanelResizeEnd, } = usePanelLayoutContext(); - const { projectId } = useStudioContext(); + const { projectId, renderQueue, waitForPendingDomEditSaves } = useStudioContext(); const { compositions, assets, @@ -45,6 +46,15 @@ export function StudioLeftSidebar({ handleContentChange, } = useFileManagerContext(); + const handleRenderComposition = useCallback( + async (comp: string) => { + await waitForPendingDomEditSaves(); + const { format, quality, fps } = getPersistedRenderSettings(); + await renderQueue.startRender({ composition: comp, format, quality, fps }); + }, + [renderQueue, waitForPendingDomEditSaves], + ); + if (leftCollapsed) { return (
@@ -107,6 +117,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/RenderQueue.tsx b/packages/studio/src/components/renders/RenderQueue.tsx index 4565560dc..e7019be4f 100644 --- a/packages/studio/src/components/renders/RenderQueue.tsx +++ b/packages/studio/src/components/renders/RenderQueue.tsx @@ -1,6 +1,7 @@ import { memo, useState, useRef, useEffect } from "react"; import { RenderQueueItem } from "./RenderQueueItem"; import type { RenderJob, ResolutionPreset } from "./useRenderQueue"; +import { getPersistedRenderSettings, persistRenderSettings } from "./renderSettings"; export interface CompositionDimensions { width: number; @@ -198,10 +199,11 @@ 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); // MOV (ProRes) is a fixed-quality codec — quality selector has no effect. const showQuality = format !== "mov"; @@ -228,7 +230,11 @@ function FormatExportButton({ {showQuality && ( setFps(Number(e.target.value) as 24 | 30 | 60)} + onChange={(e) => { + const v = Number(e.target.value) as 24 | 30 | 60; + setFps(v); + persistRenderSettings(format, quality, v); + }} disabled={isRendering} title="Frames per second" className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50" @@ -253,7 +263,11 @@ function FormatExportButton({