Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
77 changes: 77 additions & 0 deletions packages/core/src/studio-api/routes/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/studio-api/routes/render.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<string, string> = { mp4: ".mp4", webm: ".webm", mov: ".mov" };
Expand All @@ -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);
Expand All @@ -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 });
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/studio-api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
16 changes: 14 additions & 2 deletions packages/studio/src/components/StudioLeftSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
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";
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<LeftSidebarHandle | null>;
Expand All @@ -28,7 +29,7 @@ export function StudioLeftSidebar({
handlePanelResizeMove,
handlePanelResizeEnd,
} = usePanelLayoutContext();
const { projectId } = useStudioContext();
const { projectId, renderQueue, waitForPendingDomEditSaves } = useStudioContext();
const {
compositions,
assets,
Expand All @@ -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 (
<div className="flex w-10 flex-shrink-0 flex-col items-center border-r border-neutral-800/50 bg-neutral-950 pt-1">
Expand Down Expand Up @@ -107,6 +117,8 @@ export function StudioLeftSidebar({
)
) : undefined
}
onRenderComposition={handleRenderComposition}
isRendering={renderQueue.isRendering}
onLint={onLint}
linting={linting}
onToggleCollapse={toggleLeftSidebar}
Expand Down
12 changes: 11 additions & 1 deletion packages/studio/src/components/StudioRightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
26 changes: 20 additions & 6 deletions packages/studio/src/components/renders/RenderQueue.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<ResolutionPreset | "auto">("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";
Expand All @@ -228,7 +230,11 @@ function FormatExportButton({
{showQuality && (
<select
value={quality}
onChange={(e) => setQuality(e.target.value as "draft" | "standard" | "high")}
onChange={(e) => {
const v = e.target.value as "draft" | "standard" | "high";
setQuality(v);
persistRenderSettings(format, v, fps);
}}
disabled={isRendering}
title={QUALITY_OPTIONS.find((q) => q.value === quality)?.title}
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
Expand All @@ -242,7 +248,11 @@ function FormatExportButton({
)}
<select
value={fps}
onChange={(e) => 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"
Expand All @@ -253,7 +263,11 @@ function FormatExportButton({
</select>
<select
value={format}
onChange={(e) => setFormat(e.target.value as "mp4" | "webm" | "mov")}
onChange={(e) => {
const v = e.target.value as "mp4" | "webm" | "mov";
setFormat(v);
persistRenderSettings(v, quality, fps);
}}
disabled={isRendering}
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
>
Expand Down
38 changes: 38 additions & 0 deletions packages/studio/src/components/renders/renderSettings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const RENDER_SETTINGS_KEY = "hf-studio-render-settings";

export 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 function persistRenderSettings(
format: PersistedRenderSettings["format"],
quality: PersistedRenderSettings["quality"],
fps: PersistedRenderSettings["fps"],
): void {
try {
localStorage.setItem(RENDER_SETTINGS_KEY, JSON.stringify({ format, quality, fps }));
} catch {
/* ignore */
}
}
12 changes: 11 additions & 1 deletion packages/studio/src/components/renders/useRenderQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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`, {
Expand Down
Loading
Loading