Skip to content

Commit d078675

Browse files
committed
chore(studio): render queue improvements + producer build
Render queue progress indicators, download improvements, and producer build optimizations. Independent of keyframe feature.
1 parent a468550 commit d078675

5 files changed

Lines changed: 204 additions & 217 deletions

File tree

packages/producer/build.mjs

Lines changed: 25 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ mkdirSync("dist", { recursive: true });
1515

1616
const scriptDir = dirname(fileURLToPath(import.meta.url));
1717

18+
// The banner provides a real `require` function via createRequire so that
19+
// esbuild's CJS interop (__require) works correctly in ESM output.
20+
// Without this, bundled CJS deps (recast, yauzl, etc.) that call
21+
// require("fs") throw "Dynamic require of 'fs' is not supported".
22+
const cjsBanner = {
23+
js: "import { createRequire as __cjsRequire } from 'module'; const require = __cjsRequire(import.meta.url);",
24+
};
25+
1826
const workspaceAliasPlugin = {
1927
name: "workspace-alias",
2028
setup(build) {
@@ -36,80 +44,32 @@ const workspaceAliasPlugin = {
3644
},
3745
};
3846

47+
const sharedOpts = {
48+
bundle: true,
49+
platform: "node",
50+
target: "node22",
51+
format: "esm",
52+
external: ["puppeteer", "esbuild", "postcss"],
53+
plugins: [workspaceAliasPlugin],
54+
minify: false,
55+
sourcemap: true,
56+
banner: cjsBanner,
57+
};
58+
3959
await Promise.all([
60+
build({ ...sharedOpts, entryPoints: ["src/index.ts"], outfile: "dist/index.js" }),
61+
build({ ...sharedOpts, entryPoints: ["src/server.ts"], outfile: "dist/public-server.js" }),
4062
build({
41-
bundle: true,
42-
platform: "node",
43-
target: "node22",
44-
format: "esm",
45-
external: ["puppeteer", "esbuild", "postcss", "wawoff2"],
46-
plugins: [workspaceAliasPlugin],
47-
minify: false,
48-
sourcemap: true,
49-
entryPoints: ["src/index.ts"],
50-
outfile: "dist/index.js",
51-
}),
52-
build({
53-
bundle: true,
54-
platform: "node",
55-
target: "node22",
56-
format: "esm",
57-
external: ["puppeteer", "esbuild", "postcss", "wawoff2"],
58-
plugins: [workspaceAliasPlugin],
59-
minify: false,
60-
sourcemap: true,
61-
entryPoints: ["src/server.ts"],
62-
outfile: "dist/public-server.js",
63-
}),
64-
// PNG decode + alpha-blit worker (hf#732 lever-4). Loaded by
65-
// `pngDecodeBlitWorkerPool.createPngDecodeBlitWorkerPool` via
66-
// `new Worker(<path>)`. Must be a separate entry point so the worker
67-
// module is standalone and shares no parent module-graph state.
68-
build({
69-
bundle: true,
70-
platform: "node",
71-
target: "node22",
72-
format: "esm",
73-
external: ["puppeteer", "esbuild", "postcss", "wawoff2"],
74-
plugins: [workspaceAliasPlugin],
75-
minify: false,
76-
sourcemap: true,
63+
...sharedOpts,
7764
entryPoints: ["src/services/pngDecodeBlitWorker.ts"],
7865
outfile: "dist/services/pngDecodeBlitWorker.js",
7966
}),
80-
// Shader-blend worker (hf#677 follow-up). Loaded by
81-
// `shaderTransitionWorkerPool.createShaderTransitionWorkerPool` via
82-
// `new Worker(<path>)`. Same bundling rationale as the
83-
// `pngDecodeBlitWorker` entry above.
8467
build({
85-
bundle: true,
86-
platform: "node",
87-
target: "node22",
88-
format: "esm",
89-
external: ["puppeteer", "esbuild", "postcss", "wawoff2"],
90-
plugins: [workspaceAliasPlugin],
91-
minify: false,
92-
sourcemap: true,
68+
...sharedOpts,
9369
entryPoints: ["src/services/shaderTransitionWorker.ts"],
9470
outfile: "dist/services/shaderTransitionWorker.js",
9571
}),
96-
// `@hyperframes/producer/distributed` subpath — the public distributed
97-
// render primitives (plan / renderChunk / assemble). Bundled as a
98-
// separate entry so adopters that don't need the in-process renderer
99-
// (Lambda chunk workers, CDK constructs, thin orchestrators) can import
100-
// only this surface and skip the rest of the producer's dependency tree.
101-
build({
102-
bundle: true,
103-
platform: "node",
104-
target: "node22",
105-
format: "esm",
106-
external: ["puppeteer", "esbuild", "postcss", "wawoff2"],
107-
plugins: [workspaceAliasPlugin],
108-
minify: false,
109-
sourcemap: true,
110-
entryPoints: ["src/distributed.ts"],
111-
outfile: "dist/distributed.js",
112-
}),
72+
build({ ...sharedOpts, entryPoints: ["src/distributed.ts"], outfile: "dist/distributed.js" }),
11373
]);
11474

11575
// Copy core runtime artifacts so the producer can find them at dist/

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

Lines changed: 121 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -154,22 +154,22 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
154154
strokeWidth="2"
155155
strokeLinecap="round"
156156
strokeLinejoin="round"
157-
className="text-neutral-600 hover:text-neutral-400 transition-colors cursor-help"
157+
className="text-panel-text-5 hover:text-panel-text-3 transition-colors cursor-help"
158158
>
159159
<circle cx="12" cy="12" r="10" />
160160
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3" />
161161
<line x1="12" y1="17" x2="12.01" y2="17" />
162162
</svg>
163163
{open && (
164-
<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">
165-
<p className="text-[10px] font-semibold text-neutral-200 mb-0.5">{info.label}</p>
166-
<p className="text-[9px] text-neutral-400 leading-tight">{info.desc}</p>
164+
<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">
165+
<p className="text-[10px] font-semibold text-panel-text-1 mb-0.5">{info.label}</p>
166+
<p className="text-[9px] text-panel-text-3 leading-tight">{info.desc}</p>
167167
<div className="mt-1.5 pt-1.5 border-t border-neutral-800">
168168
{(["mp4", "mov", "webm"] as const)
169169
.filter((f) => f !== format)
170170
.map((f) => (
171-
<p key={f} className="text-[9px] text-neutral-500 leading-relaxed">
172-
<span className="text-neutral-400 font-medium">{FORMAT_INFO[f].label}</span>
171+
<p key={f} className="text-[9px] text-panel-text-4 leading-relaxed">
172+
<span className="text-panel-text-3 font-medium">{FORMAT_INFO[f].label}</span>
173173
{" — "}
174174
{FORMAT_INFO[f].desc}
175175
</p>
@@ -209,80 +209,97 @@ function FormatExportButton({
209209
// MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
210210
const showQuality = format !== "mov";
211211

212+
const selectCls =
213+
"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";
214+
212215
return (
213-
<div className="flex items-center gap-1 flex-wrap justify-end">
214-
<FormatInfoTooltip format={format} />
215-
{/* Resolution must remain the leftmost <select> in this row — it
216-
carries `rounded-l` for the joined-button look. If you ever hide it
217-
(feature-flag, etc.), move `rounded-l` to whichever element ends up
218-
leftmost. */}
219-
<select
220-
value={resolution}
221-
onChange={(e) => setResolution(e.target.value as ResolutionPreset | "auto")}
222-
disabled={isRendering}
223-
className="h-5 px-1 text-[10px] rounded-l bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
224-
>
225-
{SCALE_OPTION_ORDER.map((value) => (
226-
<option key={value} value={value} disabled={!scaleApplies(value, compositionDimensions)}>
227-
{scaleOptionLabel(value, compositionDimensions)}
228-
</option>
229-
))}
230-
</select>
231-
{showQuality && (
232-
<select
233-
value={quality}
234-
onChange={(e) => {
235-
const v = e.target.value as "draft" | "standard" | "high";
236-
setQuality(v);
237-
persistRenderSettings(format, v, fps);
238-
}}
239-
disabled={isRendering}
240-
title={QUALITY_OPTIONS.find((q) => q.value === quality)?.title}
241-
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
242-
>
243-
{QUALITY_OPTIONS.map((q) => (
244-
<option key={q.value} value={q.value} title={q.title}>
245-
{q.label}
246-
</option>
247-
))}
248-
</select>
249-
)}
250-
<select
251-
value={fps}
252-
onChange={(e) => {
253-
const v = Number(e.target.value) as 24 | 30 | 60;
254-
setFps(v);
255-
persistRenderSettings(format, quality, v);
256-
}}
257-
disabled={isRendering}
258-
title="Frames per second"
259-
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
260-
>
261-
<option value={24}>24fps</option>
262-
<option value={30}>30fps</option>
263-
<option value={60}>60fps</option>
264-
</select>
265-
<select
266-
value={format}
267-
onChange={(e) => {
268-
const v = e.target.value as "mp4" | "webm" | "mov";
269-
setFormat(v);
270-
persistRenderSettings(v, quality, fps);
271-
}}
272-
disabled={isRendering}
273-
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
274-
>
275-
<option value="mp4">MP4</option>
276-
<option value="mov">MOV</option>
277-
<option value="webm">WebM</option>
278-
</select>
216+
<div className="flex flex-col gap-3">
217+
<div className="grid grid-cols-2 gap-2">
218+
<div className="flex flex-col gap-1">
219+
<div className="flex items-center gap-1">
220+
<span className="text-[10px] text-panel-text-4">Format</span>
221+
<FormatInfoTooltip format={format} />
222+
</div>
223+
<select
224+
value={format}
225+
onChange={(e) => {
226+
const v = e.target.value as "mp4" | "webm" | "mov";
227+
setFormat(v);
228+
persistRenderSettings(v, quality, fps);
229+
}}
230+
disabled={isRendering}
231+
className={selectCls}
232+
>
233+
<option value="mp4">MP4</option>
234+
<option value="mov">MOV (ProRes)</option>
235+
<option value="webm">WebM</option>
236+
</select>
237+
</div>
238+
<div className="flex flex-col gap-1">
239+
<span className="text-[10px] text-panel-text-4">Resolution</span>
240+
<select
241+
value={resolution}
242+
onChange={(e) => setResolution(e.target.value as ResolutionPreset | "auto")}
243+
disabled={isRendering}
244+
className={selectCls}
245+
>
246+
{SCALE_OPTION_ORDER.map((value) => (
247+
<option
248+
key={value}
249+
value={value}
250+
disabled={!scaleApplies(value, compositionDimensions)}
251+
>
252+
{scaleOptionLabel(value, compositionDimensions)}
253+
</option>
254+
))}
255+
</select>
256+
</div>
257+
<div className="flex flex-col gap-1">
258+
<span className="text-[10px] text-panel-text-4">Frame rate</span>
259+
<select
260+
value={fps}
261+
onChange={(e) => {
262+
const v = Number(e.target.value) as 24 | 30 | 60;
263+
setFps(v);
264+
persistRenderSettings(format, quality, v);
265+
}}
266+
disabled={isRendering}
267+
className={selectCls}
268+
>
269+
<option value={24}>24 fps</option>
270+
<option value={30}>30 fps</option>
271+
<option value={60}>60 fps</option>
272+
</select>
273+
</div>
274+
{showQuality && (
275+
<div className="flex flex-col gap-1">
276+
<span className="text-[10px] text-panel-text-4">Quality</span>
277+
<select
278+
value={quality}
279+
onChange={(e) => {
280+
const v = e.target.value as "draft" | "standard" | "high";
281+
setQuality(v);
282+
persistRenderSettings(format, v, fps);
283+
}}
284+
disabled={isRendering}
285+
className={selectCls}
286+
>
287+
{QUALITY_OPTIONS.map((q) => (
288+
<option key={q.value} value={q.value}>
289+
{q.label}
290+
</option>
291+
))}
292+
</select>
293+
</div>
294+
)}
295+
</div>
279296
<button
280297
onClick={() => {
281298
trackStudioEvent("render_start", { format, quality, resolution, fps });
282299
void onStartRender(format, quality, resolution, fps);
283300
}}
284301
disabled={isRendering}
285-
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"
302+
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"
286303
>
287304
{isRendering ? "Rendering..." : "Export"}
288305
</button>
@@ -313,23 +330,12 @@ export const RenderQueue = memo(function RenderQueue({
313330

314331
return (
315332
<div className="flex flex-col h-full">
316-
{/* Header — no title, already shown in header button */}
317-
<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">
318-
<div className="flex items-center gap-1.5">
319-
{completedCount > 0 && (
320-
<button
321-
onClick={onClearCompleted}
322-
className="text-[10px] text-neutral-600 hover:text-neutral-400 transition-colors"
323-
>
324-
Clear
325-
</button>
326-
)}
327-
<FormatExportButton
328-
onStartRender={onStartRender}
329-
isRendering={isRendering}
330-
compositionDimensions={compositionDimensions}
331-
/>
332-
</div>
333+
<div className="px-3 py-3 border-b border-panel-border flex-shrink-0">
334+
<FormatExportButton
335+
onStartRender={onStartRender}
336+
isRendering={isRendering}
337+
compositionDimensions={compositionDimensions}
338+
/>
333339
</div>
334340

335341
{/* Job list */}
@@ -343,7 +349,7 @@ export const RenderQueue = memo(function RenderQueue({
343349
fill="none"
344350
stroke="currentColor"
345351
strokeWidth="1.5"
346-
className="text-neutral-700"
352+
className="text-panel-text-5"
347353
>
348354
<rect
349355
x="2"
@@ -361,17 +367,32 @@ export const RenderQueue = memo(function RenderQueue({
361367
strokeLinejoin="round"
362368
/>
363369
</svg>
364-
<p className="text-[10px] text-neutral-600 text-center">No renders yet</p>
370+
<p className="text-[10px] text-panel-text-5 text-center">No renders yet</p>
365371
</div>
366372
) : (
367-
jobs.map((job) => (
368-
<RenderQueueItem
369-
key={job.id}
370-
job={job}
371-
projectId={projectId}
372-
onDelete={() => onDelete(job.id)}
373-
/>
374-
))
373+
<div>
374+
{completedCount > 0 && (
375+
<div className="flex items-center justify-between px-3 py-1.5 border-b border-panel-border">
376+
<span className="text-[10px] text-panel-text-4">
377+
{jobs.length} render{jobs.length === 1 ? "" : "s"}
378+
</span>
379+
<button
380+
onClick={onClearCompleted}
381+
className="text-[10px] text-panel-text-4 hover:text-panel-text-2 transition-colors"
382+
>
383+
Clear
384+
</button>
385+
</div>
386+
)}
387+
{jobs.map((job) => (
388+
<RenderQueueItem
389+
key={job.id}
390+
job={job}
391+
projectId={projectId}
392+
onDelete={() => onDelete(job.id)}
393+
/>
394+
))}
395+
</div>
375396
)}
376397
</div>
377398
</div>

0 commit comments

Comments
 (0)