From 458ee974e2f233dc6ecb3ee1b643e48f2370b012 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 9 Apr 2026 17:16:20 +0000 Subject: [PATCH] feat(producer): add request-level render concurrency semaphore Add a FIFO semaphore to limit concurrent renders in the producer server, preventing Chrome CPU contention that causes beginFrame failures. - New Semaphore utility class (packages/producer/src/utils/semaphore.ts) - Both blocking render and SSE renderStream handlers acquire/release the semaphore - SSE stream sends a "queued" event when request must wait - New GET /render/queue endpoint exposes active/queued render counts - Configurable via HandlerOptions.maxConcurrentRenders or PRODUCER_MAX_CONCURRENT_RENDERS env var (default: 2) - New --max-concurrent-renders CLI flag (1-10) Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/guides/rendering.mdx | 56 ++++++++++++++++++++++++ packages/cli/src/commands/render.ts | 17 +++++++ packages/producer/src/server.ts | 33 +++++++++++++- packages/producer/src/utils/semaphore.ts | 39 +++++++++++++++++ 4 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 packages/producer/src/utils/semaphore.ts diff --git a/docs/guides/rendering.mdx b/docs/guides/rendering.mdx index 02eae285db..e30e033a0e 100644 --- a/docs/guides/rendering.mdx +++ b/docs/guides/rendering.mdx @@ -118,6 +118,7 @@ Render your Hyperframes [compositions](/concepts/compositions) to MP4, MOV, or W | `--fps` | 24, 30, 60 | 30 | Frames per second | | `--quality` | draft, standard, high | standard | Encoding quality preset | | `--workers` | 1-8 or `auto` | auto | Parallel render workers (see [Workers](#workers) below) | +| `--max-concurrent-renders` | 1-10 | 2 | Max simultaneous renders via the producer server (see [Concurrent Renders](#concurrent-renders) below) | | `--gpu` | — | off | GPU encoding (NVENC, VideoToolbox, VAAPI) | | `--docker` | — | off | Use Docker for [deterministic rendering](/concepts/determinism) | | `--quiet` | — | off | Suppress verbose output | @@ -168,6 +169,61 @@ npx hyperframes render --workers 8 --output output.mp4 - Dedicated render machines or CI runners - Docker mode on a well-provisioned host +## Concurrent Renders + +When multiple render requests hit the producer server simultaneously (common with AI agents), each render spawns its own set of Chrome worker processes. Too many concurrent renders can exhaust CPU and cause failures. + +The producer server uses a **request-level semaphore** to queue renders. Only `maxConcurrentRenders` renders execute at a time — additional requests wait in a FIFO queue until a slot opens. + +### Configuration + +```bash Terminal +# CLI flag +npx hyperframes render --max-concurrent-renders 2 --output output.mp4 + +# Environment variable (for the producer server) +PRODUCER_MAX_CONCURRENT_RENDERS=2 +``` + +The default is **2** concurrent renders, which works well on 8-core machines where each render uses 2-3 workers. + +### Queue status + +The producer server exposes a `GET /render/queue` endpoint that returns the current state: + +```json +{ + "maxConcurrentRenders": 2, + "activeRenders": 1, + "queuedRenders": 3 +} +``` + +AI agents can poll this endpoint to decide whether to submit a render or wait. + +### SSE queue events + +When using the streaming endpoint (`POST /render/stream`), queued requests receive a `queued` event before rendering begins: + +```json +{"type": "queued", "requestId": "...", "position": 2} +``` + +This lets agents report "waiting in queue" to users rather than appearing stuck. + +### Choosing a concurrency limit + +| Machine | CPU cores | Recommended limit | +|---------|-----------|------------------| +| 4-core VM | 4 | 1 | +| 8-core workstation | 8 | 2 | +| 16-core server | 16 | 3-4 | +| 32-core render box | 32 | 5-6 | + + + When in doubt, use 1. Renders will queue up and execute sequentially, but each one gets full CPU and finishes as fast as possible. This is better than 3 renders fighting for CPU and all finishing slowly — or failing. + + ## Transparent Video Hyperframes supports rendering with a transparent background — useful for overlays, lower thirds, subscribe cards, and any element you want to composite over other footage in a video editor. diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index b1341bfd1a..c6dd7359e7 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -95,6 +95,10 @@ export default defineCommand({ description: "Fail render on lint errors AND warnings", default: false, }, + "max-concurrent-renders": { + type: "string", + description: "Max concurrent renders when using the producer server (1-10). Default: 2.", + }, }, async run({ args }) { // ── Resolve project ──────────────────────────────────────────────────── @@ -135,6 +139,19 @@ export default defineCommand({ workers = parsed; } + // ── Validate max-concurrent-renders ───────────────────────────────── + if (args["max-concurrent-renders"] != null) { + const parsed = parseInt(args["max-concurrent-renders"], 10); + if (isNaN(parsed) || parsed < 1 || parsed > 10) { + errorBox( + "Invalid max-concurrent-renders", + `Got "${args["max-concurrent-renders"]}". Must be a number between 1 and 10.`, + ); + process.exit(1); + } + process.env.PRODUCER_MAX_CONCURRENT_RENDERS = String(parsed); + } + // ── Resolve output path ─────────────────────────────────────────────── const rendersDir = resolve("renders"); const ext = FORMAT_EXT[format] ?? ".mp4"; diff --git a/packages/producer/src/server.ts b/packages/producer/src/server.ts index 03f8e82618..861141a7d9 100644 --- a/packages/producer/src/server.ts +++ b/packages/producer/src/server.ts @@ -7,6 +7,7 @@ * Routes: * POST /render — blocking render, returns JSON * POST /render/stream — SSE streaming render with progress + * GET /render/queue — current render queue status * POST /lint — blocking Hyperframe lint * GET /health — health check * GET /outputs/:token — download rendered MP4 @@ -36,6 +37,7 @@ import { import { prepareHyperframeLintBody, runHyperframeLint } from "./services/hyperframeLint.js"; import { resolveRenderPaths } from "./utils/paths.js"; import { defaultLogger, type ProducerLogger } from "./logger.js"; +import { Semaphore } from "./utils/semaphore.js"; // --------------------------------------------------------------------------- // Types @@ -51,6 +53,8 @@ export interface HandlerOptions { outputUrlPrefix?: string; /** TTL for output artifact download tokens (ms). Default: 15 minutes. */ artifactTtlMs?: number; + /** Max renders that execute simultaneously. Queued requests wait FIFO. Default: 2. */ + maxConcurrentRenders?: number; } export interface ServerOptions extends HandlerOptions { @@ -232,6 +236,7 @@ export interface RenderHandlers { lint: (c: Context) => Promise; health: (c: Context) => Response; outputs: (c: Context) => Response; + queue: (c: Context) => Response; } /** @@ -248,6 +253,9 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle const artifactTtlMs = options.artifactTtlMs ?? Number(process.env.PRODUCER_OUTPUT_ARTIFACT_TTL_MS || 15 * 60 * 1000); const store = createArtifactStore(artifactTtlMs); + const maxConcurrentRenders = + options.maxConcurrentRenders ?? Number(process.env.PRODUCER_MAX_CONCURRENT_RENDERS || 2); + const renderSemaphore = new Semaphore(maxConcurrentRenders); const startTime = Date.now(); const health = (c: Context): Response => @@ -316,6 +324,8 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle const outputDir = dirname(absoluteOutputPath); if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true }); + const release = await renderSemaphore.acquire(); + log.info("render started", { requestId, projectDir: input.projectDir, @@ -387,6 +397,7 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle 500, ); } finally { + release(); cleanupTempDir(cleanupProjectDir, log); } }; @@ -451,6 +462,17 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle abortController.abort(new RenderCancelledError("request_aborted")); c.req.raw.signal.addEventListener("abort", onRequestAbort, { once: true }); + if (renderSemaphore.activeCount >= maxConcurrentRenders) { + await stream.writeSSE({ + data: JSON.stringify({ + type: "queued", + requestId, + position: renderSemaphore.waitingCount, + }), + }); + } + const release = await renderSemaphore.acquire(); + try { await executeRenderJob( job, @@ -519,6 +541,7 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle }), }); } finally { + release(); c.req.raw.signal.removeEventListener("abort", onRequestAbort); cleanupTempDir(cleanupProjectDir, log); } @@ -545,7 +568,14 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle }); }; - return { render, renderStream, lint, health, outputs }; + const queue = (c: Context): Response => + c.json({ + maxConcurrentRenders, + activeRenders: renderSemaphore.activeCount, + queuedRenders: renderSemaphore.waitingCount, + }); + + return { render, renderStream, lint, health, outputs, queue }; } // --------------------------------------------------------------------------- @@ -562,6 +592,7 @@ export function createProducerApp(options: HandlerOptions = {}): Hono { app.get("/health", handlers.health); app.post("/render", handlers.render); app.post("/render/stream", handlers.renderStream); + app.get("/render/queue", handlers.queue); app.post("/lint", handlers.lint); app.get("/outputs/:token", handlers.outputs); diff --git a/packages/producer/src/utils/semaphore.ts b/packages/producer/src/utils/semaphore.ts new file mode 100644 index 0000000000..0590fb2b37 --- /dev/null +++ b/packages/producer/src/utils/semaphore.ts @@ -0,0 +1,39 @@ +/** + * Simple async semaphore for limiting concurrent operations. + */ +export class Semaphore { + private queue: Array<() => void> = []; + private active = 0; + + constructor(private readonly maxConcurrent: number) {} + + async acquire(): Promise<() => void> { + if (this.active < this.maxConcurrent) { + this.active++; + return () => this.release(); + } + + return new Promise<() => void>((resolve) => { + this.queue.push(() => { + this.active++; + resolve(() => this.release()); + }); + }); + } + + private release(): void { + this.active--; + const next = this.queue.shift(); + if (next) next(); + } + + /** Current number of active slots. */ + get activeCount(): number { + return this.active; + } + + /** Number of waiters in the queue. */ + get waitingCount(): number { + return this.queue.length; + } +}