Skip to content

Commit 8b790a1

Browse files
committed
fix(studio): address PR review — CLI adapter, path guard, a11y, tests, 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
1 parent 5a4646b commit 8b790a1

6 files changed

Lines changed: 146 additions & 13 deletions

File tree

packages/cli/src/server/studioServer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
263263
format: opts.format,
264264
outputResolution: opts.outputResolution,
265265
...(manualEditsRenderScript ? { renderBodyScripts: [manualEditsRenderScript] } : {}),
266+
...(opts.composition ? { entryFile: opts.composition } : {}),
266267
});
267268
const startTime = Date.now();
268269
const onProgress = (j: { progress: number; currentStage?: string }) => {

packages/core/src/studio-api/routes/render.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,83 @@ describe("POST /projects/:id/render — outputResolution forwarding", () => {
117117
});
118118
});
119119

120+
describe("POST /projects/:id/render — composition forwarding", () => {
121+
it("forwards a valid composition path to the adapter", async () => {
122+
const spy = vi.fn();
123+
const { app, cleanup } = buildApp(spy);
124+
try {
125+
const res = await app.request("http://localhost/projects/demo/render", {
126+
method: "POST",
127+
headers: { "content-type": "application/json" },
128+
body: JSON.stringify({
129+
fps: 30,
130+
quality: "standard",
131+
format: "mp4",
132+
composition: "compositions/intro.html",
133+
}),
134+
});
135+
expect(res.status).toBe(200);
136+
expect(spy).toHaveBeenCalledOnce();
137+
expect(spy.mock.calls[0][0].composition).toBe("compositions/intro.html");
138+
} finally {
139+
cleanup();
140+
}
141+
});
142+
143+
it("omits composition when not specified", async () => {
144+
const spy = vi.fn();
145+
const { app, cleanup } = buildApp(spy);
146+
try {
147+
const res = await app.request("http://localhost/projects/demo/render", {
148+
method: "POST",
149+
headers: { "content-type": "application/json" },
150+
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4" }),
151+
});
152+
expect(res.status).toBe(200);
153+
expect(spy.mock.calls[0][0].composition).toBeUndefined();
154+
} finally {
155+
cleanup();
156+
}
157+
});
158+
159+
it("omits composition when empty string", async () => {
160+
const spy = vi.fn();
161+
const { app, cleanup } = buildApp(spy);
162+
try {
163+
const res = await app.request("http://localhost/projects/demo/render", {
164+
method: "POST",
165+
headers: { "content-type": "application/json" },
166+
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4", composition: "" }),
167+
});
168+
expect(res.status).toBe(200);
169+
expect(spy.mock.calls[0][0].composition).toBeUndefined();
170+
} finally {
171+
cleanup();
172+
}
173+
});
174+
175+
it("rejects path-traversal attempts with 400", async () => {
176+
const spy = vi.fn();
177+
const { app, cleanup } = buildApp(spy);
178+
try {
179+
const res = await app.request("http://localhost/projects/demo/render", {
180+
method: "POST",
181+
headers: { "content-type": "application/json" },
182+
body: JSON.stringify({
183+
fps: 30,
184+
quality: "standard",
185+
format: "mp4",
186+
composition: "../../../etc/passwd",
187+
}),
188+
});
189+
expect(res.status).toBe(400);
190+
expect(spy).not.toHaveBeenCalled();
191+
} finally {
192+
cleanup();
193+
}
194+
});
195+
});
196+
120197
describe("POST /projects/:id/render — fps wire format", () => {
121198
// The fps fraction-syntax feature accepts JSON `number` (integer fps) and
122199
// JSON `string` (ffmpeg-style rational) on the wire, normalizing both to

packages/core/src/studio-api/routes/render.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Hono } from "hono";
22
import { streamSSE } from "hono/streaming";
33
import { existsSync, readFileSync, mkdirSync, unlinkSync, readdirSync, statSync } from "node:fs";
4-
import { join } from "node:path";
4+
import { join, resolve, sep } from "node:path";
55
import type { StudioApiAdapter, RenderJobState } from "../types.js";
66
import { VALID_CANVAS_RESOLUTIONS, parseFps, type CanvasResolution } from "../../core.types.js";
77

@@ -77,10 +77,14 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
7777
const outputResolution = VALID_RESOLUTIONS.has(body.resolution ?? "")
7878
? (body.resolution as CanvasResolution)
7979
: undefined;
80-
const composition =
81-
typeof body.composition === "string" && body.composition.length > 0
82-
? body.composition
83-
: undefined;
80+
let composition: string | undefined;
81+
if (typeof body.composition === "string" && body.composition.length > 0) {
82+
const resolved = resolve(project.dir, body.composition);
83+
if (!resolved.startsWith(resolve(project.dir) + sep)) {
84+
return c.json({ error: "composition path must be within the project directory" }, 400);
85+
}
86+
composition = body.composition;
87+
}
8488

8589
const now = new Date();
8690
const datePart = now.toISOString().slice(0, 10);

packages/studio/src/components/StudioLeftSidebar.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { isMediaFile } from "../utils/mediaTypes";
66
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
77
import { useStudioContext } from "../contexts/StudioContext";
88
import { useFileManagerContext } from "../contexts/FileManagerContext";
9+
import { getPersistedRenderSettings } from "./renders/RenderQueue";
910

1011
export interface StudioLeftSidebarProps {
1112
leftSidebarRef: RefObject<LeftSidebarHandle | null>;
@@ -48,7 +49,8 @@ export function StudioLeftSidebar({
4849
const handleRenderComposition = useCallback(
4950
async (comp: string) => {
5051
await waitForPendingDomEditSaves();
51-
await renderQueue.startRender({ composition: comp });
52+
const { format, quality, fps } = getPersistedRenderSettings();
53+
await renderQueue.startRender({ composition: comp, format, quality, fps });
5254
},
5355
[renderQueue, waitForPendingDomEditSaves],
5456
);

packages/studio/src/components/renders/RenderQueue.tsx

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,34 @@
1-
import { memo, useState, useRef, useEffect } from "react";
1+
import { memo, useState, useRef, useEffect, useCallback } from "react";
22
import { RenderQueueItem } from "./RenderQueueItem";
33
import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
44

5+
const RENDER_SETTINGS_KEY = "hf-studio-render-settings";
6+
7+
interface PersistedRenderSettings {
8+
format: "mp4" | "webm" | "mov";
9+
quality: "draft" | "standard" | "high";
10+
fps: 24 | 30 | 60;
11+
}
12+
13+
export function getPersistedRenderSettings(): PersistedRenderSettings {
14+
try {
15+
const raw = localStorage.getItem(RENDER_SETTINGS_KEY);
16+
if (raw) {
17+
const parsed = JSON.parse(raw);
18+
return {
19+
format: ["mp4", "webm", "mov"].includes(parsed.format) ? parsed.format : "mp4",
20+
quality: ["draft", "standard", "high"].includes(parsed.quality)
21+
? parsed.quality
22+
: "standard",
23+
fps: [24, 30, 60].includes(parsed.fps) ? parsed.fps : 30,
24+
};
25+
}
26+
} catch {
27+
/* ignore */
28+
}
29+
return { format: "mp4", quality: "standard", fps: 30 };
30+
}
31+
532
export interface CompositionDimensions {
633
width: number;
734
height: number;
@@ -198,10 +225,19 @@ function FormatExportButton({
198225
isRendering: boolean;
199226
compositionDimensions?: CompositionDimensions | null;
200227
}) {
201-
const [format, setFormat] = useState<"mp4" | "webm" | "mov">("mp4");
202-
const [quality, setQuality] = useState<"draft" | "standard" | "high">("standard");
228+
const persisted = getPersistedRenderSettings();
229+
const [format, setFormat] = useState<"mp4" | "webm" | "mov">(persisted.format);
230+
const [quality, setQuality] = useState<"draft" | "standard" | "high">(persisted.quality);
203231
const [resolution, setResolution] = useState<ResolutionPreset | "auto">("auto");
204-
const [fps, setFps] = useState<24 | 30 | 60>(30);
232+
const [fps, setFps] = useState<24 | 30 | 60>(persisted.fps);
233+
234+
const persistSettings = useCallback((f: typeof format, q: typeof quality, fp: typeof fps) => {
235+
try {
236+
localStorage.setItem(RENDER_SETTINGS_KEY, JSON.stringify({ format: f, quality: q, fps: fp }));
237+
} catch {
238+
/* ignore */
239+
}
240+
}, []);
205241

206242
// MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
207243
const showQuality = format !== "mov";
@@ -228,7 +264,11 @@ function FormatExportButton({
228264
{showQuality && (
229265
<select
230266
value={quality}
231-
onChange={(e) => setQuality(e.target.value as "draft" | "standard" | "high")}
267+
onChange={(e) => {
268+
const v = e.target.value as "draft" | "standard" | "high";
269+
setQuality(v);
270+
persistSettings(format, v, fps);
271+
}}
232272
disabled={isRendering}
233273
title={QUALITY_OPTIONS.find((q) => q.value === quality)?.title}
234274
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
@@ -242,7 +282,11 @@ function FormatExportButton({
242282
)}
243283
<select
244284
value={fps}
245-
onChange={(e) => setFps(Number(e.target.value) as 24 | 30 | 60)}
285+
onChange={(e) => {
286+
const v = Number(e.target.value) as 24 | 30 | 60;
287+
setFps(v);
288+
persistSettings(format, quality, v);
289+
}}
246290
disabled={isRendering}
247291
title="Frames per second"
248292
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
@@ -253,7 +297,11 @@ function FormatExportButton({
253297
</select>
254298
<select
255299
value={format}
256-
onChange={(e) => setFormat(e.target.value as "mp4" | "webm" | "mov")}
300+
onChange={(e) => {
301+
const v = e.target.value as "mp4" | "webm" | "mov";
302+
setFormat(v);
303+
persistSettings(v, quality, fps);
304+
}}
257305
disabled={isRendering}
258306
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
259307
>

packages/studio/src/components/sidebar/CompositionsTab.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ function CompCard({
210210
<button
211211
type="button"
212212
title={isRendering ? "Rendering..." : `Render ${name}`}
213+
aria-label={isRendering ? "Rendering..." : `Render ${name}`}
213214
disabled={isRendering}
214215
onClick={(e) => {
215216
e.stopPropagation();

0 commit comments

Comments
 (0)