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
56 changes: 56 additions & 0 deletions docs/guides/rendering.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |

<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

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.
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────
Expand Down Expand Up @@ -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";
Expand Down
33 changes: 32 additions & 1 deletion packages/producer/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -232,6 +236,7 @@ export interface RenderHandlers {
lint: (c: Context) => Promise<Response>;
health: (c: Context) => Response;
outputs: (c: Context) => Response;
queue: (c: Context) => Response;
}

/**
Expand All @@ -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 =>
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -387,6 +397,7 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle
500,
);
} finally {
release();
cleanupTempDir(cleanupProjectDir, log);
}
};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -519,6 +541,7 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle
}),
});
} finally {
release();
c.req.raw.signal.removeEventListener("abort", onRequestAbort);
cleanupTempDir(cleanupProjectDir, log);
}
Expand All @@ -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 };
}

// ---------------------------------------------------------------------------
Expand All @@ -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);

Expand Down
39 changes: 39 additions & 0 deletions packages/producer/src/utils/semaphore.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading