mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-09 03:16:38 +00:00
## Summary
- **Enable browser pool by default** (`enableBrowserPool: true`) — parallel capture workers now share a single Chrome process via reference-counted pool instead of each spawning their own (~256MB each). A 6-worker render drops from 7+ browser parent processes to 1 shared pool.
- **Add launch-promise deduplication** in `acquireBrowser` — when multiple workers race into the pool simultaneously (via `Promise.all`), they await the same launch Promise instead of each triggering a separate Chrome spawn. Same pattern as the existing `_autoBrowserGpuModeCache` for GPU probes.
- **Add `connected` health check** on pool hit — if Chrome crashes mid-render, subsequent acquires detect the dead browser and launch fresh instead of returning a stale reference.
- **Add `drainBrowserPool()`** for explicit cleanup between independent render jobs.
- **CLI studio server** now uses the shared pool instead of its own redundant `enableBrowserPool: false` singleton, so thumbnail generation shares Chrome with render workers.
## Problem
The engine had a reference-counted browser pool (`browserManager.ts:73-75`) but it was **disabled by default** (`enableBrowserPool: false`). This meant:
1. **Every parallel worker spawned its own Chrome** — a `--workers 6` render launched 7+ independent Chrome processes (1 probe + 6 workers), each ~256MB.
2. **The pool had a race condition** — even if manually enabled, concurrent workers calling `acquireBrowser()` via `Promise.all` could all see `pooledBrowser === null` before the first launch completed, spawning N Chromes instead of 1.
3. **No crash recovery** — if Chrome died, the pool still held the dead reference. Subsequent acquires got a disconnected browser.
4. **CLI studio server ran its own singleton** — `studioServer.ts` explicitly set `enableBrowserPool: false` and managed a separate browser, so thumbnails and renders could never share.
Over time, orphaned Chrome processes accumulated across renders and previews. We observed **344 headless Chrome processes** consuming **569% CPU and 20% memory** on a dev machine.
## Before / After (6-worker parallel render)
| Metric | Before (pool off) | After (pool on) |
|--------|-------------------|-----------------|
| Browser parent processes | 7+ (1 probe + 6 workers) | **2** (1 GPU probe + 1 shared) |
| Total Chrome processes (with helpers) | 40-50+ | **14** |
| Memory during capture | ~20%+ | **4.6%** |
| Render time (1200 frames, 30fps) | ~64s | **53s** (~17% faster) |
| Post-render orphans | Accumulated over time | **0** |
## Changes
| File | Change |
|------|--------|
| `engine/src/config.ts` | `enableBrowserPool` default `false` → `true` |
| `engine/src/services/browserManager.ts` | Extract `launchBrowser()`, add `_pooledBrowserLaunchPromise` dedup, add `connected` check on pool hit, add `drainBrowserPool()` and `_resetBrowserPoolForTests()` |
| `engine/src/index.ts` | Export `drainBrowserPool` |
| `engine/src/services/browserManager.test.ts` | Pool dedup and drain tests |
| `cli/src/server/studioServer.ts` | Remove `enableBrowserPool: false` override — thumbnails now share the pool |
| `producer/src/services/browserManager.ts` | Re-export `drainBrowserPool` |
## Backward compatibility
- `PRODUCER_ENABLE_BROWSER_POOL=false` env var disables pooling (same as before).
- Callers passing `{ enableBrowserPool: false }` explicitly still get isolated browsers.
- Tests that set `enableBrowserPool: false` in their config fixtures continue to work.
## Test plan
- [x] Engine tests pass (597/597)
- [x] Producer tests pass (406/407, 1 pre-existing flaky test in `pngDecodeBlitWorkerPool`)
- [x] Build passes (lint, format, typecheck all green via lefthook pre-commit)
- [x] Manual render: `shortform-financial` with `--workers 6` → 1200 frames in 53s, 0 orphaned Chrome processes after completion
- [x] Process monitoring during render confirmed 2 browser parents (1 GPU probe + 1 shared pool) instead of 7+
669 lines
22 KiB
JavaScript
669 lines
22 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* @hyperframes/producer — Public Server
|
|
*
|
|
* Clean HTTP API for rendering HTML compositions to video.
|
|
*
|
|
* 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
|
|
*/
|
|
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
statSync,
|
|
mkdtempSync,
|
|
writeFileSync,
|
|
rmSync,
|
|
createReadStream,
|
|
} from "node:fs";
|
|
import { resolve, dirname, join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { parseArgs } from "node:util";
|
|
import crypto from "node:crypto";
|
|
import { Hono, type Context } from "hono";
|
|
import { streamSSE } from "hono/streaming";
|
|
import { serve } from "@hono/node-server";
|
|
import {
|
|
RenderCancelledError,
|
|
createRenderJob,
|
|
executeRenderJob,
|
|
} from "./services/renderOrchestrator.js";
|
|
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";
|
|
import { parseFps } from "@hyperframes/core";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
export interface HandlerOptions {
|
|
/** Custom logger. Defaults to console-based defaultLogger. */
|
|
logger?: ProducerLogger;
|
|
/** Extract or generate a request ID. Defaults to x-request-id header or random UUID. */
|
|
getRequestId?: (c: Context) => string;
|
|
/** Directory for rendered output files. Defaults to PRODUCER_RENDERS_DIR or /tmp. */
|
|
rendersDir?: string;
|
|
/** Prefix for output URLs in responses. Default: "/outputs". */
|
|
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 {
|
|
/** Port to listen on. Default: 9847. */
|
|
port?: number;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared validation helpers
|
|
// ---------------------------------------------------------------------------
|
|
interface RenderInput {
|
|
projectDir: string;
|
|
outputPath?: string | null;
|
|
fps: import("@hyperframes/core").Fps;
|
|
quality: "draft" | "standard" | "high";
|
|
format?: "mp4" | "webm" | "mov";
|
|
workers?: number;
|
|
useGpu: boolean;
|
|
debug: boolean;
|
|
entryFile?: string;
|
|
}
|
|
|
|
interface PreparedRenderInput {
|
|
input: RenderInput;
|
|
cleanupProjectDir?: string;
|
|
}
|
|
|
|
function parseRenderOptions(body: Record<string, unknown>): Omit<RenderInput, "projectDir"> {
|
|
// Accept either a JSON `number` (integer fps) or a JSON `string` (rational
|
|
// like "30000/1001"). Falls back to 30 fps on parse failure to preserve the
|
|
// forgiving behaviour the original whitelist had — the producer surfaces a
|
|
// clearer downstream error if the value is genuinely unusable.
|
|
const fpsRaw = body.fps;
|
|
const fpsParse =
|
|
typeof fpsRaw === "number" || typeof fpsRaw === "string" ? parseFps(fpsRaw) : null;
|
|
const fps = fpsParse && fpsParse.ok ? fpsParse.value : ({ num: 30, den: 1 } as const);
|
|
const quality = (
|
|
["draft", "standard", "high"].includes(body.quality as string) ? body.quality : "high"
|
|
) as "draft" | "standard" | "high";
|
|
const workers = typeof body.workers === "number" ? body.workers : undefined;
|
|
const useGpu = body.gpu === true;
|
|
const debug = body.debug === true;
|
|
const outputPath =
|
|
typeof body.outputPath === "string" && body.outputPath.trim().length > 0
|
|
? body.outputPath
|
|
: typeof body.output === "string" && body.output.trim().length > 0
|
|
? body.output
|
|
: null;
|
|
|
|
const entryFile =
|
|
typeof body.entryFile === "string" && body.entryFile.trim().length > 0
|
|
? body.entryFile.trim()
|
|
: undefined;
|
|
|
|
const format = (
|
|
["mp4", "webm", "mov"].includes(body.format as string) ? body.format : undefined
|
|
) as "mp4" | "webm" | "mov" | undefined;
|
|
|
|
return { outputPath, fps, quality, workers, useGpu, debug, entryFile, format };
|
|
}
|
|
|
|
async function prepareRenderBody(
|
|
body: Record<string, unknown>,
|
|
): Promise<{ prepared: PreparedRenderInput } | { error: string }> {
|
|
const options = parseRenderOptions(body);
|
|
const projectDir = typeof body.projectDir === "string" ? body.projectDir : undefined;
|
|
if (projectDir) {
|
|
const absProjectDir = resolve(projectDir);
|
|
if (!existsSync(absProjectDir) || !statSync(absProjectDir).isDirectory()) {
|
|
return { error: `Project directory not found: ${absProjectDir}` };
|
|
}
|
|
const entry = options.entryFile || "index.html";
|
|
if (!existsSync(resolve(absProjectDir, entry))) {
|
|
return { error: `Entry file "${entry}" not found in project directory: ${absProjectDir}` };
|
|
}
|
|
return { prepared: { input: { projectDir: absProjectDir, ...options } } };
|
|
}
|
|
|
|
const previewUrl = typeof body.previewUrl === "string" ? body.previewUrl.trim() : "";
|
|
const inlineHtml = typeof body.html === "string" ? body.html : "";
|
|
if (!previewUrl && !inlineHtml) {
|
|
return { error: "Missing render source: provide projectDir, previewUrl, or html" };
|
|
}
|
|
|
|
let htmlContent = inlineHtml;
|
|
if (!htmlContent) {
|
|
try {
|
|
const response = await fetch(previewUrl, { method: "GET" });
|
|
if (!response.ok) {
|
|
return { error: `Failed to fetch previewUrl: ${response.status} ${response.statusText}` };
|
|
}
|
|
htmlContent = await response.text();
|
|
} catch (error) {
|
|
return {
|
|
error: `Failed to fetch previewUrl: ${error instanceof Error ? error.message : String(error)}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
const tempRoot = process.env.PRODUCER_TMP_PROJECT_DIR || tmpdir();
|
|
const tempProjectDir = mkdtempSync(join(tempRoot, "producer-project-"));
|
|
writeFileSync(join(tempProjectDir, "index.html"), htmlContent, "utf-8");
|
|
return {
|
|
prepared: {
|
|
input: {
|
|
projectDir: tempProjectDir,
|
|
...options,
|
|
},
|
|
cleanupProjectDir: tempProjectDir,
|
|
},
|
|
};
|
|
}
|
|
|
|
function resolveOutputPath(
|
|
projectDir: string,
|
|
outputCandidate: string | null | undefined,
|
|
rendersDir: string,
|
|
log: ProducerLogger,
|
|
): string {
|
|
try {
|
|
return resolveRenderPaths(projectDir, outputCandidate, rendersDir).absoluteOutputPath;
|
|
} catch (error) {
|
|
const fallbackPath = resolve(rendersDir, `producer-fallback-${Date.now()}.mp4`);
|
|
log.warn("Failed to resolve output path, using fallback", {
|
|
fallback: fallbackPath,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
return fallbackPath;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Output artifact management
|
|
// ---------------------------------------------------------------------------
|
|
interface OutputArtifact {
|
|
path: string;
|
|
expiresAtMs: number;
|
|
}
|
|
|
|
function createArtifactStore(ttlMs: number) {
|
|
const artifacts = new Map<string, OutputArtifact>();
|
|
|
|
const cleanup = setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [token, artifact] of artifacts.entries()) {
|
|
if (artifact.expiresAtMs <= now) {
|
|
artifacts.delete(token);
|
|
}
|
|
}
|
|
}, 60_000);
|
|
cleanup.unref();
|
|
|
|
return {
|
|
register(path: string): string {
|
|
const token = crypto.randomUUID();
|
|
artifacts.set(token, { path, expiresAtMs: Date.now() + ttlMs });
|
|
return token;
|
|
},
|
|
get(token: string): OutputArtifact | undefined {
|
|
return artifacts.get(token);
|
|
},
|
|
delete(token: string) {
|
|
artifacts.delete(token);
|
|
},
|
|
};
|
|
}
|
|
|
|
function cleanupTempDir(dir: string | undefined, log: ProducerLogger): void {
|
|
if (!dir) return;
|
|
try {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
} catch (error) {
|
|
log.warn("Failed to cleanup temp project dir", {
|
|
cleanupProjectDir: dir,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Handler factory
|
|
// ---------------------------------------------------------------------------
|
|
export interface RenderHandlers {
|
|
render: (c: Context) => Promise<Response>;
|
|
renderStream: (c: Context) => Response | Promise<Response>;
|
|
lint: (c: Context) => Promise<Response>;
|
|
health: (c: Context) => Response;
|
|
outputs: (c: Context) => Response;
|
|
queue: (c: Context) => Response;
|
|
}
|
|
|
|
/**
|
|
* Create route handler functions for the producer server.
|
|
*
|
|
* These can be mounted on any Hono app at any path prefix.
|
|
*/
|
|
export function createRenderHandlers(options: HandlerOptions = {}): RenderHandlers {
|
|
const log = options.logger ?? defaultLogger;
|
|
const getRequestId =
|
|
options.getRequestId ?? ((c: Context) => c.req.header("x-request-id") || crypto.randomUUID());
|
|
const outputUrlPrefix = options.outputUrlPrefix ?? "/outputs";
|
|
const rendersDir = options.rendersDir ?? process.env.PRODUCER_RENDERS_DIR ?? "/tmp";
|
|
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 =>
|
|
c.json({
|
|
status: "ok",
|
|
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
|
|
const lint = async (c: Context): Promise<Response> => {
|
|
const requestId = getRequestId(c);
|
|
|
|
let body: Record<string, unknown>;
|
|
try {
|
|
body = await c.req.json();
|
|
} catch {
|
|
return c.json({ success: false, requestId, error: "Invalid JSON body" }, 400);
|
|
}
|
|
|
|
const preparedResult = prepareHyperframeLintBody(body);
|
|
if ("error" in preparedResult) {
|
|
return c.json({ success: false, requestId, error: preparedResult.error }, 400);
|
|
}
|
|
|
|
const result = runHyperframeLint(preparedResult.prepared);
|
|
log.info("lint completed", {
|
|
requestId,
|
|
entryFile: preparedResult.prepared.entryFile,
|
|
source: preparedResult.prepared.source,
|
|
errorCount: result.errorCount,
|
|
warningCount: result.warningCount,
|
|
});
|
|
|
|
return c.json({
|
|
success: true,
|
|
requestId,
|
|
entryFile: preparedResult.prepared.entryFile,
|
|
source: preparedResult.prepared.source,
|
|
result,
|
|
});
|
|
};
|
|
|
|
const render = async (c: Context): Promise<Response> => {
|
|
const requestId = getRequestId(c);
|
|
const t0 = Date.now();
|
|
|
|
let body: Record<string, unknown>;
|
|
try {
|
|
body = await c.req.json();
|
|
} catch {
|
|
return c.json({ success: false, requestId, error: "Invalid JSON body" }, 400);
|
|
}
|
|
|
|
const preparedResult = await prepareRenderBody(body);
|
|
if ("error" in preparedResult) {
|
|
return c.json({ success: false, requestId, error: preparedResult.error }, 400);
|
|
}
|
|
|
|
const { input, cleanupProjectDir } = preparedResult.prepared;
|
|
const absoluteOutputPath = resolveOutputPath(
|
|
input.projectDir,
|
|
input.outputPath,
|
|
rendersDir,
|
|
log,
|
|
);
|
|
const outputDir = dirname(absoluteOutputPath);
|
|
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
|
|
|
const release = await renderSemaphore.acquire();
|
|
|
|
log.info("render started", {
|
|
requestId,
|
|
projectDir: input.projectDir,
|
|
fps: input.fps,
|
|
quality: input.quality,
|
|
});
|
|
|
|
const job = createRenderJob({
|
|
fps: input.fps,
|
|
quality: input.quality,
|
|
format: input.format,
|
|
workers: input.workers,
|
|
useGpu: input.useGpu,
|
|
debug: input.debug,
|
|
entryFile: input.entryFile,
|
|
logger: log,
|
|
});
|
|
|
|
let lastLoggedPct = -10;
|
|
try {
|
|
await executeRenderJob(job, input.projectDir, absoluteOutputPath, async (j, message) => {
|
|
const pct = Math.floor(j.progress * 100);
|
|
if (pct >= lastLoggedPct + 10) {
|
|
lastLoggedPct = pct;
|
|
log.info(`render progress ${pct}%`, { requestId, stage: j.currentStage, message });
|
|
}
|
|
});
|
|
|
|
const fileSize = existsSync(absoluteOutputPath) ? statSync(absoluteOutputPath).size : 0;
|
|
const durationMs = Date.now() - t0;
|
|
const outputToken = store.register(absoluteOutputPath);
|
|
const outputUrl = `${outputUrlPrefix}/${outputToken}`;
|
|
log.info("render completed", {
|
|
requestId,
|
|
durationMs,
|
|
fileSize,
|
|
perf: job.perfSummary ?? null,
|
|
});
|
|
|
|
return c.json({
|
|
success: true,
|
|
requestId,
|
|
outputPath: absoluteOutputPath,
|
|
outputToken,
|
|
outputUrl,
|
|
fileSize,
|
|
durationMs,
|
|
videoDurationSeconds: job.duration ?? null,
|
|
perf: job.perfSummary ?? null,
|
|
});
|
|
} catch (error) {
|
|
const durationMs = Date.now() - t0;
|
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
log.error("render failed", {
|
|
requestId,
|
|
durationMs,
|
|
error: errorMsg,
|
|
stage: job.currentStage,
|
|
});
|
|
return c.json(
|
|
{
|
|
success: false,
|
|
requestId,
|
|
error: errorMsg,
|
|
stage: job.currentStage,
|
|
durationMs,
|
|
errorDetails: job.errorDetails ?? null,
|
|
},
|
|
500,
|
|
);
|
|
} finally {
|
|
release();
|
|
cleanupTempDir(cleanupProjectDir, log);
|
|
}
|
|
};
|
|
|
|
const renderStream = (c: Context) => {
|
|
return streamSSE(c, async (stream) => {
|
|
const requestId = getRequestId(c);
|
|
const t0 = Date.now();
|
|
|
|
let body: Record<string, unknown>;
|
|
try {
|
|
body = await c.req.json();
|
|
} catch {
|
|
await stream.writeSSE({
|
|
data: JSON.stringify({
|
|
type: "error",
|
|
requestId,
|
|
error: "Invalid JSON body",
|
|
stage: "validation",
|
|
}),
|
|
});
|
|
return;
|
|
}
|
|
|
|
const preparedResult = await prepareRenderBody(body);
|
|
if ("error" in preparedResult) {
|
|
await stream.writeSSE({
|
|
data: JSON.stringify({
|
|
type: "error",
|
|
requestId,
|
|
error: preparedResult.error,
|
|
stage: "validation",
|
|
}),
|
|
});
|
|
return;
|
|
}
|
|
|
|
const { input, cleanupProjectDir } = preparedResult.prepared;
|
|
const absoluteOutputPath = resolveOutputPath(
|
|
input.projectDir,
|
|
input.outputPath,
|
|
rendersDir,
|
|
log,
|
|
);
|
|
const outputDir = dirname(absoluteOutputPath);
|
|
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
|
|
|
log.info("render-stream started", { requestId, projectDir: input.projectDir });
|
|
|
|
const job = createRenderJob({
|
|
fps: input.fps,
|
|
quality: input.quality,
|
|
format: input.format,
|
|
workers: input.workers,
|
|
useGpu: input.useGpu,
|
|
debug: input.debug,
|
|
entryFile: input.entryFile,
|
|
logger: log,
|
|
});
|
|
const abortController = new AbortController();
|
|
const onRequestAbort = () =>
|
|
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,
|
|
input.projectDir,
|
|
absoluteOutputPath,
|
|
async (j, message) => {
|
|
await stream.writeSSE({
|
|
data: JSON.stringify({
|
|
type: "progress",
|
|
requestId,
|
|
stage: j.currentStage,
|
|
progress: j.progress,
|
|
framesRendered: j.framesRendered ?? 0,
|
|
totalFrames: j.totalFrames ?? 0,
|
|
message,
|
|
}),
|
|
});
|
|
},
|
|
abortController.signal,
|
|
);
|
|
|
|
const fileSize = existsSync(absoluteOutputPath) ? statSync(absoluteOutputPath).size : 0;
|
|
const outputToken = store.register(absoluteOutputPath);
|
|
const outputUrl = `${outputUrlPrefix}/${outputToken}`;
|
|
log.info("render-stream completed", { requestId, fileSize, perf: job.perfSummary ?? null });
|
|
await stream.writeSSE({
|
|
data: JSON.stringify({
|
|
type: "complete",
|
|
requestId,
|
|
outputPath: absoluteOutputPath,
|
|
outputToken,
|
|
outputUrl,
|
|
fileSize,
|
|
videoDurationSeconds: job.duration ?? null,
|
|
perf: job.perfSummary ?? null,
|
|
}),
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof RenderCancelledError) {
|
|
await stream.writeSSE({
|
|
data: JSON.stringify({
|
|
type: "cancelled",
|
|
requestId,
|
|
stage: job.currentStage,
|
|
message: error.message,
|
|
}),
|
|
});
|
|
return;
|
|
}
|
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
const elapsedMs = Date.now() - t0;
|
|
log.error("render-stream failed", {
|
|
requestId,
|
|
elapsedMs,
|
|
error: errorMsg,
|
|
stage: job.currentStage,
|
|
});
|
|
await stream.writeSSE({
|
|
data: JSON.stringify({
|
|
type: "error",
|
|
requestId,
|
|
error: errorMsg,
|
|
stage: job.currentStage,
|
|
elapsedMs,
|
|
errorDetails: job.errorDetails ?? null,
|
|
}),
|
|
});
|
|
} finally {
|
|
release();
|
|
c.req.raw.signal.removeEventListener("abort", onRequestAbort);
|
|
cleanupTempDir(cleanupProjectDir, log);
|
|
}
|
|
});
|
|
};
|
|
|
|
const outputs = (c: Context): Response => {
|
|
const token = c.req.param("token") ?? "";
|
|
const artifact = store.get(token);
|
|
if (!artifact) {
|
|
return c.json({ success: false, error: "Output artifact not found or expired" }, 404);
|
|
}
|
|
if (!existsSync(artifact.path)) {
|
|
store.delete(token);
|
|
return c.json({ success: false, error: "Output artifact file missing" }, 404);
|
|
}
|
|
const stats = statSync(artifact.path);
|
|
return new Response(createReadStream(artifact.path) as unknown as ReadableStream, {
|
|
headers: {
|
|
"content-type": "video/mp4",
|
|
"content-length": String(stats.size),
|
|
"cache-control": "no-store",
|
|
},
|
|
});
|
|
};
|
|
|
|
const queue = (c: Context): Response =>
|
|
c.json({
|
|
maxConcurrentRenders,
|
|
activeRenders: renderSemaphore.activeCount,
|
|
queuedRenders: renderSemaphore.waitingCount,
|
|
});
|
|
|
|
return { render, renderStream, lint, health, outputs, queue };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public app factory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Create a Hono app with clean public routes for OSS use.
|
|
*/
|
|
export function createProducerApp(options: HandlerOptions = {}): Hono {
|
|
const app = new Hono();
|
|
const handlers = createRenderHandlers(options);
|
|
|
|
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);
|
|
|
|
return app;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Standalone server
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Start the producer HTTP server with graceful shutdown.
|
|
*/
|
|
export function startServer(options: ServerOptions = {}) {
|
|
const port = options.port ?? parseInt(process.env.PRODUCER_PORT ?? "9847", 10);
|
|
const log = options.logger ?? defaultLogger;
|
|
const app = createProducerApp(options);
|
|
|
|
const server = serve({ fetch: app.fetch, port }, () => {
|
|
log.info(`Listening on http://localhost:${port}`);
|
|
});
|
|
|
|
// Disable timeouts for long renders
|
|
server.setTimeout(0);
|
|
(server as unknown as import("node:http").Server).requestTimeout = 0;
|
|
(server as unknown as import("node:http").Server).keepAliveTimeout = 0;
|
|
|
|
async function shutdown(signal: string) {
|
|
log.info(`Received ${signal}, shutting down`);
|
|
const { drainBrowserPool } = await import("@hyperframes/engine");
|
|
await drainBrowserPool().catch(() => {});
|
|
server.close(() => {
|
|
log.info("Server closed");
|
|
process.exit(0);
|
|
});
|
|
setTimeout(() => {
|
|
log.warn("Forced exit after 30s timeout");
|
|
process.exit(1);
|
|
}, 30_000).unref();
|
|
}
|
|
|
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
|
|
return server;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Self-executable: node dist/public-server.js
|
|
// ---------------------------------------------------------------------------
|
|
// Only auto-start when this file is the explicit entry point.
|
|
// In esbuild bundles, import.meta.url is shared across inlined modules,
|
|
// so we check argv[1] against known public server filenames.
|
|
const entryScript = process.argv[1] ? resolve(process.argv[1]) : "";
|
|
const isPublicServerEntry =
|
|
entryScript.endsWith("/public-server.js") || entryScript.endsWith("/src/server.ts");
|
|
|
|
if (isPublicServerEntry) {
|
|
const { values } = parseArgs({
|
|
options: {
|
|
port: { type: "string", short: "p", default: process.env.PRODUCER_PORT ?? "9847" },
|
|
},
|
|
});
|
|
startServer({ port: parseInt(values.port as string, 10) });
|
|
}
|