Files
openmaic/OpenMAIC/render-service/src/main.ts
2026-08-16 14:58:47 +08:00

298 lines
13 KiB
TypeScript

/**
* @openmaic/render-service — HTTP entrypoint.
*
* Renders exported Hyperframes projects (the ZIP the app builds with
* `packageVideoZip`) to MP4 using `@hyperframes/producer`, isolated in a
* Node 22 + Chromium + FFmpeg container (issue #866).
*
* The contract is intentionally minimal and stable so the internals (in-memory
* vs Redis job store, local-disk vs S3 artifacts) can be swapped for a
* demo-scale deployment without the app noticing:
*
* POST /render multipart: project(zip) + fps/quality/format → 202 { jobId }
* GET /render/:jobId → { status, progress, currentStage, done, ... }
* GET /render/:jobId/download → stream MP4 (or 302 to a presigned URL)
* DELETE /render/:jobId → cancel
* GET /health → { ok: true }
*
* NOTE: this file must NOT be named `server.ts`. `@hyperframes/producer`'s main
* module auto-starts its own bundled HTTP server (on PRODUCER_PORT, default
* 9847) as an import side effect when the process entry path ends with
* `/src/server.ts` or `/public-server.js`. We use the producer as a library, so
* the entrypoint is `main.ts` to avoid spawning that phantom server.
*/
import { createReadStream } from 'node:fs';
import { mkdir, stat } from 'node:fs/promises';
import { Readable } from 'node:stream';
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { config } from './config.js';
import { InMemoryJobStore } from './job-store.js';
import { LocalDiskArtifactStore } from './artifact-store.js';
import {
RenderCoordinator,
RenderRejectedError,
makeProjectDir as defaultMakeProjectDir,
} from './render-coordinator.js';
import { InProcessExecutor } from './render-executor.js';
import { InvalidProjectError, unzipProject as defaultUnzipProject } from './unzip.js';
import { capBodyStream } from './capped-stream.js';
import { Semaphore } from './semaphore.js';
import type { JobStore } from './job-store.js';
import type { ArtifactStore } from './artifact-store.js';
import { isTerminal, type RenderOptions } from './types.js';
import { collectRuntimeVersions } from './runtime-info.js';
import { publicResourceProfile, validateResourceProfileStartup } from './resource-profile.js';
import type { RuntimeVersions } from './types.js';
/** Thrown inside the gated section for an oversized body (→ HTTP 413). */
class UploadTooLargeError extends Error {}
/** Thrown inside the gated section for a malformed request (→ HTTP 400). */
class BadRequestError extends Error {}
/** Collaborators the app depends on; injectable so the routes are testable. */
export interface AppDeps {
jobs: JobStore;
artifacts: ArtifactStore;
coordinator: RenderCoordinator;
/** Bounds concurrent *buffering + extraction* (the whole RAM-heavy section). */
extractionGate: Semaphore;
/** Extract a validated archive into a dir. Overridable in tests. */
unzipProject?: (zip: Uint8Array, destDir: string) => Promise<void>;
/** Create a fresh per-render scratch dir. Overridable in tests. */
makeProjectDir?: () => Promise<string>;
/** Runtime identity reported by health and copied into per-render metrics. */
runtimeVersions?: RuntimeVersions;
}
/** Parse + validate the multipart render options. Returns options or an error string. */
function parseOptions(form: FormData): RenderOptions | string {
const fps = Number.parseInt(String(form.get('fps') ?? '30'), 10);
if (!Number.isFinite(fps) || fps <= 0 || fps > 120) return 'Invalid fps';
const quality = String(form.get('quality') ?? 'standard');
if (quality !== 'draft' && quality !== 'standard' && quality !== 'high') {
return 'Invalid quality (expected draft|standard|high)';
}
const format = String(form.get('format') ?? 'mp4');
if (format !== 'mp4') return 'Unsupported format (only mp4)';
return { fps, quality, format };
}
/**
* Build the render-service HTTP app over injected collaborators.
*
* Admission ordering is the security boundary here:
* 1. `reserve()` (queue + per-identity) runs FIRST, before anything is read —
* a rejected caller never buffers a byte.
* 2. The whole RAM-heavy section — buffering the multipart (`formData()` is
* what materializes the uploaded file into memory), parsing, reading the
* file bytes, and extracting — runs INSIDE `extractionGate`. So at most
* `maxConcurrentExtractions` bodies are ever buffered at once; the rest wait
* with their request body still unconsumed (backpressured on the socket),
* not held in RAM. This is what stops a near-cap burst from OOMing the box.
*/
export function createApp(deps: AppDeps): Hono {
const { jobs, artifacts, coordinator, extractionGate } = deps;
const unzipProject = deps.unzipProject ?? defaultUnzipProject;
const makeProjectDir = deps.makeProjectDir ?? defaultMakeProjectDir;
const app = new Hono();
app.get('/health', (c) =>
c.json({
ok: true,
resourceProfile: publicResourceProfile(config.resourceProfile),
versions: deps.runtimeVersions ?? null,
}),
);
app.post('/render', async (c) => {
// Reject an oversized body by declared length first (courtesy 413 for honest
// clients). The real bound is the byte-counting cap below, since
// Content-Length is client-supplied and absent on chunked uploads.
const declared = Number(c.req.header('content-length') ?? '0');
if (Number.isFinite(declared) && declared > config.maxUploadBytes) {
return c.json({ error: 'Upload too large' }, 413);
}
// Identity is derived by the trusted proxy (client IP) and passed in a header;
// a client-supplied multipart `userId` is deliberately ignored so it can't be
// rotated to bypass the per-identity guard.
const identity = c.req.header('x-openmaic-client')?.trim() || 'anonymous';
// Reserve a queue slot BEFORE the buffering permit, so a rejected caller
// (queue full / per-identity limit) never enters buffering or extraction.
let reservation;
try {
reservation = coordinator.reserve(identity);
} catch (error) {
if (error instanceof RenderRejectedError) return c.json({ error: error.message }, 429);
throw error;
}
// From here every failure MUST release the reservation.
let projectDir: string | undefined;
try {
// The ENTIRE memory-heavy section runs under the extraction permit:
// buffering the body (formData), reading the file, and unzipping. Requests
// beyond the permit wait here with their body still unconsumed, so only
// `maxConcurrentExtractions` bodies are buffered concurrently.
const jobId = await extractionGate.run(async () => {
const raw = c.req.raw;
let form: FormData;
let capped: ReturnType<typeof capBodyStream> | undefined;
try {
if (raw.body) {
// Cap the raw body as it streams into formData(), so a chunked /
// length-lying upload can't exceed the byte ceiling mid-parse.
capped = capBodyStream(raw.body, config.maxUploadBytes);
const bounded = new Request(raw.url, {
method: raw.method,
headers: raw.headers,
body: capped.stream,
// duplex is required for a streaming request body.
duplex: 'half',
} as RequestInit);
form = await bounded.formData();
} else {
form = await c.req.formData();
}
} catch {
if (capped?.exceeded()) throw new UploadTooLargeError('Upload too large');
throw new BadRequestError('Expected multipart/form-data');
}
const options = parseOptions(form);
if (typeof options === 'string') throw new BadRequestError(options);
const file = form.get('project');
if (!(file instanceof File)) {
throw new BadRequestError('Missing "project" file field');
}
projectDir = await makeProjectDir();
const bytes = new Uint8Array(await file.arrayBuffer());
await unzipProject(bytes, projectDir);
return coordinator.submit(reservation, projectDir, options);
});
return c.json({ jobId }, 202);
} catch (error) {
coordinator.release(reservation);
if (projectDir) await coordinator.cleanupProject(projectDir);
if (error instanceof UploadTooLargeError) return c.json({ error: error.message }, 413);
if (error instanceof BadRequestError) return c.json({ error: error.message }, 400);
if (error instanceof InvalidProjectError) return c.json({ error: error.message }, 400);
if (error instanceof RenderRejectedError) return c.json({ error: error.message }, 429);
throw error;
}
});
app.get('/render/:jobId', async (c) => {
const job = await jobs.get(c.req.param('jobId'));
if (!job) return c.json({ error: 'Job not found' }, 404);
return c.json({
jobId: job.id,
status: job.status,
progress: job.progress,
currentStage: job.currentStage,
framesRendered: job.framesRendered,
totalFrames: job.totalFrames,
metrics: job.metrics,
error: job.error,
done: isTerminal(job.status),
});
});
app.delete('/render/:jobId', async (c) => {
const ok = await coordinator.cancel(c.req.param('jobId'));
if (!ok) return c.json({ error: 'Job not found' }, 404);
return c.json({ cancelled: true });
});
app.get('/render/:jobId/download', async (c) => {
const jobId = c.req.param('jobId');
const job = await jobs.get(jobId);
if (!job) return c.json({ error: 'Job not found' }, 404);
if (job.status !== 'succeeded') {
return c.json({ error: `Job not ready (status: ${job.status})` }, 409);
}
const location = await artifacts.locate(jobId);
if (!location) return c.json({ error: 'Artifact expired or missing' }, 404);
// Presigned-URL stores (demo layer) redirect the browser straight to storage.
if (location.kind === 'url') return c.redirect(location.href, 302);
const { size } = await stat(location.path).catch(() => ({ size: 0 }));
if (!size) return c.json({ error: 'Artifact missing on disk' }, 404);
const webStream = Readable.toWeb(createReadStream(location.path)) as ReadableStream;
return new Response(webStream, {
headers: {
'Content-Type': 'video/mp4',
'Content-Length': String(size),
'Content-Disposition': `attachment; filename="${jobId}.mp4"`,
},
});
});
return app;
}
/** Wire the production collaborators and start the server (skipped under tests). */
async function main(): Promise<void> {
const artifacts = new LocalDiskArtifactStore();
validateResourceProfileStartup(config.resourceProfile);
const runtimeVersions = await collectRuntimeVersions();
const executor = new InProcessExecutor({ runtimeVersions });
// Assigned after `jobs` so its reap callback can close over the coordinator.
// eslint-disable-next-line prefer-const
let coordinator: RenderCoordinator;
const jobs = new InMemoryJobStore(config.jobTtlMs, (record) => {
// A reaped job's artifact + project dir go with it.
void artifacts.remove(record.id);
void coordinator.cleanupProject(record.projectDir);
});
coordinator = new RenderCoordinator(executor, jobs, artifacts);
const app = createApp({
jobs,
artifacts,
coordinator,
// Bounds concurrent buffering + extraction so the per-archive RAM ceiling
// can't stack across a burst of admitted requests.
extractionGate: new Semaphore(config.maxConcurrentExtractions),
runtimeVersions,
});
// Ensure the scratch root exists before accepting work. On the documented
// standalone path nothing creates /tmp/openmaic-renders, so without this every
// makeProjectDir() would ENOENT. mktemp still creates a fresh subdir per job.
await mkdir(config.tmpDir, { recursive: true }).catch(() => {});
serve({ fetch: app.fetch, port: config.port }, (info) => {
console.log(
`[render-service] listening on :${info.port} ` +
`(resourceProfile=${config.resourceProfile.name}, ` +
`requestedCaptureMode=${config.resourceProfile.requestedCaptureMode}, ` +
`maxConcurrency=${config.maxConcurrency}, producerWorkers=${config.producerWorkers}, ` +
`browserGpuMode=${process.env.PRODUCER_BROWSER_GPU_MODE ?? 'producer-default'}, ` +
`browserPool=${process.env.PRODUCER_ENABLE_BROWSER_POOL ?? 'producer-default'}, ` +
`lowMemoryMode=${process.env.PRODUCER_LOW_MEMORY_MODE ?? 'auto'}, ` +
`staticDedup=${process.env.HF_STATIC_DEDUP ?? 'producer-default'}, ` +
`headlessShell=${process.env.PRODUCER_HEADLESS_SHELL_PATH ?? 'unset'}, ` +
`requireBeginFrame=${config.requireBeginFrame}, ` +
`producer=${runtimeVersions.producer}, node=${runtimeVersions.node}, ` +
`chromium=${runtimeVersions.chromium}, ffmpeg=${runtimeVersions.ffmpeg})`,
);
});
}
// Only auto-start when run as the entrypoint, not when imported by tests.
if (process.env.RENDER_SERVICE_NO_LISTEN !== 'true') {
await main();
}