mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(producer): add request-level render concurrency semaphore (#232)
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
d86e4cb3c3
commit
9115d7364c
@@ -118,6 +118,7 @@ Render your Hyperframes [compositions](/concepts/compositions) to MP4, MOV, or W
|
|||||||
| `--fps` | 24, 30, 60 | 30 | Frames per second |
|
| `--fps` | 24, 30, 60 | 30 | Frames per second |
|
||||||
| `--quality` | draft, standard, high | standard | Encoding quality preset |
|
| `--quality` | draft, standard, high | standard | Encoding quality preset |
|
||||||
| `--workers` | 1-8 or `auto` | auto | Parallel render workers (see [Workers](#workers) below) |
|
| `--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) |
|
| `--gpu` | — | off | GPU encoding (NVENC, VideoToolbox, VAAPI) |
|
||||||
| `--docker` | — | off | Use Docker for [deterministic rendering](/concepts/determinism) |
|
| `--docker` | — | off | Use Docker for [deterministic rendering](/concepts/determinism) |
|
||||||
| `--quiet` | — | off | Suppress verbose output |
|
| `--quiet` | — | off | Suppress verbose output |
|
||||||
@@ -168,6 +169,61 @@ npx hyperframes render --workers 8 --output output.mp4
|
|||||||
- Dedicated render machines or CI runners
|
- Dedicated render machines or CI runners
|
||||||
- Docker mode on a well-provisioned host
|
- 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 |
|
||||||
|
|
||||||
|
<Tip>
|
||||||
|
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.
|
||||||
|
</Tip>
|
||||||
|
|
||||||
## Transparent Video
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -95,6 +95,10 @@ export default defineCommand({
|
|||||||
description: "Fail render on lint errors AND warnings",
|
description: "Fail render on lint errors AND warnings",
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
"max-concurrent-renders": {
|
||||||
|
type: "string",
|
||||||
|
description: "Max concurrent renders when using the producer server (1-10). Default: 2.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async run({ args }) {
|
async run({ args }) {
|
||||||
// ── Resolve project ────────────────────────────────────────────────────
|
// ── Resolve project ────────────────────────────────────────────────────
|
||||||
@@ -135,6 +139,19 @@ export default defineCommand({
|
|||||||
workers = parsed;
|
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 ───────────────────────────────────────────────
|
// ── Resolve output path ───────────────────────────────────────────────
|
||||||
const rendersDir = resolve("renders");
|
const rendersDir = resolve("renders");
|
||||||
const ext = FORMAT_EXT[format] ?? ".mp4";
|
const ext = FORMAT_EXT[format] ?? ".mp4";
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* Routes:
|
* Routes:
|
||||||
* POST /render — blocking render, returns JSON
|
* POST /render — blocking render, returns JSON
|
||||||
* POST /render/stream — SSE streaming render with progress
|
* POST /render/stream — SSE streaming render with progress
|
||||||
|
* GET /render/queue — current render queue status
|
||||||
* POST /lint — blocking Hyperframe lint
|
* POST /lint — blocking Hyperframe lint
|
||||||
* GET /health — health check
|
* GET /health — health check
|
||||||
* GET /outputs/:token — download rendered MP4
|
* GET /outputs/:token — download rendered MP4
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
import { prepareHyperframeLintBody, runHyperframeLint } from "./services/hyperframeLint.js";
|
import { prepareHyperframeLintBody, runHyperframeLint } from "./services/hyperframeLint.js";
|
||||||
import { resolveRenderPaths } from "./utils/paths.js";
|
import { resolveRenderPaths } from "./utils/paths.js";
|
||||||
import { defaultLogger, type ProducerLogger } from "./logger.js";
|
import { defaultLogger, type ProducerLogger } from "./logger.js";
|
||||||
|
import { Semaphore } from "./utils/semaphore.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -51,6 +53,8 @@ export interface HandlerOptions {
|
|||||||
outputUrlPrefix?: string;
|
outputUrlPrefix?: string;
|
||||||
/** TTL for output artifact download tokens (ms). Default: 15 minutes. */
|
/** TTL for output artifact download tokens (ms). Default: 15 minutes. */
|
||||||
artifactTtlMs?: number;
|
artifactTtlMs?: number;
|
||||||
|
/** Max renders that execute simultaneously. Queued requests wait FIFO. Default: 2. */
|
||||||
|
maxConcurrentRenders?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerOptions extends HandlerOptions {
|
export interface ServerOptions extends HandlerOptions {
|
||||||
@@ -232,6 +236,7 @@ export interface RenderHandlers {
|
|||||||
lint: (c: Context) => Promise<Response>;
|
lint: (c: Context) => Promise<Response>;
|
||||||
health: (c: Context) => Response;
|
health: (c: Context) => Response;
|
||||||
outputs: (c: Context) => Response;
|
outputs: (c: Context) => Response;
|
||||||
|
queue: (c: Context) => Response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -248,6 +253,9 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle
|
|||||||
const artifactTtlMs =
|
const artifactTtlMs =
|
||||||
options.artifactTtlMs ?? Number(process.env.PRODUCER_OUTPUT_ARTIFACT_TTL_MS || 15 * 60 * 1000);
|
options.artifactTtlMs ?? Number(process.env.PRODUCER_OUTPUT_ARTIFACT_TTL_MS || 15 * 60 * 1000);
|
||||||
const store = createArtifactStore(artifactTtlMs);
|
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 startTime = Date.now();
|
||||||
|
|
||||||
const health = (c: Context): Response =>
|
const health = (c: Context): Response =>
|
||||||
@@ -316,6 +324,8 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle
|
|||||||
const outputDir = dirname(absoluteOutputPath);
|
const outputDir = dirname(absoluteOutputPath);
|
||||||
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
||||||
|
|
||||||
|
const release = await renderSemaphore.acquire();
|
||||||
|
|
||||||
log.info("render started", {
|
log.info("render started", {
|
||||||
requestId,
|
requestId,
|
||||||
projectDir: input.projectDir,
|
projectDir: input.projectDir,
|
||||||
@@ -387,6 +397,7 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle
|
|||||||
500,
|
500,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
release();
|
||||||
cleanupTempDir(cleanupProjectDir, log);
|
cleanupTempDir(cleanupProjectDir, log);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -451,6 +462,17 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle
|
|||||||
abortController.abort(new RenderCancelledError("request_aborted"));
|
abortController.abort(new RenderCancelledError("request_aborted"));
|
||||||
c.req.raw.signal.addEventListener("abort", onRequestAbort, { once: true });
|
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 {
|
try {
|
||||||
await executeRenderJob(
|
await executeRenderJob(
|
||||||
job,
|
job,
|
||||||
@@ -519,6 +541,7 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
release();
|
||||||
c.req.raw.signal.removeEventListener("abort", onRequestAbort);
|
c.req.raw.signal.removeEventListener("abort", onRequestAbort);
|
||||||
cleanupTempDir(cleanupProjectDir, log);
|
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.get("/health", handlers.health);
|
||||||
app.post("/render", handlers.render);
|
app.post("/render", handlers.render);
|
||||||
app.post("/render/stream", handlers.renderStream);
|
app.post("/render/stream", handlers.renderStream);
|
||||||
|
app.get("/render/queue", handlers.queue);
|
||||||
app.post("/lint", handlers.lint);
|
app.post("/lint", handlers.lint);
|
||||||
app.get("/outputs/:token", handlers.outputs);
|
app.get("/outputs/:token", handlers.outputs);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user