Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
90 changes: 25 additions & 65 deletions packages/producer/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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/
Expand Down
221 changes: 121 additions & 100 deletions packages/studio/src/components/renders/RenderQueue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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 */}
Expand All @@ -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"
Expand All @@ -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>
Expand Down
Loading
Loading