Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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/RenderQueue";

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
62 changes: 55 additions & 7 deletions packages/studio/src/components/renders/RenderQueue.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<ResolutionPreset | "auto">("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";
Expand All @@ -228,7 +264,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);
persistSettings(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 +282,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);
persistSettings(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 +297,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);
persistSettings(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
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