* fix(video-export): bridge play_video element id → media ref Generated-media records are keyed by the element's media ref (gen_vid_…), but a play_video action targets the slide element by its .id. The app-side compiler deps looked media/durations up by the raw element id, so every generated video missed → present:false → no <video> emitted, no bytes collected → the clip vanished from the export (only its static first frame, baked into the base PNG, remained). Build an elementId → mediaRef bridge across all slide scenes in createVideoTimelineDeps (mirroring the live engine's resolveMediaPlaceholderId) and resolve both media() and videoDurationMs() through it. Refs #867. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(video-export): re-measure KaTeX fit-scale after fonts settle KaTeX sizes large delimiters (\left\{, \begin{cases}) from its KaTeX_Size faces, which load asynchronously. KatexContent measured its shrink-to-fit scale once on mount, against the fallback font, and never recomputed — so a cold slide snapshot baked a stale scale and the big brace desynced from the piecewise body ("大括号后面的分段函数与大括号错位"). - Re-run the fit-scale measurement on document.fonts.ready and on each `loadingdone`, keeping the shrink-only (cap-at-1) behavior. - slideToPng waits two extra frames before capture so the font-triggered React re-measure commits into the DOM html2canvas reads. Refs #867. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(video-export): place spotlight/laser/video on the rendered content box The pure geometry helper resolves an element's authored outer box against a fixed 1000×562.5 base, but the live overlays — and the exported frame PNG — measure the element's `.element-content` box, which for horizontal text is auto-height plus 10px content padding. So a spotlight/laser sat offset from where the text actually renders ("spotlight 与对应元素的位置有偏差", #867 item 5). Add an optional GeometryProbe to the compiler's DI boundary: when supplied, the geometry pass prefers each element's measured content-box geometry (video keeps its rotation from the authored element), degrading to the pure authored-box calc on a miss so the compiler stays deterministic in unit contexts. App side: measureSlideElementGeometry (new, in @openmaic/renderer/snapshot) mounts the slide off-screen and reads the same `.element-content`-against- container box the live SpotlightOverlay uses; createVideoTimelineDeps pre-measures every spotlight/laser/play_video target per scene and serves the probe as a synchronous table lookup. Tests: geometry.test.ts quantifies the outer-box vs content-box delta and proves probe-wins / fallback / rotation-preservation; timeline-deps.test.ts covers the pre-measure wiring. Refs #867. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(video-export): decode a first-frame poster for posterless videos A play_video clip only shows during its play window; the rest of the time the static base-frame PNG backs that spot. But html2canvas can't draw a <video>, so the snapshot swaps it for its poster <img> — and generated videos usually carry no poster, leaving the video area blank whenever the clip isn't playing. When a video record has no stored poster bytes, decode the video blob's first frame (seek slightly off 0, draw to a canvas) and set element.poster before the base snapshot, so that spot shows the first frame — the "paused on frame one" look — instead of blank. Falls back to no poster on decode failure / timeout / CORS taint, never failing the export. Refs #867. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(video-export): split subtitles, make burn-in optional, centered modal Burned-in captions used one cue per whole narration paragraph, so a long cue became a 4–6 line block covering the lower half of the slide, and there was no way to turn it off. - Split cues (pure, compiler layer): lib/video-export/split-cue.ts splits each cue on sentence → clause → hard-wrap to ≤~40 CJK-equiv units, distributing the parent window by character weight (last piece pinned to the parent end, sub-1.2s slivers merged). Wired into the timeline pass, so the burned-in overlay and the SRT/VTT sidecar share one split track and can't drift. - Burn-in optional (default off, #867 item 2): emitHyperframes gains burnInSubtitles; off → clean video + sidecar SRT/VTT only. Caption layout hard- clamped to 2 lines so an outlier can't grow tall again. - Download subtitles without rendering: compileSubtitles() runs only the compiler (no assets/zip/service); new useDownloadSubtitles saves an .srt. - Centered modal: video-export-dialog.tsx replaces the cramped dropdown section, grouping Output / Subtitles / Actions / Progress with a stable lifecycle for multi-minute renders. header-controls opens it from an "Export Video" entry; old video-export-menu.tsx removed. i18n keys added across all 8 locales. Refs #867. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style: prettier format video-export fidelity changes Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: drop unused playVideo helper in timeline-deps test The media-bridge tests assert via deps.assets.media(...) directly, so the playVideo helper (and its PlayVideoAction import) were dead. Flagged by the code-quality bot on #952. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(video-export): resolve cross-review findings + lower subtitle band - emit-hyperframes: drop duplicate `display:-webkit-box` on subtitle cues so they start truly `display:none` (a second declaration overrode the `none`, showing every cue stacked at t=0 when burn-in was enabled). - geometry: don't feed the measured content-box AABB to a rotated video clip — it already encloses the rotation, so re-applying `rotate` doubled it. Rotated elements fall back to the authored box (one, un-doubled source of truth). - build-export-zip: extract shared `compileStageIr` so buildExportZip and compileSubtitles can't drift on the timing/assets/geometry wiring. - split-cue: join merged CJK cue pieces without a spurious ASCII space. - subtitles: lower the caption band (bottomRatio 0.055 → 0.01) and hoist the band's magic numbers into a named SUBTITLE constants block. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(video-export): stop the snapshot onclone override from reshaping KaTeX The export snapshot injects a neutral-kerning / geometricPrecision style over `.slide-renderer-prose *` to keep CJK prose+table glyphs from mis-advancing under html2canvas-pro. KaTeX formulas (output:'html') also live inside `.slide-renderer-prose`, so the override was reshaping their math glyph advances — a burned-in formula rendered subtly differently from the live canvas, which applies no such override. Empirical check (Playwright + system Chrome, cases+fraction formula, native paint vs html2canvas): the override raised the formula's mean per-pixel delta vs the live paint from 120.1 to 139.2 (~16%) and moved ~75% of inked pixels. Restoring native font-kerning/text-rendering for `.katex` subtrees brings the formula's delta back to 120.1 (bit-identical to the no-override capture) while prose and table text keep the override. The residual 120.1 is the deeper html2canvas rasterization gap (vlist/frac-line/delimiter), a separate track. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(video-export): render slides via native paint, bundle KaTeX font embed Root-fix the exported-vs-live formula distortion (and the filter/mask/video gaps) by rasterizing slides with native Chrome paint instead of html2canvas's reimplementation. - slideToPng now captures via html-to-image (foreignObject → the same Chrome engine that paints the live classroom rasterizes the same DOM). KaTeX formulas, CSS filter, soft-edge masks, and mixed CJK/Latin text come out as the classroom shows them. html2canvas-pro stays as a fallback (with the filter/mask bakes + KaTeX text-rendering reset) for when native paint can't run — e.g. a cross-origin image taints the canvas. - A foreignObject SVG can't reach the document font registry, so fonts must be inlined. KaTeX math faces are prepended from the bundled woff2 (KATEX_FONT_EMBED_CSS, generated by scripts/generate-katex-fonts.mjs), NOT left to runtime getFontEmbedCSS — which reads cssRules and silently drops a cross-origin KaTeX stylesheet, collapsing a large brace to a fallback glyph (the observed "formula render failure" on e.g. a \begin{cases} with \text{}). A per-face guard verifies every KaTeX face the formula references is embedded, falling back to html2canvas otherwise. Verified in a Playwright spike (native vs html2canvas vs html-to-image): formula inked-delta 143→53; filter/mask/video match native; and with getFontEmbedCSS forced empty, the bundled embed alone still renders the failing formula correctly. Renderer package tests: 226 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(video-export): prettier-format the generated KaTeX font-embed module The generator emitted an unformatted TS file (double quotes, long unwrapped CSS literal) that failed CI `prettier --check`. Run the generated output through the repo's Prettier config before writing, so every rebuild produces CI-clean, stable output. Regenerated the committed file to match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(lint): rename reserved `module` var in katex-fonts generator; ignore .scratch - generate-katex-fonts.mjs assigned to a variable named `module`, tripping @next/next/no-assign-module-variable (the one hard eslint error). Renamed to `fileContents`; generated output is byte-identical (deterministic). - Add `.scratch/**` to the eslint globalIgnores (alongside .claude/.worktrees) so local throwaway verification scaffolding doesn't pollute `pnpm lint`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(video-export): address PR #952 review — scene-scoped media bridge, safer cue splitting Resolves the two blocking correctness issues and three follow-ups from the cross-review on #952. - Scope the elementId→mediaRef bridge by scene (was deck-wide/last-writer-wins): slide-local ids like `video_001` now resolve within their own scene. `assets.media` uses its scene arg; `timing.videoDurationMs` resolves via action object identity. Adds a cross-scene collision test. - Stop treating every ASCII period as a sentence boundary: a lone `.` splits only when not between word chars (3.14, v1.2) and not an abbreviation dot (e.g.), collapsing repeated-punctuation runs. Adds decimal/version/abbrev/ellipsis tests. - Share the module-level in-flight guard across ZIP export and subtitle download (extracted to export-in-flight.ts); dialog `busy` folds in `downloading`. - Skip the off-screen geometry render for the subtitles-only path (skipGeometry); duration probes are kept so sidecar cue timing stays in sync with the video. - Align measure.ts font settling with slideToPng: force-load each used face before fonts.ready so measured content-boxes don't bake fallback advance widths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(video-export): prettier-format split-cue test Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(video-export): enable beginFrame capture via PRODUCER_BROWSER_GPU_MODE=hardware The producer defaults to `software` GPU mode, which silently force-enables the CPU-bound `Page.captureScreenshot` fallback: SwiftShader pins every core on rasterization and caps frame capture at ~10 fps. Selecting `hardware` keeps Chromium's `HeadlessExperimental.beginFrame` capture active instead. Measured on a 900-frame (30s) clip, no real GPU on the host: - capture ~10 -> ~19 fps, total 100s -> 62s (~38% faster) - CPU peak ~810% -> ~98%; peak mem 2.2 GiB -> 0.9 GiB - static frames pixel-identical; animation frames differ only in sub-pixel edge antialiasing (same-path re-render is deterministic/inf PSNR) No real GPU is required — the win is the faster capture API, not hardware rasterization; beginFrame falls back gracefully if the host lacks a GPU. Also eases the RENDER_JOB_DEADLINE_MS timeout risk on long videos. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(video-export): sentence splitting via Intl.Segmenter + abbreviation merge Addresses the PR #952 follow-up: separate sentence-boundary detection from cue shaping and stop growing a custom punctuation heuristic. - splitSentences now delegates boundary detection to Intl.Segmenter (locale- agnostic ICU), the multilingual/CJK baseline. It already preserves 3.14, v1.2, e.g., U.S., ellipses, and CJK 。!?… without any hand-rolled period scan. - ICU still ends a sentence after a titlecase abbreviation before a capitalized word (Dr. Smith, Fig. 3) — the edge case the reviewer flagged. A small abbreviation-aware post-merge re-joins those, driven by an ABBREVIATIONS set. - Semicolons are no longer sentence ends (they join clauses); moved ;; into the clause-level SECONDARY splitter so over-budget sentences still break there. - Cue shaping (budget / clause / hard-wrap / weighted timing) is unchanged, so detection and shaping are now distinct stages. Not using `sbd` as suggested: lib/video-export/*.ts sits behind the machine- enforced eslint purity boundary (imports limited to @openmaic/dsl, zod, ../choreography, siblings), so a new npm dep is rejected. Intl.Segmenter is a pure Node >=20.9 builtin, matching the reviewer's "small abbreviation-aware post-merge layer" alternative. Tests: add Dr. Smith / Fig. 3 / U.S. coverage; the rhetorical ellipsis now stays one under-budget cue (was split); add a semicolon over-budget case. 22/22 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(video-export): prettier-format split-cue sentence refactor Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * i18n(es-MX): add 7 subtitle/video export keys to align with en-US main added the Spanish (Mexico) locale (#942) after this branch's subtitle keys landed, so es-MX was the only locale missing export.subtitles* and export.videoBurnSubtitles* — which failed the i18n key-alignment CI check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(video-export): restrict title post-merge so it never deletes real boundaries Addresses the blocking re-review on the Intl.Segmenter change: the abbreviation post-merge unconditionally rejoined the next ICU segment whenever the previous one ended in a listed abbreviation, which erased genuine sentence boundaries — `It was made by Acme Inc. Next point.` collapsed into one cue, and even an explicit newline (`Acme Inc.\nNext topic`) was rejoined. The merge now fires only for the case ICU actually mis-splits — a title (Mr/Mrs/Ms/Dr/Prof/Sr/Jr) before a capitalized proper noun — and only when: - the previous segment does not end at a newline (hard boundary), and - the next segment starts with a capital letter. Sentence-trailing abbreviations (Inc./Ltd./etc./vs./No.) are dropped from the set entirely: they legitimately end sentences, and ICU already keeps a title before a number (No. 5, Fig. 3) or a lowercase continuation (etc. and…) whole without any merge, so nothing there needs rejoining. Regression tests: Inc./etc. boundary preserved, newline-after-title preserved, lowercase continuation kept whole, plus the retained Dr. Smith / Fig. 3 / U.S. cases. 26 split-cue tests pass; full video-export suite 123 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7.4 KiB
@openmaic/render-service
Isolated MP4 render service for OpenMAIC's classroom video export (issue #866).
The main app compiles a classroom to a self-contained Hyperframes project ZIP
(index.html + assets/ + vendored GSAP) entirely in the browser. This service
takes that ZIP and renders it to an MP4 with @hyperframes/producer, which
drives headless Chromium (frame capture) + FFmpeg (encode). It runs in its own
Node 22 container because the producer needs Node ≥ 22, Chromium, and FFmpeg —
none of which belong in the Next.js runtime.
It is an opt-in capability: when the app has no RENDER_SERVICE_URL
configured, in-app export degrades to downloading the project ZIP for local CLI
rendering. Nothing here is required for the app to run.
HTTP API
Rendering is asynchronous (a 10-minute video can take tens of minutes): submit, poll, then download. Job ids are opaque.
| Method + path | Purpose |
|---|---|
POST /render |
multipart: project (the ZIP) + fps, quality, format fields → 202 { jobId } |
GET /render/:jobId |
{ status, progress, currentStage, framesRendered, totalFrames, done, error } |
GET /render/:jobId/download |
stream the MP4 (or 302 to a presigned URL) once succeeded |
DELETE /render/:jobId |
cancel a queued/running job |
GET /health |
{ ok: true } |
status is one of queued | running | succeeded | failed | cancelled;
progress is 0..1.
Environment
| Var | Default | Meaning |
|---|---|---|
PORT |
9000 |
Listen port. |
RENDER_MAX_CONCURRENCY |
2 |
Renders that execute simultaneously; extras queue FIFO. |
RENDER_MAX_CONCURRENT_EXTRACTIONS |
2 |
Archives expanded simultaneously; bounds the RAM multiplier (≈ this × max expanded size). |
RENDER_MAX_JOBS_PER_USER |
1 |
Active jobs allowed per client identity (0 disables the guard — see note below). |
RENDER_MAX_QUEUE |
20 |
Max jobs in the system (reserved+queued+running) before new submits get 429. |
RENDER_JOB_TTL_MS |
1800000 |
How long finished jobs + artifacts live before cleanup. |
RENDER_JOB_DEADLINE_MS |
2700000 |
Hard per-job wall-clock deadline; overruns are aborted and marked failed. |
RENDER_MAX_UPLOAD_BYTES |
314572800 |
Max compressed archive size accepted (300 MB); enforced on real bytes, before buffering. |
RENDER_MAX_ENTRIES |
5000 |
Max entries allowed in the archive. |
RENDER_MAX_ENTRY_BYTES |
209715200 |
Max expanded size of any single entry (200 MB). |
RENDER_MAX_EXPANDED_BYTES |
536870912 |
Max total expanded size across all entries (512 MB). |
RENDER_MAX_COMPRESSION_RATIO |
200 |
Max expanded:compressed ratio per entry (ZIP-bomb guard). |
RENDER_EGRESS_LOCKDOWN |
true |
Install the iptables egress lockdown at startup (needs root + CAP_NET_ADMIN); fails closed — the container exits if the rules can't be applied. Set false to run unisolated. |
PRODUCER_TMP_PROJECT_DIR |
/tmp/openmaic-renders |
Scratch dir for unzipped projects + outputs. |
PRODUCER_BROWSER_GPU_MODE |
hardware (set in Compose) |
Capture path selector. Producer's own default is software, which force-enables the CPU-bound Page.captureScreenshot fallback (~10 fps, all cores pinned). hardware keeps Chromium's HeadlessExperimental.beginFrame capture active — ~2x throughput at ~half the CPU, pixel-identical static frames. No real GPU required; falls back gracefully if absent. |
PUPPETEER_EXECUTABLE_PATH |
/usr/bin/chromium |
System Chromium (set in the image). |
Client identity for the per-user guard is taken from the x-openmaic-client
header, which the app's proxy sets. A client-supplied userId form field is
ignored. The app derives that header from x-forwarded-for/x-real-ip only
when the operator sets TRUST_PROXY_HEADERS=true (and a real reverse proxy
overwrites those headers); otherwise all callers share one direct identity, so
the default directly-exposed Compose topology can't be gamed by spoofing
forwarding headers.
Per-user guard vs. shared identity. When identity can't be trusted (no reverse proxy → everyone is
direct), aRENDER_MAX_JOBS_PER_USERof 1 would throttle the whole deployment to one render at a time. The default Compose therefore setsRENDER_MAX_JOBS_PER_USER=0(guard off) and relies onRENDER_MAX_CONCURRENCY+RENDER_MAX_QUEUE. Enable the per-user guard only behind a trusted proxy that supplies a real per-user identity.
Security / isolation
The uploaded archive is untrusted, so extraction is bounded before any bytes are decompressed (entry count, per-entry and total expanded size, and compression ratio — see the limits above), guarding against ZIP bombs. Extraction runs on fflate's worker (off the event loop) and is concurrency-capped so admitted jobs can't stack the per-archive RAM ceiling.
The composition HTML is then executed in headless Chromium. Two boundaries keep that untrusted page contained:
- No inbound-to-app bridge. The container's entrypoint installs an iptables
egress lockdown (drop all outbound except loopback + replies on app-initiated
connections), so Chromium can't open connections back to the app — even though
they share the Compose network so the app can reach the service. This needs the
container to run with
CAP_NET_ADMIN(cap_add: [NET_ADMIN], already set in the Compose file). WithRENDER_EGRESS_LOCKDOWN=true(the default) the entrypoint fails closed: if the rules can't be applied (missing capability, backend mismatch) the container exits non-zero rather than start an unisolated service the app would still advertise as healthy. An operator who knowingly accepts an unisolated standalone setup opts out withRENDER_EGRESS_LOCKDOWN=false.scripts/egress-smoke.sh <image>asserts the boundary end-to-end (lockdown active, loopback works, a new outbound connection is blocked). - No internet. In Compose the
rendernetwork isinternal: true(no host or internet gateway). The export ZIP bundles every asset (and GSAP) at build time, so the render needs no outbound at all.
When running standalone, place the service on an isolated network yourself (and keep the egress lockdown on, or accept the risk with the toggle) — it needs no outbound access.
Run
Docker (recommended)
The root docker-compose.yml wires this service under the video-export
profile and points the app at it:
docker compose --profile video-export up --build
Standalone (development)
Requires Node ≥ 22, plus Chromium and FFmpeg on PATH:
cd render-service
npm install
PUPPETEER_EXECUTABLE_PATH=$(which chromium) npm start
Scalability
The service is built with two swap points so it can move from a single OSS host to a horizontally-scaled demo deployment without changing the HTTP contract or the app:
JobStore(src/job-store.ts) — Part A shipsInMemoryJobStore. ARedisJobStoreimplementing the same interface lets any replica serve poll / download requests.ArtifactStore(src/artifact-store.ts) — Part A shipsLocalDiskArtifactStore(streams through the app proxy). AnS3ArtifactStorewhoselocatereturns a presigned URL makes the download route302the browser straight to object storage, bypassing the proxy.
Chunked distributed rendering (@hyperframes/producer/distributed) to cut
single-job latency is a further, separate follow-up.