mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): per-composition render button in compositions tab (#874)
* 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) * fix(studio): make composition render buttons always visible The hover-only opacity made them undiscoverable. * 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 * refactor(studio): extract render settings persistence to own module Move getPersistedRenderSettings/persistRenderSettings out of RenderQueue.tsx into renderSettings.ts so code-splitting the component doesn't drag along the helper.
This commit is contained in:
@@ -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 }) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string> = { 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 });
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<LeftSidebarHandle | null>;
|
||||
@@ -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 (
|
||||
<div className="flex w-10 flex-shrink-0 flex-col items-center border-r border-neutral-800/50 bg-neutral-950 pt-1">
|
||||
@@ -107,6 +117,8 @@ export function StudioLeftSidebar({
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
onRenderComposition={handleRenderComposition}
|
||||
isRendering={renderQueue.isRendering}
|
||||
onLint={onLint}
|
||||
linting={linting}
|
||||
onToggleCollapse={toggleLeftSidebar}
|
||||
|
||||
@@ -198,7 +198,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}
|
||||
|
||||
@@ -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<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";
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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,38 @@ function CompCard({
|
||||
<span className="text-[11px] font-medium text-neutral-300 truncate block">{name}</span>
|
||||
<span className="text-[9px] text-neutral-600 truncate block">{comp}</span>
|
||||
</div>
|
||||
{onRender && (
|
||||
<button
|
||||
type="button"
|
||||
title={isRendering ? "Rendering..." : `Render ${name}`}
|
||||
aria-label={isRendering ? "Rendering..." : `Render ${name}`}
|
||||
disabled={isRendering}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRender();
|
||||
}}
|
||||
className={`flex-shrink-0 p-1 rounded transition-colors ${
|
||||
isRendering
|
||||
? "text-neutral-600 cursor-not-allowed"
|
||||
: "text-neutral-600 hover:text-studio-accent hover:bg-neutral-800"
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -209,6 +247,8 @@ export const CompositionsTab = memo(function CompositionsTab({
|
||||
compositions,
|
||||
activeComposition,
|
||||
onSelect,
|
||||
onRenderComposition,
|
||||
isRendering,
|
||||
}: CompositionsTabProps) {
|
||||
if (compositions.length === 0) {
|
||||
return (
|
||||
@@ -227,6 +267,8 @@ export const CompositionsTab = memo(function CompositionsTab({
|
||||
comp={comp}
|
||||
isActive={activeComposition === comp}
|
||||
onSelect={() => onSelect(comp)}
|
||||
onRender={onRenderComposition ? () => onRenderComposition(comp) : undefined}
|
||||
isRendering={isRendering}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user