Files
hyperframes/packages/producer/src/services/fileServer.ts
T
f84cc492de perf(engine): faster shader transitions via page-side WebGL compositing (#832)
* fix(cli): prefer puppeteer cache + numeric version sort (staff review)

Two correctness fixes from PR #821 self-review:

1. Cache priority order. Previous order was hyperframes-managed cache →
   puppeteer cache. HF cache is pinned to CHROME_VERSION (131-era) which
   lags 17+ releases behind upstream; if a user separately installed a
   newer chrome-headless-shell via @puppeteer/browsers install, the CLI
   would silently hand engine the older HF-cache binary while engine's
   own resolveHeadlessShellPath would have picked the newer one. Flip
   the priority so puppeteer cache wins, matching engine semantics.

2. Numeric (not lexicographic) version sort. `readdirSync.sort().reverse()`
   over names like `linux-148.0.7778.97` and `linux-99.0.6533.123` would
   return `linux-99...` first because character '9' outranks '1'. Parse
   each name into integer segments and compare them numerically.

Tests: add both-caches-populated and linux-148-beats-linux-99 cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* perf(engine): page-side compositing for shader transitions (opt-in spike)

Add an opt-in `--page-side-compositing` flag (CLI) backed by a new engine
config field `enablePageSideCompositing` and env var `HF_PAGE_SIDE_COMPOSITING`.
When set, SDR shader-transition compositions skip the Node-side layered blend
(the hf#677 chain) and instead run the shader inside Chrome via a page-side
WebGL canvas; the engine then captures ONE opaque RGB frame per output frame
via the existing streaming capture path.

This is the strongest non-beginFrame perf lever for Mac users, who cannot
take the beginFrame `~5×` path (Chromium structural limit, crbug.com/40656275).
Stacks on top of the hf#677 1.95× baseline.

Default OFF — existing fixture pins (byte-exact MP4 output) are preserved.
Opt-in path is intentionally PSNR-pinned, not byte-equal (WebGL is f32; Node
is f64). HDR content forces the existing layered path regardless.

Implementation:
- engine: new `EngineConfig.enablePageSideCompositing` (default false).
- producer/fileServer: new `HF_PAGE_SIDE_COMPOSITING_STUB` early-page script
  injected into the served HTML head when the flag is on.
- producer/renderOrchestrator: when the flag + no HDR + no png-sequence,
  route SDR transitions through the streaming path instead of the layered
  HDR stage.
- shader-transitions: new `engineModePageComposite.ts` installs a fullscreen
  WebGL compositor overlay and wraps `window.__hf.seek` so each seek inside
  a transition window captures both scenes via the Chromium
  `drawElementImage` API to GL textures, runs the fragment shader, and
  displays the composited result on the overlay canvas. The engine takes
  one screenshot per frame and sees the composited overlay.
- cli: new `--page-side-compositing` flag sets `HF_PAGE_SIDE_COMPOSITING=true`
  before producer load.
- scripts/page-side-compositing-smoke: bundled-CLI smoke that renders a
  representative fixture with and without the flag, validates the canary
  strings are in the shipped bundles, and writes a wall-time pair.

Determinism trade documented in the engine config doc-comment. The smoke
script enforces the bundled-CLI validation discipline from prior perf work
(see internal feedback note `validate_bundled_cli_not_dev_path`).

Runtime requirement: Chromium's `CanvasDrawElement` feature (already
enabled by the engine's `--enable-features=CanvasDrawElement` launch flag).
When the runtime feature is unavailable, the page-side installer logs a
warning and falls back to opacity-flip mode — the engine still takes the
streaming path; the transition window degrades to a hard scene swap. Vance
will validate on Mac Chrome where the feature is supported.

Co-Authored-By: Vai <vai@heygen.com>

* fix(shader-transitions): use html2canvas for page-side compositor capture

The original drawElementImage approach fails in engine render mode because
the virtual-time shim prevents Chromium from generating paint records for
cloned elements. drawElementImage requires a cached paint record from the
browser's compositor — clones created at capture time never receive one
because (a) shimmed rAFs deadlock inside the seek wrapper, (b) original
rAFs don't produce real paints under virtual-time control, and
(c) layoutsubtree canvases don't apply CSS stylesheet rules to children.

Switch scene capture to html2canvas (foreignObjectRendering: false), the
same JS-based renderer already used by the preview-mode fallback path in
capture.ts. html2canvas reads computed styles and renders via its own
canvas drawing pipeline with no dependency on the browser paint cycle.

Also fixes:
- Engine seek must return the result so Puppeteer awaits async seek
  promises (frameCapture.ts).
- GSAP opacity cache: compositor must restore scene opacity before seek,
  not after — GSAP caches inline values and skips re-writes.
- Support check gates on WebGL availability, not drawElementImage.

Perf: 15-scene shader-perf fixture (28s, 14 transitions, 30fps)
  Baseline (Node-side layered): 137s
  Page-side (html2canvas+WebGL): 33s → 4.1× speedup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(shader-transitions): simplify review fixes for page-side compositor

- Use uploadTexture (zeroes canvas backing store after upload) to prevent
  ~2.2GB transient memory pressure across 280 html2canvas calls per render
- Add ignoreElements + stabilizeTransformedBoxShadows to html2canvas call,
  matching the preview-path capture.ts behavior
- Parallelize from/to scene captures with Promise.all
- Wrap post-capture render in try/finally so opacity is always restored
- Fix WebGL context leak in isPageSideCompositingSupported probe
- Remove dead ResolvedTransition.index field
- Export stabilizeTransformedBoxShadows from capture.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(producer): unify page-side compositing gating and Docker forwarding

Addresses three issues from staff review:

1. ignoreElements filter stripped all in-scene canvases (Chart.js, D3,
   p5.js) — narrowed to data-no-capture only since the compositor canvas
   is a body sibling never in the scene subtree.

2. Docker mode silently dropped --page-side-compositing — thread
   pageSideCompositing through DockerRenderOptions/buildDockerRunArgs
   with regression tests.

3. Fragmented gating across 4 independent sites could disagree:
   - Stub injection gated only on cfg flag (leaked into HDR/alpha)
   - Probe-created fileServer never got the stub
   - needsAlpha (WebM/MOV) not excluded from the gate
   - WebGL-unavailable fallback claimed layered path would run but
     orchestrator had already disabled it

   Fix: compute stub injection at the same site as the layered-bypass
   decision (after hasHdrContent is known), using addPreHeadScript on
   the already-running fileServer. Single predicate now gates both
   decisions, including !needsAlpha.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* perf(engine): two-phase drawElementImage capture for page-side compositing

Replace html2canvas with native drawElementImage for scene capture in
the page-side compositor. drawElementImage reads from the browser's own
paint cache, giving pixel-identical output to the preview path.

The blocker was that cloned elements inside layoutsubtree canvases have
no cached paint record under virtual time — the compositor only paints
when explicitly triggered. Fix: split the seek+composite into two phases
with an engine-forced paint between them.

Phase 1 (seek wrapper, page-side):
  - GSAP seek positions the timeline
  - Clone FROM/TO scenes into visible layoutsubtree staging canvases
  - Set window.__hf_page_composite_pending flag

Engine paint force (frameCapture.ts):
  - Detect pending flag after seek returns
  - Fire micro Page.captureScreenshot (1x1 clip) via CDP to force the
    browser compositor to paint all visible elements including staging
    canvas children

Phase 2 (page.evaluate, page-side):
  - drawElementImage reads the now-valid paint records
  - Upload textures to WebGL, run shader, show GL overlay

Key insight: staging canvases must be visible (not opacity:0) for the
browser to paint their children. They sit at z-index:-9998, behind
the main DOM and covered by the GL overlay during transitions.

Perf: 15-scene fixture (28s, 14 transitions, 30fps):
  Baseline (Node-side layered): 137s
  html2canvas + WebGL:           33s (3.7×)
  drawElementImage + WebGL:      21s (6.6×)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* perf(engine): optimize two-phase compositor hot path

- uploadTextureSource instead of uploadTexture: eliminates ~2.3GB of
  canvas buffer alloc/dealloc churn (persistent staging canvases don't
  need the one-shot zeroing behavior)
- Fold hasPending check into seek page.evaluate: eliminates one CDP
  round-trip per frame (~700 unnecessary IPC calls on non-transition
  frames)
- Fix renderShader error handling: on failure, leave source scenes
  visible as fallback instead of hiding both scenes + GL overlay
  (which produced black frames)
- Move mutable state declarations above resolveComposite to prevent
  TDZ risk on refactor

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(engine): staff review — staging cleanup, pending flag, beginFrame guard

- Clear staging canvas children when leaving transition window (prevents
  visible clone bleed-through on transparent compositions)
- Clear __hf_page_composite_pending on all resolveComposite exit paths
- Guard micro-screenshot paint force against beginFrame mode (CDP
  Page.captureScreenshot conflicts with beginFrame compositor control)
- Update CLI flag description: document video/canvas limitation, remove
  stale PSNR claim

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(engine): default-on page-side compositing for SDR shader transitions

Page-side compositing is now enabled by default for SDR shader-transition
renders without video content. The 6.6× speedup applies automatically —
no flag needed.

Auto-disables when:
- HDR content detected
- Alpha output (WebM/MOV/PNG-sequence)
- Composition contains <video> elements (cloneNode loses playback state)
- beginFrame capture mode (Linux headless)

Use --no-page-side-compositing to force the Node-side layered path.

Changes:
- Engine config: enablePageSideCompositing defaults to true
- CLI: flag default flipped to true; --no-page-side-compositing disables
- Orchestrator: added composition.videos.length === 0 gate
- Docker: forwards --no-page-side-compositing when explicitly disabled
- Config tests updated for new default

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(engine): support video elements on page-side compositing fast path

Three-phase capture protocol lets shader transitions render video scenes
without falling back to the slow Node-side layered pipeline:

1. Seek → compositor records transition metadata, sets pending flag
2. onBeforeCapture → video frame injector updates <img> replacements
3. prepare → cloneNode picks up current video frames, img.decode() awaits
4. micro-screenshot → forces browser to paint cloned elements
5. resolve → drawElementImage reads paint records, shader composites

Key changes:
- Remove `composition.videos.length === 0` gate from orchestrator
- Split compositor resolve into prepare (clone) + resolve (shader)
- Move onBeforeCapture before compositor prepare in frameCapture.ts
- Await img.decode() on cloned data-URI images to prevent stale frames
- Stop manipulating scene opacity in compositor (GL canvas overlay suffices)
- Add gsap.set declaration for shader-transitions ambient types
- Add video_missing_timing_attrs lint rule for <video> without id/data-start/data-end

Performance: compositions with video now render at 7.5s (6 workers) instead
of 2m38s on the layered path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(core): auto-inject data-start on video/audio so frame extraction works without explicit attrs

The timing compiler now injects data-start="0" on <video> and <audio>
elements that lack it. This makes discoverMediaFromBrowser() find the
element (it queries video[data-start]), so the frame extraction pipeline
activates automatically. Videos "just work" without requiring authors to
add data-start, data-end, or id attributes.

Also removes the video_missing_timing_attrs lint rule — the compiler
handles the missing attributes automatically, so the lint rule would
only false-positive.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(core): add data-hf-auto-start sentinel on auto-injected video timing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(producer): add discoverVideoVisibilityFromTimeline for runtime video discovery

Seeks the GSAP timeline in Puppeteer to discover when each video's parent
scene is visible (opacity > 0). Uses coarse sampling at 100ms steps followed
by binary search refinement to frame-level precision (1/60s). Only processes
videos with the data-hf-auto-start sentinel so author-specified timing is
never overridden.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(producer): integrate runtime video visibility discovery into probe stage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(producer): trigger browser probe for auto-start videos, remove debug logging

The probe stage was skipping browser launch when composition duration was
already known, which meant discoverVideoVisibilityFromTimeline never ran.
Now needsBrowser also checks for data-hf-auto-start sentinel in compiled HTML.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(scripts): use mkdtempSync for smoke test work directory

Replaces hardcoded /tmp/hf-page-side-smoke with a unique temp directory
via mkdtempSync to resolve CodeQL "insecure temporary file" alert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: format smoke test script

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Vai <vai@heygen.com>
2026-05-15 16:23:12 -07:00

677 lines
24 KiB
TypeScript

/**
* File Server for Render Mode
*
* Lightweight HTTP server that serves the project directory inside Docker.
* Key responsibility: inject the verified Hyperframe runtime + render mode extension
* into index.html on-the-fly, so Puppeteer can load the composition with
* all relative URLs (compositions, CSS, JS, assets) resolving correctly.
*/
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import type { IncomingMessage } from "node:http";
import { readFileSync, existsSync, realpathSync, statSync } from "node:fs";
import { join, extname, resolve, sep } from "node:path";
import { injectScriptsAtHeadStart, injectScriptsIntoHtml } from "@hyperframes/core/compiler";
import { getVerifiedHyperframeRuntimeSource } from "./hyperframeRuntimeLoader.js";
export { injectScriptsAtHeadStart, injectScriptsIntoHtml };
type PathModuleLike = {
resolve: (...segments: string[]) => string;
sep: string;
};
type IsPathInsideOptions = {
resolveSymlinks?: boolean;
/**
* Path module used for resolution and separator comparison. Defaults to
* `node:path` for the running platform. Tests inject `path.win32` /
* `path.posix` to exercise cross-platform behavior on a single OS.
*/
pathModule?: PathModuleLike;
};
/**
* Returns true iff `child` is the same as, or nested inside, `parent` after
* path normalization. Used to reject path-traversal attempts (e.g.
* GET `/../etc/passwd`) before opening any file.
*
* `path.join(root, "..")` normalizes traversal segments and can escape `root`
* entirely, so the join return value alone is not a safe guard. Callers must
* resolve both sides and compare prefixes with the platform separator
* appended to `parent` to avoid `/foo` matching `/foobar`.
*
* Exported for unit tests; not part of the public package surface.
*/
export function isPathInside(
child: string,
parent: string,
options: IsPathInsideOptions = {},
): boolean {
const { resolveSymlinks = false, pathModule } = options;
const resolveFn = pathModule?.resolve ?? resolve;
const separator = pathModule?.sep ?? sep;
const resolvedChild = resolveFn(child);
const resolvedParent = resolveFn(parent);
const normalizedChild =
resolveSymlinks && existsSync(resolvedChild)
? realpathSync.native(resolvedChild)
: resolvedChild;
const normalizedParent =
resolveSymlinks && existsSync(resolvedParent)
? realpathSync.native(resolvedParent)
: resolvedParent;
if (normalizedChild === normalizedParent) return true;
const parentWithSep = normalizedParent.endsWith(separator)
? normalizedParent
: normalizedParent + separator;
return normalizedChild.startsWith(parentWithSep);
}
const MIME_TYPES: Record<string, string> = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".webp": "image/webp",
".mp4": "video/mp4",
".webm": "video/webm",
".mp3": "audio/mpeg",
".wav": "audio/wav",
".ogg": "audio/ogg",
".aac": "audio/aac",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".otf": "font/otf",
};
/**
* Options for {@link buildVirtualTimeShim}.
*/
export interface VirtualTimeShimOptions {
/**
* When `true`, the shim additionally replaces `Math.random` and
* `crypto.getRandomValues` with a Mulberry32-seeded PRNG keyed by the
* current frame's virtual time. Compositions that call `Math.random()`
* during render then produce byte-identical pixels across machines and
* across replays of the same `(planDir, chunkIndex)` pair.
*
* Default `false`: leaves `Math.random` / `crypto.getRandomValues` native,
* preserving the in-process renderer's non-deterministic behavior for
* compositions that rely on it.
*/
seedRandomFromFrame: boolean;
}
/**
* Build the page-side virtual-time shim script.
*
* The shim freezes `Date.now`, `performance.now`, and the rAF/setTimeout
* pipeline so a render seek can deterministically advance the page's
* notion of "now". The renderer issues `__HF_VIRTUAL_TIME__.seekToTime(ms)`
* before every frame capture; everything timing-related on the page sees
* exactly `ms` until the next seek.
*
* When `options.seedRandomFromFrame` is `true`, the returned script also
* installs a seeded `Math.random` / `crypto.getRandomValues` keyed by the
* current virtual time — so compositions with stochastic visuals retry
* identically. When `false`, the shim emits no random-override code; the
* page's native `Math.random` is left alone (the in-process default).
*/
export function buildVirtualTimeShim(options: VirtualTimeShimOptions): string {
const seedRandomFromFrame = options.seedRandomFromFrame === true;
// The seeded-RNG block is gated at build time so the unlocked shim is
// byte-identical to the pre-flag form. Producer regression baselines
// compare on rendered pixels — but the file-server unit tests in
// `fileServer.test.ts` also string-match `VIRTUAL_TIME_SHIM`, and we want
// those matches to remain stable.
const seededRandomBlock = seedRandomFromFrame
? String.raw`
// Seeded Math.random / crypto.getRandomValues, keyed by virtual time.
// Mulberry32 — single uint32 state, deterministic, fast.
var rngState = 0;
function mulberry32() {
rngState |= 0; rngState = (rngState + 0x6D2B79F5) | 0;
var t = rngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
function reseedRngFromTime(ms) {
var ms32 = Math.max(0, Math.floor(Number(ms) || 0)) | 0;
// Knuth's multiplicative hash + golden-ratio offset — gives a well-
// distributed seed even for frame 0 (otherwise rngState=0 degenerates
// the PRNG's first few outputs).
rngState = (Math.imul(ms32, -1640531527) + 0x9E3779B9) | 0;
}
reseedRngFromTime(0);
try {
Math.random = function() { return mulberry32(); };
} catch (e) {}
if (window.crypto && typeof window.crypto.getRandomValues === "function") {
try {
var __seededGetRandomValues = function(arr) {
if (!arr || typeof arr.byteLength !== "number" || !arr.buffer) return arr;
var byteLen = arr.byteLength;
if (byteLen <= 0) return arr;
var view = new DataView(arr.buffer, arr.byteOffset, byteLen);
var i = 0;
for (; i + 4 <= byteLen; i += 4) {
var word = ((mulberry32() * 4294967296) >>> 0);
view.setUint32(i, word, true);
}
for (; i < byteLen; i++) {
view.setUint8(i, (mulberry32() * 256) | 0);
}
return arr;
};
window.crypto.getRandomValues = __seededGetRandomValues;
} catch (e) {}
}
`
: "";
// The seekToTime hook reseeds when seeding is on; under seedRandomFromFrame=false
// we emit no extra call so the function body is byte-identical to the
// unseeded shim.
const seekToTimeReseedCall = seedRandomFromFrame ? "reseedRngFromTime(safeTimeMs);\n " : "";
return String.raw`(function() {
if (window.__HF_VIRTUAL_TIME__) return;
var virtualNowMs = 0;
var rafId = 1;
var rafQueue = [];
var OriginalDate = Date;
var originalSetTimeout = window.setTimeout.bind(window);
var originalClearTimeout = window.clearTimeout.bind(window);
var originalSetInterval = window.setInterval.bind(window);
var originalClearInterval = window.clearInterval.bind(window);
var originalRequestAnimationFrame = window.requestAnimationFrame
? window.requestAnimationFrame.bind(window)
: null;
var originalCancelAnimationFrame = window.cancelAnimationFrame
? window.cancelAnimationFrame.bind(window)
: null;
${seededRandomBlock}
function flushAnimationFrame() {
if (!rafQueue.length) return;
var current = rafQueue.slice();
rafQueue.length = 0;
for (var i = 0; i < current.length; i++) {
var entry = current[i];
if (entry.cancelled) continue;
try {
entry.callback(virtualNowMs);
} catch {}
}
}
function VirtualDate() {
var args = Array.prototype.slice.call(arguments);
if (!(this instanceof VirtualDate)) {
return OriginalDate.apply(null, args.length ? args : [virtualNowMs]);
}
var instance = args.length ? new (Function.prototype.bind.apply(OriginalDate, [null].concat(args)))() : new OriginalDate(virtualNowMs);
Object.setPrototypeOf(instance, VirtualDate.prototype);
return instance;
}
VirtualDate.prototype = OriginalDate.prototype;
Object.setPrototypeOf(VirtualDate, OriginalDate);
VirtualDate.now = function() { return virtualNowMs; };
VirtualDate.parse = OriginalDate.parse.bind(OriginalDate);
VirtualDate.UTC = OriginalDate.UTC.bind(OriginalDate);
try {
Object.defineProperty(window, "Date", {
configurable: true,
writable: true,
value: VirtualDate,
});
} catch {}
if (window.performance && typeof window.performance.now === "function") {
try {
Object.defineProperty(window.performance, "now", {
configurable: true,
value: function() { return virtualNowMs; },
});
} catch {}
}
window.requestAnimationFrame = function(callback) {
if (typeof callback !== "function") return 0;
var entry = { id: rafId++, callback: callback, cancelled: false };
rafQueue.push(entry);
return entry.id;
};
window.cancelAnimationFrame = function(id) {
for (var i = 0; i < rafQueue.length; i++) {
if (rafQueue[i].id === id) {
rafQueue[i].cancelled = true;
}
}
};
window.__HF_VIRTUAL_TIME__ = {
originalSetTimeout: originalSetTimeout,
originalClearTimeout: originalClearTimeout,
originalSetInterval: originalSetInterval,
originalClearInterval: originalClearInterval,
originalRequestAnimationFrame: originalRequestAnimationFrame,
originalCancelAnimationFrame: originalCancelAnimationFrame,
seekToTime: function(nextTimeMs) {
var safeTimeMs = Math.max(0, Number(nextTimeMs) || 0);
virtualNowMs = safeTimeMs;
${seekToTimeReseedCall}flushAnimationFrame();
return virtualNowMs;
},
getTime: function() {
return virtualNowMs;
},
};
})();`;
}
/**
* Default in-process virtual-time shim — `seedRandomFromFrame: false`.
* Existing call sites (`renderOrchestrator`, `probeStage`) import this
* constant. Distributed callers build their own with seeding enabled.
*/
const VIRTUAL_TIME_SHIM = buildVirtualTimeShim({ seedRandomFromFrame: false });
/**
* Render mode extension -- adds renderSeek() for frame-accurate seeking
* without media sync (videos are replaced with frame images during render).
*/
const RENDER_SEEK_MODE =
process.env.PRODUCER_RUNTIME_RENDER_SEEK_MODE === "strict-boundary"
? "strict-boundary"
: "preview-phase";
const RENDER_SEEK_DIAGNOSTICS = process.env.PRODUCER_DEBUG_SEEK_DIAGNOSTICS === "true";
const RENDER_SEEK_STEP = Math.max(
1 / 600,
Number(process.env.PRODUCER_RENDER_SEEK_STEP || 1 / 120),
);
const RENDER_SEEK_OFFSET_FRACTION = Math.max(
0,
Math.min(0.95, Number(process.env.PRODUCER_RUNTIME_RENDER_SEEK_OFFSET_FRACTION || 0.5)),
);
const RENDER_MODE_SCRIPT = `(function() {
var __realSetTimeout =
window.__HF_VIRTUAL_TIME__ && typeof window.__HF_VIRTUAL_TIME__.originalSetTimeout === "function"
? window.__HF_VIRTUAL_TIME__.originalSetTimeout
: window.setTimeout.bind(window);
var __seekMode = ${JSON.stringify(RENDER_SEEK_MODE)};
var __seekDiagnostics = ${RENDER_SEEK_DIAGNOSTICS ? "true" : "false"};
var __seekStep = ${RENDER_SEEK_STEP};
var __seekOffsetFraction = ${RENDER_SEEK_OFFSET_FRACTION};
window.__HF_EXPORT_RENDER_SEEK_CONFIG = {
mode: __seekMode,
diagnostics: __seekDiagnostics,
step: __seekStep,
offsetFraction: __seekOffsetFraction,
owner: "runtime",
};
function installMediaFallbackPlayer() {
if (document.querySelector('[data-composition-id]')) return false;
var mediaEls = Array.from(document.querySelectorAll('video, audio'));
if (!mediaEls.length) return false;
var isPlaying = false;
var currentTime = 0;
function fallbackDuration() {
var maxDuration = 0;
for (var i = 0; i < mediaEls.length; i++) {
var d = Number(mediaEls[i].duration);
if (isFinite(d) && d > maxDuration) maxDuration = d;
}
return Math.max(0, maxDuration);
}
function syncFallbackMedia(time, playing) {
for (var i = 0; i < mediaEls.length; i++) {
var media = mediaEls[i];
var existing = Number(media.currentTime) || 0;
if (Math.abs(existing - time) > 0.3) {
try { media.currentTime = time; } catch (e) {}
}
if (playing) {
if (media.paused) {
media.play().catch(function() {});
}
} else if (!media.paused) {
media.pause();
}
}
}
var basePlayer = window.__player && typeof window.__player === 'object' ? window.__player : {};
window.__player = {
...basePlayer,
_timeline: null,
play: function() {
isPlaying = true;
syncFallbackMedia(currentTime, true);
},
pause: function() {
isPlaying = false;
syncFallbackMedia(currentTime, false);
},
seek: function(time) {
var safeTime = Math.max(0, Number(time) || 0);
currentTime = safeTime;
isPlaying = false;
syncFallbackMedia(safeTime, false);
},
renderSeek: function(time) {
var safeTime = Math.max(0, Number(time) || 0);
currentTime = safeTime;
isPlaying = false;
syncFallbackMedia(safeTime, false);
},
getTime: function() {
var primary = mediaEls[0];
if (!primary) return currentTime;
var t = Number(primary.currentTime);
return isFinite(t) ? t : currentTime;
},
getDuration: function() {
return fallbackDuration();
},
isPlaying: function() {
return isPlaying;
},
};
window.__playerReady = true;
window.__renderReady = true;
return true;
}
function waitForPlayer() {
var hasComposition = Boolean(document.querySelector('[data-composition-id]'));
if (hasComposition) {
if (window.__player && typeof window.__player.renderSeek === "function") {
window.__playerReady = true;
window.__renderReady = true;
return;
}
__realSetTimeout(waitForPlayer, 50);
return;
}
if (installMediaFallbackPlayer()) {
return;
}
__realSetTimeout(waitForPlayer, 50);
}
waitForPlayer();
})();`;
/**
* Early stub: ensures `window.__hf` exists *before* any user `<script>` in
* `<body>` executes. Without this, libraries that opportunistically write to
* `__hf` during page-script execution (notably `@hyperframes/shader-transitions`,
* which writes the active transition map to `__hf.transitions` inside its
* `init()` call) silently no-op because `__hf` hasn't been created yet — the
* full bridge script is injected at end-of-body and runs *after* user scripts.
*
* Injected at the very start of `<head>` so it runs before all other scripts.
*/
const HF_EARLY_STUB = `(function() {
if (typeof window === "undefined") return;
if (!window.__hf) window.__hf = {};
})();`;
/**
* Page-side compositing opt-in flag stub.
*
* When the engine is launched with `enablePageSideCompositing: true`, the
* orchestrator injects this stub into the very top of every served HTML
* page. The flag is read by `@hyperframes/shader-transitions`' engine-mode
* `init()` to switch from the default opacity-flip mode (which leaves
* shader blending to the Node side via the hf#677 layered pipeline) to a
* page-side WebGL compositor that runs the shader inside Chrome and
* exposes a single opaque RGB frame for the engine to capture.
*
* Sentinel ONLY — no logic here. The compositor itself ships inside
* `@hyperframes/shader-transitions` and is loaded by the composition's
* regular script bundle.
*
* Default OFF: when the flag is not set, behavior is byte-identical to
* the existing layered path.
*/
export const HF_PAGE_SIDE_COMPOSITING_STUB = `(function() {
if (typeof window === "undefined") return;
window.__HF_PAGE_SIDE_COMPOSITING__ = true;
})();`;
/**
* Bridge script: maps window.__player (Hyperframe runtime) → window.__hf (engine protocol).
* Injected after RENDER_MODE_SCRIPT so the engine's frameCapture can find window.__hf.
*
* This script *patches* the existing __hf object rather than replacing it, so
* fields written during page-script execution (e.g. transitions metadata from
* @hyperframes/shader-transitions) are preserved through to engine query time.
*/
const HF_BRIDGE_SCRIPT = `(function() {
var __realSetInterval =
window.__HF_VIRTUAL_TIME__ && typeof window.__HF_VIRTUAL_TIME__.originalSetInterval === "function"
? window.__HF_VIRTUAL_TIME__.originalSetInterval
: window.setInterval.bind(window);
var __realClearInterval =
window.__HF_VIRTUAL_TIME__ && typeof window.__HF_VIRTUAL_TIME__.originalClearInterval === "function"
? window.__HF_VIRTUAL_TIME__.originalClearInterval
: window.clearInterval.bind(window);
function getDeclaredDuration() {
var root = document.querySelector('[data-composition-id]');
if (!root) return 0;
var d = Number(root.getAttribute('data-duration'));
return Number.isFinite(d) && d > 0 ? d : 0;
}
function seekSameOriginChildFrames(frameWindow, nextTimeMs) {
var frames;
try {
frames = frameWindow.frames;
} catch (_error) {
return;
}
if (!frames || typeof frames.length !== "number") return;
for (var i = 0; i < frames.length; i++) {
var childWindow = null;
try {
childWindow = frames[i];
if (!childWindow || childWindow === frameWindow) continue;
if (
childWindow.__HF_VIRTUAL_TIME__ &&
typeof childWindow.__HF_VIRTUAL_TIME__.seekToTime === "function"
) {
childWindow.__HF_VIRTUAL_TIME__.seekToTime(nextTimeMs);
}
} catch (_error) {
continue;
}
seekSameOriginChildFrames(childWindow, nextTimeMs);
}
}
function bridge() {
var p = window.__player;
if (!p || typeof p.renderSeek !== "function" || typeof p.getDuration !== "function") {
return false;
}
var hf = window.__hf || {};
Object.defineProperty(hf, "duration", {
configurable: true,
enumerable: true,
get: function() {
var d = p.getDuration();
return d > 0 ? d : getDeclaredDuration();
},
});
hf.seek = function(t) {
p.renderSeek(t);
var nextTimeMs = (Math.max(0, Number(t) || 0)) * 1000;
if (window.__HF_VIRTUAL_TIME__ && typeof window.__HF_VIRTUAL_TIME__.seekToTime === "function") {
window.__HF_VIRTUAL_TIME__.seekToTime(nextTimeMs);
}
seekSameOriginChildFrames(window, nextTimeMs);
};
window.__hf = hf;
return true;
}
if (bridge()) return;
var iv = __realSetInterval(function() {
if (bridge()) __realClearInterval(iv);
}, 50);
})();`;
export interface FileServerOptions {
projectDir: string;
compiledDir?: string;
port?: number;
/** Scripts injected into <head> of every served HTML file before authored scripts. */
preHeadScripts?: string[];
/** Scripts injected into <head> of index.html. Default: verified Hyperframe runtime. */
headScripts?: string[];
/** Scripts injected before </body> of index.html. Default: render mode extension. */
bodyScripts?: string[];
/** Strip embedded runtime scripts from HTML before injection. Default: true. */
stripEmbeddedRuntime?: boolean;
}
export interface FileServerHandle {
url: string;
port: number;
close: () => void;
addPreHeadScript: (script: string) => void;
}
export function createFileServer(options: FileServerOptions): Promise<FileServerHandle> {
const { projectDir, compiledDir, port = 0, stripEmbeddedRuntime = true } = options;
// HF_EARLY_STUB must run before *any* page script so libraries that write
// to window.__hf during page-script execution (e.g. shader-transitions
// populating __hf.transitions) find it already defined. The full bridge in
// bodyScripts later upgrades this stub with `seek` / `duration` once the
// Hyperframe runtime's __player is ready, while preserving any fields
// already written.
const preHeadScripts = [HF_EARLY_STUB, ...(options.preHeadScripts ?? [])];
// Default scripts: Hyperframe runtime in <head>, render mode in </body>
const headScripts = options.headScripts ?? [getVerifiedHyperframeRuntimeSource()];
const bodyScripts = options.bodyScripts ?? [RENDER_MODE_SCRIPT, HF_BRIDGE_SCRIPT];
const app = new Hono();
app.get("/*", (c) => {
let requestPath = c.req.path;
if (requestPath === "/") requestPath = "/index.html";
const relativePath = requestPath
.replace(/^\//, "")
.split("/")
.map((seg) => {
try {
return decodeURIComponent(seg);
} catch {
return seg;
}
})
.join("/");
// Resolve against compiledDir first (preferred — overrides project files
// for compositions emitted by the build), then projectDir as fallback.
// Each candidate is rejected if `..` segments push it outside the
// intended root: `path.join` normalizes traversal but does not enforce
// containment, so a request like `GET /../etc/passwd` would otherwise
// be served straight off the filesystem. Keep this lexical so project
// symlinks to sibling asset directories behave like preview mode.
let filePath: string | null = null;
if (compiledDir) {
const candidate = join(compiledDir, relativePath);
if (
existsSync(candidate) &&
isPathInside(candidate, compiledDir) &&
statSync(candidate).isFile()
) {
filePath = candidate;
}
}
if (!filePath) {
const candidate = join(projectDir, relativePath);
if (
existsSync(candidate) &&
isPathInside(candidate, projectDir) &&
statSync(candidate).isFile()
) {
filePath = candidate;
}
}
if (!filePath) {
if (!/favicon\.ico$/i.test(requestPath)) {
console.warn(`[FileServer] 404 Not Found: ${requestPath}`);
}
return c.text("Not found", 404);
}
const ext = extname(filePath).toLowerCase();
const contentType = MIME_TYPES[ext] || "application/octet-stream";
if (ext === ".html") {
const rawHtml = readFileSync(filePath, "utf-8");
const isIndex = relativePath === "index.html";
let html = rawHtml;
if (preHeadScripts.length > 0) {
html = injectScriptsAtHeadStart(html, preHeadScripts);
}
html = isIndex
? injectScriptsIntoHtml(html, headScripts, bodyScripts, stripEmbeddedRuntime)
: html;
return c.text(html, 200, { "Content-Type": contentType });
}
const content = readFileSync(filePath);
return new Response(content, {
status: 200,
headers: { "Content-Type": contentType },
});
});
return new Promise((resolve) => {
// Track open connections so we can force-destroy them on close.
// Without this, server.close() waits for keep-alive connections to
// drain, holding the Node.js event loop open indefinitely.
const connections = new Set<IncomingMessage["socket"]>();
// @hono/node-server serve() returns the http.Server directly.
// Register the connection tracker before the listen callback fires
// to avoid missing early connections.
const server = serve({ fetch: app.fetch, port }, (info) => {
resolve({
url: `http://localhost:${info.port}`,
port: info.port,
addPreHeadScript: (script: string) => {
preHeadScripts.push(script);
},
close: () => {
for (const socket of connections) socket.destroy();
connections.clear();
server.close();
},
});
});
server.on("connection", (socket: IncomingMessage["socket"]) => {
connections.add(socket);
socket.on("close", () => connections.delete(socket));
});
});
}
export { HF_BRIDGE_SCRIPT, HF_EARLY_STUB, VIRTUAL_TIME_SHIM };