chore(studio): render queue improvements + producer build (#1306)

Render queue progress indicators, download improvements, and producer
build optimizations. Independent of keyframe feature.
This commit is contained in:
Miguel Ángel
2026-06-09 18:31:06 -04:00
committed by GitHub
parent a468550f82
commit 6776bb9994
5 changed files with 204 additions and 217 deletions
+25 -65
View File
@@ -15,6 +15,14 @@ mkdirSync("dist", { recursive: true });
const scriptDir = dirname(fileURLToPath(import.meta.url));
// The banner provides a real `require` function via createRequire so that
// esbuild's CJS interop (__require) works correctly in ESM output.
// Without this, bundled CJS deps (recast, yauzl, etc.) that call
// require("fs") throw "Dynamic require of 'fs' is not supported".
const cjsBanner = {
js: "import { createRequire as __cjsRequire } from 'module'; const require = __cjsRequire(import.meta.url);",
};
const workspaceAliasPlugin = {
name: "workspace-alias",
setup(build) {
@@ -36,80 +44,32 @@ const workspaceAliasPlugin = {
},
};
const sharedOpts = {
bundle: true,
platform: "node",
target: "node22",
format: "esm",
external: ["puppeteer", "esbuild", "postcss"],
plugins: [workspaceAliasPlugin],
minify: false,
sourcemap: true,
banner: cjsBanner,
};
await Promise.all([
build({ ...sharedOpts, entryPoints: ["src/index.ts"], outfile: "dist/index.js" }),
build({ ...sharedOpts, entryPoints: ["src/server.ts"], outfile: "dist/public-server.js" }),
build({
bundle: true,
platform: "node",
target: "node22",
format: "esm",
external: ["puppeteer", "esbuild", "postcss", "wawoff2"],
plugins: [workspaceAliasPlugin],
minify: false,
sourcemap: true,
entryPoints: ["src/index.ts"],
outfile: "dist/index.js",
}),
build({
bundle: true,
platform: "node",
target: "node22",
format: "esm",
external: ["puppeteer", "esbuild", "postcss", "wawoff2"],
plugins: [workspaceAliasPlugin],
minify: false,
sourcemap: true,
entryPoints: ["src/server.ts"],
outfile: "dist/public-server.js",
}),
// PNG decode + alpha-blit worker (hf#732 lever-4). Loaded by
// `pngDecodeBlitWorkerPool.createPngDecodeBlitWorkerPool` via
// `new Worker(<path>)`. Must be a separate entry point so the worker
// module is standalone and shares no parent module-graph state.
build({
bundle: true,
platform: "node",
target: "node22",
format: "esm",
external: ["puppeteer", "esbuild", "postcss", "wawoff2"],
plugins: [workspaceAliasPlugin],
minify: false,
sourcemap: true,
...sharedOpts,
entryPoints: ["src/services/pngDecodeBlitWorker.ts"],
outfile: "dist/services/pngDecodeBlitWorker.js",
}),
// Shader-blend worker (hf#677 follow-up). Loaded by
// `shaderTransitionWorkerPool.createShaderTransitionWorkerPool` via
// `new Worker(<path>)`. Same bundling rationale as the
// `pngDecodeBlitWorker` entry above.
build({
bundle: true,
platform: "node",
target: "node22",
format: "esm",
external: ["puppeteer", "esbuild", "postcss", "wawoff2"],
plugins: [workspaceAliasPlugin],
minify: false,
sourcemap: true,
...sharedOpts,
entryPoints: ["src/services/shaderTransitionWorker.ts"],
outfile: "dist/services/shaderTransitionWorker.js",
}),
// `@hyperframes/producer/distributed` subpath — the public distributed
// render primitives (plan / renderChunk / assemble). Bundled as a
// separate entry so adopters that don't need the in-process renderer
// (Lambda chunk workers, CDK constructs, thin orchestrators) can import
// only this surface and skip the rest of the producer's dependency tree.
build({
bundle: true,
platform: "node",
target: "node22",
format: "esm",
external: ["puppeteer", "esbuild", "postcss", "wawoff2"],
plugins: [workspaceAliasPlugin],
minify: false,
sourcemap: true,
entryPoints: ["src/distributed.ts"],
outfile: "dist/distributed.js",
}),
build({ ...sharedOpts, entryPoints: ["src/distributed.ts"], outfile: "dist/distributed.js" }),
]);
// Copy core runtime artifacts so the producer can find them at dist/
@@ -154,22 +154,22 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-neutral-600 hover:text-neutral-400 transition-colors cursor-help"
className="text-panel-text-5 hover:text-panel-text-3 transition-colors cursor-help"
>
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
{open && (
<div className="absolute top-full right-0 mt-1.5 w-52 p-2 rounded bg-neutral-900 border border-neutral-700 shadow-lg z-50">
<p className="text-[10px] font-semibold text-neutral-200 mb-0.5">{info.label}</p>
<p className="text-[9px] text-neutral-400 leading-tight">{info.desc}</p>
<div className="absolute top-full right-0 mt-1.5 w-52 p-2 rounded bg-panel-input border border-neutral-700 shadow-lg z-50">
<p className="text-[10px] font-semibold text-panel-text-1 mb-0.5">{info.label}</p>
<p className="text-[9px] text-panel-text-3 leading-tight">{info.desc}</p>
<div className="mt-1.5 pt-1.5 border-t border-neutral-800">
{(["mp4", "mov", "webm"] as const)
.filter((f) => f !== format)
.map((f) => (
<p key={f} className="text-[9px] text-neutral-500 leading-relaxed">
<span className="text-neutral-400 font-medium">{FORMAT_INFO[f].label}</span>
<p key={f} className="text-[9px] text-panel-text-4 leading-relaxed">
<span className="text-panel-text-3 font-medium">{FORMAT_INFO[f].label}</span>
{" — "}
{FORMAT_INFO[f].desc}
</p>
@@ -209,80 +209,97 @@ function FormatExportButton({
// MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
const showQuality = format !== "mov";
const selectCls =
"h-7 w-full px-2 text-[11px] bg-panel-input rounded-md text-panel-text-1 outline-none cursor-pointer disabled:opacity-50 hover:bg-panel-hover transition-colors";
return (
<div className="flex items-center gap-1 flex-wrap justify-end">
<FormatInfoTooltip format={format} />
{/* Resolution must remain the leftmost <select> in this row — it
carries `rounded-l` for the joined-button look. If you ever hide it
(feature-flag, etc.), move `rounded-l` to whichever element ends up
leftmost. */}
<select
value={resolution}
onChange={(e) => setResolution(e.target.value as ResolutionPreset | "auto")}
disabled={isRendering}
className="h-5 px-1 text-[10px] rounded-l bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
>
{SCALE_OPTION_ORDER.map((value) => (
<option key={value} value={value} disabled={!scaleApplies(value, compositionDimensions)}>
{scaleOptionLabel(value, compositionDimensions)}
</option>
))}
</select>
{showQuality && (
<select
value={quality}
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"
>
{QUALITY_OPTIONS.map((q) => (
<option key={q.value} value={q.value} title={q.title}>
{q.label}
</option>
))}
</select>
)}
<select
value={fps}
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"
>
<option value={24}>24fps</option>
<option value={30}>30fps</option>
<option value={60}>60fps</option>
</select>
<select
value={format}
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"
>
<option value="mp4">MP4</option>
<option value="mov">MOV</option>
<option value="webm">WebM</option>
</select>
<div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<span className="text-[10px] text-panel-text-4">Format</span>
<FormatInfoTooltip format={format} />
</div>
<select
value={format}
onChange={(e) => {
const v = e.target.value as "mp4" | "webm" | "mov";
setFormat(v);
persistRenderSettings(v, quality, fps);
}}
disabled={isRendering}
className={selectCls}
>
<option value="mp4">MP4</option>
<option value="mov">MOV (ProRes)</option>
<option value="webm">WebM</option>
</select>
</div>
<div className="flex flex-col gap-1">
<span className="text-[10px] text-panel-text-4">Resolution</span>
<select
value={resolution}
onChange={(e) => setResolution(e.target.value as ResolutionPreset | "auto")}
disabled={isRendering}
className={selectCls}
>
{SCALE_OPTION_ORDER.map((value) => (
<option
key={value}
value={value}
disabled={!scaleApplies(value, compositionDimensions)}
>
{scaleOptionLabel(value, compositionDimensions)}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<span className="text-[10px] text-panel-text-4">Frame rate</span>
<select
value={fps}
onChange={(e) => {
const v = Number(e.target.value) as 24 | 30 | 60;
setFps(v);
persistRenderSettings(format, quality, v);
}}
disabled={isRendering}
className={selectCls}
>
<option value={24}>24 fps</option>
<option value={30}>30 fps</option>
<option value={60}>60 fps</option>
</select>
</div>
{showQuality && (
<div className="flex flex-col gap-1">
<span className="text-[10px] text-panel-text-4">Quality</span>
<select
value={quality}
onChange={(e) => {
const v = e.target.value as "draft" | "standard" | "high";
setQuality(v);
persistRenderSettings(format, v, fps);
}}
disabled={isRendering}
className={selectCls}
>
{QUALITY_OPTIONS.map((q) => (
<option key={q.value} value={q.value}>
{q.label}
</option>
))}
</select>
</div>
)}
</div>
<button
onClick={() => {
trackStudioEvent("render_start", { format, quality, resolution, fps });
void onStartRender(format, quality, resolution, fps);
}}
disabled={isRendering}
className="flex items-center gap-1 px-2 py-0.5 text-[10px] font-semibold rounded-r bg-studio-accent text-[#09090B] hover:brightness-110 transition-colors disabled:opacity-50"
className="w-full flex items-center justify-center h-8 text-[11px] font-semibold rounded-md bg-panel-accent text-[#09090B] hover:brightness-110 transition-colors disabled:opacity-50"
>
{isRendering ? "Rendering..." : "Export"}
</button>
@@ -313,23 +330,12 @@ export const RenderQueue = memo(function RenderQueue({
return (
<div className="flex flex-col h-full">
{/* Header — no title, already shown in header button */}
<div className="flex items-center justify-end flex-wrap gap-y-1.5 px-3 py-2 border-b border-neutral-800/50 flex-shrink-0">
<div className="flex items-center gap-1.5">
{completedCount > 0 && (
<button
onClick={onClearCompleted}
className="text-[10px] text-neutral-600 hover:text-neutral-400 transition-colors"
>
Clear
</button>
)}
<FormatExportButton
onStartRender={onStartRender}
isRendering={isRendering}
compositionDimensions={compositionDimensions}
/>
</div>
<div className="px-3 py-3 border-b border-panel-border flex-shrink-0">
<FormatExportButton
onStartRender={onStartRender}
isRendering={isRendering}
compositionDimensions={compositionDimensions}
/>
</div>
{/* Job list */}
@@ -343,7 +349,7 @@ export const RenderQueue = memo(function RenderQueue({
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-neutral-700"
className="text-panel-text-5"
>
<rect
x="2"
@@ -361,17 +367,32 @@ export const RenderQueue = memo(function RenderQueue({
strokeLinejoin="round"
/>
</svg>
<p className="text-[10px] text-neutral-600 text-center">No renders yet</p>
<p className="text-[10px] text-panel-text-5 text-center">No renders yet</p>
</div>
) : (
jobs.map((job) => (
<RenderQueueItem
key={job.id}
job={job}
projectId={projectId}
onDelete={() => onDelete(job.id)}
/>
))
<div>
{completedCount > 0 && (
<div className="flex items-center justify-between px-3 py-1.5 border-b border-panel-border">
<span className="text-[10px] text-panel-text-4">
{jobs.length} render{jobs.length === 1 ? "" : "s"}
</span>
<button
onClick={onClearCompleted}
className="text-[10px] text-panel-text-4 hover:text-panel-text-2 transition-colors"
>
Clear
</button>
</div>
)}
{jobs.map((job) => (
<RenderQueueItem
key={job.id}
job={job}
projectId={projectId}
onDelete={() => onDelete(job.id)}
/>
))}
</div>
)}
</div>
</div>
@@ -142,54 +142,53 @@ export const RenderQueueItem = memo(function RenderQueueItem({
)}
</div>
{/* Actions — always visible to prevent layout shifts */}
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={isComplete ? handleDownload : undefined}
className={`p-1 rounded transition-colors ${
isComplete
? "text-panel-text-5 hover:text-panel-accent"
: "text-panel-text-5/30 pointer-events-none"
}`}
title={isComplete ? "Download" : "Rendering..."}
disabled={!isComplete}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{/* Actions */}
{hovered && (
<div className="flex items-center gap-1 flex-shrink-0">
{isComplete && (
<button
onClick={handleDownload}
className="p-1 rounded text-panel-text-4 hover:text-panel-accent transition-colors"
title="Download"
>
<svg
width="12"
height="12"
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>
)}
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="p-1 rounded text-panel-text-4 hover:text-red-400 transition-colors"
title="Remove"
>
<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>
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="p-1 rounded text-panel-text-5 hover:text-red-400 transition-colors"
title="Remove"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
)}
</div>
</div>
);
+9 -5
View File
@@ -18,15 +18,19 @@ export default {
accent: "#3CE6AC",
},
panel: {
bg: "#0a0a0a",
border: "#262626",
hover: "#1a1a1a",
accent: "#3CE6AC",
"text-1": "#FAFAFA",
bg: "#0C0C0E",
input: "#161618",
surface: "#18181B",
hover: "#27272A",
border: "#1E1E1E",
"border-input": "#27272A",
"text-1": "#E4E4E7",
"text-2": "#A1A1AA",
"text-3": "#71717A",
"text-4": "#52525B",
"text-5": "#3F3F46",
accent: "#3CE6AC",
danger: "#EF4444",
},
},
},
+3
View File
@@ -192,4 +192,7 @@ export default defineConfig({
// "Dynamic require of fs is not supported". Browser bundles never reach them.
external: ["recast", "@babel/parser", "ast-types"],
},
test: {
exclude: ["data/**", "node_modules/**"],
},
});