mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-06 22:57:12 +00:00
* fix(runtime): show the right scene when the preview DOM comes from another window The Studio preview sometimes builds the composition body in the editor window and adopts it into the preview frame's document. Adopted nodes keep the prototypes of the realm that created them, so `node instanceof HTMLElement` is false for every element in the composition even though the elements are ordinary HTML sitting in that document. Every guard in the runtime written as `if (!(node instanceof HTMLElement))` then skipped the whole document, silently: nothing threw, nothing logged, and readiness still reported success. The timed-element visibility pass wrote no inline visibility at all, so on those loads the preview painted every scene on top of every other and the editor drew off-canvas markers for elements the user could not see. The auto-stamp pass stopped stamping too, which is why the composition came up one timeline clip short. Replace every realm-sensitive element check in the runtime with structural predicates that ask what a node IS (node type, namespace, tag name) rather than which window's constructor made it. Two local `doc.defaultView.HTMLElement` workarounds are deleted with it: they fix only the case where the nodes belong to the document's own realm, and adoption is exactly what breaks that. * fix(studio): scrub the music track the timeline named, not the first audio `resolveScrubAudioEl` tested `byId instanceof HTMLAudioElement` on a node from the preview iframe's document. That node is an instance of the IFRAME's `HTMLAudioElement`, never this module's, so the check was false on every load and the `musicId` hint was dead. Scrub fell through to the first `<audio>` in the document, which the comment right above it warns can be the voiceover, so dragging the playhead could preview the wrong track. Ask what the node is instead. Also close the gaps an independent review found in the runtime fix: - the cross-realm predicate test had no `<audio>` and no `<img>`, so reverting `isAudioElement` or `isImageElement` to `instanceof` left it green. It no longer does, and the audio case also pins `isMediaElement`, which composes from it and gates the media sync path. - nothing stopped the runtime regressing. `lint-runtime-preview-guards.ts` gains a second check kind: patterns that must be ABSENT under a directory, seeded with DOM-typed `instanceof` under `src/runtime`, pointing at domRealm.ts. Comment lines and tests are exempt, both on purpose. - domRealm.ts stated the adoption mechanism as settled fact. The mixed prototypes are measured; how the nodes get into the frame is not identified, and the docstring now says which is which. Its ownership claim is scoped to the runtime, since packages/studio still hand-rolls its own checks.
348 lines
14 KiB
TypeScript
348 lines
14 KiB
TypeScript
import { postRuntimeMessage } from "./bridge";
|
|
import { swallow } from "./diagnostics";
|
|
import { evictMediaSyncState } from "./media";
|
|
import { findInjectedRenderFrame } from "./renderFrameSibling";
|
|
import type { RuntimeJson } from "./types";
|
|
import { isVideoElement } from "./domRealm";
|
|
|
|
/**
|
|
* One entry per project-root-relative asset pathname, injected by the
|
|
* server as `window.__HF_MEDIA_CODEC_MAP__` (see the plan's server-contract
|
|
* KTD). `representativeMime` is a coarse per-codec-family MIME string (e.g.
|
|
* `video/mp4; codecs="hvc1.1.6.L120.B0"` for hevc) fed to `canPlayType`;
|
|
* `null` when ffprobe couldn't produce one, in which case the browser check
|
|
* is skipped and `browserHostile` alone decides.
|
|
*/
|
|
export type MediaCodecMapEntry = {
|
|
codecName: string;
|
|
browserHostile: boolean;
|
|
representativeMime: string | null;
|
|
/** Source carries an alpha channel and therefore needs a VP8/WebM proxy.
|
|
* Optional so pre-alpha-aware maps stay assignable; absent means "no alpha detected". */
|
|
hasAlpha?: boolean;
|
|
};
|
|
|
|
declare global {
|
|
interface Window {
|
|
__HF_MEDIA_CODEC_MAP__?: Record<string, MediaCodecMapEntry>;
|
|
}
|
|
}
|
|
|
|
const PROXY_QUERY_PARAM = "hf-proxy";
|
|
|
|
/** Fired whenever an element is swapped to its authoring proxy (any trigger). */
|
|
const DIAGNOSTIC_FALLBACK_CODE = "runtime_media_proxy_fallback";
|
|
/** Fired when the runtime detects an undecodable video but cannot (or already
|
|
* did) proxy it — a remote asset, or the proxy URL itself failing. */
|
|
const DIAGNOSTIC_UNAVAILABLE_CODE = "runtime_media_proxy_unavailable";
|
|
|
|
type ProxyTrigger = "proactive" | "reactive" | "tertiary";
|
|
|
|
// Elements already swapped to their proxy src. Gates every trigger so a
|
|
// second undecodable-video signal (another zero-width metadata tick, a
|
|
// stray error event) never re-swaps or loops.
|
|
const swappedElements = new WeakSet<HTMLMediaElement>();
|
|
// Elements that already got the "can't help you" diagnostic (cross-origin,
|
|
// or the proxy itself failing) — one-shot per element, independent of
|
|
// `swappedElements` so the proxy-failed case (which fires AFTER a real swap)
|
|
// still gets its own single diagnostic.
|
|
const unavailableDiagnosedElements = new WeakSet<HTMLMediaElement>();
|
|
|
|
function currentSrcValue(el: HTMLMediaElement): string {
|
|
return el.currentSrc || el.src;
|
|
}
|
|
|
|
/**
|
|
* Render mode never proxies: the codec map arrives by injection (not a
|
|
* fetch, so determinism holds) but render always decodes the original via
|
|
* FFmpeg frame extraction, never a browser-side element. Mirrors the two
|
|
* render-mode signals already used elsewhere in the runtime — the global
|
|
* export-seek config (init.ts) and the per-element `__render_frame_<id>__`
|
|
* sibling image the producer's frame-injection pipeline creates during
|
|
* render (the same check `syncRuntimeMedia`'s `skipForInjectedVideo` makes
|
|
* in media.ts).
|
|
*/
|
|
function isRenderMode(el: HTMLMediaElement): boolean {
|
|
if (window.__HF_EXPORT_RENDER_SEEK_CONFIG) return true;
|
|
return isVideoElement(el) && !!findInjectedRenderFrame(el);
|
|
}
|
|
|
|
/**
|
|
* Resolve an element's codec-map key: the served-root-relative,
|
|
* percent-decoded, query-string-stripped URL pathname (per the server
|
|
* contract KTD). Returns null when there's no usable src, or the src is
|
|
* cross-origin — the server can't have scanned (or proxy) a file it doesn't
|
|
* host, so there is never a map entry to look up.
|
|
*/
|
|
export function deriveCodecMapKey(el: HTMLMediaElement): string | null {
|
|
const raw = currentSrcValue(el);
|
|
if (!raw) return null;
|
|
let url: URL;
|
|
try {
|
|
url = new URL(raw, document.baseURI);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (url.origin !== window.location.origin) return null;
|
|
try {
|
|
return decodeURIComponent(url.pathname);
|
|
} catch {
|
|
return url.pathname;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The composition is served under a route prefix on some surfaces —
|
|
* studio-server's preview route injects `<base href="/api/projects/:id/preview/">`
|
|
* (packages/studio-server/src/routes/preview.ts) so every relative asset
|
|
* resolves through that prefix — while the codec map (per the server
|
|
* contract) is keyed by project-root-relative pathnames with no prefix.
|
|
* `play` and the CLI's static project server serve from the root with no
|
|
* such prefix, where an exact match already succeeds. So: try exact first
|
|
* (covers unprefixed servers), then fall back to the longest map key that
|
|
* is a suffix of the pathname. No separate segment-boundary check is
|
|
* needed: every key is contractually root-relative and leading-slash
|
|
* (`"/assets/x.mp4"`, never `"assets/x.mp4"`), so a suffix match's boundary
|
|
* is always that leading `/` itself — `pathname.endsWith(key)` can't match
|
|
* a partial segment (e.g. key `"/foo.mp4"` can never match a pathname
|
|
* ending in `"/notfoo.mp4"`, since that would require the literal substring
|
|
* `"/foo.mp4"` to appear where the last `/` already fell a segment later).
|
|
*/
|
|
function lookupLongestExactSuffix(
|
|
pathname: string,
|
|
map: Record<string, MediaCodecMapEntry>,
|
|
): MediaCodecMapEntry | null {
|
|
let bestKey: string | null = null;
|
|
for (const key of Object.keys(map)) {
|
|
if (!pathname.endsWith(key)) continue;
|
|
if (bestKey === null || key.length > bestKey.length) bestKey = key;
|
|
}
|
|
return bestKey ? (map[bestKey] ?? null) : null;
|
|
}
|
|
|
|
function lookupNormalizedSuffix(
|
|
pathname: string,
|
|
map: Record<string, MediaCodecMapEntry>,
|
|
): MediaCodecMapEntry | null {
|
|
// Case-insensitive filesystems and Unicode-normalizing filesystems can
|
|
// serve an authored URL whose spelling differs from the canonical path
|
|
// ffprobe returned (Clip.mp4 vs clip.mp4, NFC vs NFD). Exact matching stays
|
|
// authoritative; this normalized fallback is used only when it finds one
|
|
// unambiguous longest suffix, so case-sensitive projects containing both
|
|
// spellings never select the wrong asset.
|
|
const normalizedPathname = pathname.normalize("NFC").toLowerCase();
|
|
let normalizedBestLength = -1;
|
|
let normalizedBest: MediaCodecMapEntry | null = null;
|
|
let ambiguous = false;
|
|
for (const [key, entry] of Object.entries(map)) {
|
|
const normalizedKey = key.normalize("NFC").toLowerCase();
|
|
if (!normalizedPathname.endsWith(normalizedKey)) continue;
|
|
if (normalizedKey.length > normalizedBestLength) {
|
|
normalizedBestLength = normalizedKey.length;
|
|
normalizedBest = entry;
|
|
ambiguous = false;
|
|
} else if (normalizedKey.length === normalizedBestLength) {
|
|
ambiguous = true;
|
|
}
|
|
}
|
|
return ambiguous ? null : normalizedBest;
|
|
}
|
|
|
|
function lookupCodecMapEntry(
|
|
pathname: string,
|
|
map: Record<string, MediaCodecMapEntry>,
|
|
): MediaCodecMapEntry | null {
|
|
return (
|
|
map[pathname] ??
|
|
lookupLongestExactSuffix(pathname, map) ??
|
|
lookupNormalizedSuffix(pathname, map)
|
|
);
|
|
}
|
|
|
|
function appendProxyParam(src: string, entry: MediaCodecMapEntry | null): string {
|
|
const url = new URL(src, document.baseURI);
|
|
url.searchParams.set(PROXY_QUERY_PARAM, entry ? (entry.hasAlpha ? "vp8" : "h264") : "auto");
|
|
return url.href;
|
|
}
|
|
|
|
type UnavailableReason =
|
|
| "cross_origin"
|
|
| "proxy_playback_failed"
|
|
| "browser_safe_codec"
|
|
| "invalid_source_url";
|
|
|
|
const UNAVAILABLE_NOTES: Record<UnavailableReason, string> = {
|
|
cross_origin:
|
|
"video reports zero decodable width but its source is cross-origin; no local proxy can be served for it",
|
|
proxy_playback_failed: "the authoring proxy itself failed to decode; render output is unaffected",
|
|
browser_safe_codec:
|
|
"the file errored but its codec is browser-decodable; a proxy cannot help (the file itself is likely corrupt)",
|
|
invalid_source_url: "the media source URL is malformed and cannot be proxied",
|
|
};
|
|
|
|
function emitUnavailableDiagnostic(
|
|
el: HTMLMediaElement,
|
|
reason: UnavailableReason,
|
|
asset: string,
|
|
): void {
|
|
if (unavailableDiagnosedElements.has(el)) return;
|
|
unavailableDiagnosedElements.add(el);
|
|
const note = UNAVAILABLE_NOTES[reason];
|
|
const details: Record<string, RuntimeJson> = {
|
|
asset,
|
|
codecName: null,
|
|
reason,
|
|
note,
|
|
};
|
|
postRuntimeMessage({
|
|
source: "hf-preview",
|
|
type: "diagnostic",
|
|
code: DIAGNOSTIC_UNAVAILABLE_CODE,
|
|
details,
|
|
});
|
|
// Mirrors swapToProxy's fallback line: the stable diagnostic code is in the
|
|
// text so checkBrowser.ts's console scraper can match a token, not prose.
|
|
console.info(`[hyperframes] ${DIAGNOSTIC_UNAVAILABLE_CODE}: "${asset}" (${reason}): ${note}`);
|
|
}
|
|
|
|
/**
|
|
* Swap `el` to its alpha-aware proxy URL, evict stale per-source sync state, and
|
|
* emit the one-time diagnostic + console line. Safe to call from any of the
|
|
* three triggers (proactive/reactive/tertiary); a no-op if already swapped.
|
|
*/
|
|
export function swapToProxy(
|
|
el: HTMLMediaElement,
|
|
entry: MediaCodecMapEntry | null = null,
|
|
trigger: ProxyTrigger = "reactive",
|
|
): void {
|
|
if (swappedElements.has(el)) return;
|
|
const originalSrc = currentSrcValue(el);
|
|
let proxiedSrc: string;
|
|
try {
|
|
proxiedSrc = appendProxyParam(originalSrc, entry);
|
|
} catch (err) {
|
|
swallow("runtime.mediaProxy.swap", err);
|
|
emitUnavailableDiagnostic(el, "invalid_source_url", originalSrc);
|
|
return;
|
|
}
|
|
swappedElements.add(el);
|
|
// The swapped src points at a different file — sync state (drift offsets,
|
|
// seek-retry latches, volume tracking) computed against the original
|
|
// source must not carry over, or the next tick misreads a fresh file's
|
|
// buffering as drift. Evict before `load()` so the very next sync tick
|
|
// treats this element as a first tick.
|
|
evictMediaSyncState(el);
|
|
el.src = proxiedSrc;
|
|
el.load();
|
|
const codecName = entry?.codecName ?? null;
|
|
const details: Record<string, RuntimeJson> = {
|
|
asset: originalSrc,
|
|
codecName,
|
|
trigger,
|
|
note: "render output is unaffected; only this preview element was swapped to an authoring proxy",
|
|
};
|
|
postRuntimeMessage({
|
|
source: "hf-preview",
|
|
type: "diagnostic",
|
|
code: DIAGNOSTIC_FALLBACK_CODE,
|
|
details,
|
|
});
|
|
// The diagnostic code doubles as the stable token check's console scraper
|
|
// matches on (packages/cli/src/utils/checkBrowser.ts); keep it in the text.
|
|
console.info(
|
|
`[hyperframes] ${DIAGNOSTIC_FALLBACK_CODE}: "${originalSrc}" uses a codec (${codecName ?? "unknown"}) this browser can't decode; ` +
|
|
"auto-swapped to an authoring proxy for this preview only. Render output is unaffected.",
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Proactive trigger: consult the codec map before an element's first
|
|
* `load()` and swap ahead of time when the browser is known-unlikely to
|
|
* decode it, avoiding an error flash. `<audio>` is never proxied (per the
|
|
* plan's KTD — an HEVC container's AAC track demuxes fine regardless of the
|
|
* browser's video codec support).
|
|
*/
|
|
export function maybeProxyProactively(el: HTMLMediaElement): void {
|
|
if (isRenderMode(el)) return;
|
|
if (!isVideoElement(el)) return;
|
|
if (swappedElements.has(el)) return;
|
|
const map = window.__HF_MEDIA_CODEC_MAP__;
|
|
if (!map) return;
|
|
const key = deriveCodecMapKey(el);
|
|
if (key === null) return;
|
|
const entry = lookupCodecMapEntry(key, map);
|
|
if (!entry || !entry.browserHostile) return;
|
|
const canPlay = entry.representativeMime ? el.canPlayType(entry.representativeMime) : "";
|
|
if (canPlay === "probably" || canPlay === "maybe") return;
|
|
swapToProxy(el, entry, "proactive");
|
|
}
|
|
|
|
/**
|
|
* Reactive trigger (the primary catch-all per the plan's KTD): a `<video>`
|
|
* reporting `videoWidth === 0` at `loadedmetadata` has an undecodable (or
|
|
* absent) video track — the common case for an HEVC+AAC file, which fires
|
|
* no error event because the AAC track satisfies the demuxer. Swaps once
|
|
* per element; if the element is already on its proxy src, the proxy
|
|
* itself is the one failing, so this only emits the failure diagnostic.
|
|
*
|
|
* No map, no swaps: the codec map global is only injected on surfaces where
|
|
* auto-proxying is enabled and served, so its absence means a `?hf-proxy=`
|
|
* request would 404 — never swap there. When the map is present but has no
|
|
* entry for this key, swapping stays allowed (unlisted-asset rescue). A
|
|
* mapped alpha entries select the VP8/WebM proxy variant.
|
|
*/
|
|
export function handleMetadataForProxy(el: HTMLMediaElement): void {
|
|
if (isRenderMode(el)) return;
|
|
if (!isVideoElement(el)) return;
|
|
if (el.videoWidth !== 0) return;
|
|
const src = currentSrcValue(el);
|
|
if (swappedElements.has(el)) {
|
|
emitUnavailableDiagnostic(el, "proxy_playback_failed", src);
|
|
return;
|
|
}
|
|
const map = window.__HF_MEDIA_CODEC_MAP__;
|
|
if (!map) return;
|
|
const key = deriveCodecMapKey(el);
|
|
if (key === null) {
|
|
emitUnavailableDiagnostic(el, "cross_origin", src);
|
|
return;
|
|
}
|
|
const entry = lookupCodecMapEntry(key, map);
|
|
if (entry && !entry.browserHostile) {
|
|
emitUnavailableDiagnostic(el, "browser_safe_codec", src);
|
|
return;
|
|
}
|
|
swapToProxy(el, entry, "reactive");
|
|
}
|
|
|
|
/**
|
|
* Tertiary trigger: the `error` event, for the rarer zero-decodable-stream
|
|
* file (video-only hostile codec) where the demuxer has nothing to satisfy
|
|
* it and `loadedmetadata` never fires. Same guards and once-per-element
|
|
* behavior as the reactive path, plus one extra skip: an entry the scan
|
|
* mapped as browser-SAFE that still errors is a corrupt-but-safe file — an
|
|
* proxy of a broken source can't help, so only diagnose.
|
|
*/
|
|
export function handleErrorForProxy(el: HTMLMediaElement): void {
|
|
if (isRenderMode(el)) return;
|
|
if (!isVideoElement(el)) return;
|
|
const src = currentSrcValue(el);
|
|
if (swappedElements.has(el)) {
|
|
emitUnavailableDiagnostic(el, "proxy_playback_failed", src);
|
|
return;
|
|
}
|
|
const map = window.__HF_MEDIA_CODEC_MAP__;
|
|
if (!map) return;
|
|
const key = deriveCodecMapKey(el);
|
|
if (key === null) {
|
|
emitUnavailableDiagnostic(el, "cross_origin", src);
|
|
return;
|
|
}
|
|
const entry = lookupCodecMapEntry(key, map);
|
|
if (entry && !entry.browserHostile) {
|
|
emitUnavailableDiagnostic(el, "browser_safe_codec", src);
|
|
return;
|
|
}
|
|
swapToProxy(el, entry, "tertiary");
|
|
}
|