mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
* 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>
677 lines
24 KiB
TypeScript
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 };
|