mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
* feat(audio): composition master bus (data-fx-chain on the root) A clip can belong to only one audio group, so there was no way to put one effect, such as a limiter, across the whole mix. The data-fx-chain on the top-level composition root now runs once over the sum of every clip and every group, after each group's own chain and fader. No new attribute: a sub-composition root's data-fx-chain was never applied to anything and still is not. Render: when a master chain exists, mixAudioTracks writes a float WAV instead of AAC, applyAudioFxChain runs the chain over it, and ffmpeg encodes AAC last, so the chain reads unclipped samples. An unreadable chain is a mix failure, as for a group. Preview: the transport splices the chain between the clips' sum and the monitor, re-anchored per play generation and watched for edits. Only the top-level root is read. No master automation: the root's data-automation is not read as the bus's. No lint rule: data-fx-chain is valid on clips, groups and roots, and the one place it does nothing (a sub-composition root) has always been silent. — Jerrai * fix(core): preview routes every audible video through the master bus A root data-fx-chain is attached only inside the Web Audio graph, but a plainly audible <video> joined that graph only for its own chain, group, automation or a volume over 1. So preview limited the music and left the talking head dry, and a composition whose only audio is video got no master chain at all, while export sums every audible element into the master. A master chain on the top-level composition root is now a reason to join, the same as a video's own chain. Elements under a root with no chain keep native output. Sub-composition roots are still ignored. — Jerrai * fix(audio): master bus review round 2 (temp dir, tests, docs, Studio meter) - audioMixer: a fatal FX error in the master pass or a group bus left the work directory (the full float sum, about 1.4 GB an hour) on disk. The final mix now runs in try/finally and the group rethrow removes it first. - Tests: the "once over the sum" case now uses a limiter then a gain, so a per-clip chain (-10.4) and a doubled one (-18) both fail it; a new case proves the sum stays float until the chain reads it; the fader case uses a group volume of 0.5 so the fader's position is exercised; the work-dir removal is covered for both paths. - Docs: "Three rules exist" (the master-bus lint rule is gone), a bad master chain stops the render with audio_processing_failed, a sub-composition file opened on its own has its root chain as the master bus, and a seek clears a group or master limiter. - Studio: TimelineElement.masterChain carries the root's data-fx-chain, so the program meter shows for a video-only composition whose only audio routing is the master chain, as preview now plays it. - Comment blocks over four lines cut; webAudioTransport.setRate split so its CRAP score is under the Fallow threshold. — Jerrai * fix(audio): master chain reaches tracks the render adds; honest plain-limiter copy - Master chain: it was carried on each parsed AudioElement, so a track the producer adds itself (appendAutoDetectedVideoAudio, the browser-discovered audio in probeStage) never had it, and a composition whose only audio was such a track exported with no master chain, no ceiling and no warning. It is now a composition property: parseMasterFxChain(html) at compile, carried on CompiledComposition and CompositionMetadata, passed to processCompositionAudio by runAudioStage (in-process and distributed plan). AudioElement.masterFxChain is removed so there is one source. Each forward (compile stage, local orchestrator, distributed plan) has a test that fails when it is dropped. - Plain limiter: it follows the level with no lookahead and Level after is applied past the ceiling, so it cannot promise "nothing gets past" or "never clips". Studio copy (description, panel text, knob label, summary) and the hyperframes-audio skill now say a fast or inter-sample peak can pass it and point to the true-peak limiter for a delivery limit. A copy test guards it. — Jerrai * fix(producer): keep master bus comments within the ratchet; run the plan test with the ffmpeg tests Drop three one-line field comments that raised their files' comment share, and list plan.masterBus.test.ts with the integration tests because plan() reads the ffmpeg version. — Jerrai * chore(ci): repin the producer test-classification source in the reachability map The reachability map pins the hash of test-classification.mjs, which now lists one more integration test. The map's producer lane command already covers every test under src, in both the unit and integration matrix lanes. — Jerrai
5205 lines
220 KiB
TypeScript
5205 lines
220 KiB
TypeScript
// fallow-ignore-file code-duplication complexity
|
|
import { setPreviewRasterScale } from "./previewRasterHints";
|
|
import { refreshSvgSelectorAliases } from "../compiler/svgSelectorAliases";
|
|
import { RUNTIME_FILLER } from "./protocol";
|
|
import { preloadMedia, releaseMedia, lengthIsAuthored, stopMediaDownload } from "./preloadMedia";
|
|
import { installRuntimeControlBridge, postRuntimeMessage, setRuntimeProtocolFps } from "./bridge";
|
|
import { instantTolerance } from "../clipFacts";
|
|
import { isInClipWindow } from "./clipWindow";
|
|
import { revealTimedClipsAfterFirstPass, SKIPPED_CLIP, skipsHiddenImages } from "./timedClipHide";
|
|
import {
|
|
PREVIEW_ONLY_ATTRS,
|
|
RUNTIME_STAMPED_ATTR,
|
|
STUDIO_PREVIEW_LAZY_ATTR,
|
|
STUDIO_PREVIEW_UPCOMING_ATTR,
|
|
} from "../studioPreviewMark";
|
|
import { initRuntimeAnalytics, emitAnalyticsEvent } from "./analytics";
|
|
import { injectCompositionCssVariables } from "./getVariables";
|
|
import { createCssAdapter } from "./adapters/css";
|
|
import { createGsapAdapter, rerenderGsapTimelineAt } from "./adapters/gsap";
|
|
import { createAnimeJsAdapter } from "./adapters/animejs";
|
|
import { createLottieAdapter } from "./adapters/lottie";
|
|
import { createThreeAdapter } from "./adapters/three";
|
|
import { createMapboxAdapter } from "./adapters/mapbox";
|
|
import { createLeafletAdapter } from "./adapters/leaflet";
|
|
import { createGoogleMapsAdapter } from "./adapters/google-maps";
|
|
import { createMaplibreAdapter } from "./adapters/maplibre";
|
|
import { createD3Adapter } from "./adapters/d3";
|
|
import { createTypegpuAdapter } from "./adapters/typegpu";
|
|
import { createFrameSourceAdapter } from "./frameSources";
|
|
import {
|
|
patchVideoTextureCompat,
|
|
patchWebGLVideoTextureCompat,
|
|
} from "./adapters/video-texture-compat";
|
|
import {
|
|
forceDispatchSeekEvent,
|
|
registerSeekCompletion,
|
|
waitForSeekCompletion,
|
|
} from "./adapters/seek-dispatch";
|
|
import { sourceTimeAt } from "../speedRamp";
|
|
import { createWaapiAdapter } from "./adapters/waapi";
|
|
import {
|
|
MEDIA_SYNC_TOLERANCE_SECONDS,
|
|
isUnplayable,
|
|
readElementPlaybackRate,
|
|
readElementPlaybackStart,
|
|
readElementRateSpec,
|
|
refreshRuntimeMediaCache,
|
|
resolveNaturalMediaTimelineDuration,
|
|
resolveRuntimeMediaClipDuration,
|
|
syncRuntimeMedia,
|
|
type RuntimeMediaClip,
|
|
} from "./media";
|
|
import {
|
|
handleErrorForProxy,
|
|
handleMetadataForProxy,
|
|
maybeProxyProactively,
|
|
setProxyDisplayScale,
|
|
} from "./mediaProxy";
|
|
import { probeAndCacheElementVolume, type VolumeKeyframe } from "./mediaVolumeEnvelope.js";
|
|
import { createPickerModule } from "./picker";
|
|
import { createRuntimePlayer, resolveRenderSeekTime, type RuntimePlayerTransport } from "./player";
|
|
import { createRuntimeState } from "./state";
|
|
import {
|
|
collectRuntimeTimelinePayload,
|
|
isRuntimeElementVisibleAt,
|
|
parseAuthoredTrack,
|
|
} from "./timeline";
|
|
import { findRootCompositionElement, parseLayoutDimension } from "./compositionDimension";
|
|
import { resolveCompositionDuration } from "@hyperframes/parsers/composition-duration";
|
|
import {
|
|
LOOP_INFLATED_TIMELINE_SECONDS,
|
|
MIN_VALID_TIMELINE_DURATION_SECONDS,
|
|
readCompositionSize,
|
|
resolveAuthoredCompositionFloorSeconds,
|
|
resolveCompositionLengthSeconds,
|
|
resolveContentDerivedDuration as resolveContentDerivedDurationFor,
|
|
resolveMediaWindowDurationSeconds as resolveMediaWindowDurationSecondsIn,
|
|
} from "./compositionLength";
|
|
import { createRuntimeStartTimeResolver } from "./startResolver";
|
|
import { createClipTree } from "./clipTree";
|
|
import { loadCompositions } from "./compositionLoader";
|
|
import { runScriptsAfterFonts } from "./afterFonts";
|
|
import {
|
|
applyCaptionOverrides,
|
|
applyFetchedCaptionOverrides,
|
|
fetchCaptionOverrides,
|
|
} from "./captionOverrides";
|
|
import {
|
|
SCENE_NO_SWAP_ATTR,
|
|
SCENE_PART_ATTR,
|
|
SCENE_PARTS_META,
|
|
type SceneParts,
|
|
} from "../sceneParts";
|
|
import {
|
|
applyPositionEdits,
|
|
EDIT_ORIGINAL_TRANSLATE_ATTR,
|
|
forgetPositionEdit,
|
|
installPositionEditsSeekReapply,
|
|
} from "./positionEdits";
|
|
import { unproxiedSrc } from "./proxySrc";
|
|
import { applyVariableBindings, unproxiedMediaSrc } from "./applyVariableBindings";
|
|
import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading";
|
|
import { COLOR_GRADING_AUTHORED_OPACITY_ATTR } from "../colorGrading";
|
|
import { initVfx, paintVfx } from "./vfx";
|
|
import { TransportClock } from "./clock";
|
|
import { WebAudioTransport } from "./webAudioTransport";
|
|
import {
|
|
classifyWebAudioMediaRoute,
|
|
isRouteSelectionSettled,
|
|
reportWebAudioMediaRoute,
|
|
} from "./webAudioRoute.js";
|
|
import {
|
|
audioGroupOf,
|
|
ensureAudioGroupInertStyle,
|
|
HF_AUDIO_GROUP_TAG,
|
|
isMemberGroupHidden,
|
|
readMasterFxChain,
|
|
} from "../audioGroups";
|
|
import { clampNativeMediaVolume } from "../audioGain";
|
|
import { quantizeTimeToFrame } from "../inline-scripts/parityContract";
|
|
import { createManualEditGestureWatch } from "./manualEditGestureWatch";
|
|
import type {
|
|
HeldSeek,
|
|
RuntimeDeterministicAdapter,
|
|
RuntimeJson,
|
|
RuntimeSeekOptions,
|
|
RuntimeTimelineChildLike,
|
|
RuntimeTimelineLike,
|
|
SceneAnimation,
|
|
} from "./types";
|
|
import type { PlayerAPI } from "../core.types";
|
|
import { recordStartupError, swallow } from "./diagnostics";
|
|
import { readTimelineDurationSeconds } from "./timelineDuration";
|
|
import {
|
|
CHANGE_DRIVEN_SERVICE_MIN_INTERVAL_MS,
|
|
MEDIA_BIND_INTERVAL_FRAMES,
|
|
PLAYING_POLL_INTERVAL_MS,
|
|
TIMELINE_POST_INTERVAL_FRAMES,
|
|
shouldAttemptPeriodicTimelineBind,
|
|
} from "./timelineRebindPolicy";
|
|
import { installStudioCustomEase } from "./customEase";
|
|
import { parseStrictFiniteTimingNumber, resolveMediaElementDurationSeconds } from "./playbackRate";
|
|
import { cutToHostSlots, MEDIA_START_BASIS_ATTR } from "../mediaTiming";
|
|
import { settleFirstFrameCompositionReadiness } from "../compositionReadiness";
|
|
import { AUTHORED_DURATION_ATTR, AUTHORED_END_ATTR } from "./authoredTiming";
|
|
import {
|
|
clearRuntimeData,
|
|
setRuntimeData,
|
|
setRuntimeDataAppliedReporter,
|
|
setRuntimeDataErrorReporter,
|
|
} from "./runtimeData";
|
|
import {
|
|
isAudioElement,
|
|
isElementNode,
|
|
isHtmlElement,
|
|
isImageElement,
|
|
isMediaElement,
|
|
isVideoElement,
|
|
} from "./domRealm";
|
|
import { audibleVideoNeedsWebAudio, isAudibleVideoElement } from "../audibleVideo";
|
|
|
|
/**
|
|
* A `window.__timelines` entry is authored content and may be a PARTIAL
|
|
* RuntimeTimelineLike — e.g. duration/seek only, no `pause()`. Such
|
|
* compositions render fine (the render path only seeks and never pauses), so
|
|
* timeline resolution stays permissive by design; the interactive transport
|
|
* must not crash on the missing method (top recurring studio:unhandled_error:
|
|
* "E.pause is not a function"). One analytics event per page so the
|
|
* composition author can find the partial timeline.
|
|
*/
|
|
let warnedTimelineMissingPause = false;
|
|
function pauseTimelineIfPossible(tl: RuntimeTimelineLike | null | undefined): void {
|
|
if (!tl) return;
|
|
if (typeof tl.pause !== "function") {
|
|
if (!warnedTimelineMissingPause) {
|
|
warnedTimelineMissingPause = true;
|
|
emitAnalyticsEvent("timeline_missing_pause", {});
|
|
}
|
|
return;
|
|
}
|
|
try {
|
|
tl.pause();
|
|
} catch (err) {
|
|
swallow("runtime.timeline.pause", err);
|
|
}
|
|
}
|
|
|
|
/** A timeline the runtime already padded is padded again from scratch, as a fresh load pads it. */
|
|
function dropRuntimeFillers(timeline: RuntimeTimelineLike): void {
|
|
const removable = timeline as RuntimeTimelineLike & { remove?: (child: unknown) => unknown };
|
|
if (typeof removable.remove !== "function") return;
|
|
for (const child of timeline.getChildren?.(false, true, false) ?? []) {
|
|
if (child.data === RUNTIME_FILLER) removable.remove(child);
|
|
}
|
|
}
|
|
|
|
type ExportRenderFpsResolution = {
|
|
fps: number | null;
|
|
source: "render-options" | "default" | "unknown";
|
|
rawFpsSource: unknown;
|
|
rawFps: unknown;
|
|
fallbackReason?: "missing" | "invalid";
|
|
};
|
|
|
|
function resolveExportRenderFps(): ExportRenderFpsResolution {
|
|
const config = window.__HF_EXPORT_RENDER_SEEK_CONFIG;
|
|
const rawFps = config?.fps;
|
|
const rawFpsSource = config?.fpsSource;
|
|
const fps = Number(rawFps);
|
|
if (!config || rawFps == null) {
|
|
return { fps: null, source: "default", rawFpsSource, rawFps, fallbackReason: "missing" };
|
|
}
|
|
if (!Number.isFinite(fps) || fps <= 0) {
|
|
return { fps: null, source: "default", rawFpsSource, rawFps, fallbackReason: "invalid" };
|
|
}
|
|
const source =
|
|
rawFpsSource === "render-options" || rawFpsSource === "default" ? rawFpsSource : "unknown";
|
|
return {
|
|
fps,
|
|
source,
|
|
rawFpsSource,
|
|
rawFps,
|
|
fallbackReason: config.fpsFallbackReason,
|
|
};
|
|
}
|
|
|
|
function samePromiseSet(a: PromiseLike<unknown>[], b: PromiseLike<unknown>[] | null): boolean {
|
|
return b !== null && a.length === b.length && a.every((p, i) => p === b[i]);
|
|
}
|
|
|
|
// `Promise.all` allocates a fresh promise every call, so comparing ITS
|
|
// identity across repeat polls is never stable — that re-arms a `.then()`
|
|
// on every poll and never lets the caller observe "settled", even once the
|
|
// underlying work is done. Compares the source promises themselves instead.
|
|
function createSettledTracker(
|
|
collectPromises: () => PromiseLike<unknown>[],
|
|
onSettled: () => void,
|
|
onError: (err: unknown) => void,
|
|
): () => boolean {
|
|
let tracked: PromiseLike<unknown>[] | null = null;
|
|
let settled = true;
|
|
return () => {
|
|
const promises = collectPromises();
|
|
if (promises.length === 0) {
|
|
tracked = null;
|
|
settled = true;
|
|
return true;
|
|
}
|
|
if (samePromiseSet(promises, tracked)) return settled;
|
|
tracked = promises;
|
|
settled = false;
|
|
const combined: PromiseLike<unknown> =
|
|
promises.length === 1 ? promises[0]! : Promise.all(promises);
|
|
void Promise.resolve(combined).then(
|
|
() => {
|
|
if (tracked !== promises) return;
|
|
settled = true;
|
|
onSettled();
|
|
},
|
|
(err) => {
|
|
if (tracked !== promises) return;
|
|
settled = true;
|
|
onError(err);
|
|
onSettled();
|
|
},
|
|
);
|
|
return settled;
|
|
};
|
|
}
|
|
|
|
function readSceneParts(doc: Document): SceneParts | null {
|
|
const content = doc.querySelector(`meta[name="${SCENE_PARTS_META}"]`)?.getAttribute("content");
|
|
if (!content) return null;
|
|
try {
|
|
return JSON.parse(content) as SceneParts;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// A media element's attributes and content, less the grading capture stamped on it at parse time.
|
|
const authoredShape = (el: Element): string =>
|
|
JSON.stringify([
|
|
Array.from(el.attributes, (a) => [a.name, a.value]).filter(
|
|
([name]) => name !== COLOR_GRADING_AUTHORED_OPACITY_ATTR,
|
|
),
|
|
el.innerHTML,
|
|
]);
|
|
|
|
// What the runtime writes on a video or audio: style (reset on a kept one), preload, the opacity stamp, a moved
|
|
// element's original translate, preview marks and its own __hf- classes. A proxied src is compared as written.
|
|
const RUNTIME_MEDIA_ATTRS = new Set([
|
|
"style",
|
|
"preload",
|
|
"src",
|
|
COLOR_GRADING_AUTHORED_OPACITY_ATTR,
|
|
EDIT_ORIGINAL_TRANSLATE_ATTR,
|
|
...PREVIEW_ONLY_ATTRS,
|
|
]);
|
|
const ownClasses = (value: string) =>
|
|
value
|
|
.split(/\s+/)
|
|
.filter((c) => c && !c.startsWith("__hf-"))
|
|
.join(" ");
|
|
const writtenShape = (el: Element): string =>
|
|
JSON.stringify([
|
|
Array.from(el.attributes, ({ name, value }) => [
|
|
name,
|
|
name === "class" ? ownClasses(value) : value,
|
|
])
|
|
.filter(([name, value]) => !RUNTIME_MEDIA_ATTRS.has(name!) && !(name === "class" && !value))
|
|
.sort(([a], [b]) => (a! < b! ? -1 : 1)),
|
|
el.hasAttribute("data-var-src") ? null : unproxiedSrc(el),
|
|
el.innerHTML,
|
|
]);
|
|
|
|
// Each video and audio as written, recorded as the page parses: a scene script can write to it before init.
|
|
const authoredMedia = new WeakMap<Element, string>();
|
|
const isMedia = (el: Element) => el.localName === "video" || el.localName === "audio";
|
|
const recordAuthoredMedia = (node: Node) => {
|
|
if (!isElementNode(node)) return;
|
|
// The parser adds one childless element at a time, so most nodes stop at the first check.
|
|
const media = isMedia(node)
|
|
? [node]
|
|
: node.firstElementChild
|
|
? node.querySelectorAll("video, audio")
|
|
: [];
|
|
for (const el of media) if (!authoredMedia.has(el)) authoredMedia.set(el, authoredShape(el));
|
|
};
|
|
let authoredMediaObserver: MutationObserver | null = null;
|
|
|
|
/** On a page with a scene manifest, records media as it parses, before scene scripts run; init stops it. */
|
|
export function installAuthoredMediaCapture(): void {
|
|
if (typeof MutationObserver === "undefined" || !readSceneParts(document)) return;
|
|
recordAuthoredMedia(document.documentElement);
|
|
authoredMediaObserver = new MutationObserver((records) => {
|
|
for (const record of records) {
|
|
record.addedNodes.forEach(recordAuthoredMedia);
|
|
// The parser can yield inside a <video>, so its <source> children may arrive after it.
|
|
const { target } = record;
|
|
if (isElementNode(target) && isMedia(target))
|
|
authoredMedia.set(target, authoredShape(target));
|
|
}
|
|
});
|
|
authoredMediaObserver.observe(document.documentElement, { childList: true, subtree: true });
|
|
}
|
|
|
|
// Mid-tween, GSAP's default force3D writes translate3d, and Chrome snaps a crop edge on it to whole pixels.
|
|
// Runs at script evaluation: GSAP is configured as its bundle assigns window.gsap, before a composition's
|
|
// set() or from() parses an element. Chains to an accessor already there (the producer's early stub).
|
|
export function installFlatGsapTransforms(): void {
|
|
const flatten = (g: Window["gsap"]) => g?.config?.({ force3D: false });
|
|
const prior = Object.getOwnPropertyDescriptor(window, "gsap");
|
|
let loaded = window.gsap;
|
|
flatten(loaded);
|
|
if (prior?.configurable === false || (prior?.get as { hfFlat?: true } | undefined)?.hfFlat)
|
|
return;
|
|
const get = Object.assign(() => (prior?.get ? prior.get.call(window) : loaded), {
|
|
hfFlat: true,
|
|
});
|
|
Object.defineProperty(window, "gsap", {
|
|
configurable: true,
|
|
enumerable: true,
|
|
get,
|
|
set: (g: Window["gsap"]) => {
|
|
if (prior?.set) prior.set.call(window, g);
|
|
else loaded = g;
|
|
flatten(g);
|
|
},
|
|
});
|
|
}
|
|
|
|
// URL attributes a scene swap checks besides src, poster and srcset, by tag.
|
|
const MEDIA_URL_ATTRS = new Map([
|
|
["image", ["href", "xlink:href"]],
|
|
["object", ["data"]],
|
|
]);
|
|
|
|
const SLOW_IDLE_HEARTBEAT_MS = 1000;
|
|
|
|
// One document.getAnimations() per seek and the pause after it, read on first use, shared by all adapters.
|
|
function pageAnimationsForOnePass(): () => Animation[] {
|
|
let list: Animation[] | undefined;
|
|
return () => (list ??= document.getAnimations());
|
|
}
|
|
|
|
// A `<video>` joins only for a gain `el.volume` cannot express or a root master chain: capture is a one-way door.
|
|
const joinsWebAudio = (el: Element): el is HTMLMediaElement =>
|
|
isAudioElement(el) ||
|
|
(isVideoElement(el) &&
|
|
isAudibleVideoElement(el) &&
|
|
audibleVideoNeedsWebAudio({
|
|
volume: Number.parseFloat(el.dataset.volume ?? ""),
|
|
fxChain: el.getAttribute("data-fx-chain"),
|
|
automation: el.getAttribute("data-automation"),
|
|
audioGroup: el.getAttribute("data-audio-group"),
|
|
masterChain: readMasterFxChain(el.ownerDocument),
|
|
}));
|
|
const WEB_AUDIO_MEDIA = "audio[data-start], video[data-start]";
|
|
const webAudioMediaIn = (root: ParentNode): HTMLMediaElement[] =>
|
|
Array.from(root.querySelectorAll(WEB_AUDIO_MEDIA)).filter(joinsWebAudio);
|
|
|
|
/** Studio's lazy images in `root` load now: a hidden clip has no layout box, so lazy ones would never fetch. */
|
|
function loadPreviewImagesIn(root: Element): void {
|
|
const images = root.matches(`[${STUDIO_PREVIEW_LAZY_ATTR}]`)
|
|
? [root]
|
|
: Array.from(root.querySelectorAll(`img[${STUDIO_PREVIEW_LAZY_ATTR}]`)).filter(
|
|
(img) => img.closest("[data-start]") === root,
|
|
);
|
|
for (const img of images) img.setAttribute("loading", "eager");
|
|
}
|
|
|
|
export function initSandboxRuntimeModular(): void {
|
|
const state = createRuntimeState();
|
|
const stampsForStudio = window.parent !== window;
|
|
authoredMediaObserver?.disconnect();
|
|
authoredMediaObserver = null;
|
|
if (readSceneParts(document)) recordAuthoredMedia(document.documentElement);
|
|
// Runtime-data handlers may replace the timeline object they mutate. Keep the
|
|
// reconciliation callback late-bound because the reporter is installed before
|
|
// the timeline resolver/binder is declared below. Delivery cannot complete
|
|
// until after init has installed the final callback.
|
|
let reconcileTimelineAfterRuntimeData: () => void = () => undefined;
|
|
// Own the analytics bridge before any best-effort runtime installation so
|
|
// early failures are observable instead of disappearing before player setup.
|
|
initRuntimeAnalytics(postRuntimeMessage as (payload: unknown) => void);
|
|
setRuntimeDataErrorReporter((channel, requestId, error) => {
|
|
postRuntimeMessage({
|
|
source: "hf-preview",
|
|
type: "runtime-data-error",
|
|
channel,
|
|
requestId,
|
|
message: error instanceof Error ? error.message : String(error),
|
|
});
|
|
});
|
|
setRuntimeDataAppliedReporter((channel, requestId) => {
|
|
try {
|
|
reconcileTimelineAfterRuntimeData();
|
|
} catch (error) {
|
|
postRuntimeMessage({
|
|
source: "hf-preview",
|
|
type: "runtime-data-error",
|
|
channel,
|
|
requestId,
|
|
message: error instanceof Error ? error.message : String(error),
|
|
});
|
|
return;
|
|
}
|
|
postRuntimeMessage({
|
|
source: "hf-preview",
|
|
type: "runtime-data-applied",
|
|
channel,
|
|
requestId,
|
|
});
|
|
});
|
|
// SDK moveElement edits must render even when no usable GSAP timeline ever
|
|
// binds (CSS/WAAPI-animated or fully static compositions) — apply at init.
|
|
// This runs at DOMContentLoaded, after inline composition scripts have
|
|
// parsed their tweens, so GSAP (when present) won't fold the translate.
|
|
// Re-applied on every timeline bind for the rebind/soft-reload paths.
|
|
applyPositionEdits(document);
|
|
// Declarative variable bindings (data-var-src / data-var-text / --{id} CSS
|
|
// custom props) — values are fixed for the page's lifetime, so applying
|
|
// once at init keeps renders deterministic and seeks safe.
|
|
applyVariableBindings(document);
|
|
// `<hf-audio-group>` is metadata, so it must not occupy a box — see
|
|
// ensureAudioGroupInertStyle. Injected here, before timelines bind, so no
|
|
// captured frame ever sees the group as a layout item.
|
|
ensureAudioGroupInertStyle(document);
|
|
const exportRenderFps = resolveExportRenderFps();
|
|
state.canonicalFps = exportRenderFps.fps ?? state.canonicalFps;
|
|
setRuntimeProtocolFps(state.canonicalFps);
|
|
if (window.__HF_EXPORT_RENDER_SEEK_CONFIG) {
|
|
const fpsDetail = JSON.stringify({
|
|
canonicalFps: state.canonicalFps,
|
|
source: exportRenderFps.source,
|
|
rawFpsSource: exportRenderFps.rawFpsSource,
|
|
rawFps: exportRenderFps.rawFps,
|
|
fallbackReason: exportRenderFps.fallbackReason,
|
|
});
|
|
console.info(`[hyperframes] render runtime fps ${fpsDetail}`);
|
|
}
|
|
let colorGradingRuntime: RuntimeColorGradingApi | null = null;
|
|
let runtimeErrorListener: ((event: ErrorEvent) => void) | null = null;
|
|
let runtimeUnhandledRejectionListener: ((event: PromiseRejectionEvent) => void) | null = null;
|
|
const runtimeCleanupCallbacks: Array<() => void> = [];
|
|
const postedDiagnosticKeys = new Set<string>();
|
|
let rootStageDiagnosticRafId: number | null = null;
|
|
const reportedRuntimeIssues = new Set<string>();
|
|
const reportRuntimeIssueOnce = (
|
|
key: string,
|
|
event: "auto_marker_install_failed" | "custom_ease_install_failed",
|
|
properties: Record<string, string>,
|
|
): void => {
|
|
if (reportedRuntimeIssues.has(key)) return;
|
|
reportedRuntimeIssues.add(key);
|
|
emitAnalyticsEvent(event, properties);
|
|
};
|
|
if (typeof window.__hfRuntimeTeardown === "function") {
|
|
try {
|
|
window.__hfRuntimeTeardown();
|
|
} catch (err) {
|
|
// keep runtime resilient across reinits
|
|
swallow("runtime.init.site1", err);
|
|
}
|
|
}
|
|
// Transport resources are initialized before any player or media closures.
|
|
// This removes the old temporal-dead-zone fallback and lets the public player
|
|
// be constructed once with its final clock-backed behavior.
|
|
const clock = new TransportClock();
|
|
state.transportClock = clock;
|
|
const webAudio = new WebAudioTransport();
|
|
let webAudioReady = false;
|
|
void webAudio.init().then((ok) => {
|
|
webAudioReady = ok;
|
|
});
|
|
window.__hf = window.__hf || {};
|
|
/** Hidden by an ancestor, or by the BUS this clip belongs to. The bus is
|
|
* never an ancestor — membership is on the member's `data-audio-group` — so
|
|
* `closest()` alone could not see a muted group, which the render drops. */
|
|
const isSilencedByHidden = (el: Element): boolean =>
|
|
el.closest("[data-hidden]") !== null || isMemberGroupHidden(el.ownerDocument, el);
|
|
// `_auto` is a Studio-internal keyframe marker (an auto-tracked endpoint the
|
|
// parser reads back), NOT an animatable property. Register it as a no-op GSAP
|
|
// plugin so GSAP doesn't log "Invalid property _auto" on every tween build —
|
|
// that per-frame warning destabilizes the preview and makes the selection
|
|
// overlay stop tracking the pointer. Idempotent + best-effort.
|
|
const ensureAutoMarkerNoop = (): void => {
|
|
const g = window.gsap as { registerPlugin?: (plugin: unknown) => void } | undefined;
|
|
const w = window as Window & { __hfAutoNoopRegistered?: boolean };
|
|
if (!g?.registerPlugin || w.__hfAutoNoopRegistered) return;
|
|
try {
|
|
g.registerPlugin({ name: "_auto", init: () => false });
|
|
w.__hfAutoNoopRegistered = true;
|
|
} catch (err) {
|
|
reportRuntimeIssueOnce("auto_marker_install_failed", "auto_marker_install_failed", {
|
|
reason: "threw",
|
|
});
|
|
swallow("runtime.autoMarker.install", err);
|
|
// a stray warning is preferable to a broken runtime
|
|
}
|
|
};
|
|
const ensureStudioCustomEase = (): void => {
|
|
const g = window.gsap;
|
|
if (!g) {
|
|
reportRuntimeIssueOnce("custom_ease_missing_gsap", "custom_ease_install_failed", {
|
|
reason: "missing_gsap",
|
|
});
|
|
return;
|
|
}
|
|
try {
|
|
if (!installStudioCustomEase(g)) {
|
|
reportRuntimeIssueOnce("custom_ease_no_parse_ease", "custom_ease_install_failed", {
|
|
reason: "no_parseEase",
|
|
});
|
|
}
|
|
} catch (err) {
|
|
reportRuntimeIssueOnce("custom_ease_install_threw", "custom_ease_install_failed", {
|
|
reason: "threw",
|
|
});
|
|
swallow("runtime.customEase.install", err);
|
|
// falling back to GSAP's default ease is preferable to a broken runtime
|
|
}
|
|
};
|
|
ensureAutoMarkerNoop();
|
|
ensureStudioCustomEase();
|
|
// Normalize html/body so browser defaults (8px margin, white background) never
|
|
// bleed into renders as white bars. Runs in both preview and render contexts,
|
|
// eliminating the preview/render parity gap that existed when only the React
|
|
// component's normalizePreviewViewport call applied this normalization.
|
|
if (document.documentElement) {
|
|
document.documentElement.style.margin = "0";
|
|
document.documentElement.style.padding = "0";
|
|
document.documentElement.style.overflow = "hidden";
|
|
}
|
|
if (document.body) {
|
|
document.body.style.margin = "0";
|
|
document.body.style.padding = "0";
|
|
document.body.style.overflow = "hidden";
|
|
}
|
|
|
|
// figma brand-token chain: define declared composition variables as CSS
|
|
// custom properties so imported var(--slug, literal) fills resolve from the
|
|
// live variable instead of always falling back to the frozen literal.
|
|
try {
|
|
injectCompositionCssVariables(document);
|
|
} catch (err) {
|
|
swallow("runtime.init.cssVariables", err);
|
|
}
|
|
|
|
window.__timelines = window.__timelines || {};
|
|
|
|
// Agents often write `window.__timelines = [tl]` (array) instead of the
|
|
// keyed-by-composition-id object the runtime expects. Normalize at init so
|
|
// the rest of the pipeline can assume a Record<string, timeline>.
|
|
if (Array.isArray(window.__timelines)) {
|
|
const arr = window.__timelines as unknown[];
|
|
const rootId = findRootCompositionElement()?.getAttribute("data-composition-id") ?? "root";
|
|
const normalized: Record<string, unknown> = {};
|
|
if (arr.length === 1) {
|
|
normalized[rootId] = arr[0];
|
|
} else {
|
|
for (let i = 0; i < arr.length; i++) normalized[`tl-${i}`] = arr[i];
|
|
}
|
|
(window as unknown as Record<string, unknown>).__timelines = normalized;
|
|
}
|
|
|
|
// Agents sometimes omit data-start on the root composition element. The
|
|
// runtime skips timed-visibility for elements without it, making clips
|
|
// invisible and timelines non-seekable. Default to 0 for the root.
|
|
const rootComp = findRootCompositionElement();
|
|
if (rootComp && !rootComp.hasAttribute("data-start")) {
|
|
rootComp.setAttribute("data-start", "0");
|
|
}
|
|
|
|
const registerRuntimeCleanup = (callback: () => void) => {
|
|
runtimeCleanupCallbacks.push(callback);
|
|
};
|
|
const postRuntimeDiagnosticOnce = (
|
|
code: string,
|
|
details: Record<string, RuntimeJson>,
|
|
dedupeKey?: string,
|
|
) => {
|
|
const key = dedupeKey ?? `${code}:${JSON.stringify(details)}`;
|
|
if (postedDiagnosticKeys.has(key)) {
|
|
return;
|
|
}
|
|
postedDiagnosticKeys.add(key);
|
|
postRuntimeMessage({
|
|
source: "hf-preview",
|
|
type: "diagnostic",
|
|
code,
|
|
details,
|
|
});
|
|
};
|
|
const createPlayerApiCompat = (basePlayer: {
|
|
_timeline: RuntimeTimelineLike | null;
|
|
play: () => void;
|
|
pause: () => void;
|
|
seek: (timeSeconds: number, options?: { keepPlaying?: boolean }) => HeldSeek;
|
|
getTime: () => number;
|
|
getDuration: () => number;
|
|
isPlaying: () => boolean;
|
|
renderSeek: (timeSeconds: number, options?: RuntimeSeekOptions) => void;
|
|
}): PlayerAPI => {
|
|
const defaultStageZoom: ReturnType<PlayerAPI["getStageZoom"]> = {
|
|
scale: 1,
|
|
focusX: 960,
|
|
focusY: 540,
|
|
};
|
|
const emptyStageZoomKeyframes: ReturnType<PlayerAPI["getStageZoomKeyframes"]> = [];
|
|
const emptyVisibleElements: ReturnType<PlayerAPI["getVisibleElements"]> = [];
|
|
const defaultRenderState: ReturnType<PlayerAPI["getRenderState"]> = {
|
|
time: basePlayer.getTime(),
|
|
duration: basePlayer.getDuration(),
|
|
isPlaying: basePlayer.isPlaying(),
|
|
renderMode: false,
|
|
timelineDirty: false,
|
|
};
|
|
return {
|
|
play: basePlayer.play,
|
|
pause: basePlayer.pause,
|
|
seek: basePlayer.seek,
|
|
getTime: basePlayer.getTime,
|
|
getDuration: basePlayer.getDuration,
|
|
isPlaying: basePlayer.isPlaying,
|
|
getMainTimeline: () => null,
|
|
getElementBounds: () => {},
|
|
getElementsAtPoint: () => {},
|
|
setElementPosition: () => {},
|
|
previewElementPosition: () => {},
|
|
setElementKeyframes: () => {},
|
|
setElementScale: () => {},
|
|
setElementFontSize: () => {},
|
|
setElementTextContent: () => {},
|
|
setElementTextColor: () => {},
|
|
setElementTextShadow: () => {},
|
|
setElementTextFontWeight: () => {},
|
|
setElementTextFontFamily: () => {},
|
|
setElementTextOutline: () => {},
|
|
setElementTextHighlight: () => {},
|
|
setElementVolume: () => {},
|
|
setStageZoom: () => {},
|
|
getStageZoom: () => defaultStageZoom,
|
|
setStageZoomKeyframes: () => {},
|
|
getStageZoomKeyframes: () => emptyStageZoomKeyframes,
|
|
addElement: () => false,
|
|
removeElement: () => false,
|
|
updateElementTiming: () => false,
|
|
setElementTiming: () => {},
|
|
updateElementSrc: () => false,
|
|
updateElementLayer: () => false,
|
|
updateElementBasePosition: () => false,
|
|
markTimelineDirty: () => {},
|
|
isTimelineDirty: () => false,
|
|
rebuildTimeline: () => {},
|
|
ensureTimeline: () => {},
|
|
enableRenderMode: () => {},
|
|
disableRenderMode: () => {},
|
|
renderSeek: basePlayer.renderSeek,
|
|
getElementVisibility: () => ({ visible: false }),
|
|
getVisibleElements: () => emptyVisibleElements,
|
|
getRenderState: () => ({
|
|
...defaultRenderState,
|
|
time: basePlayer.getTime(),
|
|
duration: basePlayer.getDuration(),
|
|
isPlaying: basePlayer.isPlaying(),
|
|
}),
|
|
};
|
|
};
|
|
|
|
const TIMELINE_FLOOR_COVERAGE_RATIO = 0.75;
|
|
const METADATA_REBIND_MIN_DURATION_GAIN_SECONDS = 0.05;
|
|
const METADATA_REBIND_DEBOUNCE_MS = 100;
|
|
const MAX_DIAGNOSTIC_MESSAGE_LENGTH = 240;
|
|
|
|
const normalizeDiagnosticMessage = (value: unknown): string => {
|
|
if (value instanceof Error) {
|
|
return value.message || String(value);
|
|
}
|
|
if (typeof value === "string") {
|
|
return value;
|
|
}
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch {
|
|
return String(value ?? "");
|
|
}
|
|
};
|
|
|
|
const classifyRuntimeScriptFailure = (
|
|
rawMessage: string,
|
|
): {
|
|
code: string;
|
|
category: string;
|
|
} => {
|
|
const message = rawMessage.toLowerCase();
|
|
if (
|
|
message.includes("cannot read properties of null") ||
|
|
message.includes("cannot set properties of null")
|
|
) {
|
|
return { code: "runtime_null_dom_access", category: "dom-null-access" };
|
|
}
|
|
if (message.includes("failed to execute 'queryselector'")) {
|
|
return { code: "runtime_invalid_selector", category: "selector-invalid" };
|
|
}
|
|
if (message.includes("is not defined")) {
|
|
return { code: "runtime_reference_missing", category: "reference-missing" };
|
|
}
|
|
return { code: "runtime_script_error", category: "script-error" };
|
|
};
|
|
|
|
const parseDimensionPx = (value: string | null): string | null => {
|
|
const parsed = parseLayoutDimension(value);
|
|
return parsed === null ? null : `${parsed}px`;
|
|
};
|
|
|
|
const resolveRootCompositionElement = (): HTMLElement | null => findRootCompositionElement();
|
|
|
|
const applyCompositionSizing = () => {
|
|
const rootEl = resolveRootCompositionElement();
|
|
if (!rootEl) return;
|
|
const forcedWidth = parseDimensionPx(rootEl.getAttribute("data-width"));
|
|
const forcedHeight = parseDimensionPx(rootEl.getAttribute("data-height"));
|
|
if (forcedWidth) rootEl.style.width = forcedWidth;
|
|
if (forcedHeight) rootEl.style.height = forcedHeight;
|
|
if (forcedWidth) rootEl.style.setProperty("--comp-width", forcedWidth);
|
|
if (forcedHeight) rootEl.style.setProperty("--comp-height", forcedHeight);
|
|
// A scaffolded project's `html, body` CSS is fixed at init time to whatever
|
|
// resolution the template shipped with. An agent that edits ONLY the root's
|
|
// data-width/data-height (without `hyperframes init --resolution`, which
|
|
// rewrites html/body together with the root) leaves body at the stale
|
|
// size, so its `overflow: hidden` (set unconditionally above, to keep
|
|
// browser-default margins from bleeding into renders as white bars)
|
|
// clips this composition — sized correctly above — at the stale height.
|
|
// Mirror the SAME forced values onto documentElement/body (not a second
|
|
// read of the root's own dimensions): once body's own size agrees with
|
|
// the root it contains, `overflow: hidden` clips nothing that matters and
|
|
// the white-bar guard stays intact. `findRootCompositionElement` returns
|
|
// the outermost `[data-root="true"]` composition by convention, not by a
|
|
// structural guarantee — this only ever affects a document whose author
|
|
// marked a NESTED composition `data-root="true"` too, which nothing in
|
|
// this runtime currently validates.
|
|
if (forcedWidth) document.documentElement.style.width = forcedWidth;
|
|
if (forcedHeight) document.documentElement.style.height = forcedHeight;
|
|
if (forcedWidth) document.body.style.width = forcedWidth;
|
|
if (forcedHeight) document.body.style.height = forcedHeight;
|
|
};
|
|
|
|
const sanitizeCompositionDurationAttributes = () => {
|
|
const rootEl = resolveRootCompositionElement();
|
|
const compositionNodes = Array.from(document.querySelectorAll("[data-composition-id]")).filter(
|
|
(n) => n.hasAttribute("data-duration") || n.hasAttribute("data-end"),
|
|
) as HTMLElement[];
|
|
for (const node of compositionNodes) {
|
|
// Preserve explicit root duration so timeline payload can distinguish
|
|
// authored finite duration from loop-inflated timeline duration.
|
|
if (rootEl && node === rootEl) continue;
|
|
// Preserve authored timing for reference-start resolution in Studio and
|
|
// timeline payload generation. The runtime still strips the public attrs
|
|
// so visibility/parity continues to derive from the live sub-timeline.
|
|
const authoredDuration = node.getAttribute("data-duration");
|
|
const authoredEnd = node.getAttribute("data-end");
|
|
if (authoredDuration != null && !node.hasAttribute(AUTHORED_DURATION_ATTR)) {
|
|
node.setAttribute(AUTHORED_DURATION_ATTR, authoredDuration);
|
|
}
|
|
if (authoredEnd != null && !node.hasAttribute(AUTHORED_END_ATTR)) {
|
|
node.setAttribute(AUTHORED_END_ATTR, authoredEnd);
|
|
}
|
|
// Strip public timing attrs on non-root compositions after preserving
|
|
// authored values privately. Runtime timing can still distinguish
|
|
// authored host windows from live child timeline durations.
|
|
node.removeAttribute("data-duration");
|
|
node.removeAttribute("data-end");
|
|
}
|
|
};
|
|
|
|
const applyClipLayout = () => {
|
|
const rootEl = resolveRootCompositionElement();
|
|
if (!rootEl) return;
|
|
if (!rootEl.style.position) {
|
|
rootEl.style.position = "relative";
|
|
}
|
|
rootEl.style.overflow = "hidden";
|
|
const rootWidth = parseDimensionPx(rootEl.getAttribute("data-width"));
|
|
const rootHeight = parseDimensionPx(rootEl.getAttribute("data-height"));
|
|
if (rootWidth) rootEl.style.width = rootWidth;
|
|
if (rootHeight) rootEl.style.height = rootHeight;
|
|
const clips = (Array.from(rootEl.children) as HTMLElement[]).filter((el) => {
|
|
const tag = el.tagName.toLowerCase();
|
|
if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") return false;
|
|
if (!el.hasAttribute("data-start")) return false;
|
|
// Runtime-stamped clips are NOT authored overlay clips. In Studio/preview
|
|
// the runtime stamps `data-start` onto ID'd or GSAP-targeted flow children
|
|
// (a <header>/<footer> in a flex column) so the design panel can discover
|
|
// them — see the stamping pass in bindCapturedTimeline. Forcing those out
|
|
// of document flow collapses the layout: the footer shrink-wraps and its
|
|
// `justify-content: space-between` clusters in the top-left. Leave them in
|
|
// flow so the preview matches the rendered video, which never stamps
|
|
// (production renders run as the top-level page, not in an iframe).
|
|
return !el.hasAttribute(RUNTIME_STAMPED_ATTR);
|
|
});
|
|
const displayNoneLiftedToMeasureShown = clips
|
|
.filter((el) => el.style.getPropertyValue("display") === "none")
|
|
.map((el) => ({ el, priority: el.style.getPropertyPriority("display") }));
|
|
for (const { el } of displayNoneLiftedToMeasureShown) el.style.removeProperty("display");
|
|
for (const el of clips) {
|
|
const tag = el.tagName.toLowerCase();
|
|
const hasLegacyAnchoredDefaults =
|
|
(el.style.top === "0px" || el.style.top === "0") &&
|
|
(el.style.left === "0px" || el.style.left === "0") &&
|
|
el.style.width === "100%" &&
|
|
el.style.height === "100%";
|
|
const hasCenteringTransform = /translate\(\s*-50%\s*,\s*-50%\s*\)/.test(el.style.transform);
|
|
if (
|
|
hasLegacyAnchoredDefaults &&
|
|
hasCenteringTransform &&
|
|
!el.hasAttribute("data-width") &&
|
|
!el.hasAttribute("data-height")
|
|
) {
|
|
const previousTop = el.style.top;
|
|
const previousLeft = el.style.left;
|
|
const previousWidth = el.style.width;
|
|
const previousHeight = el.style.height;
|
|
el.style.top = "";
|
|
el.style.left = "";
|
|
el.style.width = "";
|
|
el.style.height = "";
|
|
const clearedComputed = window.getComputedStyle(el);
|
|
const cssProvidesClipLayout =
|
|
clearedComputed.top !== "auto" ||
|
|
clearedComputed.bottom !== "auto" ||
|
|
clearedComputed.left !== "auto" ||
|
|
clearedComputed.right !== "auto" ||
|
|
clearedComputed.width !== "0px" ||
|
|
clearedComputed.height !== "0px";
|
|
if (!cssProvidesClipLayout) {
|
|
el.style.top = previousTop;
|
|
el.style.left = previousLeft;
|
|
el.style.width = previousWidth;
|
|
el.style.height = previousHeight;
|
|
}
|
|
}
|
|
const computed = window.getComputedStyle(el);
|
|
const computedPosition = computed.position;
|
|
// Root-level timed clips should stack in the same viewport layer.
|
|
// Relative positioning keeps clips in document flow and can push later
|
|
// compositions below the viewport (eg. checkerboard-style overlays).
|
|
const shouldForceAbsolute = computedPosition !== "absolute" && computedPosition !== "fixed";
|
|
if (shouldForceAbsolute) {
|
|
el.style.position = "absolute";
|
|
}
|
|
if (tag !== "audio") {
|
|
const forcedWidth = parseDimensionPx(el.getAttribute("data-width"));
|
|
const forcedHeight = parseDimensionPx(el.getAttribute("data-height"));
|
|
const hasMeaningfulComputedWidth = computed.width !== "0px" && computed.width !== "auto";
|
|
const hasMeaningfulComputedHeight = computed.height !== "0px" && computed.height !== "auto";
|
|
if (forcedWidth) {
|
|
if (!el.style.width && !hasMeaningfulComputedWidth) {
|
|
el.style.width = forcedWidth;
|
|
}
|
|
} else if (!el.style.width && computed.width === "0px") {
|
|
el.style.width = "100%";
|
|
}
|
|
if (forcedHeight) {
|
|
if (!el.style.height && !hasMeaningfulComputedHeight) {
|
|
el.style.height = forcedHeight;
|
|
}
|
|
} else if (!el.style.height && computed.height === "0px") {
|
|
el.style.height = "100%";
|
|
}
|
|
}
|
|
}
|
|
for (const { el, priority } of displayNoneLiftedToMeasureShown) {
|
|
el.style.setProperty("display", "none", priority);
|
|
}
|
|
};
|
|
|
|
const createTimingResolver = (includeAuthoredTimingAttrs: boolean) =>
|
|
createRuntimeStartTimeResolver({
|
|
timelineRegistry: (window.__timelines ?? {}) as Record<
|
|
string,
|
|
RuntimeTimelineLike | undefined
|
|
>,
|
|
includeAuthoredTimingAttrs,
|
|
});
|
|
|
|
// `createRuntimeStartTimeResolver` memoizes starts and durations in WeakMaps,
|
|
// but a resolver built per call throws those caches away before the second
|
|
// lookup ever happens, so one pass re-walks every ancestor chain once per
|
|
// element. `withTimingResolver` installs ONE resolver for the duration of a
|
|
// synchronous callback; every resolve inside shares its caches.
|
|
//
|
|
// NEVER widen these into a single scope spanning a whole media pass.
|
|
// `syncRuntimeMedia` calls `el.load()` on the seek-past-buffered-range retry
|
|
// (media.ts), which synchronously resets `el.duration` to NaN, and
|
|
// `resolveDurationForElement` reads `element.duration` (startResolver.ts). A
|
|
// cache living across `refreshRuntimeMediaCache` -> `syncRuntimeMedia` ->
|
|
// `syncTimedElementVisibility` would serve the pre-`load()` duration to a
|
|
// post-`load()` read. Two scopes with the write in between is what makes that
|
|
// impossible. Do not merge them.
|
|
let activeTimingResolver: ReturnType<typeof createTimingResolver> | null = null;
|
|
|
|
const withTimingResolver = <T>(fn: () => T): T => {
|
|
const previous = activeTimingResolver;
|
|
activeTimingResolver = createTimingResolver(true);
|
|
try {
|
|
return fn();
|
|
} finally {
|
|
activeTimingResolver = previous;
|
|
}
|
|
};
|
|
|
|
// The installed resolver carries authored timing attrs; a caller that opts
|
|
// out gets its own, exactly as before.
|
|
const timingResolverFor = (includeAuthoredTimingAttrs: boolean) =>
|
|
includeAuthoredTimingAttrs && activeTimingResolver
|
|
? activeTimingResolver
|
|
: createTimingResolver(includeAuthoredTimingAttrs);
|
|
|
|
const resolveStartForElement = (
|
|
element: Element,
|
|
fallback = 0,
|
|
opts?: { includeAuthoredTimingAttrs?: boolean },
|
|
): number =>
|
|
timingResolverFor(opts?.includeAuthoredTimingAttrs ?? true).resolveStartForElement(
|
|
element,
|
|
fallback,
|
|
);
|
|
|
|
const resolveDurationForElement = (
|
|
element: Element,
|
|
opts?: { includeAuthoredTimingAttrs?: boolean },
|
|
): number | null =>
|
|
timingResolverFor(opts?.includeAuthoredTimingAttrs ?? true).resolveDurationForElement(element);
|
|
|
|
const resolveMediaCompositionContext = (element: Element) => {
|
|
const compositionRoot = element.closest("[data-composition-id]");
|
|
const inheritedStart = compositionRoot ? resolveStartForElement(compositionRoot, 0) : null;
|
|
const inheritedDuration = compositionRoot
|
|
? resolveDurationForElement(compositionRoot, { includeAuthoredTimingAttrs: true })
|
|
: null;
|
|
return { compositionRoot, inheritedStart, inheritedDuration };
|
|
};
|
|
|
|
const resolveMediaHostSlots = (element: Element) =>
|
|
timingResolverFor(true).resolveHostSlotsForElement(element);
|
|
|
|
/** A clip's own window from `start` and `data-duration`, cut by its in-point hosts. */
|
|
const playedMediaWindow = (element: HTMLMediaElement, start: number) => {
|
|
const duration = parseStrictFiniteTimingNumber(element.dataset.duration);
|
|
const end = duration != null && duration > 0 ? start + duration : Infinity;
|
|
return cutToHostSlots({ start, end }, resolveMediaHostSlots(element));
|
|
};
|
|
|
|
// Single owner: `createRuntimeStartTimeResolver` (startResolver.ts). The clip
|
|
// manifest resolves media starts through the same method, so what the studio
|
|
// draws and what the transport plays cannot drift apart.
|
|
const resolveAbsoluteMediaStartSeconds = (element: Element): number =>
|
|
timingResolverFor(true).resolveMediaStartForElement(element);
|
|
|
|
window.__hfResolveMediaStartSeconds = resolveAbsoluteMediaStartSeconds;
|
|
runtimeCleanupCallbacks.push(() => {
|
|
if (window.__hfResolveMediaStartSeconds === resolveAbsoluteMediaStartSeconds) {
|
|
delete window.__hfResolveMediaStartSeconds;
|
|
}
|
|
});
|
|
|
|
const hasExternalCompositions = !!document.querySelector("[data-composition-src]");
|
|
let hasInlineTemplateCompositions = false;
|
|
{
|
|
const candidates = document.querySelectorAll(
|
|
"[data-composition-id]:not([data-composition-src])",
|
|
);
|
|
for (const el of candidates) {
|
|
const cid = el.getAttribute("data-composition-id");
|
|
if (
|
|
cid &&
|
|
el.children.length === 0 &&
|
|
document.querySelector(`template#${CSS.escape(cid)}-template`)
|
|
) {
|
|
hasInlineTemplateCompositions = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
let externalCompositionsReady = !hasExternalCompositions && !hasInlineTemplateCompositions;
|
|
|
|
const getTimelineDurationSeconds = (timeline: RuntimeTimelineLike | null): number | null => {
|
|
const raw = readTimelineDurationSeconds(timeline);
|
|
return raw == null ? null : Math.max(0, raw);
|
|
};
|
|
|
|
const isUsableTimelineDuration = (durationSeconds: number | null): durationSeconds is number =>
|
|
typeof durationSeconds === "number" &&
|
|
Number.isFinite(durationSeconds) &&
|
|
durationSeconds > MIN_VALID_TIMELINE_DURATION_SECONDS;
|
|
|
|
type TimelineResolution = {
|
|
timeline: RuntimeTimelineLike | null;
|
|
selectedTimelineIds?: string[];
|
|
selectedDurationSeconds?: number | null;
|
|
mediaDurationFloorSeconds?: number | null;
|
|
diagnostics?: {
|
|
code: string;
|
|
details: Record<string, string | number | boolean | null | string[]>;
|
|
};
|
|
};
|
|
|
|
// Scope 3 of 3 (see `withTimingResolver`). Every media element resolves its
|
|
// composition ancestry here, and this runs on every transport tick via
|
|
// `getSafeTimelineDurationSeconds`, so it is the heaviest consumer of the
|
|
// per-call resolvers.
|
|
//
|
|
// The scope sits HERE and not on `getSafeTimelineDurationSeconds`, which
|
|
// would look like the tidier boundary: that function also calls
|
|
// `timeline.duration()` (author-supplied GSAP) and every adapter's
|
|
// `getInferredDurationSeconds()` (third-party runtimes). Foreign code inside
|
|
// a cache scope can touch the DOM between two resolves. This loop is DOM
|
|
// reads only, so nothing can change under it.
|
|
const resolveMediaWindowDurationSeconds = (): number | null => {
|
|
const mediaNodes = Array.from(
|
|
document.querySelectorAll("video[data-start], audio[data-start]"),
|
|
) as HTMLMediaElement[];
|
|
// Checked before the scope opens: a composition with no timed media runs
|
|
// this every tick, and it should not pay for a resolver it never uses.
|
|
if (mediaNodes.length === 0) return null;
|
|
return withTimingResolver(() =>
|
|
resolveMediaWindowDurationSecondsIn(document, {
|
|
mediaStart: resolveAbsoluteMediaStartSeconds,
|
|
mediaDuration: resolveMediaElementDurationSeconds,
|
|
}),
|
|
);
|
|
};
|
|
|
|
const resolveAuthoredCompositionDurationFloorSeconds = (): number | null => {
|
|
const rootEl = resolveRootCompositionElement();
|
|
if (!rootEl) return null;
|
|
const timelines = (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>;
|
|
const startResolver = createRuntimeStartTimeResolver({
|
|
timelineRegistry: timelines,
|
|
includeAuthoredTimingAttrs: true,
|
|
});
|
|
// getSafeTimelineDurationSeconds returns the declared length first; here it only sizes the
|
|
// stand-in timeline that resolveRootTimelineFromDocument builds for a root timeline with no
|
|
// length.
|
|
return resolveAuthoredCompositionFloorSeconds(rootEl, startResolver);
|
|
};
|
|
|
|
/** The last-resort length: the latest end among the root's timed clips, used only when no
|
|
* timeline, floor or caller-supplied length exists. Pending media is counted, not guessed. */
|
|
// Lottie registers its animations from author scripts, often after the runtime is ready, and
|
|
// nothing in the DOM says when: a loaded library means a length that is still pending.
|
|
const hasLottieLibrary = (): boolean => {
|
|
const lottieWindow = window as Window & { lottie?: unknown; DotLottie?: unknown };
|
|
return Boolean(lottieWindow.lottie || lottieWindow.DotLottie);
|
|
};
|
|
const resolveContentDerivedDuration = () => {
|
|
const rootEl = resolveRootCompositionElement();
|
|
if (!rootEl)
|
|
return resolveCompositionDuration({ authoredDurationSeconds: null, clipEndsSeconds: [] });
|
|
const startResolver = createRuntimeStartTimeResolver({
|
|
timelineRegistry: (window.__timelines ?? {}) as Record<
|
|
string,
|
|
RuntimeTimelineLike | undefined
|
|
>,
|
|
includeAuthoredTimingAttrs: true,
|
|
});
|
|
return resolveContentDerivedDurationFor(rootEl, startResolver, {
|
|
unregisteredLottie: hasLottieLibrary(),
|
|
});
|
|
};
|
|
|
|
let contentDerivedCache: {
|
|
revision: number;
|
|
result: ReturnType<typeof resolveContentDerivedDuration>;
|
|
} | null = null;
|
|
/** Cached per timing revision like the floors; the render path never reads a cache. */
|
|
const readContentDerivedDuration = () => {
|
|
if (renderCaptureSeekStarted) return resolveContentDerivedDuration();
|
|
const revision = readCompositionTimingRevision();
|
|
if (contentDerivedCache?.revision !== revision) {
|
|
contentDerivedCache = { revision, result: resolveContentDerivedDuration() };
|
|
}
|
|
return contentDerivedCache.result;
|
|
};
|
|
|
|
/** Carries how a length that no timeline supplied was found, for render telemetry. */
|
|
const publishDerivedDuration = (result: ReturnType<typeof resolveCompositionDuration>) => {
|
|
window.__hf = window.__hf || {};
|
|
window.__hf.durationSource = {
|
|
source: result.source,
|
|
seconds: result.seconds,
|
|
pendingClips: result.pendingClips,
|
|
};
|
|
if (result.source !== "derived") return;
|
|
postRuntimeDiagnosticOnce(
|
|
"composition_duration_derived",
|
|
{ source: result.source, seconds: result.seconds, pendingClips: result.pendingClips },
|
|
`composition_duration_derived:${result.seconds}`,
|
|
);
|
|
};
|
|
|
|
const resolveMediaDurationFloorSeconds = (): number | null => {
|
|
const mediaWindowDuration = resolveMediaWindowDurationSeconds();
|
|
if (
|
|
typeof mediaWindowDuration !== "number" ||
|
|
!Number.isFinite(mediaWindowDuration) ||
|
|
mediaWindowDuration <= MIN_VALID_TIMELINE_DURATION_SECONDS
|
|
) {
|
|
return null;
|
|
}
|
|
return mediaWindowDuration;
|
|
};
|
|
|
|
const resolveMinCandidateDurationSeconds = (mediaDurationFloorSeconds: number | null): number => {
|
|
if (!isUsableTimelineDuration(mediaDurationFloorSeconds)) {
|
|
return MIN_VALID_TIMELINE_DURATION_SECONDS;
|
|
}
|
|
return Math.max(
|
|
MIN_VALID_TIMELINE_DURATION_SECONDS,
|
|
mediaDurationFloorSeconds * TIMELINE_FLOOR_COVERAGE_RATIO,
|
|
);
|
|
};
|
|
|
|
// Non-GSAP runtimes (CSS, WAAPI, Lottie) have no window.__timelines entry
|
|
// and thus no authored source of truth for total duration. Adapters that
|
|
// implement getInferredDurationSeconds() report the longest end time they
|
|
// can discover from their own animations (see runtime/types.ts). Folding
|
|
// that into the duration floor here — the same mechanism data-duration and
|
|
// media windows already use — makes data-duration optional wherever the
|
|
// runtime can figure the duration out on its own, instead of hard-failing
|
|
// capture with "Composition has zero duration".
|
|
const resolveAdapterDurationFloorSeconds = (oneCycle = false): number | null => {
|
|
let maxSeconds = 0;
|
|
for (const adapter of state.deterministicAdapters) {
|
|
const getter = oneCycle
|
|
? adapter.getAnimationCycleEndSeconds
|
|
: adapter.getInferredDurationSeconds;
|
|
if (typeof getter !== "function") continue;
|
|
let inferred: number | null = null;
|
|
try {
|
|
inferred = getter();
|
|
} catch (err) {
|
|
swallow("runtime.init.adapterDuration", err);
|
|
}
|
|
if (typeof inferred === "number" && Number.isFinite(inferred) && inferred > 0) {
|
|
maxSeconds = Math.max(maxSeconds, inferred);
|
|
}
|
|
}
|
|
return maxSeconds > MIN_VALID_TIMELINE_DURATION_SECONDS ? maxSeconds : null;
|
|
};
|
|
|
|
// Flips true on the first renderSeek call — the render/producer capture
|
|
// protocol's signal that it has started deterministically driving frames.
|
|
// One-way for this page lifetime; every producer render gets a fresh runtime.
|
|
// See scheduleMetadataDurationHydration for why this gates the async
|
|
// metadata rebind off once set, and resolveDurationFloors for why the
|
|
// derived-duration cache is bypassed entirely once it is set.
|
|
let renderCaptureSeekStarted = false;
|
|
|
|
/**
|
|
* An export render is driving frames deterministically.
|
|
*
|
|
* The PAIR, never `renderCaptureSeekStarted` alone: `renderSeek` is also what
|
|
* Studio's own preview falls back to for overhanging timelines
|
|
* (`playbackAdapter.createStaticSeekPlaybackAdapter`), so the flag alone
|
|
* latches on the first Studio scrub of such a project and stays true for the
|
|
* rest of that session. `__HF_EXPORT_RENDER_SEEK_CONFIG` is injected by the
|
|
* producer's page script before a single frame is driven, so the pair is
|
|
* true for a real render and nothing else.
|
|
*
|
|
* Two callers rely on this being one decision with one owner: the async
|
|
* metadata rebind, which must not race the capture loop, and the transport
|
|
* loop, which must not park while a render is in charge of frames.
|
|
*/
|
|
const isExportRenderDrivingFrames = (): boolean =>
|
|
renderCaptureSeekStarted && window.__HF_EXPORT_RENDER_SEEK_CONFIG != null;
|
|
|
|
// The media and authored-composition duration floors are derived from the
|
|
// DOM and the timeline registry — never from the playhead — so they change
|
|
// only when the composition changes. transportTick asks for them on EVERY
|
|
// animation frame, and deriving them scans every media element and walks
|
|
// each one's composition ancestry, which is the largest single cost of a
|
|
// paused, untouched editor. Derive once and reuse until an input changes.
|
|
//
|
|
// Every input that can change the answer, and the signal that catches it:
|
|
// - timing attributes edited (Studio live editing, variables re-applied,
|
|
// the runtime's own autostamping) -> MutationObserver, attribute filter below
|
|
// - media or timed elements added/removed (nested compositions mount
|
|
// asynchronously, well after init) -> MutationObserver, childList + subtree
|
|
// - media metadata arriving or being reset (`el.load()` puts `duration`
|
|
// back to NaN) — no DOM mutation at all -> capture-phase media events below
|
|
// - a timeline registered, or an existing one's duration changing, in
|
|
// `window.__timelines` (a plain object, not observable) -> registry
|
|
// signature compared on read
|
|
// The bias is deliberate: a redundant derivation is a missed optimisation,
|
|
// a missed one is a wrong duration.
|
|
type DurationFloors = { media: number | null; authoredComposition: number | null };
|
|
const DURATION_FLOOR_INPUT_ATTRIBUTES = [
|
|
"data-start",
|
|
"data-duration",
|
|
"data-end",
|
|
"data-composition-id",
|
|
"data-composition-src",
|
|
"data-composition-file",
|
|
"data-root",
|
|
"data-hf-authored-duration",
|
|
"data-hf-authored-end",
|
|
"data-hf-auto-start",
|
|
MEDIA_START_BASIS_ATTR,
|
|
"data-playback-rate",
|
|
"data-automation",
|
|
"data-playback-start",
|
|
"data-media-start",
|
|
// `data-start` may be an expression referencing another element by id, and
|
|
// a media element's `duration` follows its source.
|
|
"id",
|
|
"src",
|
|
];
|
|
const COMPOSITION_TIMING_MEDIA_EVENTS = ["loadedmetadata", "durationchange", "emptied"] as const;
|
|
let durationFloorsCache: DurationFloors | null = null;
|
|
let durationFloorsRevision = -1;
|
|
let timelineRegistrySignature: string | null = null;
|
|
let compositionTimingObserver: MutationObserver | null = null;
|
|
|
|
// Bumped whenever anything above can have moved a clip's window. Two caches
|
|
// read it: the duration floors, and the media clip index below. It is a
|
|
// counter rather than a per-cache flag because DRAINING the observer is what
|
|
// detects a change, and only the first reader in a task gets the records — a
|
|
// second cache checking for itself would be told nothing changed.
|
|
let compositionTimingRevision = 0;
|
|
|
|
// Restarts the transport loop when it has parked itself (see
|
|
// `scheduleNextTransportFrame`). Held as a reassignable binding because the
|
|
// invalidation hooks below are installed long before the transport exists,
|
|
// and reaching forward into its `const` would be a temporal-dead-zone throw
|
|
// during init.
|
|
let wakeTransport: () => void = () => {};
|
|
|
|
const invalidateCompositionTimingCaches = () => {
|
|
compositionTimingRevision += 1;
|
|
// The same records that stale the duration caches are what a parked
|
|
// transport is waiting for: a timing attribute edited, a sub-composition
|
|
// mounted, media metadata arriving. One signal, two readers.
|
|
wakeTransport();
|
|
};
|
|
|
|
const deriveDurationFloors = (): DurationFloors => ({
|
|
media: resolveMediaDurationFloorSeconds(),
|
|
authoredComposition: resolveAuthoredCompositionDurationFloorSeconds(),
|
|
});
|
|
|
|
const readTimelineRegistrySignature = (): string => {
|
|
const timelines = (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>;
|
|
let signature = "";
|
|
for (const timelineId of Object.keys(timelines)) {
|
|
const timeline = timelines[timelineId];
|
|
if (!timeline) continue;
|
|
signature += `${timelineId}=${getTimelineDurationSeconds(timeline) ?? "?"};`;
|
|
}
|
|
return signature;
|
|
};
|
|
|
|
const watchCompositionTimingInputs = () => {
|
|
if (compositionTimingObserver || typeof MutationObserver === "undefined") return;
|
|
compositionTimingObserver = new MutationObserver(invalidateCompositionTimingCaches);
|
|
compositionTimingObserver.observe(document.documentElement, {
|
|
childList: true,
|
|
subtree: true,
|
|
attributes: true,
|
|
// Sizing inputs too: postTimeline applies them, and it runs when this revision moves.
|
|
attributeFilter: [...DURATION_FLOOR_INPUT_ATTRIBUTES, "data-width", "data-height"],
|
|
});
|
|
// Media duration changes are published as events, not DOM mutations, and
|
|
// they do not bubble — so listen in the capture phase, which reaches every
|
|
// media element including ones added long after this binds.
|
|
for (const eventType of COMPOSITION_TIMING_MEDIA_EVENTS) {
|
|
document.addEventListener(eventType, invalidateCompositionTimingCaches, true);
|
|
}
|
|
runtimeCleanupCallbacks.push(() => {
|
|
compositionTimingObserver?.disconnect();
|
|
compositionTimingObserver = null;
|
|
invalidateCompositionTimingCaches();
|
|
for (const eventType of COMPOSITION_TIMING_MEDIA_EVENTS) {
|
|
document.removeEventListener(eventType, invalidateCompositionTimingCaches, true);
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* The current revision, after taking every signal that could have moved it.
|
|
* Call this exactly once per read, and compare it with the revision a cache
|
|
* was built at.
|
|
*/
|
|
const readCompositionTimingRevision = (): number => {
|
|
watchCompositionTimingInputs();
|
|
// MutationObserver records are delivered in a microtask, so a caller that
|
|
// edits a timing attribute and reads a window back in the SAME synchronous
|
|
// block would otherwise be served the pre-edit value. Draining the queue
|
|
// here makes the caches correct within a task, not just across tasks.
|
|
// Taking the records suppresses the callback for them, which is exactly
|
|
// equivalent since the callback only bumps the revision.
|
|
if (compositionTimingObserver && compositionTimingObserver.takeRecords().length > 0) {
|
|
invalidateCompositionTimingCaches();
|
|
}
|
|
// `window.__timelines` is a plain object: no MutationObserver and no event
|
|
// can see a composition script registering into it or lengthening what it
|
|
// already registered, so its shape is compared on every read.
|
|
const signature = readTimelineRegistrySignature();
|
|
if (signature !== timelineRegistrySignature) {
|
|
timelineRegistrySignature = signature;
|
|
invalidateCompositionTimingCaches();
|
|
}
|
|
return compositionTimingRevision;
|
|
};
|
|
|
|
/**
|
|
* `revision` lets a caller that has ALREADY read the revision in this task
|
|
* hand it over instead of paying for a second read. Reading is not free —
|
|
* it rebuilds a signature over every registered timeline — and it is also
|
|
* what DRAINS the observer, so a second read in the same task is told
|
|
* nothing changed and the work is pure duplication.
|
|
*/
|
|
const resolveDurationFloors = (revision?: number): DurationFloors => {
|
|
// The render path never reads a cached floor. Capture depends on the exact
|
|
// duration and a frame that rendered against a wrong one cannot be
|
|
// recovered, so it pays the full derivation on every frame exactly as
|
|
// before — a correct slow render beats a fast wrong one.
|
|
if (renderCaptureSeekStarted) return deriveDurationFloors();
|
|
revision ??= readCompositionTimingRevision();
|
|
if (durationFloorsCache && durationFloorsRevision === revision) return durationFloorsCache;
|
|
durationFloorsRevision = revision;
|
|
durationFloorsCache = deriveDurationFloors();
|
|
return durationFloorsCache;
|
|
};
|
|
|
|
const getSafeTimelineDurationSeconds = (
|
|
timeline: RuntimeTimelineLike | null,
|
|
fallback = 0,
|
|
timingRevision?: number,
|
|
): number => {
|
|
let derivedDuration: ReturnType<typeof resolveContentDerivedDuration> | null = null;
|
|
const seconds = resolveCompositionLengthSeconds({
|
|
declared: parseStrictFiniteTimingNumber(
|
|
resolveRootCompositionElement()?.getAttribute("data-duration"),
|
|
),
|
|
timeline: () => getTimelineDurationSeconds(timeline),
|
|
floors: () => {
|
|
const { media, authoredComposition } = resolveDurationFloors(timingRevision);
|
|
// Deliberately NOT cached: adapters infer their duration from live animation objects
|
|
// (CSS/WAAPI/Lottie), which can change without any DOM mutation to observe.
|
|
return [media, authoredComposition, resolveAdapterDurationFloorSeconds()];
|
|
},
|
|
fallback,
|
|
derived: () => {
|
|
derivedDuration = readContentDerivedDuration();
|
|
return derivedDuration.seconds ?? 0;
|
|
},
|
|
});
|
|
// The published source describes only a length that was derived; any other source clears it.
|
|
if (derivedDuration) publishDerivedDuration(derivedDuration);
|
|
else if (window.__hf?.durationSource) delete window.__hf.durationSource;
|
|
return seconds;
|
|
};
|
|
|
|
// Sub-composition timelines the runtime nested into the root: the host composition id, and where.
|
|
const autoNested = new WeakMap<object, { hostId: string; parent: unknown; at: number }>();
|
|
// The composite that holds the children of a root with no timeline, and how many it holds.
|
|
let missingRootComposite: { timeline: RuntimeTimelineLike; size: number } | null = null;
|
|
|
|
const resolveRootTimelineFromDocument = (): TimelineResolution => {
|
|
const timelines = (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>;
|
|
// DX fallback (#6): when the root timeline cannot be resolved by id but
|
|
// EXACTLY ONE usable timeline is registered, bind it rather than silently
|
|
// rendering the frozen t=0 DOM. Safe because with a single registered
|
|
// timeline there is no ambiguity about which one is the composition's
|
|
// root. Multiple registered → ambiguous, so we still return null and let
|
|
// the loud warning fire.
|
|
const resolveSoleTimelineFallback = (reason: string): TimelineResolution => {
|
|
const usable = Object.entries(timelines).filter(
|
|
(entry): entry is [string, RuntimeTimelineLike] =>
|
|
!!entry[1] && typeof entry[1].play === "function" && typeof entry[1].pause === "function",
|
|
);
|
|
if (usable.length !== 1) return { timeline: null };
|
|
const sole = usable[0];
|
|
if (!sole) return { timeline: null };
|
|
const [soleId, soleTimeline] = sole;
|
|
return {
|
|
timeline: soleTimeline,
|
|
selectedTimelineIds: [soleId],
|
|
selectedDurationSeconds: getTimelineDurationSeconds(soleTimeline),
|
|
diagnostics: {
|
|
code: "root_timeline_sole_registered_fallback",
|
|
details: { reason, soleTimelineId: soleId },
|
|
},
|
|
};
|
|
};
|
|
const startResolver = createRuntimeStartTimeResolver({
|
|
timelineRegistry: timelines,
|
|
includeAuthoredTimingAttrs: true,
|
|
});
|
|
const {
|
|
media: mediaDurationFloorSeconds,
|
|
authoredComposition: authoredCompositionDurationFloorSeconds,
|
|
} = resolveDurationFloors();
|
|
const durationFloorSeconds =
|
|
Math.max(mediaDurationFloorSeconds ?? 0, authoredCompositionDurationFloorSeconds ?? 0) ||
|
|
null;
|
|
const minCandidateDurationSeconds = resolveMinCandidateDurationSeconds(durationFloorSeconds);
|
|
const resolveCompositionStartSeconds = (compositionId: string): number => {
|
|
const node = document.querySelector(
|
|
`[data-composition-id="${CSS.escape(compositionId)}"]`,
|
|
) as Element | null;
|
|
if (!node) return 0;
|
|
return startResolver.resolveStartForElement(node, 0);
|
|
};
|
|
const nestAtHostStart = (
|
|
parent: RuntimeTimelineLike,
|
|
candidate: { compositionId: string; timeline: RuntimeTimelineLike },
|
|
): void => {
|
|
const at = resolveCompositionStartSeconds(candidate.compositionId);
|
|
parent.add(candidate.timeline, at);
|
|
autoNested.set(candidate.timeline, { hostId: candidate.compositionId, parent, at });
|
|
};
|
|
/** Placed by the runtime in `root` and still there; under another root a re-run script owns its place. */
|
|
const placedIn = (
|
|
root: RuntimeTimelineLike,
|
|
candidate: { compositionId: string; timeline: RuntimeTimelineLike },
|
|
) => {
|
|
const nested = candidate.timeline as RuntimeTimelineLike & RuntimeTimelineChildLike;
|
|
const placed = autoNested.get(nested);
|
|
return placed && placed.parent === root && nested.parent === (root as unknown)
|
|
? placed
|
|
: null;
|
|
};
|
|
const atHostStart = (placed: { at: number }, candidate: { compositionId: string }) =>
|
|
Math.abs(placed.at - resolveCompositionStartSeconds(candidate.compositionId)) < 1e-6;
|
|
const followHostStart = (
|
|
root: RuntimeTimelineLike,
|
|
candidate: { compositionId: string; timeline: RuntimeTimelineLike },
|
|
): void => {
|
|
const placed = placedIn(root, candidate);
|
|
if (!placed || atHostStart(placed, candidate)) return;
|
|
// GSAP takes a child out of its parent before adding it; the rebind's unpause aligns it to the root.
|
|
nestAtHostStart(root, candidate);
|
|
};
|
|
const createCompositeTimelineFromCandidates = (
|
|
candidates: Array<{
|
|
compositionId: string;
|
|
timeline: RuntimeTimelineLike;
|
|
durationSeconds: number;
|
|
}>,
|
|
): RuntimeTimelineLike | null => {
|
|
const gsapApi = window.gsap;
|
|
if (!gsapApi || typeof gsapApi.timeline !== "function") return null;
|
|
const compositeTimeline = gsapApi.timeline({ paused: true }) as RuntimeTimelineLike;
|
|
for (const candidate of candidates) nestAtHostStart(compositeTimeline, candidate);
|
|
// Before any duration read: GSAP leaves paused children out of the duration it caches.
|
|
ensureChildCandidatesActive(nestedCandidates(compositeTimeline, candidates));
|
|
return compositeTimeline;
|
|
};
|
|
const createDurationFloorTimeline = (
|
|
durationSeconds: number,
|
|
existingRootTimeline: RuntimeTimelineLike | null,
|
|
): RuntimeTimelineLike | null => {
|
|
if (!isUsableTimelineDuration(durationSeconds)) return null;
|
|
const gsapApi = window.gsap;
|
|
if (!gsapApi || typeof gsapApi.timeline !== "function") return null;
|
|
const fallbackTimeline = gsapApi.timeline({ paused: true }) as RuntimeTimelineLike;
|
|
if (existingRootTimeline) {
|
|
try {
|
|
fallbackTimeline.add(existingRootTimeline, 0);
|
|
} catch (err) {
|
|
swallow("runtime.init.site2", err);
|
|
}
|
|
// A paused child never renders under its parent's seek; the wrapper drives it now.
|
|
ensureChildCandidatesActive(
|
|
nestedCandidates(fallbackTimeline, [{ timeline: existingRootTimeline }]),
|
|
);
|
|
}
|
|
const withTween = fallbackTimeline as RuntimeTimelineLike & {
|
|
to?: (target: object, vars: { duration?: number; data?: string }) => unknown;
|
|
};
|
|
if (typeof withTween.to === "function") {
|
|
try {
|
|
withTween.to({}, { duration: durationSeconds, data: RUNTIME_FILLER });
|
|
} catch (err) {
|
|
// no-op; if tween creation fails, caller will discard by unusable duration
|
|
swallow("runtime.init.site3", err);
|
|
}
|
|
}
|
|
return fallbackTimeline;
|
|
};
|
|
// Read back from the parent rather than trusting add(): a child the parent
|
|
// does not hold stays on GSAP's global ticker, where unpaused means free-running.
|
|
const nestedCandidates = <C extends { timeline: RuntimeTimelineLike }>(
|
|
parent: RuntimeTimelineLike | null,
|
|
candidates: C[],
|
|
): C[] => {
|
|
if (!parent || typeof parent.getChildren !== "function") return [];
|
|
try {
|
|
const held = parent.getChildren(true, true, true);
|
|
return Array.isArray(held) ? candidates.filter((c) => held.includes(c.timeline)) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
};
|
|
const addMissingChildCandidatesToRootTimeline = (
|
|
rootTimeline: RuntimeTimelineLike,
|
|
candidates: Array<{
|
|
compositionId: string;
|
|
timeline: RuntimeTimelineLike;
|
|
durationSeconds: number;
|
|
}>,
|
|
): { addedIds: string[]; nested: typeof candidates } => {
|
|
const rootWithChildren = rootTimeline as RuntimeTimelineLike & {
|
|
getChildren?: (...args: unknown[]) => unknown[];
|
|
};
|
|
const none = { addedIds: [], nested: [] };
|
|
if (typeof rootWithChildren.getChildren !== "function") return none;
|
|
try {
|
|
const existingChildren = rootWithChildren.getChildren(true, true, true) ?? [];
|
|
if (!Array.isArray(existingChildren)) return none;
|
|
const addedIds: string[] = [];
|
|
for (const candidate of candidates) {
|
|
if (existingChildren.includes(candidate.timeline)) {
|
|
followHostStart(rootTimeline, candidate);
|
|
continue;
|
|
}
|
|
try {
|
|
nestAtHostStart(rootTimeline, candidate);
|
|
addedIds.push(candidate.compositionId);
|
|
} catch (err) {
|
|
// ignore broken child add attempts
|
|
swallow("runtime.init.site4", err);
|
|
}
|
|
}
|
|
return { addedIds, nested: nestedCandidates(rootTimeline, candidates) };
|
|
} catch {
|
|
return none;
|
|
}
|
|
};
|
|
const rootCompositionNode = resolveRootCompositionElement();
|
|
const rootCompositionId = rootCompositionNode?.getAttribute("data-composition-id") ?? null;
|
|
if (!rootCompositionId) {
|
|
return resolveSoleTimelineFallback("root_missing_composition_id");
|
|
}
|
|
const rootTimeline = timelines[rootCompositionId] ?? null;
|
|
const collectRootChildCandidates = (): Array<{
|
|
compositionId: string;
|
|
timeline: RuntimeTimelineLike;
|
|
durationSeconds: number;
|
|
}> => {
|
|
if (!rootCompositionNode) return [];
|
|
const seen = new Set<string>();
|
|
const childNodes = Array.from(rootCompositionNode.querySelectorAll("[data-composition-id]"));
|
|
const candidates: Array<{
|
|
compositionId: string;
|
|
timeline: RuntimeTimelineLike;
|
|
durationSeconds: number;
|
|
}> = [];
|
|
for (const childNode of childNodes) {
|
|
const childId = childNode.getAttribute("data-composition-id");
|
|
if (!childId || childId === rootCompositionId) continue;
|
|
if (seen.has(childId)) continue;
|
|
seen.add(childId);
|
|
const candidateTimeline = timelines[childId] ?? null;
|
|
if (!candidateTimeline) continue;
|
|
if (
|
|
typeof candidateTimeline.play !== "function" ||
|
|
typeof candidateTimeline.pause !== "function"
|
|
) {
|
|
continue;
|
|
}
|
|
const candidateDuration = getTimelineDurationSeconds(candidateTimeline);
|
|
candidates.push({
|
|
compositionId: childId,
|
|
timeline: candidateTimeline,
|
|
durationSeconds: candidateDuration ?? 0,
|
|
});
|
|
}
|
|
return candidates;
|
|
};
|
|
const rootChildCandidates = collectRootChildCandidates();
|
|
const ensureChildCandidatesActive = (
|
|
candidates: Array<{ timeline: RuntimeTimelineLike }>,
|
|
): void => {
|
|
for (const candidate of candidates) {
|
|
const timelineWithPaused = candidate.timeline as RuntimeTimelineLike & {
|
|
paused?: (value?: boolean) => unknown;
|
|
};
|
|
if (typeof timelineWithPaused.paused !== "function") continue;
|
|
try {
|
|
timelineWithPaused.paused(false);
|
|
} catch (err) {
|
|
// keep runtime resilient against timeline API quirks
|
|
swallow("runtime.init.site5", err);
|
|
}
|
|
}
|
|
};
|
|
if (rootTimeline) {
|
|
// Only a child the root actually holds may run unpaused: the paused root
|
|
// drives it. A standalone registry child is re-seeked by the transport and
|
|
// must stay paused, or it free-runs on the global ticker while paused.
|
|
const nesting =
|
|
rootChildCandidates.length > 0
|
|
? addMissingChildCandidatesToRootTimeline(rootTimeline, rootChildCandidates)
|
|
: { addedIds: [], nested: [] };
|
|
const autoNestedChildren = nesting.addedIds;
|
|
ensureChildCandidatesActive(nesting.nested);
|
|
// Mark children as bound so the polling loop stops re-resolving
|
|
if (
|
|
rootChildCandidates.length > 0 ||
|
|
!document.querySelector(
|
|
"[data-composition-id]:not([data-composition-id='" + rootCompositionId + "'])",
|
|
)
|
|
) {
|
|
childrenBound = true;
|
|
}
|
|
|
|
// Force GSAP to render the current frame so child animations show their correct state.
|
|
// Without this, children added after the root was created may still show initial styles.
|
|
if (autoNestedChildren.length > 0) {
|
|
try {
|
|
const currentTime = rootTimeline.time();
|
|
rootTimeline.seek(currentTime, false); // false = don't suppress events
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
dropRuntimeFillers(rootTimeline);
|
|
const rootDurationSeconds = getTimelineDurationSeconds(rootTimeline);
|
|
if (!isUsableTimelineDuration(rootDurationSeconds) && rootChildCandidates.length > 0) {
|
|
const selectedTimelineIds = rootChildCandidates.map((candidate) => candidate.compositionId);
|
|
const compositeTimeline = createCompositeTimelineFromCandidates(rootChildCandidates);
|
|
const compositeDurationSeconds = getTimelineDurationSeconds(compositeTimeline);
|
|
if (compositeTimeline && isUsableTimelineDuration(compositeDurationSeconds)) {
|
|
return {
|
|
timeline: compositeTimeline,
|
|
selectedTimelineIds,
|
|
selectedDurationSeconds: compositeDurationSeconds,
|
|
mediaDurationFloorSeconds,
|
|
diagnostics: {
|
|
code: "root_timeline_unusable_fallback",
|
|
details: {
|
|
rootCompositionId,
|
|
rootDurationSeconds,
|
|
fallbackKind: "composite_by_root_children",
|
|
minCandidateDurationSeconds,
|
|
selectedDurationSeconds: compositeDurationSeconds,
|
|
mediaDurationFloorSeconds,
|
|
authoredCompositionDurationFloorSeconds,
|
|
selectedTimelineIds,
|
|
autoNestedChildren,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
const durationFloorTimeline = createDurationFloorTimeline(
|
|
durationFloorSeconds ?? 0,
|
|
rootTimeline,
|
|
);
|
|
const floorTimelineDurationSeconds = getTimelineDurationSeconds(durationFloorTimeline);
|
|
if (durationFloorTimeline && isUsableTimelineDuration(floorTimelineDurationSeconds)) {
|
|
return {
|
|
timeline: durationFloorTimeline,
|
|
selectedTimelineIds: [rootCompositionId],
|
|
selectedDurationSeconds: floorTimelineDurationSeconds,
|
|
mediaDurationFloorSeconds,
|
|
diagnostics: {
|
|
code: "root_timeline_unusable_media_floor_fallback",
|
|
details: {
|
|
rootCompositionId,
|
|
rootDurationSeconds,
|
|
fallbackKind: "media_duration_floor",
|
|
mediaDurationFloorSeconds,
|
|
authoredCompositionDurationFloorSeconds,
|
|
selectedDurationSeconds: floorTimelineDurationSeconds,
|
|
selectedTimelineIds: [rootCompositionId],
|
|
autoNestedChildren,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
}
|
|
if (!isUsableTimelineDuration(rootDurationSeconds) && rootChildCandidates.length === 0) {
|
|
const durationFloorTimeline = createDurationFloorTimeline(
|
|
durationFloorSeconds ?? 0,
|
|
rootTimeline,
|
|
);
|
|
const floorTimelineDurationSeconds = getTimelineDurationSeconds(durationFloorTimeline);
|
|
if (durationFloorTimeline && isUsableTimelineDuration(floorTimelineDurationSeconds)) {
|
|
return {
|
|
timeline: durationFloorTimeline,
|
|
selectedTimelineIds: [rootCompositionId],
|
|
selectedDurationSeconds: floorTimelineDurationSeconds,
|
|
mediaDurationFloorSeconds,
|
|
diagnostics: {
|
|
code: "root_timeline_unusable_media_floor_fallback",
|
|
details: {
|
|
rootCompositionId,
|
|
rootDurationSeconds,
|
|
fallbackKind: "media_duration_floor",
|
|
mediaDurationFloorSeconds,
|
|
authoredCompositionDurationFloorSeconds,
|
|
selectedDurationSeconds: floorTimelineDurationSeconds,
|
|
selectedTimelineIds: [rootCompositionId],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
}
|
|
// If the authored composition schedule meaningfully exceeds the captured
|
|
// GSAP timeline, extend the timeline in-place with a zero-duration no-op
|
|
// tween. Studio previews can inline only part of the timeline registry
|
|
// while preserving the full host schedule in data-hf-authored-duration.
|
|
const rootDeclaredDur = parseStrictFiniteTimingNumber(
|
|
rootCompositionNode?.getAttribute("data-duration"),
|
|
);
|
|
const rootDurationFloorSeconds = Math.max(
|
|
isUsableTimelineDuration(rootDeclaredDur) ? rootDeclaredDur : 0,
|
|
authoredCompositionDurationFloorSeconds ?? 0,
|
|
);
|
|
if (rootDurationFloorSeconds > 0) {
|
|
if (
|
|
isUsableTimelineDuration(rootDurationFloorSeconds) &&
|
|
isUsableTimelineDuration(rootDurationSeconds) &&
|
|
// Only pad when the gap is meaningful (>= 0.5s) to avoid floating-point
|
|
// false positives on compositions whose GSAP duration is already close
|
|
// to data-duration.
|
|
rootDurationFloorSeconds >= rootDurationSeconds + 0.5
|
|
) {
|
|
const tlWithTo = rootTimeline as RuntimeTimelineLike & {
|
|
to?: (
|
|
target: object,
|
|
vars: { duration: number; data: string },
|
|
position: number,
|
|
) => unknown;
|
|
};
|
|
if (typeof tlWithTo.to === "function") {
|
|
try {
|
|
// Placing a zero-duration tween at the floor extends
|
|
// timeline.duration() to exactly that point.
|
|
tlWithTo.to({}, { duration: 0, data: RUNTIME_FILLER }, rootDurationFloorSeconds);
|
|
} catch (err) {
|
|
// keep runtime resilient
|
|
swallow("runtime.init.site6", err);
|
|
}
|
|
}
|
|
const newDur = getTimelineDurationSeconds(rootTimeline);
|
|
if (isUsableTimelineDuration(newDur)) {
|
|
return {
|
|
timeline: rootTimeline,
|
|
selectedTimelineIds: [rootCompositionId],
|
|
selectedDurationSeconds: newDur,
|
|
mediaDurationFloorSeconds,
|
|
diagnostics: {
|
|
code: "root_timeline_padded_to_declared_duration",
|
|
details: {
|
|
rootCompositionId,
|
|
rootDurationSeconds,
|
|
rootDeclaredDur,
|
|
authoredCompositionDurationFloorSeconds,
|
|
newDur,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
timeline: rootTimeline,
|
|
selectedTimelineIds: [rootCompositionId],
|
|
selectedDurationSeconds: rootDurationSeconds,
|
|
mediaDurationFloorSeconds,
|
|
diagnostics:
|
|
autoNestedChildren.length > 0
|
|
? {
|
|
code: "root_timeline_auto_nested_children",
|
|
details: {
|
|
rootCompositionId,
|
|
selectedDurationSeconds: rootDurationSeconds,
|
|
autoNestedChildren,
|
|
},
|
|
}
|
|
: undefined,
|
|
};
|
|
}
|
|
if (rootChildCandidates.length > 0) {
|
|
const selectedTimelineIds = rootChildCandidates.map((candidate) => candidate.compositionId);
|
|
// A rebuild re-parents every child at time 0 and paints it there, under a paused drag too,
|
|
// so the same children, each still at its host's start, keep the composite they sit in.
|
|
const held = missingRootComposite;
|
|
const reused =
|
|
held?.size === rootChildCandidates.length &&
|
|
isUsableTimelineDuration(getTimelineDurationSeconds(held.timeline)) &&
|
|
rootChildCandidates.every((candidate) => {
|
|
const placed = placedIn(held.timeline, candidate);
|
|
return placed !== null && atHostStart(placed, candidate);
|
|
})
|
|
? held.timeline
|
|
: null;
|
|
const compositeTimeline =
|
|
reused ?? createCompositeTimelineFromCandidates(rootChildCandidates);
|
|
const compositeDurationSeconds = getTimelineDurationSeconds(compositeTimeline);
|
|
if (compositeTimeline) {
|
|
if (!reused) {
|
|
missingRootComposite = { timeline: compositeTimeline, size: rootChildCandidates.length };
|
|
}
|
|
return {
|
|
timeline: compositeTimeline,
|
|
selectedTimelineIds,
|
|
selectedDurationSeconds: compositeDurationSeconds,
|
|
mediaDurationFloorSeconds,
|
|
diagnostics: {
|
|
code: "root_timeline_missing_fallback",
|
|
details: {
|
|
rootCompositionId,
|
|
fallbackKind: "composite_by_root_children",
|
|
minCandidateDurationSeconds,
|
|
selectedDurationSeconds: compositeDurationSeconds,
|
|
mediaDurationFloorSeconds,
|
|
selectedTimelineIds,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
}
|
|
return resolveSoleTimelineFallback("root_composition_id_unmatched_in_registry");
|
|
};
|
|
|
|
// Track whether child composition timelines have been added to the root.
|
|
// This prevents the polling loop from skipping rebind when TARGET_DURATION
|
|
// makes the root "usable" before children register. Assumption: child scripts
|
|
// must register timelines synchronously or in the immediate microtask queue
|
|
// (setTimeout(0)). Scripts using requestAnimationFrame or longer delays may
|
|
// not be discovered.
|
|
let childrenBound = false;
|
|
// A GSAP keyframes tween (`{ keyframes: {...}, ease }`) builds an INNER timeline
|
|
// whose own `_ease` GSAP resolves ONCE, at build time, via the internal
|
|
// `_parseEase(vars.ease)` (gsap-core: `tl._ease = _parseEase(keyframes.ease ||
|
|
// vars.ease || "none")`). On render it calls that inner `timeline._ease(...)`.
|
|
// The composition's inline `<script>` runs and builds these tweens BEFORE this
|
|
// runtime finishes registering the custom eases (hold/spring/wiggle/custom) in
|
|
// GSAP's internal ease map — so for a custom container ease the inner `_ease`
|
|
// bakes to `undefined`, and the first render throws "_ease is not a function"
|
|
// (a masked cross-origin Script error). Registering the eases afterward can't
|
|
// retro-fix that already-baked value, so re-resolve every keyframes tween's
|
|
// inner `_ease` here, once the eases are registered.
|
|
const repairKeyframeInnerEase = (tlLike: unknown): void => {
|
|
const g = (window as unknown as { gsap?: { parseEase?: (e: unknown) => unknown } }).gsap;
|
|
const tl = tlLike as { getChildren?: (a: boolean, b: boolean, c: boolean) => unknown[] } | null;
|
|
if (!tl || typeof tl.getChildren !== "function" || !g || typeof g.parseEase !== "function")
|
|
return;
|
|
for (const child of tl.getChildren(true, true, true)) {
|
|
const k = child as {
|
|
timeline?: { _ease?: unknown };
|
|
vars?: { ease?: unknown; keyframes?: unknown };
|
|
};
|
|
const inner = k.timeline;
|
|
if (!inner || !("_ease" in inner) || typeof inner._ease === "function") continue;
|
|
const kf = k.vars?.keyframes;
|
|
const kfEase = kf && !Array.isArray(kf) ? (kf as { ease?: unknown }).ease : undefined;
|
|
const ease = kfEase ?? k.vars?.ease ?? "none";
|
|
try {
|
|
const resolved = g.parseEase(ease);
|
|
if (typeof resolved === "function") inner._ease = resolved;
|
|
} catch (err) {
|
|
emitAnalyticsEvent("keyframe_ease_repair_failed", {
|
|
ease: typeof ease === "string" ? ease : String(ease),
|
|
});
|
|
swallow("runtime.keyframeEase.repair", err);
|
|
}
|
|
}
|
|
};
|
|
// fallow-ignore-next-line complexity
|
|
const bindRootTimelineIfAvailable = (): boolean => {
|
|
// Custom eases (hold/spring/wiggle/custom) must be registered in GSAP's
|
|
// internal ease map BEFORE this function's prime render (progress/totalTime
|
|
// below), or a keyframe segment using one resolves to a non-function ease
|
|
// and GSAP throws "_ease is not a function" at render. The one-shot call in
|
|
// init runs early, but if GSAP wasn't ready then (load-order race) it's a
|
|
// no-op with no retry — so re-assert here, at the render site. Idempotent.
|
|
ensureStudioCustomEase();
|
|
if (!externalCompositionsReady) return false;
|
|
const currentTimeline = state.capturedTimeline;
|
|
const currentDuration = getTimelineDurationSeconds(currentTimeline);
|
|
const currentTimelineUsable = isUsableTimelineDuration(currentDuration);
|
|
// Skip rebind ONLY if we already have a usable timeline AND children have been bound.
|
|
// Without childrenBound check, the TARGET_DURATION spacer makes the timeline "usable"
|
|
// before child composition timelines are added, causing them to never be discovered.
|
|
if (currentTimeline && currentTimelineUsable && childrenBound) return false;
|
|
const resolution = resolveRootTimelineFromDocument();
|
|
if (!resolution.timeline) return false;
|
|
if (currentTimeline && currentTimeline === resolution.timeline) {
|
|
if (typeof currentTimeline.timeScale === "function") {
|
|
currentTimeline.timeScale(state.playbackRate);
|
|
}
|
|
return false;
|
|
}
|
|
state.capturedTimeline = resolution.timeline;
|
|
if (typeof state.capturedTimeline.timeScale === "function") {
|
|
state.capturedTimeline.timeScale(state.playbackRate);
|
|
}
|
|
// Repair keyframe inner-timeline eases before any prime render (see helper above).
|
|
repairKeyframeInnerEase(state.capturedTimeline);
|
|
const boundDuration = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
|
|
if (boundDuration <= 0) {
|
|
// No resolvable duration (e.g. a set()-only timeline, or one whose
|
|
// duration isn't known yet). Kick GSAP off the creation position so the
|
|
// set() renders. For a finite-but-zero timeline progress(1) === progress(0);
|
|
// for an infinite-repeat timeline this lands on the first iteration's end
|
|
// frame, which is the best we can do without a known cycle length.
|
|
if (typeof state.capturedTimeline.progress === "function") {
|
|
state.capturedTimeline.progress(1, true);
|
|
state.capturedTimeline.progress(0, false);
|
|
pauseTimelineIfPossible(state.capturedTimeline);
|
|
}
|
|
}
|
|
if (boundDuration > 0) {
|
|
try {
|
|
clock.setDuration(boundDuration);
|
|
} catch {
|
|
// clock not yet initialized — duration will be set during TransportClock setup
|
|
}
|
|
|
|
if (typeof state.capturedTimeline.totalTime === "function") {
|
|
// GSAP won't render tl.set() at position 0 when the paused timeline
|
|
// starts there — play/pause/seek/totalTime are all no-ops at the
|
|
// creation position. Force the set to render by cycling progress past
|
|
// 0 (when the timeline implements it), then seek to the prior playhead
|
|
// (state.currentTime) so a rebind after a user scrub or soft-reload
|
|
// restore doesn't snap back to 0.
|
|
if (typeof state.capturedTimeline.progress === "function") {
|
|
state.capturedTimeline.progress(0.0001, true);
|
|
}
|
|
const seekTime = Math.max(0, state.currentTime || 0);
|
|
state.capturedTimeline.totalTime(seekTime, false);
|
|
pauseTimelineIfPossible(state.capturedTimeline);
|
|
}
|
|
|
|
// GSAP bakes the CSS `translate` into style.transform on seek.
|
|
// The Studio seek wrapper (installStudioManualEditSeekReapply) calls
|
|
// reapplyPositionEditsAfterSeek to un-bake it. Call the apply hook
|
|
// directly here as well, since the wrapper may not be installed yet
|
|
// during initial rebind (timing race on first load / soft reload).
|
|
const applyFn = (window as unknown as Record<string, unknown>).__hfStudioManualEditsApply;
|
|
if (typeof applyFn === "function") applyFn();
|
|
|
|
// SDK moveElement edits (data-hf-edit-base-x/y markers) render as a
|
|
// CSS translate delta. Must run after the timeline is bound so GSAP has
|
|
// already parsed the elements — a translate present at first parse gets
|
|
// folded into the cached transform and lost per-axis on seek.
|
|
applyPositionEdits(document);
|
|
}
|
|
if (resolution.diagnostics) {
|
|
postRuntimeMessage({
|
|
source: "hf-preview",
|
|
type: "diagnostic",
|
|
code: resolution.diagnostics.code,
|
|
details: resolution.diagnostics.details,
|
|
});
|
|
}
|
|
postRuntimeMessage({
|
|
source: "hf-preview",
|
|
type: "diagnostic",
|
|
code: "timeline_bound",
|
|
details: {
|
|
selectedTimelineIds: resolution.selectedTimelineIds ?? [],
|
|
selectedDurationSeconds: resolution.selectedDurationSeconds ?? null,
|
|
mediaDurationFloorSeconds: resolution.mediaDurationFloorSeconds ?? null,
|
|
},
|
|
});
|
|
// Stamp data-start / data-duration on GSAP-targeted elements that lack
|
|
// them so the Studio timeline can discover individual animated elements.
|
|
// Only when embedded in an iframe (Studio preview) — production renders
|
|
// run as the top-level page and must not mutate element timing.
|
|
if (stampsForStudio) {
|
|
const rootComp = resolveRootCompositionElement();
|
|
const rootDuration = boundDuration > 0 ? boundDuration : 0;
|
|
const dur = String(rootDuration > 0 ? rootDuration : 1);
|
|
const seen = new Set<Element>();
|
|
|
|
// Only an AUTHORED clip (data-start already in the source, captured before
|
|
// we stamp anything) should suppress stamping its descendants. An animated
|
|
// scene container we auto-stamp below (e.g. an opacity-crossfaded scene)
|
|
// must NOT suppress its own animated children — otherwise those children
|
|
// never become timeline clips and that scene can't inline-expand.
|
|
// A bus is not a clip. `<hf-audio-group>` carries a group's label, fader,
|
|
// mute and FX chain and has no timing of its own, so stamping it put it in
|
|
// `__clipManifest` as a full-duration element — which the studio drew as an
|
|
// ordinary clip row above the real group header. That row was draggable,
|
|
// trimmable and deletable, and deleting it removed the bus, taking the
|
|
// group's automation lanes and FX rack with it.
|
|
const isAudioGroupBus = (el: Element): boolean =>
|
|
el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG;
|
|
const authoredTimed = new Set<Element>(document.querySelectorAll("[data-start]"));
|
|
const hasAuthoredTimedAncestor = (element: HTMLElement): boolean => {
|
|
let node = element.parentElement;
|
|
while (node && node !== rootComp) {
|
|
if (authoredTimed.has(node)) return true;
|
|
node = node.parentElement;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
// Stamp GSAP-targeted elements
|
|
if (state.capturedTimeline.getChildren) {
|
|
try {
|
|
for (const child of state.capturedTimeline.getChildren(true)) {
|
|
if (typeof child.targets !== "function") continue;
|
|
for (const target of child.targets()) {
|
|
if (!isHtmlElement(target)) continue;
|
|
if (target === rootComp) continue;
|
|
if (isAudioGroupBus(target)) continue;
|
|
if (target.hasAttribute("data-start")) continue;
|
|
if (hasAuthoredTimedAncestor(target)) continue;
|
|
if (seen.has(target)) continue;
|
|
seen.add(target);
|
|
target.setAttribute("data-start", "0");
|
|
target.setAttribute("data-duration", dur);
|
|
// Mark as runtime-stamped so applyClipLayout leaves it in document
|
|
// flow instead of treating it as an authored overlay clip.
|
|
target.setAttribute(RUNTIME_STAMPED_ATTR, "1");
|
|
}
|
|
}
|
|
} catch {
|
|
/* timeline access guard */
|
|
}
|
|
}
|
|
|
|
// Stamp all ID'd children of the composition root so they appear
|
|
// in the timeline even without animations. Enables selecting and
|
|
// adding animations from the design panel on a blank canvas.
|
|
if (isHtmlElement(rootComp)) {
|
|
for (const el of rootComp.querySelectorAll("[id]")) {
|
|
if (!isHtmlElement(el)) continue;
|
|
if (el === rootComp) continue;
|
|
if (el.hasAttribute("data-start")) continue;
|
|
if (hasAuthoredTimedAncestor(el)) continue;
|
|
if (seen.has(el)) continue;
|
|
if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || el.tagName === "LINK") continue;
|
|
if (isAudioGroupBus(el)) continue;
|
|
seen.add(el);
|
|
el.setAttribute("data-start", "0");
|
|
el.setAttribute("data-duration", dur);
|
|
// Mark as runtime-stamped so applyClipLayout leaves it in document
|
|
// flow instead of treating it as an authored overlay clip.
|
|
el.setAttribute(RUNTIME_STAMPED_ATTR, "1");
|
|
}
|
|
}
|
|
}
|
|
|
|
// (Re-)probe all already-bound media elements against the new timeline.
|
|
// Clear the cache first so elements probed against a prior timeline get fresh keyframes.
|
|
for (const el of metadataBoundMedia) {
|
|
volumeKeyframeCache.delete(el);
|
|
probeAndCacheVolumeKeyframes(el);
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const reconcileTimeline = () => {
|
|
if (state.tornDown) return;
|
|
const resolution = resolveRootTimelineFromDocument();
|
|
if (!resolution.timeline) {
|
|
// A successful clear must not leave the player seeking a killed timeline.
|
|
state.capturedTimeline = null;
|
|
childrenBound = false;
|
|
clock.setDuration(0);
|
|
syncTimedElementVisibility(state.currentTime);
|
|
return;
|
|
}
|
|
|
|
// Avoid needlessly invalidating the child-binding cache when a handler
|
|
// updates data in place. A replacement object is the signal that a rebind
|
|
// is required.
|
|
if (state.capturedTimeline !== resolution.timeline) {
|
|
childrenBound = false;
|
|
bindRootTimelineIfAvailable();
|
|
} else {
|
|
const length = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
|
|
if (length > 0 && length !== clock.getDuration()) {
|
|
clock.setDuration(length);
|
|
postTimeline();
|
|
}
|
|
}
|
|
syncTimedElementVisibility(state.currentTime);
|
|
};
|
|
reconcileTimelineAfterRuntimeData = () => {
|
|
reconcileTimeline();
|
|
// The parent treats runtime-data-applied as permission to re-seek immediately. Publish the
|
|
// replacement duration first; otherwise that seek is clamped by the bootstrap timeline (often
|
|
// one second) and a style switch appears frozen on the first caption segment until some later
|
|
// polling tick happens to post the rebuilt timeline.
|
|
postTimeline();
|
|
};
|
|
(window as Window & { __hfForceTimelineRebind?: () => void }).__hfForceTimelineRebind =
|
|
reconcileTimeline;
|
|
|
|
const emitRootStageLayoutDiagnostics = () => {
|
|
const rootNode = resolveRootCompositionElement();
|
|
if (!isHtmlElement(rootNode)) {
|
|
return;
|
|
}
|
|
const rect = rootNode.getBoundingClientRect();
|
|
const declaredWidth = parseLayoutDimension(rootNode.getAttribute("data-width"));
|
|
const declaredHeight = parseLayoutDimension(rootNode.getAttribute("data-height"));
|
|
const computedStyle = window.getComputedStyle(rootNode);
|
|
const hasDeclaredDimensions = declaredWidth !== null && declaredHeight !== null;
|
|
const looksCollapsed =
|
|
rect.width <= 0 ||
|
|
rect.height <= 0 ||
|
|
rootNode.clientWidth <= 0 ||
|
|
rootNode.clientHeight <= 0;
|
|
if (!hasDeclaredDimensions || !looksCollapsed) {
|
|
return;
|
|
}
|
|
postRuntimeDiagnosticOnce(
|
|
"root_stage_layout_zero",
|
|
{
|
|
compositionId: rootNode.getAttribute("data-composition-id") ?? null,
|
|
declaredWidth,
|
|
declaredHeight,
|
|
rectWidth: Math.round(rect.width),
|
|
rectHeight: Math.round(rect.height),
|
|
clientWidth: rootNode.clientWidth,
|
|
clientHeight: rootNode.clientHeight,
|
|
display: computedStyle.display,
|
|
visibility: computedStyle.visibility,
|
|
overflow: computedStyle.overflow,
|
|
},
|
|
`root-stage-layout-zero:${rootNode.getAttribute("data-composition-id") ?? "unknown"}`,
|
|
);
|
|
};
|
|
|
|
const scheduleRootStageLayoutDiagnostics = () => {
|
|
if (state.tornDown) {
|
|
return;
|
|
}
|
|
if (rootStageDiagnosticRafId != null) {
|
|
window.cancelAnimationFrame(rootStageDiagnosticRafId);
|
|
}
|
|
rootStageDiagnosticRafId = window.requestAnimationFrame(() => {
|
|
rootStageDiagnosticRafId = null;
|
|
emitRootStageLayoutDiagnostics();
|
|
});
|
|
};
|
|
|
|
const installRuntimeErrorDiagnostics = () => {
|
|
runtimeErrorListener = (event: ErrorEvent) => {
|
|
const normalized = normalizeDiagnosticMessage(event.error ?? event.message).slice(
|
|
0,
|
|
MAX_DIAGNOSTIC_MESSAGE_LENGTH,
|
|
);
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
const classified = classifyRuntimeScriptFailure(normalized);
|
|
postRuntimeMessage({
|
|
source: "hf-preview",
|
|
type: "diagnostic",
|
|
code: classified.code,
|
|
details: {
|
|
category: classified.category,
|
|
message: normalized,
|
|
filename: event.filename || null,
|
|
line: Number.isFinite(event.lineno) ? event.lineno : null,
|
|
column: Number.isFinite(event.colno) ? event.colno : null,
|
|
},
|
|
});
|
|
};
|
|
runtimeUnhandledRejectionListener = (event: PromiseRejectionEvent) => {
|
|
const normalized = normalizeDiagnosticMessage(event.reason).slice(
|
|
0,
|
|
MAX_DIAGNOSTIC_MESSAGE_LENGTH,
|
|
);
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
const classified = classifyRuntimeScriptFailure(normalized);
|
|
postRuntimeMessage({
|
|
source: "hf-preview",
|
|
type: "diagnostic",
|
|
code: `${classified.code}_unhandled_rejection`,
|
|
details: {
|
|
category: `${classified.category}-unhandled-rejection`,
|
|
message: normalized,
|
|
},
|
|
});
|
|
};
|
|
window.addEventListener("error", runtimeErrorListener);
|
|
window.addEventListener("unhandledrejection", runtimeUnhandledRejectionListener);
|
|
};
|
|
|
|
const assetNodesWithDiagnostics = new WeakSet<Element>();
|
|
const installAssetFailureDiagnostics = () => {
|
|
const assetNodes = Array.from(
|
|
document.querySelectorAll("img, video, audio, source, link[rel='stylesheet']"),
|
|
);
|
|
for (const node of assetNodes) {
|
|
if (assetNodesWithDiagnostics.has(node)) continue;
|
|
assetNodesWithDiagnostics.add(node);
|
|
const onError = () => {
|
|
if (!isElementNode(node)) {
|
|
return;
|
|
}
|
|
const tagName = node.tagName.toLowerCase();
|
|
const assetUrl =
|
|
node.getAttribute("src") ??
|
|
node.getAttribute("href") ??
|
|
node.getAttribute("poster") ??
|
|
null;
|
|
const diagnosticCode =
|
|
tagName === "link" ? "runtime_stylesheet_load_failed" : "runtime_asset_load_failed";
|
|
postRuntimeDiagnosticOnce(
|
|
diagnosticCode,
|
|
{
|
|
tagName,
|
|
assetUrl,
|
|
currentSrc:
|
|
isImageElement(node) || isMediaElement(node) ? node.currentSrc || null : null,
|
|
readyState: isMediaElement(node) ? node.readyState : null,
|
|
networkState: isMediaElement(node) ? node.networkState : null,
|
|
},
|
|
`${diagnosticCode}:${tagName}:${assetUrl ?? "unknown"}`,
|
|
);
|
|
};
|
|
node.addEventListener("error", onError);
|
|
registerRuntimeCleanup(() => {
|
|
node.removeEventListener("error", onError);
|
|
});
|
|
}
|
|
|
|
const fontSet = document.fonts;
|
|
if (!fontSet) {
|
|
return;
|
|
}
|
|
void fontSet.ready
|
|
.then(() => {
|
|
if (state.tornDown) {
|
|
return;
|
|
}
|
|
const failedFamilies = Array.from(fontSet)
|
|
.filter((face) => face.status === "error")
|
|
.map((face) => face.family)
|
|
.filter((family) => Boolean(family))
|
|
.slice(0, 10);
|
|
if (failedFamilies.length === 0) {
|
|
return;
|
|
}
|
|
postRuntimeDiagnosticOnce(
|
|
"runtime_font_load_issue",
|
|
{
|
|
failedFamilies,
|
|
totalFaces: Array.from(fontSet).length,
|
|
},
|
|
`runtime-font-load-issue:${failedFamilies.join("|")}`,
|
|
);
|
|
})
|
|
.catch(() => {
|
|
// ignore font readiness failures
|
|
});
|
|
};
|
|
|
|
const rebindTimelineFromResolution = (
|
|
resolution: TimelineResolution,
|
|
reason: "loop_guard" | "manual",
|
|
): boolean => {
|
|
if (!resolution.timeline) return false;
|
|
const previousTimeline = state.capturedTimeline;
|
|
if (previousTimeline && previousTimeline === resolution.timeline) {
|
|
return false;
|
|
}
|
|
const previousTime = Math.max(0, state.currentTime || 0);
|
|
state.capturedTimeline = resolution.timeline;
|
|
if (typeof state.capturedTimeline.timeScale === "function") {
|
|
state.capturedTimeline.timeScale(state.playbackRate);
|
|
}
|
|
try {
|
|
// pause guarded separately: a PARTIAL timeline without pause() must not
|
|
// abort restoring the transport time below (the catch would swallow it too).
|
|
pauseTimelineIfPossible(state.capturedTimeline);
|
|
if (typeof state.capturedTimeline.seek === "function") {
|
|
state.capturedTimeline.seek(previousTime, false);
|
|
}
|
|
} catch (err) {
|
|
// keep runtime resilient even if a timeline implementation throws
|
|
swallow("runtime.init.site7", err);
|
|
}
|
|
postRuntimeMessage({
|
|
source: "hf-preview",
|
|
type: "diagnostic",
|
|
code: "timeline_loop_guard_rebind",
|
|
details: {
|
|
reason,
|
|
previousTime,
|
|
selectedTimelineIds: resolution.selectedTimelineIds ?? [],
|
|
selectedDurationSeconds: resolution.selectedDurationSeconds ?? null,
|
|
mediaDurationFloorSeconds: resolution.mediaDurationFloorSeconds ?? null,
|
|
},
|
|
});
|
|
return true;
|
|
};
|
|
|
|
let metadataRebindDebounceTimerId: number | null = null;
|
|
let metadataRebindApplied = false;
|
|
const metadataBoundMedia = new Set<HTMLMediaElement>();
|
|
const volumeKeyframeCache = new WeakMap<HTMLMediaElement, VolumeKeyframe[]>();
|
|
|
|
const scheduleMetadataDurationHydration = () => {
|
|
if (state.tornDown) return;
|
|
if (metadataRebindDebounceTimerId != null) {
|
|
window.clearTimeout(metadataRebindDebounceTimerId);
|
|
}
|
|
metadataRebindDebounceTimerId = window.setTimeout(() => {
|
|
if (state.tornDown) return;
|
|
metadataRebindDebounceTimerId = null;
|
|
// The render/producer capture protocol drives frames deterministically
|
|
// via renderSeek — once it has claimed the timeline, an async
|
|
// loadedmetadata/durationchange rebind racing that loop is exactly the
|
|
// "double composite" hazard from HF#2550: this handler runs off its own
|
|
// debounced browser-side timer, uncoordinated with the capture loop's
|
|
// own seeks, so a rebind mid-capture can reflow the DOM between one
|
|
// BeginFrame call and the next. Render-mode duration correction has
|
|
// already happened deterministically during the probe stage before
|
|
// capture starts, so once frames are being driven there is nothing left
|
|
// for this self-correction to usefully do.
|
|
//
|
|
// Gated on the pair rather than renderCaptureSeekStarted alone, because
|
|
// the flag alone would silently disable this self-correction for a live
|
|
// Studio scrub too, where duration hasn't been pre-resolved by a probe
|
|
// stage and still needs it. See isExportRenderDrivingFrames.
|
|
if (isExportRenderDrivingFrames()) return;
|
|
const resolution = resolveRootTimelineFromDocument();
|
|
if (!resolution.timeline) return;
|
|
const hasResolvedMediaFloor = isUsableTimelineDuration(
|
|
resolution.mediaDurationFloorSeconds ?? null,
|
|
);
|
|
if (!hasResolvedMediaFloor) return;
|
|
if (!state.capturedTimeline) {
|
|
if (bindRootTimelineIfAvailable()) {
|
|
postTimeline();
|
|
postState(true);
|
|
}
|
|
return;
|
|
}
|
|
if (metadataRebindApplied) return;
|
|
const currentDuration = getTimelineDurationSeconds(state.capturedTimeline);
|
|
const nextDuration =
|
|
resolution.selectedDurationSeconds ?? getTimelineDurationSeconds(resolution.timeline);
|
|
const isBetterCandidate =
|
|
isUsableTimelineDuration(nextDuration) &&
|
|
(!isUsableTimelineDuration(currentDuration) ||
|
|
nextDuration >= currentDuration + METADATA_REBIND_MIN_DURATION_GAIN_SECONDS);
|
|
if (!isBetterCandidate) return;
|
|
if (rebindTimelineFromResolution(resolution, "manual")) {
|
|
metadataRebindApplied = true;
|
|
postRuntimeMessage({
|
|
source: "hf-preview",
|
|
type: "diagnostic",
|
|
code: "timeline_rebind_after_media_metadata",
|
|
details: {
|
|
previousDurationSeconds: currentDuration ?? null,
|
|
selectedDurationSeconds: nextDuration ?? null,
|
|
selectedTimelineIds: resolution.selectedTimelineIds ?? [],
|
|
mediaDurationFloorSeconds: resolution.mediaDurationFloorSeconds ?? null,
|
|
},
|
|
});
|
|
postTimeline();
|
|
postState(true);
|
|
}
|
|
}, METADATA_REBIND_DEBOUNCE_MS);
|
|
};
|
|
|
|
// Reactive/tertiary undecodable-media triggers (see mediaProxy.ts). Wrapped
|
|
// as event listeners here — rather than exported directly — because
|
|
// `addEventListener` hands the listener an `Event`, not the element;
|
|
// `event.currentTarget` recovers it. Bound/unbound alongside the metadata
|
|
// listeners below, reusing `metadataBoundMedia` as the once-per-element
|
|
// dedupe (no separate tracking set needed).
|
|
const onMediaLoadedMetadataForProxy = (event: Event) => {
|
|
if (isMediaElement(event.currentTarget)) {
|
|
handleMetadataForProxy(event.currentTarget);
|
|
}
|
|
};
|
|
const onMediaErrorForProxy = (event: Event) => {
|
|
if (isMediaElement(event.currentTarget)) {
|
|
handleErrorForProxy(event.currentTarget);
|
|
}
|
|
};
|
|
|
|
// Only media `joinsWebAudio` admits when a play is scheduled reaches
|
|
// `createMediaElementSource`, so no other cross-origin media may be reported as if it did.
|
|
const reportWebAudioRoute = (mediaEl: HTMLMediaElement) => {
|
|
if (!joinsWebAudio(mediaEl)) return;
|
|
// Before resource selection settles, the verdict is built from `<source>`
|
|
// children the browser might still pass over — good enough for the
|
|
// schedule path's conservative withhold, not good enough to put in front
|
|
// of a human as a diagnostic. Skip; the `loadedmetadata` call to this same
|
|
// function (see below) always has a settled `currentSrc` and will report
|
|
// for real once the guess would no longer be one.
|
|
if (!isRouteSelectionSettled(mediaEl)) return;
|
|
reportWebAudioMediaRoute(mediaEl, classifyWebAudioMediaRoute(mediaEl));
|
|
};
|
|
|
|
const onMediaLoadedMetadataForRoute = (event: Event) => {
|
|
const target = event.currentTarget;
|
|
if (isMediaElement(target)) reportWebAudioRoute(target);
|
|
};
|
|
|
|
const unbindMedia = (mediaEl: HTMLMediaElement) => {
|
|
mediaEl.removeEventListener("loadedmetadata", scheduleMetadataDurationHydration);
|
|
mediaEl.removeEventListener("durationchange", scheduleMetadataDurationHydration);
|
|
mediaEl.removeEventListener("loadedmetadata", onMediaLoadedMetadataForProxy);
|
|
mediaEl.removeEventListener("loadedmetadata", onMediaLoadedMetadataForRoute);
|
|
mediaEl.removeEventListener("error", onMediaErrorForProxy);
|
|
metadataBoundMedia.delete(mediaEl);
|
|
};
|
|
const unbindMediaMetadataListeners = () => {
|
|
for (const mediaEl of metadataBoundMedia) unbindMedia(mediaEl);
|
|
};
|
|
// A swapped-out scene's media is detached but still buffering: stop it and drop its sources.
|
|
const releaseDetachedMedia = () => {
|
|
for (const mediaEl of metadataBoundMedia) {
|
|
if (mediaEl.isConnected) continue;
|
|
unbindMedia(mediaEl);
|
|
mediaEl.pause();
|
|
stopMediaDownload(mediaEl);
|
|
}
|
|
};
|
|
|
|
const authoredMediaVolume = (el: HTMLMediaElement): number | null => {
|
|
const parsed = Number.parseFloat(el.dataset.volume ?? "");
|
|
return Number.isFinite(parsed) ? Math.max(0, Math.min(1, parsed)) : null;
|
|
};
|
|
|
|
const bindMediaMetadataListeners = () => {
|
|
if (state.tornDown) return;
|
|
const mediaEls = Array.from(document.querySelectorAll("video, audio")) as HTMLMediaElement[];
|
|
const nearPlayheadPending: HTMLMediaElement[] = [];
|
|
for (const mediaEl of mediaEls) {
|
|
if (metadataBoundMedia.has(mediaEl)) continue;
|
|
metadataBoundMedia.add(mediaEl);
|
|
const volume = authoredMediaVolume(mediaEl);
|
|
if (volume !== null) mediaEl.volume = volume;
|
|
mediaEl.addEventListener("loadedmetadata", scheduleMetadataDurationHydration);
|
|
mediaEl.addEventListener("durationchange", scheduleMetadataDurationHydration);
|
|
// Web Audio eligibility, reported at DISCOVERY rather than only at
|
|
// schedule time. `hyperframes check` seeks, it never calls play(), so a
|
|
// diagnostic raised from the transport would be invisible to the one
|
|
// gate whose job is to surface exactly this class of silent failure.
|
|
// Bound twice on purpose: now, for a `src`/committed-`currentSrc`
|
|
// element so a composition that never plays still reports promptly, and
|
|
// again at `loadedmetadata`, when `currentSrc` is unconditionally
|
|
// authoritative. `reportWebAudioRoute` itself skips the "now" call when
|
|
// selection hasn't settled (see `isRouteSelectionSettled`) — with only
|
|
// `<source>` children to go on, the browser could still pick a
|
|
// different one than the classifier just judged, and a diagnostic is a
|
|
// claim of fact, not a guess. `reportWebAudioMediaRoute` latches per
|
|
// element, so the deferred-to-`loadedmetadata` case still reports once.
|
|
mediaEl.addEventListener("loadedmetadata", onMediaLoadedMetadataForRoute);
|
|
reportWebAudioRoute(mediaEl);
|
|
// Reactive (zero-videoWidth) + tertiary (error event) proxy-fallback
|
|
// triggers. Inert in render mode / when the codec map is absent /
|
|
// for <audio> — all guarded inside mediaProxy.ts itself.
|
|
mediaEl.addEventListener("loadedmetadata", onMediaLoadedMetadataForProxy);
|
|
mediaEl.addEventListener("error", onMediaErrorForProxy);
|
|
|
|
// Proactive proxy-fallback trigger: consult the codec map and swap
|
|
// BEFORE the eager load() below, so a known-hostile asset never even
|
|
// attempts to load (and error-flash) the original. No-op in render
|
|
// mode, for <audio>, or when the codec map is absent.
|
|
maybeProxyProactively(mediaEl);
|
|
|
|
// Studio's preview loads a timed clip only near the playhead; the visibility pass decides.
|
|
if (inPreloadWindow(mediaEl)) nearPlayheadPending.push(mediaEl);
|
|
else preloadMedia(mediaEl);
|
|
|
|
// Probe volume automation from the GSAP timeline — same approach as the
|
|
// renderer (see discoverAudioVolumeAutomationFromTimeline / audioMixer).
|
|
// Runs only when the timeline is already captured; elements bound before
|
|
// the timeline is ready are re-probed the first time bindMediaMetadataListeners
|
|
// fires after the timeline has been captured (every 30 transport ticks).
|
|
probeAndCacheVolumeKeyframes(mediaEl);
|
|
}
|
|
if (nearPlayheadPending.length > 0)
|
|
syncTimedElementVisibility(state.currentTime, nearPlayheadPending);
|
|
};
|
|
|
|
const probeAndCacheVolumeKeyframes = (mediaEl: HTMLMediaElement) => {
|
|
if (volumeKeyframeCache.has(mediaEl)) return;
|
|
probeAndCacheElementVolume(
|
|
mediaEl,
|
|
state.capturedTimeline,
|
|
getSafeTimelineDurationSeconds(state.capturedTimeline, 0),
|
|
volumeKeyframeCache,
|
|
{
|
|
allowLiveTimelineSeek: !(window as Window & { __HF_RENDER_CAPTURE_MODE?: boolean })
|
|
.__HF_RENDER_CAPTURE_MODE,
|
|
},
|
|
);
|
|
};
|
|
|
|
// fallow-ignore-next-line complexity
|
|
// Whether a timed clip participates in normal flow (static/relative/sticky).
|
|
// In-flow clips must leave the flow when hidden — `visibility:hidden` reserves
|
|
// their layout box, so a split sibling would stack below the active half
|
|
// instead of overlapping it. Positioned clips keep `visibility:hidden` (cheaper,
|
|
// and avoids disturbing absolute media playback). Computed once per element.
|
|
let timedClipInFlow = new WeakMap<Element, boolean>();
|
|
const isTimedClipInFlow = (el: HTMLElement): boolean => {
|
|
const cached = timedClipInFlow.get(el);
|
|
if (cached !== undefined) return cached;
|
|
const pos = window.getComputedStyle(el).position;
|
|
const inFlow = pos === "static" || pos === "relative" || pos === "sticky";
|
|
timedClipInFlow.set(el, inFlow);
|
|
return inFlow;
|
|
};
|
|
|
|
// `display:none` is only safe on a LEAF timed clip (no nested timed clips). On a
|
|
// container it removes the whole subtree, hiding descendants that are still inside
|
|
// their OWN visibility window — e.g. an in-flow composition root whose window
|
|
// clamps to the timeline end would black out a child video that should still
|
|
// show. `visibility:hidden` doesn't have this problem (a child can override it
|
|
// with `visibility:visible`), so containers keep that and only leaves leave-flow.
|
|
let timedClipIsLeaf = new WeakMap<Element, boolean>();
|
|
const isTimedClipLeaf = (el: HTMLElement): boolean => {
|
|
const cached = timedClipIsLeaf.get(el);
|
|
if (cached !== undefined) return cached;
|
|
const leaf = el.querySelector("[data-start]") === null;
|
|
timedClipIsLeaf.set(el, leaf);
|
|
return leaf;
|
|
};
|
|
|
|
// Both caches key on live DOM facts that change when the timed-element set
|
|
// changes: leaf status flips when a clip gains/loses a nested `[data-start]`
|
|
// descendant (sub-composition load/unload, studio insert/delete), and a swapped
|
|
// element can reuse an identity whose in-flow status differs. WeakMap has no
|
|
// `clear()`, so drop both maps wholesale — re-derived lazily on next access.
|
|
const invalidateTimedClipCaches = () => {
|
|
timedClipInFlow = new WeakMap<Element, boolean>();
|
|
timedClipIsLeaf = new WeakMap<Element, boolean>();
|
|
};
|
|
|
|
// The author's inline display (value and priority) under each `display:none` the
|
|
// visibility pass applied, so showing the element puts exactly that back. Keyed on
|
|
// what was applied, not on `isTimedClipInFlow`: that answer can flip between the hide
|
|
// and the show pass once `applyClipLayout` force-absolutizes the clip.
|
|
const displayBeforeHide = new WeakMap<HTMLElement, { value: string; priority: string }>();
|
|
const hideByDisplay = (el: HTMLElement, plainNoneMayBeLeftover: boolean) => {
|
|
if (!displayBeforeHide.has(el)) {
|
|
const value = el.style.getPropertyValue("display");
|
|
const priority = el.style.getPropertyPriority("display");
|
|
// On the timed hide, a plain none may be a hide left behind (a Studio reveal restoring ours).
|
|
const isLeftoverHide = plainNoneMayBeLeftover && value === "none" && !priority;
|
|
displayBeforeHide.set(el, isLeftoverHide ? { value: "", priority: "" } : { value, priority });
|
|
}
|
|
el.style.display = "none";
|
|
};
|
|
const restoreDisplay = (el: HTMLElement) => {
|
|
const before = displayBeforeHide.get(el);
|
|
if (!before) return;
|
|
displayBeforeHide.delete(el);
|
|
if (before.value) el.style.setProperty("display", before.value, before.priority);
|
|
else el.style.removeProperty("display");
|
|
};
|
|
const dataHiddenDisplayNodes = new WeakSet<HTMLElement>();
|
|
// A data-hidden toggle on (or affecting) an audio element must re-schedule
|
|
// WebAudio playback so the hidden clip's source is dropped/restored mid-
|
|
// playback. Batched to one call per syncTimedElementVisibility pass, not
|
|
// one per toggled node.
|
|
//
|
|
// The reschedule is paired with `stopAll()` below, for the reason
|
|
// `applyWebAudioRate` already spells out: scheduling does
|
|
// NOT replace the active set. It bumps a generation, which only rejects
|
|
// stale schedules still in flight — every source already started keeps
|
|
// playing, and there is no per-element dedup. This comment used to claim the
|
|
// opposite and the call site trusted it, so muting a track mid-playback
|
|
// started a second buffer source for every in-window clip on top of the ones
|
|
// still sounding: the whole mix audibly doubled, slightly out of phase.
|
|
let hiddenAudioDirty = false;
|
|
const affectsAudio = (el: Element): boolean =>
|
|
isMediaElement(el) &&
|
|
el.hasAttribute("data-start") &&
|
|
(joinsWebAudio(el) || webAudio.routesElement(el));
|
|
const nodeAffectsAudio = (node: HTMLElement): boolean =>
|
|
affectsAudio(node) || Array.from(node.querySelectorAll(WEB_AUDIO_MEDIA)).some(affectsAudio);
|
|
|
|
// An `<hf-audio-group>` carries no `data-start`, so it is never among
|
|
// `visibilityNodes` above — group mute needs its own small diff pass.
|
|
// Preview-side only (render reads the group's `data-hidden` directly at
|
|
// export time, per B4); this just keeps the live WebAudio group bus in
|
|
// sync with a `data-hidden` toggle made mid-playback.
|
|
const groupHiddenLast = new WeakMap<Element, boolean>();
|
|
const groupHasUncapturedMember = (groupId: string, currentTime: number): boolean => {
|
|
for (const el of webAudioMediaIn(document)) {
|
|
if (audioGroupOf(el) !== groupId) continue;
|
|
if (webAudio.routesElement(el) || isSilencedByHidden(el)) continue;
|
|
const start = resolveAbsoluteMediaStartSeconds(el);
|
|
const duration = parseStrictFiniteTimingNumber(el.dataset.duration);
|
|
const end = duration != null && duration > 0 ? start + duration : Infinity;
|
|
if (Number.isFinite(start) && currentTime < end) return true;
|
|
}
|
|
return false;
|
|
};
|
|
/** The bus gain owns a group's mute; true when an unmute leaves a member still to play outside the graph. */
|
|
const syncAudioGroupMute = (currentTime: number): boolean => {
|
|
let needsCapture = false;
|
|
for (const groupEl of document.querySelectorAll(HF_AUDIO_GROUP_TAG)) {
|
|
const hidden = groupEl.hasAttribute("data-hidden");
|
|
const last = groupHiddenLast.get(groupEl);
|
|
if (last === hidden) continue;
|
|
groupHiddenLast.set(groupEl, hidden);
|
|
if (!groupEl.id) continue;
|
|
webAudio.setGroupMuted(groupEl.id, hidden);
|
|
if (last && !hidden && groupHasUncapturedMember(groupEl.id, currentTime)) needsCapture = true;
|
|
}
|
|
return needsCapture;
|
|
};
|
|
|
|
const timedVisibilityAt = (timingRevision?: number) => {
|
|
const compositionDuration = getSafeTimelineDurationSeconds(
|
|
state.capturedTimeline,
|
|
0,
|
|
timingRevision,
|
|
);
|
|
return (node: HTMLElement, time: number) =>
|
|
isRuntimeElementVisibleAt(node, {
|
|
currentTime: time,
|
|
compositionDuration,
|
|
canonicalFps: state.canonicalFps,
|
|
exportRenderSeek: Boolean(window.__HF_EXPORT_RENDER_SEEK_CONFIG),
|
|
timelineRegistry: window.__timelines ?? {},
|
|
resolver: timingResolverFor(true),
|
|
});
|
|
};
|
|
|
|
const clipChainVisibleAt = (
|
|
from: Element | null,
|
|
time: number,
|
|
visibleAt: ReturnType<typeof timedVisibilityAt>,
|
|
rootComp: HTMLElement | null,
|
|
) => {
|
|
for (let node = from; node && node !== rootComp; node = node.parentElement)
|
|
if (isHtmlElement(node) && node.hasAttribute("data-start") && !visibleAt(node, time))
|
|
return false;
|
|
return true;
|
|
};
|
|
|
|
const hiddenImagesSkipped = skipsHiddenImages();
|
|
const LOOKAHEAD_SECONDS = 2;
|
|
const RELEASE_LOOKAHEAD_SECONDS = 4;
|
|
// Unskipped while within the window on either side of the playhead, so a step or shuttle back
|
|
// across a cut finds the clip it left still loaded; the edge checks catch a clip shorter than it.
|
|
const dueSoon = (
|
|
node: HTMLElement,
|
|
visibleAt: ReturnType<typeof timedVisibilityAt>,
|
|
t: number,
|
|
ahead = LOOKAHEAD_SECONDS,
|
|
) => {
|
|
const start = isMediaElement(node)
|
|
? resolveAbsoluteMediaStartSeconds(node)
|
|
: resolveStartForElement(node, Number.NaN);
|
|
const end = start + (resolveDurationForElement(node) ?? Number.NaN);
|
|
return (
|
|
(start > t && start <= t + ahead) ||
|
|
(end <= t && end >= t - ahead) ||
|
|
visibleAt(node, t + ahead)
|
|
);
|
|
};
|
|
// Where Studio's loop wraps to (window.__hf.setLoopStart); null when it does not loop.
|
|
let loopStartSeconds: number | null = null;
|
|
// A clip with no authored length loads as in a render: a reload would drop the duration it is
|
|
// timed by.
|
|
const inPreloadWindow = (el: HTMLMediaElement) =>
|
|
hiddenImagesSkipped && el.hasAttribute("data-start") && lengthIsAuthored(el);
|
|
|
|
// Media on screen or due within the look-ahead loads; the rest stops holding a connection to
|
|
// the origin that also serves Studio's thumbnails.
|
|
const mediaNearPlayhead = new WeakMap<HTMLMediaElement, boolean>();
|
|
// During playback a clip that starts arms the next clip on its track, which a cold 2 s look-ahead
|
|
// cannot fetch in time on a slow link.
|
|
const armedBy = new WeakMap<HTMLMediaElement, HTMLMediaElement>();
|
|
const armedFrom = new WeakSet<HTMLMediaElement>();
|
|
// An untracked clip (NaN) shares a track with nothing, as in the timeline payload.
|
|
const hostOf = (el: Element) => el.parentElement?.closest("[data-composition-id]");
|
|
const armNextOnTrack = (el: HTMLMediaElement) => {
|
|
const track = parseAuthoredTrack(el, Number.NaN);
|
|
const clips = buildRuntimeMediaCache(
|
|
Array.from(metadataBoundMedia).filter(
|
|
(m) =>
|
|
m.isConnected && parseAuthoredTrack(m, Number.NaN) === track && hostOf(m) === hostOf(el),
|
|
),
|
|
).mediaClips;
|
|
const end = clips.find((clip) => clip.el === el)?.end ?? Number.NaN;
|
|
let next: (typeof clips)[number] | undefined;
|
|
for (const clip of clips)
|
|
if (
|
|
clip.el !== el &&
|
|
clip.start >= end - instantTolerance(end) &&
|
|
!(next && next.start <= clip.start)
|
|
)
|
|
next = clip;
|
|
if (!next || !inPreloadWindow(next.el)) return;
|
|
armedBy.set(next.el, el);
|
|
if (mediaNearPlayhead.get(next.el) !== true) {
|
|
mediaNearPlayhead.set(next.el, true);
|
|
preloadMedia(next.el);
|
|
}
|
|
};
|
|
const preloadNearPlayhead = (el: HTMLMediaElement, visible: boolean, upcoming: boolean) => {
|
|
const playing = clock.isPlaying();
|
|
if (!(visible && playing)) armedFrom.delete(el);
|
|
else if (!armedFrom.has(el)) {
|
|
armedFrom.add(el);
|
|
armNextOnTrack(el);
|
|
}
|
|
// An arm lasts while the clip that set it plays on screen, so a jump away drops it.
|
|
const armer = armedBy.get(el);
|
|
const near = visible || upcoming || (armer !== undefined && armedFrom.has(armer));
|
|
const decided = mediaNearPlayhead.get(el);
|
|
if (decided === near) return;
|
|
mediaNearPlayhead.set(el, near);
|
|
if (!near) {
|
|
if (el.preload !== "none") {
|
|
el.preload = "none";
|
|
// Frees a clip the window armed; on one the parser started, it would only restart the fetch.
|
|
if (decided) releaseMedia(el);
|
|
}
|
|
} else if (!visible || decided === undefined) {
|
|
// Not a clip a jump lands on: the media sync arms that one, and load() would undo its seek.
|
|
preloadMedia(el);
|
|
}
|
|
};
|
|
// Held past the look-ahead so a scrub across its edge does not refetch, and while playing when
|
|
// due at the loop start.
|
|
const mediaStaysDue = (
|
|
el: HTMLMediaElement,
|
|
visibleAt: ReturnType<typeof timedVisibilityAt>,
|
|
t: number,
|
|
) =>
|
|
(mediaNearPlayhead.get(el) === true && dueSoon(el, visibleAt, t, RELEASE_LOOKAHEAD_SECONDS)) ||
|
|
(loopStartSeconds !== null &&
|
|
clock.isPlaying() &&
|
|
(visibleAt(el, loopStartSeconds) ||
|
|
dueSoon(el, visibleAt, loopStartSeconds, RELEASE_LOOKAHEAD_SECONDS)));
|
|
|
|
const applyTimedElementVisibility = (
|
|
currentTime: number,
|
|
visibilityNodes: Element[],
|
|
timingRevision?: number,
|
|
) => {
|
|
const rootComp = resolveRootCompositionElement();
|
|
let decidedTimedClip = false;
|
|
const visibleAt = timedVisibilityAt(timingRevision);
|
|
for (const rawNode of visibilityNodes) {
|
|
if (!isHtmlElement(rawNode)) continue;
|
|
|
|
if (rawNode.hasAttribute("data-hidden")) {
|
|
if (!dataHiddenDisplayNodes.has(rawNode)) {
|
|
dataHiddenDisplayNodes.add(rawNode);
|
|
if (nodeAffectsAudio(rawNode)) hiddenAudioDirty = true;
|
|
}
|
|
hideByDisplay(rawNode, false);
|
|
if (isVideoElement(rawNode) || isImageElement(rawNode)) {
|
|
colorGradingRuntime?.setSourceVisibility(rawNode, false);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (dataHiddenDisplayNodes.has(rawNode)) {
|
|
restoreDisplay(rawNode);
|
|
dataHiddenDisplayNodes.delete(rawNode);
|
|
if (nodeAffectsAudio(rawNode)) hiddenAudioDirty = true;
|
|
}
|
|
|
|
// Descendants must not override a hidden ancestor clip. CSS visibility can
|
|
// otherwise leak child pixels through inactive scenes because a descendant
|
|
// with visibility:visible escapes an ancestor's visibility:hidden.
|
|
const isVisibleNow =
|
|
visibleAt(rawNode, currentTime) &&
|
|
clipChainVisibleAt(rawNode.parentElement, currentTime, visibleAt, rootComp);
|
|
const isRuntimeStamped = stampsForStudio && rawNode.hasAttribute(RUNTIME_STAMPED_ATTR);
|
|
if (!isRuntimeStamped) rawNode.style.visibility = isVisibleNow ? "visible" : "hidden";
|
|
const upcoming =
|
|
hiddenImagesSkipped && !isVisibleNow && dueSoon(rawNode, visibleAt, currentTime);
|
|
if (upcoming && !rawNode.hasAttribute(STUDIO_PREVIEW_UPCOMING_ATTR))
|
|
loadPreviewImagesIn(rawNode);
|
|
rawNode.toggleAttribute(STUDIO_PREVIEW_UPCOMING_ATTR, upcoming);
|
|
if (isMediaElement(rawNode) && metadataBoundMedia.has(rawNode) && inPreloadWindow(rawNode))
|
|
preloadNearPlayhead(
|
|
rawNode,
|
|
isVisibleNow,
|
|
upcoming || mediaStaysDue(rawNode, visibleAt, currentTime),
|
|
);
|
|
if (!isMediaElement(rawNode) && !isImageElement(rawNode)) decidedTimedClip = true;
|
|
if (isVideoElement(rawNode) || isImageElement(rawNode)) {
|
|
colorGradingRuntime?.setSourceVisibility(rawNode, isVisibleNow);
|
|
}
|
|
if (isVisibleNow) {
|
|
restoreDisplay(rawNode);
|
|
} else if (isTimedClipInFlow(rawNode) && isTimedClipLeaf(rawNode)) {
|
|
hideByDisplay(rawNode, true);
|
|
}
|
|
}
|
|
if (decidedTimedClip && revealTimedClipsAfterFirstPass()) colorGradingRuntime?.refresh();
|
|
// Only when a `data-hidden` mutation actually moved something: the skips
|
|
// this reschedule exists to re-run are what change the active set, so
|
|
// firing it otherwise was an audible stop-and-restart across the whole mix
|
|
// that rebuilt an identical set.
|
|
const groupNeedsCapture = syncAudioGroupMute(currentTime);
|
|
if ((hiddenAudioDirty || groupNeedsCapture) && clock.isPlaying()) {
|
|
webAudio.stopAll();
|
|
for (const el of webAudioMediaIn(document)) {
|
|
if (isSilencedByHidden(el)) el.volume = 0;
|
|
}
|
|
scheduleWebAudioForActiveClips();
|
|
}
|
|
hiddenAudioDirty = false;
|
|
};
|
|
|
|
// Scope 2 of 3 (see `withTimingResolver`). One resolver for the whole
|
|
// visibility pass: every node costs 2 x (1 + ancestor depth) resolves, and
|
|
// ancestor chains are shared between siblings. Its `el.load()` calls reach only clips timed by an
|
|
// authored length, and it never awaits, so no duration the cache read can change.
|
|
const syncTimedElementVisibility = (
|
|
currentTime: number,
|
|
visibilityNodes: Element[] = Array.from(document.querySelectorAll("[data-start]")),
|
|
timingRevision?: number,
|
|
) =>
|
|
withTimingResolver(() =>
|
|
applyTimedElementVisibility(currentTime, visibilityNodes, timingRevision),
|
|
);
|
|
|
|
// Images a seek to `time` would reveal undecoded: skipped with their hidden clip, or still loading.
|
|
const undecodedImagesShownAt = (time: number): HTMLImageElement[] =>
|
|
withTimingResolver(() => {
|
|
if (!hiddenImagesSkipped) return [];
|
|
const rootComp = resolveRootCompositionElement();
|
|
const visibleAt = timedVisibilityAt();
|
|
return Array.from(document.images).filter(
|
|
(img) =>
|
|
img.closest(
|
|
`[data-start]:not([${RUNTIME_STAMPED_ATTR}])[style*="visibility: hidden"]`,
|
|
) !== null &&
|
|
(img.closest(SKIPPED_CLIP) !== null || !img.complete) &&
|
|
clipChainVisibleAt(img, time, visibleAt, rootComp),
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Clip windows sorted by each endpoint, so a seek can ask which windows it
|
|
* crossed instead of asking every clip whether it is active.
|
|
*
|
|
* Rebuilt whenever the composition's timing inputs move — the same revision
|
|
* the duration floors ride on, because the two are derived from the same
|
|
* attributes, the same media metadata events and the same timeline registry.
|
|
*/
|
|
interface MediaClipIndex {
|
|
revision: number;
|
|
clips: RuntimeMediaClip[];
|
|
byStart: RuntimeMediaClip[];
|
|
byEnd: RuntimeMediaClip[];
|
|
}
|
|
let mediaClipIndex: MediaClipIndex | null = null;
|
|
// The transport time the last sync ran at, and the clips whose authored window
|
|
// contained it. `null` means "unknown": the next pass visits everything and
|
|
// reseeds. Only ever advanced when `syncRuntimeMedia` actually ran, so a pass
|
|
// skipped for any reason widens the next sweep rather than skipping the
|
|
// boundaries crossed in between.
|
|
let lastSyncedMediaTimeSeconds: number | null = null;
|
|
let mediaClipsInWindow: RuntimeMediaClip[] = [];
|
|
|
|
// The one definition of "media the transport drives": timed itself, or hosted
|
|
// by a composition whose timing it inherits. Both the media cache and the
|
|
// paused-side enforcement read it, so neither can be narrower than the other.
|
|
const isTransportManagedMedia = (element: HTMLMediaElement): boolean =>
|
|
element.hasAttribute("data-start") ||
|
|
Boolean(resolveMediaCompositionContext(element).compositionRoot);
|
|
|
|
const buildRuntimeMediaCache = (elements?: Array<HTMLVideoElement | HTMLAudioElement>) =>
|
|
refreshRuntimeMediaCache({
|
|
elements,
|
|
shouldIncludeElement: isTransportManagedMedia,
|
|
resolveStartSeconds: (element) => {
|
|
return resolveAbsoluteMediaStartSeconds(element);
|
|
},
|
|
resolveDurationSeconds: (element) => {
|
|
const context = resolveMediaCompositionContext(element);
|
|
const start = resolveAbsoluteMediaStartSeconds(element);
|
|
const hostRemaining =
|
|
context.inheritedStart != null &&
|
|
context.inheritedDuration != null &&
|
|
context.inheritedDuration > 0
|
|
? Math.max(0, context.inheritedStart + context.inheritedDuration - start)
|
|
: null;
|
|
const sourceDuration = Number.isFinite(element.duration)
|
|
? resolveNaturalMediaTimelineDuration(element, element.duration)
|
|
: null;
|
|
// The element's own data-duration is an explicit clip-length trim
|
|
// (the studio writes it when you drag the clip edge). It must bound
|
|
// playback so a trimmed track stops at its edge instead of running on
|
|
// to the source-file or host-composition end. Absent → no cap (an
|
|
// untrimmed clip plays its natural source length).
|
|
const ownDuration = parseStrictFiniteTimingNumber(element.dataset.duration);
|
|
const explicitDuration = ownDuration != null && ownDuration > 0 ? ownDuration : null;
|
|
return resolveRuntimeMediaClipDuration({
|
|
isVideo: element.tagName === "VIDEO",
|
|
sourceDuration,
|
|
hostRemaining,
|
|
explicitDuration,
|
|
});
|
|
},
|
|
resolveHostSlots: resolveMediaHostSlots,
|
|
});
|
|
|
|
const resolveMediaClipIndex = (): MediaClipIndex => {
|
|
const revision = readCompositionTimingRevision();
|
|
if (mediaClipIndex && mediaClipIndex.revision === revision) return mediaClipIndex;
|
|
const clips = buildRuntimeMediaCache().mediaClips;
|
|
mediaClipIndex = {
|
|
revision,
|
|
clips,
|
|
byStart: [...clips].sort((a, b) => a.start - b.start),
|
|
byEnd: [...clips].sort((a, b) => a.end - b.end),
|
|
};
|
|
// The windows moved, so what the sweep remembers being in-window is about
|
|
// clips that no longer describe this composition. Reseed on the next pass.
|
|
lastSyncedMediaTimeSeconds = null;
|
|
mediaClipsInWindow = [];
|
|
return mediaClipIndex;
|
|
};
|
|
|
|
/** Clips whose `endpoint` lies in [lo, hi], via binary search on the sorted
|
|
* array. Inclusive at both ends: over-visiting is free, under-visiting is a
|
|
* clip left playing. */
|
|
const clipsWithEndpointBetween = (
|
|
sorted: RuntimeMediaClip[],
|
|
endpoint: (clip: RuntimeMediaClip) => number,
|
|
lo: number,
|
|
hi: number,
|
|
): RuntimeMediaClip[] => {
|
|
let low = 0;
|
|
let high = sorted.length;
|
|
while (low < high) {
|
|
const mid = (low + high) >> 1;
|
|
if (endpoint(sorted[mid]!) < lo) low = mid + 1;
|
|
else high = mid;
|
|
}
|
|
const crossed: RuntimeMediaClip[] = [];
|
|
for (let i = low; i < sorted.length && endpoint(sorted[i]!) <= hi; i += 1)
|
|
crossed.push(sorted[i]!);
|
|
return crossed;
|
|
};
|
|
|
|
/**
|
|
* The media elements this pass must visit, and the argument that the rest can
|
|
* be skipped.
|
|
*
|
|
* `isActive` requires `isInClipWindow` (or, for audio, a start within the early-start
|
|
* margin), so a clip outside that is inactive there whatever its element state. A clip that
|
|
* is in neither the previous in-window set nor the set of windows whose
|
|
* endpoint the transport just crossed was therefore out of window BEFORE and
|
|
* is out of window NOW — the pass would only have evicted sync state that is
|
|
* already empty and paused an element already paused, both of which it was
|
|
* left in the last time it went out of window, by a pass that did visit it.
|
|
*
|
|
* The endpoint search is what makes a jump honest rather than lucky: seeking
|
|
* over a hundred clips visits the hundred boundaries it crossed, and a
|
|
* single-frame step visits the one or two that flip.
|
|
*/
|
|
const collectMediaElementsToVisit = (
|
|
index: MediaClipIndex,
|
|
toSeconds: number,
|
|
cueAheadSeconds: number,
|
|
): Array<HTMLVideoElement | HTMLAudioElement> => {
|
|
const fromSeconds = lastSyncedMediaTimeSeconds;
|
|
if (fromSeconds === null) return index.clips.map((clip) => clip.el);
|
|
const sameInstantMargin =
|
|
2 * instantTolerance(Math.max(Math.abs(fromSeconds), Math.abs(toSeconds)));
|
|
const lo = Math.min(fromSeconds, toSeconds) - sameInstantMargin;
|
|
const hi = Math.max(fromSeconds, toSeconds) + sameInstantMargin;
|
|
const visiting = new Set<HTMLVideoElement | HTMLAudioElement>();
|
|
for (const clip of mediaClipsInWindow) visiting.add(clip.el);
|
|
for (const clip of clipsWithEndpointBetween(
|
|
index.byStart,
|
|
(c) => c.start,
|
|
lo,
|
|
hi + cueAheadSeconds,
|
|
)) {
|
|
visiting.add(clip.el);
|
|
}
|
|
for (const clip of clipsWithEndpointBetween(index.byEnd, (c) => c.end, lo, hi)) {
|
|
visiting.add(clip.el);
|
|
}
|
|
return [...visiting];
|
|
};
|
|
|
|
/** Elements whose paused-time playback is borrowed by a feature that legitimately
|
|
* runs media with the clock stopped: the grading preview, the Studio's scrub.
|
|
* Anything playing while paused without a lease is the defect the enforcement
|
|
* exists for. On `window.__hf` because the Studio is across the iframe boundary. */
|
|
const pausedMediaLeases = new WeakSet<HTMLMediaElement>();
|
|
const leasePausedMedia = (el: HTMLMediaElement): void => {
|
|
pausedMediaLeases.add(el);
|
|
};
|
|
const releasePausedMedia = (el: HTMLMediaElement): void => {
|
|
pausedMediaLeases.delete(el);
|
|
wakeTransport();
|
|
};
|
|
window.__hf.leasePausedMedia = leasePausedMedia;
|
|
window.__hf.releasePausedMedia = releasePausedMedia;
|
|
window.__hf.setLoopStart = (seconds) => {
|
|
loopStartSeconds = seconds != null && Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
|
|
};
|
|
// A sub-composition is hidden after its host clip, so its animation counts only until then.
|
|
const autoNestedHostEndSeconds = (child: RuntimeTimelineChildLike): number => {
|
|
const hostId = autoNested.get(child)?.hostId;
|
|
const host = hostId
|
|
? document.querySelector(`[data-composition-id="${CSS.escape(hostId)}"]`)
|
|
: null;
|
|
const duration = host ? resolveDurationForElement(host) : null;
|
|
return host && duration ? resolveStartForElement(host, 0) + duration : Infinity;
|
|
};
|
|
// GSAP's duration() of a tween is one iteration; a timeline's duration() counts its children's repeats.
|
|
const readOneCycleEndSeconds = (children: RuntimeTimelineChildLike[]): number => {
|
|
let end = 0;
|
|
for (const child of children) {
|
|
if (child.data === RUNTIME_FILLER) continue;
|
|
// A stagger or keyframes tween runs an inner timeline; its duration() counts per-item repeats.
|
|
const nested = child.getChildren
|
|
? child
|
|
: (child as { timeline?: RuntimeTimelineChildLike }).timeline;
|
|
// GSAP plays a tween's inner timeline stretched to the tween's own duration() (1 for a timeline).
|
|
const stretch = Number(child.duration?.()) / Number(nested?.duration?.());
|
|
const cycle = nested?.getChildren
|
|
? readOneCycleEndSeconds(nested.getChildren(false, true, true)) *
|
|
(Number.isFinite(stretch) && stretch > 0 ? stretch : 1)
|
|
: Number(child.duration?.()) || 0;
|
|
// startTime() is in the parent's time, the cycle in the child's own; reversed reports -1.
|
|
const scale = Math.abs(Number((child as { timeScale?: () => number }).timeScale?.()) || 1);
|
|
const childEnd = (Number(child.startTime?.()) || 0) + cycle / scale;
|
|
// A child still endless here reports ~1e10 s; skip it, as the adapters skip loops.
|
|
if (childEnd >= LOOP_INFLATED_TIMELINE_SECONDS) continue;
|
|
end = Math.max(end, Math.min(childEnd, autoNestedHostEndSeconds(child)));
|
|
}
|
|
return end;
|
|
};
|
|
window.__hf.animationEnd = () => {
|
|
const timeline = state.capturedTimeline;
|
|
const timelineEnd = timeline?.getChildren
|
|
? readOneCycleEndSeconds(timeline.getChildren(false, true, true))
|
|
: (getTimelineDurationSeconds(timeline) ?? 0);
|
|
const end = Math.max(timelineEnd, resolveAdapterDurationFloorSeconds(true) ?? 0);
|
|
return end > 0 && end < LOOP_INFLATED_TIMELINE_SECONDS ? end : null;
|
|
};
|
|
window.__hf.audioMeter = {
|
|
start: () => webAudio.startMetering(),
|
|
stop: () => webAudio.stopMetering(),
|
|
read: () => webAudio.readLevels(),
|
|
};
|
|
|
|
// Same predicate the media cache uses, so the paused side sees exactly the
|
|
// media the transport drives. Reads attributes only; no cache rebuild.
|
|
const hasRunningTimedMedia = (): boolean => {
|
|
for (const el of document.querySelectorAll("video, audio")) {
|
|
if (
|
|
isMediaElement(el) &&
|
|
!el.paused &&
|
|
isTransportManagedMedia(el) &&
|
|
!pausedMediaLeases.has(el)
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
// A parked transport runs no ticks, so the check above would never see a media
|
|
// element that starts while the preview sits idle. `play` does not bubble;
|
|
// capture-phase on the document reaches every element, including later ones
|
|
// (same reasoning as the media events watchCompositionTimingInputs binds).
|
|
const onMediaPlayWakeTransport = () => wakeTransport();
|
|
document.addEventListener("play", onMediaPlayWakeTransport, true);
|
|
runtimeCleanupCallbacks.push(() => {
|
|
document.removeEventListener("play", onMediaPlayWakeTransport, true);
|
|
});
|
|
|
|
const syncMediaForCurrentState = (timingRevision?: number) => {
|
|
// Scope 1 of 3 (see `withTimingResolver`). Closes before `syncRuntimeMedia`,
|
|
// which may call `el.load()` and invalidate every cached duration.
|
|
//
|
|
// The render/export path never consults the index: capture depends on the
|
|
// exact state of every element on every frame and a frame captured against a
|
|
// stale one cannot be recovered, so it visits all of them exactly as before.
|
|
// Same reasoning, and the same latch, as the duration floors above.
|
|
const indexed = !renderCaptureSeekStarted;
|
|
const cueAheadSeconds =
|
|
state.isPlaying && !state.mediaForceSyncNextTick && lastSyncedMediaTimeSeconds !== null
|
|
? Math.min(
|
|
Math.max(0, state.currentTime - lastSyncedMediaTimeSeconds),
|
|
MEDIA_SYNC_TOLERANCE_SECONDS * Math.min(1, state.playbackRate),
|
|
)
|
|
: 0;
|
|
const mediaClips = withTimingResolver(() => {
|
|
if (!indexed) return buildRuntimeMediaCache().mediaClips;
|
|
const index = resolveMediaClipIndex();
|
|
// Windows come from the index, but every field handed to `syncRuntimeMedia`
|
|
// is re-read here: `el.duration` is reset by any `el.load()` the retry path
|
|
// made last pass, and a cached copy of it would be exactly the stale
|
|
// duration the two-scope rule exists to prevent.
|
|
return buildRuntimeMediaCache(
|
|
collectMediaElementsToVisit(index, state.currentTime, cueAheadSeconds),
|
|
).mediaClips;
|
|
});
|
|
// Attach probed volume keyframes to clips so syncRuntimeMedia can use the
|
|
// same envelope the renderer uses instead of tracking GSAP-change diffs.
|
|
for (const clip of mediaClips) {
|
|
const kf = volumeKeyframeCache.get(clip.el as HTMLMediaElement);
|
|
if (kf) clip.volumeKeyframes = kf;
|
|
}
|
|
|
|
// A leased element is not the transport's to touch while the clock is paused;
|
|
// during playback the transport owns everything again. Filtered here rather
|
|
// than out of `mediaClips` so the in-window bookkeeping below still records it
|
|
// and the pass after its release visits it normally.
|
|
const syncedClips = state.isPlaying
|
|
? mediaClips
|
|
: mediaClips.filter((clip) => !pausedMediaLeases.has(clip.el));
|
|
|
|
const forceSync = state.mediaForceSyncNextTick;
|
|
if (forceSync) state.mediaForceSyncNextTick = false;
|
|
if (!state.nativeMediaSyncDisabled) {
|
|
syncRuntimeMedia({
|
|
clips: syncedClips,
|
|
timeSeconds: state.currentTime,
|
|
playing: state.isPlaying,
|
|
playbackRate: state.playbackRate,
|
|
outputMuted: state.mediaOutputMuted,
|
|
userMuted: state.bridgeMuted,
|
|
userVolume: state.bridgeVolume,
|
|
forceSync,
|
|
cueAheadSeconds,
|
|
getCompositionDuration: () =>
|
|
getSafeTimelineDurationSeconds(state.capturedTimeline, 0, timingRevision),
|
|
onElementVolume: (el, _effectiveVolume, authorVolume) =>
|
|
webAudio.setElementVolume(el, authorVolume),
|
|
isWebAudioOwned: (el) => webAudio.ownsElement(el),
|
|
isWebAudioRouted: (el) => webAudio.routesElement(el),
|
|
onAutoplayBlocked: () => {
|
|
if (state.mediaAutoplayBlockedPosted) return;
|
|
state.mediaAutoplayBlockedPosted = true;
|
|
postRuntimeMessage({ source: "hf-preview", type: "media-autoplay-blocked" });
|
|
},
|
|
});
|
|
if (indexed) {
|
|
lastSyncedMediaTimeSeconds = state.currentTime;
|
|
// Every clip not visited was out of window at both ends of this seek, so
|
|
// the visited ones are the only possible members.
|
|
mediaClipsInWindow = mediaClips.filter((clip) =>
|
|
isInClipWindow(state.currentTime, clip.start - MEDIA_SYNC_TOLERANCE_SECONDS, clip.end),
|
|
);
|
|
} else {
|
|
lastSyncedMediaTimeSeconds = null;
|
|
mediaClipsInWindow = [];
|
|
}
|
|
}
|
|
syncTimedElementVisibility(state.currentTime, undefined, timingRevision);
|
|
};
|
|
|
|
const postState = (force: boolean) => {
|
|
// Every transport mutation — play, pause, seek, renderSeek, a control-bridge
|
|
// command, the player's own state posts — ends in a forced post. That makes
|
|
// this the one place a parked transport needs to hear about, instead of a
|
|
// wake call bolted onto each mutator (and forgotten on the next one).
|
|
if (force) wakeTransport();
|
|
const frame = Math.max(0, Math.round((state.currentTime || 0) * state.canonicalFps));
|
|
const now = Date.now();
|
|
const shouldPost =
|
|
force ||
|
|
frame !== state.bridgeLastPostedFrame ||
|
|
state.isPlaying !== state.bridgeLastPostedPlaying ||
|
|
state.bridgeMuted !== state.bridgeLastPostedMuted ||
|
|
now - state.bridgeLastPostedAt >= state.bridgeMaxPostIntervalMs;
|
|
if (!shouldPost) return;
|
|
state.bridgeLastPostedFrame = frame;
|
|
state.bridgeLastPostedPlaying = state.isPlaying;
|
|
state.bridgeLastPostedMuted = state.bridgeMuted;
|
|
state.bridgeLastPostedAt = now;
|
|
postRuntimeMessage({
|
|
source: "hf-preview",
|
|
type: "state",
|
|
frame,
|
|
currentTime: state.currentTime || 0,
|
|
ended: clock.reachedEnd(),
|
|
isPlaying: state.isPlaying,
|
|
muted: state.bridgeMuted,
|
|
playbackRate: state.playbackRate,
|
|
});
|
|
};
|
|
|
|
// Signature the live __clipTree was built from; rebuild only when the set of
|
|
// timed elements changes (e.g. a sub-composition finishes loading), not every
|
|
// transport tick. A plain count misses same-count swaps (one sub-comp unloads
|
|
// as another loads), so the signature keys on id+tag in document order.
|
|
let clipTreeSignature = "";
|
|
let assetsReadyStarted = false;
|
|
let assetsSettled = false;
|
|
let liveRootDurationOverrideSeconds = 0;
|
|
let lastPostedManifest = "";
|
|
const computeClipTreeSignature = (): string => {
|
|
let sig = "";
|
|
for (const el of document.querySelectorAll("[data-start]")) {
|
|
sig += `${el.id}:${el.tagName}|`;
|
|
}
|
|
return sig;
|
|
};
|
|
const postTimeline = () => {
|
|
sanitizeCompositionDurationAttributes();
|
|
applyCompositionSizing();
|
|
applyClipLayout();
|
|
// Post resolved stage size so the parent can scale the iframe container
|
|
const stageSizeRootEl = resolveRootCompositionElement();
|
|
if (stageSizeRootEl) {
|
|
const { width, height } = readCompositionSize(stageSizeRootEl);
|
|
if (width !== null && height !== null) {
|
|
postRuntimeMessage({ source: "hf-preview", type: "stage-size", width, height });
|
|
}
|
|
}
|
|
bindRootTimelineIfAvailable();
|
|
const payload = collectRuntimeTimelinePayload({
|
|
canonicalFps: state.canonicalFps,
|
|
});
|
|
window.__clipManifest = payload;
|
|
lastPostedManifest = JSON.stringify(payload);
|
|
|
|
const currentSignature = computeClipTreeSignature();
|
|
if (clipTreeSignature !== currentSignature) {
|
|
// The timed-element set changed — leaf/in-flow caches may be stale.
|
|
invalidateTimedClipCaches();
|
|
}
|
|
if (!window.__clipTree || clipTreeSignature !== currentSignature) {
|
|
const runtimeWindow = window as Window & {
|
|
__timelines?: Record<string, RuntimeTimelineLike | undefined>;
|
|
};
|
|
window.__clipTree = createClipTree({
|
|
startResolver: createRuntimeStartTimeResolver({
|
|
timelineRegistry: runtimeWindow.__timelines ?? {},
|
|
includeAuthoredTimingAttrs: true,
|
|
}),
|
|
timelineRegistry: runtimeWindow.__timelines ?? {},
|
|
rootDuration: payload.durationInFrames / state.canonicalFps,
|
|
});
|
|
clipTreeSignature = currentSignature;
|
|
}
|
|
|
|
postRuntimeMessage({ ...payload, assetsReady: assetsSettled });
|
|
if (!assetsReadyStarted) {
|
|
assetsReadyStarted = true;
|
|
settleFirstFrameCompositionReadiness(document, ({ timedOut }) => {
|
|
if (state.tornDown) return;
|
|
assetsSettled = true;
|
|
postRuntimeMessage({ source: "hf-preview", type: "assets-ready", timedOut });
|
|
});
|
|
}
|
|
scheduleRootStageLayoutDiagnostics();
|
|
};
|
|
|
|
// What no observer sees (a label, a track or an inline z-index edited on a playing clip) still reaches Studio.
|
|
const postTimelineIfManifestChanged = () => {
|
|
const manifest = JSON.stringify(
|
|
collectRuntimeTimelinePayload({ canonicalFps: state.canonicalFps }),
|
|
);
|
|
if (manifest !== lastPostedManifest) postTimeline();
|
|
};
|
|
|
|
const finitePositiveDuration = (value: number | null | undefined): number =>
|
|
typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
|
|
const growRootDurationLive = (durationSeconds: number) => {
|
|
const nextDuration = finitePositiveDuration(Number(durationSeconds));
|
|
if (nextDuration <= 0) return;
|
|
const rootEl = resolveRootCompositionElement();
|
|
const rootAttrDuration = finitePositiveDuration(
|
|
parseStrictFiniteTimingNumber(rootEl?.getAttribute("data-duration")),
|
|
);
|
|
const currentDuration = Math.max(
|
|
liveRootDurationOverrideSeconds,
|
|
finitePositiveDuration(clock.getDuration()),
|
|
rootAttrDuration,
|
|
);
|
|
if (nextDuration <= currentDuration) return;
|
|
|
|
liveRootDurationOverrideSeconds = nextDuration;
|
|
rootEl?.setAttribute("data-duration", String(nextDuration));
|
|
clock.setDuration(nextDuration);
|
|
postTimeline();
|
|
postState(true);
|
|
};
|
|
|
|
const runAdapters = (
|
|
method: "discover" | "pause" | "play",
|
|
timeSeconds = 0,
|
|
pageAnimations?: () => Animation[],
|
|
) => {
|
|
for (const adapter of state.deterministicAdapters) {
|
|
try {
|
|
if (method === "discover") adapter.discover();
|
|
if (method === "pause") adapter.pause({ pageAnimations });
|
|
if (method === "play" && adapter.play) adapter.play();
|
|
} catch (err) {
|
|
// keep runtime resilient against adapter-specific failures
|
|
swallow("runtime.init.site8", err);
|
|
}
|
|
if (method === "discover") {
|
|
try {
|
|
adapter.seek({ time: timeSeconds, suppressEvents: true });
|
|
} catch (err) {
|
|
// ignore seek bootstrap failures
|
|
swallow("runtime.init.site9", err);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
let maybePublishRenderReady = () => {
|
|
window.__renderReady = false;
|
|
};
|
|
|
|
// Internal adapter-readiness tracking. Adapters with outstanding async work
|
|
// (Three.js `DefaultLoadingManager`, future fetch/font/image detectors) expose
|
|
// a `getReadyPromise()` method; the runtime waits for whatever they return
|
|
// before publishing render-ready. This is purely internal — there is no
|
|
// authored-code-facing flag (LLMs should not need to know about render
|
|
// readiness, the framework handles async asset gating automatically).
|
|
const isAdapterReadinessSettled = createSettledTracker(
|
|
() => {
|
|
const promises: PromiseLike<unknown>[] = [];
|
|
for (const adapter of state.deterministicAdapters) {
|
|
const getter = adapter.getReadyPromise;
|
|
if (typeof getter !== "function") continue;
|
|
try {
|
|
const p = getter();
|
|
if (p) promises.push(p);
|
|
} catch (err) {
|
|
// A throwing readiness gate must not permanently block render; swallow
|
|
// and continue, matching the rest of the runtime's adapter-resilience
|
|
// pattern.
|
|
swallow("runtime.init.adapterReady", err);
|
|
}
|
|
}
|
|
return promises;
|
|
},
|
|
() => maybePublishRenderReady(),
|
|
(err) => swallow("runtime.init.adapterReady", err),
|
|
);
|
|
|
|
// window.__hf.buildReady[key]: a piece registers a promise for setup no
|
|
// adapter can observe (mesh building, shader compiles). Waited the same
|
|
// way as adapter readiness, so render and preview both hold on it.
|
|
const isBuildReadinessSettled = createSettledTracker(
|
|
() => {
|
|
const registry = window.__hf?.buildReady;
|
|
if (!registry) return [];
|
|
return Object.values(registry).filter(
|
|
(p): p is PromiseLike<unknown> =>
|
|
p != null && typeof (p as PromiseLike<unknown>).then === "function",
|
|
);
|
|
},
|
|
() => maybePublishRenderReady(),
|
|
(err) => swallow("runtime.init.buildReady", err),
|
|
);
|
|
|
|
// Passes that must see scene DOM, which arrives after init: when scenes mount, or when one is
|
|
// swapped. Resolves when caption overrides have landed.
|
|
const settleSceneDom = (): Promise<void> => {
|
|
bindMediaMetadataListeners();
|
|
installAssetFailureDiagnostics();
|
|
const captionsApplied = applyCaptionOverrides();
|
|
// Per-instance scoped values: data-var-* / --{id} bindings inside scenes. Idempotent.
|
|
applyVariableBindings(document);
|
|
// An unregistered vfx chain paints nothing and logs nothing, so re-scan the new DOM.
|
|
initVfx(document.body, state.canonicalFps);
|
|
return captionsApplied;
|
|
};
|
|
if (!externalCompositionsReady) {
|
|
const compositionLoaderParams = {
|
|
injectedStyles: state.injectedCompStyles,
|
|
injectedScripts: state.injectedCompScripts,
|
|
injectedLinks: state.injectedCompLinks,
|
|
parseDimensionPx,
|
|
onDiagnostic: ({
|
|
code,
|
|
details,
|
|
}: {
|
|
code: string;
|
|
details: Record<string, string | number | boolean | null | string[]>;
|
|
}) => {
|
|
postRuntimeMessage({
|
|
source: "hf-preview",
|
|
type: "diagnostic",
|
|
code,
|
|
details,
|
|
});
|
|
},
|
|
};
|
|
void loadCompositions(compositionLoaderParams).finally(() => {
|
|
externalCompositionsReady = true;
|
|
void settleSceneDom();
|
|
maybePublishRenderReady();
|
|
});
|
|
} else {
|
|
// No external/inline compositions to load — apply caption overrides immediately
|
|
void applyCaptionOverrides();
|
|
}
|
|
|
|
const sceneUrls = (parts: Element[]): Set<string> => {
|
|
const urls = new Set<string>();
|
|
const addCssUrls = (css: string | null) => {
|
|
for (const m of (css ?? "").matchAll(/url\(\s*(['"]?)(.*?)\1\s*\)|@import\s+(['"])(.*?)\3/gi))
|
|
urls.add(m[2] ?? m[4]!);
|
|
for (const set of (css ?? "").matchAll(/image-set\((?:[^()]|\([^()]*\))*\)/gi))
|
|
for (const m of set[0].matchAll(/(['"])(.*?)\1/g)) urls.add(m[2]!);
|
|
};
|
|
for (const part of parts) {
|
|
if (part.tagName === "STYLE") addCssUrls(part.textContent);
|
|
if (part.tagName === "STYLE" || part.tagName === "SCRIPT") continue;
|
|
for (const el of [part, ...part.querySelectorAll("*")]) {
|
|
const attrs = MEDIA_URL_ATTRS.get(el.localName) ?? [];
|
|
const values = [
|
|
unproxiedMediaSrc(el),
|
|
...[...attrs, "poster", "srcset"].map((a) => el.getAttribute(a)),
|
|
];
|
|
for (const value of values) if (value) urls.add(value);
|
|
addCssUrls(el.getAttribute("style"));
|
|
}
|
|
}
|
|
return urls;
|
|
};
|
|
let sceneSwapGeneration = 0;
|
|
// A video or audio the edit left as written keeps playing, unless the scene's scripts changed: what the
|
|
// old script wrote to it directly is unknown. A rebuilt one is recorded from the new markup.
|
|
const keepUnchangedMedia = (oldHost: Element, host: Element, sameScripts: boolean) => {
|
|
const byShape = new Map<string, Element[]>();
|
|
for (const el of sameScripts ? oldHost.querySelectorAll("video, audio") : []) {
|
|
const shape = authoredMedia.get(el);
|
|
// Its grading canvas sits beside it in the old scene and cannot follow it.
|
|
if (!shape || colorGradingRuntime?.isGraded(el)) continue;
|
|
// A stream, output device or key session a script gave it is not in the markup.
|
|
const { srcObject, sinkId, mediaKeys } = el as HTMLMediaElement;
|
|
if (srcObject || sinkId || mediaKeys) continue;
|
|
byShape.set(shape, [...(byShape.get(shape) ?? []), el]);
|
|
}
|
|
for (const el of host.querySelectorAll("video, audio")) {
|
|
const shape = authoredShape(el);
|
|
const kept = byShape.get(shape)?.shift();
|
|
// Anything a script wrote on it, under the old markup or not, is not in a fresh load's copy.
|
|
if (!kept || writtenShape(kept) !== writtenShape(el)) {
|
|
authoredMedia.set(el, shape);
|
|
continue;
|
|
}
|
|
el.replaceWith(kept);
|
|
// Back as written, the tween cache emptied, so the new script's tweens read what a fresh load's do.
|
|
window.gsap?.set?.(kept, { clearProps: "all" });
|
|
const style = el.getAttribute("style");
|
|
if (style === null) kept.removeAttribute("style");
|
|
else kept.setAttribute("style", style);
|
|
// The swap's closing pass puts its move back, as a load's first pass does.
|
|
forgetPositionEdit(kept as HTMLElement);
|
|
// Properties no attribute shows, as a load sets them.
|
|
const media = kept as HTMLMediaElement;
|
|
media.volume = authoredMediaVolume(media) ?? 1;
|
|
media.muted = state.bridgeMuted || state.mediaOutputMuted || media.defaultMuted;
|
|
media.defaultPlaybackRate = 1;
|
|
media.playbackRate = state.playbackRate;
|
|
media.preservesPitch = true;
|
|
}
|
|
};
|
|
const compositionIdsIn = (host: Element) =>
|
|
[host, ...host.querySelectorAll("[data-composition-id]")].flatMap(
|
|
(el) => el.getAttribute("data-composition-id") || [],
|
|
);
|
|
// gsap binds a tween to elements, so one from outside the scene would go on moving the replaced copy.
|
|
const refuseOutsideTweens = (
|
|
name: string,
|
|
host: Element,
|
|
timelines: Record<string, RuntimeTimelineLike | undefined>,
|
|
sceneAnimations: Record<string, SceneAnimation[]>,
|
|
) => {
|
|
const own = new Set<unknown>(
|
|
compositionIdsIn(host).flatMap((id) => [timelines[id], ...(sceneAnimations[id] ?? [])]),
|
|
);
|
|
const inScene = new Set<unknown>([host, ...host.querySelectorAll("*")]);
|
|
for (const tween of window.gsap?.globalTimeline?.getChildren?.(true, true, false) ?? []) {
|
|
if (!tween.targets?.().some((target) => inScene.has(target))) continue;
|
|
let owner: RuntimeTimelineChildLike | undefined = tween;
|
|
while (owner && !own.has(owner)) owner = owner.parent;
|
|
if (!owner) {
|
|
throw new Error(
|
|
`scene ${name} cannot be swapped: an animation outside it moves its elements`,
|
|
);
|
|
}
|
|
}
|
|
// A revert can leave a value on what the swap keeps; kept media are reset, page nodes outside are refused.
|
|
const outside = (target: unknown) =>
|
|
typeof (target as Node | null)?.nodeType === "number" && !inScene.has(target);
|
|
for (const animation of own as Set<SceneAnimation | undefined>) {
|
|
for (const tween of animation
|
|
? [animation, ...(animation.getChildren?.(true, true, false) ?? [])]
|
|
: []) {
|
|
if (tween.targets?.().some(outside)) {
|
|
throw new Error(
|
|
`scene ${name} cannot be swapped: its animations write outside the scene`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
// Swap edited scenes in place from a rebuilt preview document. Refuses before changing anything unless
|
|
// the documents differ only inside existing scenes; a later failure is left to the caller's reload.
|
|
const swapScenes = async (html: string, signal?: AbortSignal): Promise<void> => {
|
|
const generation = sceneSwapGeneration;
|
|
const next = new DOMParser().parseFromString(html, "text/html");
|
|
const liveParts = readSceneParts(document);
|
|
const nextParts = readSceneParts(next);
|
|
if (!liveParts || !nextParts) throw new Error("no scene manifest");
|
|
if (liveParts.shared !== nextParts.shared)
|
|
throw new Error("the film changed outside its scenes");
|
|
const names = Object.keys(nextParts.scenes);
|
|
if (
|
|
names.length !== Object.keys(liveParts.scenes).length ||
|
|
names.some((name) => !(name in liveParts.scenes))
|
|
) {
|
|
throw new Error("scenes were added or removed");
|
|
}
|
|
const changed = names.filter((name) => nextParts.scenes[name] !== liveParts.scenes[name]);
|
|
if (changed.length === 0) throw new Error("no scene changed");
|
|
const swaps = changed.map((name) => {
|
|
const partsIn = (doc: Document) =>
|
|
Array.from(doc.querySelectorAll(`[${SCENE_PART_ATTR}="${CSS.escape(name)}"]`));
|
|
const isHost = (el: Element) => el.tagName !== "STYLE" && el.tagName !== "SCRIPT";
|
|
const oldParts = partsIn(document);
|
|
const newParts = partsIn(next);
|
|
const oldHosts = oldParts.filter(isHost);
|
|
const newHosts = newParts.filter(isHost);
|
|
const oldHost = oldHosts[0];
|
|
const newHost = newHosts[0];
|
|
if (!oldHost || !newHost || oldHosts.length > 1 || newHosts.length > 1) {
|
|
throw new Error(`scene ${name} cannot be swapped: it has no single host`);
|
|
}
|
|
// A duplicated scene's script also registers under its shared original id.
|
|
if (oldHost.hasAttribute("data-hf-original-composition-id")) {
|
|
throw new Error(`scene ${name} cannot be swapped: it is a duplicated instance`);
|
|
}
|
|
const refusal =
|
|
oldHost.getAttribute(SCENE_NO_SWAP_ATTR) ?? newHost.getAttribute(SCENE_NO_SWAP_ATTR);
|
|
if (refusal !== null) throw new Error(`scene ${name} cannot be swapped: ${refusal}`);
|
|
const loaded = sceneUrls(oldParts);
|
|
if ([...sceneUrls(newParts)].some((url) => !loaded.has(url))) {
|
|
throw new Error(
|
|
`scene ${name} cannot be swapped: it loads media this scene has not loaded`,
|
|
);
|
|
}
|
|
const styleCount = (parts: Element[]) => parts.filter((el) => el.tagName === "STYLE").length;
|
|
if (styleCount(oldParts) !== styleCount(newParts)) {
|
|
throw new Error(`scene ${name} cannot be swapped: its styles moved`);
|
|
}
|
|
return {
|
|
name,
|
|
oldParts,
|
|
homes: oldParts.map((el) => el.parentNode),
|
|
newParts,
|
|
oldHost,
|
|
newHost,
|
|
};
|
|
});
|
|
// Each parent on a scene host's way up to the film root, once, with its children in order. The runtime marks
|
|
// what it inserts beside media, such as grading canvases, as ignored.
|
|
const sceneLayout = () => {
|
|
const film = resolveRootCompositionElement();
|
|
const parents = new Set<Element>();
|
|
for (const el of document.querySelectorAll(`[${SCENE_PART_ATTR}]`)) {
|
|
if (el.tagName === "STYLE" || el.tagName === "SCRIPT") continue;
|
|
let node = el.parentElement;
|
|
for (; node; node = node === film ? null : node.parentElement) {
|
|
parents.add(node);
|
|
}
|
|
}
|
|
return [...parents].flatMap((parent) => [
|
|
parent,
|
|
...Array.from(parent.children).filter((child) => !child.hasAttribute("data-hf-ignore")),
|
|
null,
|
|
]);
|
|
};
|
|
const layout = sceneLayout();
|
|
// Fetched before the first write, so a stalled or failed request leaves the page as it was.
|
|
const captionOverrides = swaps.some(({ newHost }) => newHost.querySelector(".caption-group"))
|
|
? await fetchCaptionOverrides()
|
|
: [];
|
|
if (state.tornDown) throw new Error("the preview was torn down during the swap");
|
|
if (signal?.aborted) throw new Error("the swap was cancelled");
|
|
if (generation !== sceneSwapGeneration) {
|
|
throw new Error("the preview changed while this swap waited");
|
|
}
|
|
// A data handler or a revert callback can replace or move a scene's parts; the swap would then lose the scene.
|
|
const refuseReplacedScenes = () => {
|
|
const moved = ({ oldParts, homes }: (typeof swaps)[number]) =>
|
|
oldParts.some((el, i) => !el.isConnected || el.parentNode !== homes[i]);
|
|
const now = sceneLayout();
|
|
const relaidOut = now.length !== layout.length || now.some((node, i) => node !== layout[i]);
|
|
if (relaidOut || swaps.some(moved)) {
|
|
throw new Error("a scene changed while this swap waited");
|
|
}
|
|
};
|
|
refuseReplacedScenes();
|
|
// Read at each use: a data handler or an animation's callback may replace the registry mid-swap.
|
|
const timelines = () =>
|
|
(window.__timelines ??= {}) as Record<string, RuntimeTimelineLike | undefined>;
|
|
const sceneAnimations = () => (window.__hfSceneAnimations ??= {});
|
|
const refuseAnyOutsideTweens = () => {
|
|
for (const { name, oldHost } of swaps)
|
|
refuseOutsideTweens(name, oldHost, timelines(), sceneAnimations());
|
|
};
|
|
// Checked after the wait, which a tween could start in, and before anything changes.
|
|
refuseAnyOutsideTweens();
|
|
sceneSwapGeneration += 1;
|
|
const root = state.capturedTimeline as
|
|
| (RuntimeTimelineLike & { remove?: (child: unknown) => unknown })
|
|
| null;
|
|
// Overrides re-dim every word they touch, so only the swapped scenes' captions get them.
|
|
const captionHosts: Element[] = [];
|
|
const swappedHosts: Element[] = [];
|
|
const oldIds = swaps.flatMap(({ oldHost }) => compositionIdsIn(oldHost));
|
|
const stopped = new Set<unknown>();
|
|
const stopOldAnimations = () => {
|
|
const before = stopped.size;
|
|
for (const id of oldIds) {
|
|
// Newest first: each revert restores what the animation before it wrote.
|
|
for (const previous of [
|
|
...(sceneAnimations()[id] ?? []).slice().reverse(),
|
|
timelines()[id],
|
|
]) {
|
|
if (!previous || stopped.has(previous)) continue;
|
|
stopped.add(previous);
|
|
const old = previous as SceneAnimation;
|
|
if (old.revert) old.revert();
|
|
else old.totalTime?.(0, true);
|
|
root?.remove?.(previous);
|
|
// revert() has already killed it; a second kill() fires onInterrupt again.
|
|
if (!old.revert) old.kill?.();
|
|
}
|
|
}
|
|
return stopped.size > before;
|
|
};
|
|
// A revert fires the animation's onInterrupt, which can register more; stop those too, until none appear.
|
|
let passes = 0;
|
|
while (stopOldAnimations()) {
|
|
if (++passes > 8) throw new Error("the old scene keeps starting animations as they stop");
|
|
}
|
|
refuseReplacedScenes();
|
|
refuseAnyOutsideTweens();
|
|
for (const id of oldIds) {
|
|
delete timelines()[id];
|
|
delete sceneAnimations()[id];
|
|
}
|
|
// Released before kept media is reset: a later stop puts back the mute and volume it saved under the old script.
|
|
webAudio.stopAll();
|
|
clock.detachAudioSource();
|
|
for (const { oldParts, newParts, oldHost, newHost } of swaps) {
|
|
// Each new style takes its own old one's place: same-named @keyframes resolve by order.
|
|
const newStyles = newParts.filter((el) => el.tagName === "STYLE");
|
|
oldParts
|
|
.filter((el) => el.tagName === "STYLE")
|
|
.forEach((el, i) => el.replaceWith(document.importNode(newStyles[i]!, true)));
|
|
for (const el of oldParts) if (el !== oldHost) el.remove();
|
|
const host = document.importNode(newHost, true);
|
|
// Text only: a live script that ran no longer carries the deferred type its new copy has.
|
|
const scripts = (parts: Element[]) =>
|
|
JSON.stringify(parts.flatMap((el) => (el.tagName === "SCRIPT" ? el.textContent : [])));
|
|
keepUnchangedMedia(oldHost, host, scripts(oldParts) === scripts(newParts));
|
|
oldHost.replaceWith(host);
|
|
swappedHosts.push(host);
|
|
if (host.querySelector(".caption-group")) captionHosts.push(host);
|
|
}
|
|
// Run once every host is replaced, so no new script binds to a scene still to be swapped.
|
|
refreshSvgSelectorAliases();
|
|
const sceneScripts = swaps.flatMap(({ newParts }) =>
|
|
newParts
|
|
.filter((el) => el.tagName === "SCRIPT")
|
|
.map((el) => document.body.appendChild(document.importNode(el, true))),
|
|
);
|
|
await runScriptsAfterFonts(sceneScripts);
|
|
if (state.tornDown) throw new Error("the preview was torn down during the swap");
|
|
document
|
|
.querySelector(`meta[name="${SCENE_PARTS_META}"]`)
|
|
?.setAttribute("content", JSON.stringify(nextParts));
|
|
// Boot's order: bindings settle each src before media binding proxies and loads it.
|
|
for (const host of swappedHosts) applyVariableBindings(document, host);
|
|
bindMediaMetadataListeners();
|
|
// Rewound before the rewrite, so each rewritten tween re-reads its start from the reset word.
|
|
const rewindCaptionTimelines = () => {
|
|
for (const host of captionHosts) {
|
|
for (const el of [host, ...host.querySelectorAll("[data-composition-id]")]) {
|
|
const id = el.getAttribute("data-composition-id");
|
|
if (id) window.__timelines?.[id]?.totalTime?.(0, true);
|
|
}
|
|
}
|
|
};
|
|
initVfx(document.body, state.canonicalFps);
|
|
applyFetchedCaptionOverrides(captionOverrides, captionHosts, rewindCaptionTimelines);
|
|
releaseDetachedMedia();
|
|
childrenBound = false;
|
|
bindRootTimelineIfAvailable();
|
|
// Probed before the scene was nested, so the new media found no volume envelope.
|
|
for (const host of swappedHosts) {
|
|
for (const el of host.querySelectorAll<HTMLMediaElement>("video, audio")) {
|
|
volumeKeyframeCache.delete(el);
|
|
probeAndCacheVolumeKeyframes(el);
|
|
}
|
|
}
|
|
const duration = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
|
|
if (duration > 0) clock.setDuration(duration);
|
|
// Adapters drive only the elements they found at discover time; the new scene's are new.
|
|
runAdapters("discover", state.currentTime);
|
|
// The rebind above skips these when the root timeline object did not change.
|
|
applyPositionEdits(document);
|
|
// Redraw the current frame as a seek does; a playing film cut to end at or before it stops there, as at its end.
|
|
const cutShort = clock.isPlaying() && duration > 0 && state.currentTime >= duration;
|
|
transport.seek(cutShort ? duration : state.currentTime, { keepPlaying: !cutShort });
|
|
syncTimedElementVisibility(state.currentTime);
|
|
postTimeline();
|
|
};
|
|
// Only a preview served with a scene manifest can swap; elsewhere the caller reloads directly.
|
|
if (readSceneParts(document)) {
|
|
window.__hfSwapScenes = swapScenes;
|
|
registerRuntimeCleanup(() => {
|
|
delete window.__hfSwapScenes;
|
|
});
|
|
}
|
|
|
|
const picker = createPickerModule({
|
|
postMessage: (payload) => postRuntimeMessage(payload),
|
|
});
|
|
picker.installPickerApi();
|
|
|
|
syncTimedElementVisibility(
|
|
state.currentTime,
|
|
Array.from(document.querySelectorAll("video[data-start], img[data-start]")),
|
|
);
|
|
const colorGrading = createColorGradingRuntime({
|
|
lease: leasePausedMedia,
|
|
release: releasePausedMedia,
|
|
});
|
|
colorGradingRuntime = colorGrading;
|
|
registerRuntimeCleanup(() => {
|
|
colorGrading.destroy();
|
|
colorGradingRuntime = null;
|
|
});
|
|
// Per-pixel effect chains: compile once here, repaint on every seek below.
|
|
initVfx(document.body, state.canonicalFps);
|
|
|
|
const applyPlaybackRate = (nextRate: number) => {
|
|
const parsed = Number(nextRate);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
state.playbackRate = 1;
|
|
} else {
|
|
state.playbackRate = Math.max(0.1, Math.min(5, parsed));
|
|
}
|
|
state.mediaForceSyncNextTick = true;
|
|
if (state.capturedTimeline && typeof state.capturedTimeline.timeScale === "function") {
|
|
state.capturedTimeline.timeScale(state.playbackRate);
|
|
}
|
|
const mediaEls = document.querySelectorAll("video, audio");
|
|
for (const el of mediaEls) {
|
|
if (!isMediaElement(el)) continue;
|
|
try {
|
|
el.playbackRate = state.playbackRate;
|
|
} catch (err) {
|
|
// ignore unsupported values
|
|
swallow("runtime.init.site10", err);
|
|
}
|
|
}
|
|
};
|
|
|
|
// A paused seek that would reveal undecoded images holds the previous picture until they decode.
|
|
// Capped: a request that never settles must not freeze the preview; measured jumps settle in ~200 ms.
|
|
const SEEK_HOLD_CAP_MS = 1000;
|
|
let heldSeek: { time: number; apply: () => void } | null = null;
|
|
const flushHeldSeek = () => {
|
|
const held = heldSeek;
|
|
heldSeek = null;
|
|
held?.apply();
|
|
};
|
|
const applySeek = (quantized: number, options?: { keepPlaying?: boolean }) => {
|
|
webAudio.stopAll();
|
|
clock.detachAudioSource();
|
|
const wasPlaying = clock.isPlaying();
|
|
if (wasPlaying) clock.pause();
|
|
clock.seek(quantized);
|
|
state.currentTime = clock.now();
|
|
state.isPlaying = false;
|
|
state.mediaForceSyncNextTick = true;
|
|
const tl = state.capturedTimeline;
|
|
pauseTimelineIfPossible(tl);
|
|
const pageAnimations = seekTimelineAndAdapters(state.currentTime);
|
|
runAdapters("pause", 0, pageAnimations);
|
|
if (options?.keepPlaying && wasPlaying) {
|
|
transport.play();
|
|
return;
|
|
}
|
|
syncMediaForCurrentState();
|
|
colorGrading.redraw();
|
|
paintVfx(state.currentTime);
|
|
postState(true);
|
|
};
|
|
|
|
const transport: RuntimePlayerTransport = {
|
|
play: () => {
|
|
flushHeldSeek();
|
|
const tl = state.capturedTimeline;
|
|
if (clock.isPlaying()) return;
|
|
const dur = getSafeTimelineDurationSeconds(tl, 0);
|
|
if (dur > 0) {
|
|
clock.setDuration(dur);
|
|
if (clock.reachedEnd()) {
|
|
const start = clock.getPlayStart();
|
|
clock.seek(start);
|
|
state.currentTime = start;
|
|
seekTimelineAndAdapters(start);
|
|
}
|
|
} else {
|
|
const rootEl = resolveRootCompositionElement();
|
|
const declaredDur = Number(rootEl?.getAttribute("data-duration") ?? 0);
|
|
if (declaredDur > 0) clock.setDuration(declaredDur);
|
|
}
|
|
pauseTimelineIfPossible(tl);
|
|
if (!clock.play()) return;
|
|
state.isPlaying = true;
|
|
state.mediaForceSyncNextTick = true;
|
|
hardSyncAllMedia(clock.now());
|
|
// Schedule audio through WebAudio for sample-accurate timing.
|
|
// Falls back to HTMLMediaElement playback if WebAudio isn't ready
|
|
// or decoding fails (the syncRuntimeMedia path handles that).
|
|
if (webAudioReady && !state.nativeMediaSyncDisabled && !state.webAudioMediaDisabled) {
|
|
scheduleWebAudioForActiveClips();
|
|
}
|
|
runAdapters("play");
|
|
syncMediaForCurrentState();
|
|
colorGrading.redraw();
|
|
postState(true);
|
|
},
|
|
pause: () => {
|
|
if (!clock.isPlaying()) return;
|
|
webAudio.stopAll();
|
|
clock.detachAudioSource();
|
|
clock.pause();
|
|
state.isPlaying = false;
|
|
state.currentTime = clock.now();
|
|
state.mediaForceSyncNextTick = true;
|
|
hardSyncAllMedia(state.currentTime);
|
|
const tl = state.capturedTimeline;
|
|
pauseTimelineIfPossible(tl);
|
|
runAdapters("pause");
|
|
syncMediaForCurrentState();
|
|
colorGrading.redraw();
|
|
postState(true);
|
|
},
|
|
seek: (timeSeconds, options) => {
|
|
const quantized = quantizeTimeToFrame(
|
|
Math.max(0, Number(timeSeconds) || 0),
|
|
state.canonicalFps,
|
|
);
|
|
heldSeek = null;
|
|
const undecoded = clock.isPlaying() ? [] : undecodedImagesShownAt(quantized);
|
|
if (undecoded.length === 0) {
|
|
applySeek(quantized, options);
|
|
return;
|
|
}
|
|
const held = { time: quantized, apply: () => applySeek(quantized, options) };
|
|
heldSeek = held;
|
|
for (const img of undecoded) {
|
|
loadPreviewImagesIn(img);
|
|
for (let clip = img.closest(SKIPPED_CLIP); clip; clip = img.closest(SKIPPED_CLIP))
|
|
clip.setAttribute(STUDIO_PREVIEW_UPCOMING_ATTR, "");
|
|
}
|
|
let capTimer = 0;
|
|
const capped = new Promise<void>((resolve) => {
|
|
capTimer = window.setTimeout(() => {
|
|
if (heldSeek === held) swallow("runtime.init.seekHoldCap", undecoded);
|
|
resolve();
|
|
}, SEEK_HOLD_CAP_MS);
|
|
});
|
|
const decoded = Promise.all(
|
|
undecoded.map((img) => (img.decode ? img.decode().catch(() => {}) : undefined)),
|
|
);
|
|
const landed = Promise.race([decoded, capped]).then(() => {
|
|
window.clearTimeout(capTimer);
|
|
if (heldSeek === held) flushHeldSeek();
|
|
});
|
|
registerSeekCompletion(landed);
|
|
return landed;
|
|
},
|
|
renderSeek: (timeSeconds, options) => {
|
|
heldSeek = null;
|
|
renderCaptureSeekStarted = true;
|
|
const seekTime = resolveRenderSeekTime(
|
|
Math.max(0, Number(timeSeconds) || 0),
|
|
state.canonicalFps,
|
|
options,
|
|
);
|
|
webAudio.stopAll();
|
|
clock.detachAudioSource();
|
|
if (clock.isPlaying()) clock.pause();
|
|
clock.seek(seekTime);
|
|
state.currentTime = clock.now();
|
|
state.isPlaying = false;
|
|
state.mediaForceSyncNextTick = true;
|
|
const pageAnimations = seekTimelineAndAdapters(state.currentTime, {
|
|
activateChildren: true,
|
|
suppressEvents: options?.suppressEvents,
|
|
});
|
|
runAdapters("pause", 0, pageAnimations);
|
|
syncMediaForCurrentState();
|
|
colorGrading.redraw();
|
|
paintVfx(state.currentTime, { engineMode: true });
|
|
postState(true);
|
|
},
|
|
getTime: () => heldSeek?.time ?? clock.now(),
|
|
getDuration: () => {
|
|
const dur = clock.getDuration();
|
|
return Number.isFinite(dur) ? dur : 0;
|
|
},
|
|
isPlaying: () => clock.isPlaying(),
|
|
setPlaybackRate: (rate) => {
|
|
applyPlaybackRate(rate);
|
|
clock.setRate(state.playbackRate);
|
|
applyWebAudioRate();
|
|
},
|
|
getPlaybackRate: () => state.playbackRate,
|
|
};
|
|
|
|
const initialDuration = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
|
|
if (initialDuration > 0) clock.setDuration(initialDuration);
|
|
|
|
const player = createRuntimePlayer({
|
|
getTimeline: () => state.capturedTimeline,
|
|
setTimeline: (timeline) => {
|
|
state.capturedTimeline = timeline;
|
|
},
|
|
getTimelineRegistry: () =>
|
|
(window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>,
|
|
getIsPlaying: () => state.isPlaying,
|
|
setIsPlaying: (playing) => {
|
|
if (state.isPlaying !== playing) state.mediaForceSyncNextTick = true;
|
|
state.isPlaying = playing;
|
|
},
|
|
getPlaybackRate: () => state.playbackRate,
|
|
setPlaybackRate: applyPlaybackRate,
|
|
getCanonicalFps: () => state.canonicalFps,
|
|
onSyncMedia: (timeSeconds, playing) => {
|
|
state.currentTime = Math.max(0, Number(timeSeconds) || 0);
|
|
if (state.isPlaying !== playing) state.mediaForceSyncNextTick = true;
|
|
state.isPlaying = playing;
|
|
syncMediaForCurrentState();
|
|
},
|
|
onStatePost: postState,
|
|
onDeterministicSeek: (timeSeconds, options) => {
|
|
for (const adapter of state.deterministicAdapters) {
|
|
if (adapter.name === "gsap" && state.capturedTimeline) continue;
|
|
try {
|
|
adapter.seek({
|
|
time: Number(timeSeconds) || 0,
|
|
suppressEvents: options?.suppressEvents,
|
|
});
|
|
} catch (err) {
|
|
// ignore adapter failure
|
|
swallow("runtime.init.site11", err);
|
|
}
|
|
}
|
|
},
|
|
onDeterministicPause: () => runAdapters("pause"),
|
|
onDeterministicPlay: () => runAdapters("play"),
|
|
onRenderFrameSeek: () => {
|
|
colorGrading.redraw();
|
|
},
|
|
onShowNativeVideos: () => {},
|
|
getSafeDuration: () => getSafeTimelineDurationSeconds(state.capturedTimeline, 0),
|
|
transport,
|
|
});
|
|
|
|
window.__player = createPlayerApiCompat(player);
|
|
window.__playerReady = true;
|
|
|
|
emitAnalyticsEvent("composition_loaded", {
|
|
duration: player.getDuration(),
|
|
compositionId: findRootCompositionElement()?.getAttribute("data-composition-id") ?? null,
|
|
});
|
|
|
|
state.deterministicAdapters = [
|
|
createFrameSourceAdapter({
|
|
start: (element) => resolveStartForElement(element, 0),
|
|
duration: (element) => resolveDurationForElement(element),
|
|
compositionDuration: () => getSafeTimelineDurationSeconds(state.capturedTimeline, 0),
|
|
canonicalFps: () => state.canonicalFps,
|
|
exportRenderSeek: () => Boolean(window.__HF_EXPORT_RENDER_SEEK_CONFIG),
|
|
}),
|
|
createWaapiAdapter(),
|
|
createCssAdapter({
|
|
resolveStartSeconds: (element) => resolveStartForElement(element, 0),
|
|
}),
|
|
createAnimeJsAdapter(),
|
|
createLottieAdapter({
|
|
resolveStartSeconds: (element) => resolveStartForElement(element, 0),
|
|
}),
|
|
createThreeAdapter(),
|
|
createMapboxAdapter(),
|
|
createLeafletAdapter(),
|
|
createGoogleMapsAdapter(),
|
|
createMaplibreAdapter(),
|
|
createD3Adapter(),
|
|
createTypegpuAdapter(),
|
|
createGsapAdapter({ getTimeline: () => state.capturedTimeline }),
|
|
] as RuntimeDeterministicAdapter[];
|
|
patchVideoTextureCompat();
|
|
patchWebGLVideoTextureCompat();
|
|
// Lets the engine re-render GPU compositions after it injects decoded video
|
|
// frames, so video-textured WebGL/WebGPU scenes sample the correct frame.
|
|
window.__hfReseekGpu = (time: number) => {
|
|
const t = Math.max(0, Number(time) || 0);
|
|
window.__hfThreeTime = t;
|
|
window.__hfTypegpuTime = t;
|
|
forceDispatchSeekEvent(t);
|
|
};
|
|
window.__hfWaitForSeekCompletion = waitForSeekCompletion;
|
|
runtimeCleanupCallbacks.push(() => {
|
|
if (window.__hfWaitForSeekCompletion === waitForSeekCompletion) {
|
|
delete window.__hfWaitForSeekCompletion;
|
|
}
|
|
});
|
|
installRuntimeErrorDiagnostics();
|
|
bindMediaMetadataListeners();
|
|
runAdapters("discover");
|
|
const publishRenderReadyAfterTimelineBinding = () => {
|
|
const prevTimeline = state.capturedTimeline;
|
|
const rebound = bindRootTimelineIfAvailable();
|
|
if (
|
|
state.capturedTimeline &&
|
|
(rebound || state.capturedTimeline !== prevTimeline || !player._timeline)
|
|
) {
|
|
player._timeline = state.capturedTimeline;
|
|
}
|
|
const boundDuration = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
|
|
if (boundDuration > 0) {
|
|
clock.setDuration(boundDuration);
|
|
}
|
|
runAdapters("discover", state.currentTime);
|
|
// Loud, specific diagnostic for the #1 "looks fine, ships broken" trap:
|
|
// a root timeline never bound even though timelines ARE registered. Without
|
|
// this the render silently proceeds on the static build-time DOM (frozen at
|
|
// t=0). Only warn when GSAP timelines exist (CSS/WAAPI/Lottie-only
|
|
// compositions legitimately bind no GSAP timeline and use adapters).
|
|
if (!state.capturedTimeline) {
|
|
const registry = (window.__timelines ?? {}) as Record<string, unknown>;
|
|
const registeredKeys = Object.keys(registry).filter((k) => registry[k]);
|
|
if (registeredKeys.length > 0) {
|
|
const rootEl = resolveRootCompositionElement();
|
|
const rootCompositionId = rootEl?.getAttribute("data-composition-id") ?? null;
|
|
postRuntimeDiagnosticOnce(
|
|
"root_timeline_unbound_registry_present",
|
|
{
|
|
reason: rootCompositionId
|
|
? "root data-composition-id has no matching key in window.__timelines"
|
|
: "root composition element has no data-composition-id attribute",
|
|
rootCompositionId,
|
|
registeredTimelineKeys: registeredKeys,
|
|
},
|
|
"root_timeline_unbound_registry_present",
|
|
);
|
|
// eslint-disable-next-line no-console -- loud author-facing warning; this render would otherwise freeze at t=0
|
|
console.warn(
|
|
`[hyperframes] Root timeline not bound, so render will freeze at t=0. ` +
|
|
(rootCompositionId
|
|
? `Root data-composition-id is "${rootCompositionId}" but window.__timelines has no such key. `
|
|
: `Root composition element has no data-composition-id. `) +
|
|
`Registered timeline keys: [${registeredKeys.join(", ")}]. ` +
|
|
`Register the root timeline under its data-composition-id (window.__timelines["${rootCompositionId ?? "<root-id>"}"] = tl).`,
|
|
);
|
|
}
|
|
}
|
|
// Nothing else hides out-of-window clips on a paused page until someone seeks, so readiness does.
|
|
// Media is left to init's media pass and to seeks: they own color grading and audio scheduling.
|
|
syncTimedElementVisibility(
|
|
state.currentTime,
|
|
Array.from(document.querySelectorAll("[data-start]:not(video, audio, img)")),
|
|
);
|
|
// __renderReady = timeline binding attempted, safe for deterministic seeking.
|
|
// Set after any GSAP batching has completed. renderSeek works with or
|
|
// without a GSAP timeline (CSS/WAAPI/Lottie compositions use adapters only).
|
|
window.__renderReady = true;
|
|
try {
|
|
postTimeline();
|
|
} catch (err) {
|
|
window.__renderReady = false;
|
|
recordStartupError(err);
|
|
throw err;
|
|
}
|
|
postState(true);
|
|
};
|
|
|
|
let timelinesBuiltListener: (() => void) | null = null;
|
|
const waitForTimelinesBuilt = () => {
|
|
if (timelinesBuiltListener) return;
|
|
const onTimelinesBuilt = () => {
|
|
window.removeEventListener("hf-timelines-built", onTimelinesBuilt);
|
|
timelinesBuiltListener = null;
|
|
maybePublishRenderReady();
|
|
};
|
|
timelinesBuiltListener = onTimelinesBuilt;
|
|
window.addEventListener("hf-timelines-built", onTimelinesBuilt);
|
|
};
|
|
registerRuntimeCleanup(() => {
|
|
if (!timelinesBuiltListener) return;
|
|
window.removeEventListener("hf-timelines-built", timelinesBuiltListener);
|
|
timelinesBuiltListener = null;
|
|
});
|
|
|
|
maybePublishRenderReady = () => {
|
|
if (state.tornDown) return;
|
|
if (!externalCompositionsReady) {
|
|
window.__renderReady = false;
|
|
return;
|
|
}
|
|
if (window.__hfTimelinesBuilding) {
|
|
window.__renderReady = false;
|
|
waitForTimelinesBuilt();
|
|
return;
|
|
}
|
|
// Re-run discover so adapters can refresh their state from the current
|
|
// DOM — e.g. the Three.js adapter only hooks `DefaultLoadingManager` once
|
|
// it sees `window.THREE`, which may have loaded AFTER the initial
|
|
// bootstrap discover. Discover is idempotent in every adapter, so a
|
|
// second call here is cheap.
|
|
runAdapters("discover", state.currentTime);
|
|
if (!isAdapterReadinessSettled()) {
|
|
window.__renderReady = false;
|
|
return;
|
|
}
|
|
if (!isBuildReadinessSettled()) {
|
|
window.__renderReady = false;
|
|
return;
|
|
}
|
|
publishRenderReadyAfterTimelineBinding();
|
|
};
|
|
|
|
// When the GSAP tween-batching interceptor (HF_EARLY_STUB, fileServer.ts) is
|
|
// active, composition scripts queue tl.to() calls instead of executing them
|
|
// synchronously. Wait for the "hf-timelines-built" event before the first
|
|
// binding attempt so the transport clock receives the finished timeline
|
|
// duration instead of permanently publishing duration=0.
|
|
maybePublishRenderReady();
|
|
|
|
// When the bundler inlines compositions, data-composition-src is removed so
|
|
// loadCompositions() is skipped. But inline scripts registering child
|
|
// timelines in __timelines haven't executed yet (they run in the browser's next
|
|
// microtask). Defer a rebinding attempt to catch them.
|
|
if (externalCompositionsReady) {
|
|
setTimeout(() => {
|
|
maybePublishRenderReady();
|
|
}, 0);
|
|
}
|
|
let transportTickCount = 0;
|
|
let inTransportTick = false;
|
|
// Reuse a completed seek only for the exact requested time and captured timeline.
|
|
// Paused players and host ticks can share the frame without suppressing a newer time.
|
|
let lastTransportSeekTime = Number.NaN;
|
|
let lastTransportSeekTimeline: RuntimeTimelineLike | null = null;
|
|
let pausedSeekDeferredByManualGesture = false;
|
|
// Set while the transport is parked (see scheduleNextTransportFrame).
|
|
let transportParkTimerId: number | null = null;
|
|
let slowIdleHeartbeat = false;
|
|
let transportWakeRequested = false;
|
|
let parkedPollWitness = "";
|
|
let lastSeenTimingRevision = -1;
|
|
/** A composition change has been seen and not yet carried to consumers. */
|
|
let compositionChangePending = false;
|
|
let lastChangeDrivenServiceAtMs = Number.NEGATIVE_INFINITY;
|
|
let lastPlayingPollAtMs = Number.NEGATIVE_INFINITY;
|
|
let playingPollWitness = "";
|
|
let lastTickPlaying = false;
|
|
|
|
const seekRuntimeTimeline = (
|
|
timeline: RuntimeTimelineLike,
|
|
timeSeconds: number,
|
|
swallowLabel: string,
|
|
options?: RuntimeSeekOptions,
|
|
) => {
|
|
try {
|
|
const suppressEvents = options?.suppressEvents === true;
|
|
// Guarded: a partial timeline without pause() must still get its seek.
|
|
pauseTimelineIfPossible(timeline);
|
|
if (typeof timeline.totalTime === "function") {
|
|
timeline.totalTime(timeSeconds, suppressEvents);
|
|
} else {
|
|
timeline.seek(timeSeconds, suppressEvents);
|
|
}
|
|
} catch (err) {
|
|
swallow(swallowLabel, err);
|
|
}
|
|
};
|
|
|
|
const seekStandaloneRegisteredTimelines = (timeSeconds: number, options?: RuntimeSeekOptions) => {
|
|
const timelines = (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>;
|
|
const rootCompositionId =
|
|
resolveRootCompositionElement()?.getAttribute("data-composition-id") ?? null;
|
|
for (const [compositionId, timeline] of Object.entries(timelines)) {
|
|
if (!timeline || compositionId === rootCompositionId) continue;
|
|
const node = document.querySelector(`[data-composition-id="${CSS.escape(compositionId)}"]`);
|
|
if (!node) continue;
|
|
const start = resolveStartForElement(node, 0);
|
|
if (!Number.isFinite(start)) continue;
|
|
const timelineDuration = getTimelineDurationSeconds(timeline);
|
|
const sourceTime =
|
|
readElementPlaybackStart(node) +
|
|
sourceTimeAt(readElementRateSpec(node), Math.max(0, timeSeconds - start));
|
|
const localTime = Math.max(
|
|
0,
|
|
timelineDuration != null && timelineDuration > 0
|
|
? Math.min(timelineDuration, sourceTime)
|
|
: sourceTime,
|
|
);
|
|
seekRuntimeTimeline(timeline, localTime, "runtime.init.transport.childTimeline", options);
|
|
}
|
|
};
|
|
|
|
// Unpause all non-root timelines registered in window.__timelines (siblings
|
|
// in the registry, not GSAP child tweens). Matches the naming convention in
|
|
// player.ts:32 (forEachSiblingTimeline) and player.ts:89 (activateSiblingTimelines).
|
|
//
|
|
// The rearm is a means, not a resting state: GSAP will not propagate the root's
|
|
// totalTime() into a paused child. Returns what it touched so the caller can
|
|
// re-pause it; a sibling parented to gsap.globalTimeline free-runs on the global
|
|
// ticker the moment it is left unpaused. Mirrors player.ts's seek helper.
|
|
const activateSiblingTimelines = (masterTimeline: RuntimeTimelineLike): RuntimeTimelineLike[] => {
|
|
const timelines = (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>;
|
|
const rearmed: RuntimeTimelineLike[] = [];
|
|
for (const tl of Object.values(timelines)) {
|
|
if (!tl || tl === masterTimeline) continue;
|
|
// Recorded before the call: a play() that throws can still have unpaused.
|
|
rearmed.push(tl);
|
|
try {
|
|
tl.play();
|
|
} catch (err) {
|
|
swallow("runtime.init.activateSiblings", err);
|
|
}
|
|
}
|
|
return rearmed;
|
|
};
|
|
|
|
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
|
typeof value === "object" && value !== null;
|
|
|
|
const GSAP_CALLBACK_NAMES = [
|
|
"onStart",
|
|
"onUpdate",
|
|
"onComplete",
|
|
"onReverseComplete",
|
|
"onRepeat",
|
|
];
|
|
|
|
const readGsapDuration = (child: Record<string, unknown>, property: string): number | null => {
|
|
const getter = child[property];
|
|
if (typeof getter !== "function") return null;
|
|
try {
|
|
const value = Number(getter.call(child));
|
|
return Number.isFinite(value) ? value : null;
|
|
} catch (err) {
|
|
swallow("runtime.init.gsapCallbackDuration", err);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const hasZeroDurationCallbackTween = (timeline: RuntimeTimelineLike): boolean => {
|
|
if (!("getChildren" in timeline) || typeof timeline.getChildren !== "function") {
|
|
return false;
|
|
}
|
|
|
|
let children: unknown;
|
|
try {
|
|
children = timeline.getChildren(true, true, true);
|
|
} catch (err) {
|
|
swallow("runtime.init.gsapCallbackChildren", err);
|
|
return false;
|
|
}
|
|
if (!Array.isArray(children)) return false;
|
|
|
|
for (const child of children) {
|
|
if (!isObjectRecord(child)) continue;
|
|
const vars = child.vars;
|
|
if (!isObjectRecord(vars)) continue;
|
|
const hasCallback = GSAP_CALLBACK_NAMES.some((name) => typeof vars[name] === "function");
|
|
if (!hasCallback) continue;
|
|
|
|
const totalDuration = readGsapDuration(child, "totalDuration");
|
|
const duration = totalDuration ?? readGsapDuration(child, "duration");
|
|
if (duration != null && duration <= 0.000001) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
/**
|
|
* Borrow, seek, return. Siblings are unpaused only across the seek; restored in
|
|
* `finally`, because a throw mid-seek is when a leaked one starts free-running.
|
|
*/
|
|
function seekTimelineAndAdapters(
|
|
t: number,
|
|
opts?: { activateChildren?: boolean; suppressEvents?: boolean },
|
|
): () => Animation[] {
|
|
const tl = state.capturedTimeline;
|
|
// Critical for a sub-composition whose data-start is at or near 0: it is added
|
|
// to the root while the root is paused and may never receive an explicit
|
|
// play(), so without the rearm it holds its initial CSS state (opacity:0).
|
|
const rearmed = tl && opts?.activateChildren ? activateSiblingTimelines(tl) : [];
|
|
try {
|
|
const animations = seekRootChildrenAndAdapters(tl, t, opts);
|
|
lastTransportSeekTime = t;
|
|
lastTransportSeekTimeline = tl;
|
|
return animations;
|
|
} finally {
|
|
for (const sibling of rearmed) pauseTimelineIfPossible(sibling);
|
|
}
|
|
}
|
|
|
|
function seekRootChildrenAndAdapters(
|
|
tl: RuntimeTimelineLike | null,
|
|
t: number,
|
|
opts?: { activateChildren?: boolean; suppressEvents?: boolean },
|
|
): () => Animation[] {
|
|
const suppressEvents = opts?.suppressEvents === true;
|
|
if (tl) {
|
|
// #10: when data-duration exceeds the timeline's intrinsic length the
|
|
// engine requests frames past the last tween. Seeking a paused GSAP
|
|
// timeline past its end can revert from()-tweens to their empty initial
|
|
// state, blanking the final poster. Clamp the MASTER seek to the
|
|
// timeline's full extent so it holds the final computed frame instead.
|
|
// Adapters still receive the raw `t` (their media may run longer).
|
|
// totalDuration() includes repeats; Infinity (infinite repeat) → no clamp.
|
|
let tlSeekTime = t;
|
|
if (typeof tl.totalDuration === "function") {
|
|
try {
|
|
const total = Number(tl.totalDuration());
|
|
if (Number.isFinite(total) && total > 0 && t > total) {
|
|
tlSeekTime = total;
|
|
}
|
|
} catch (err) {
|
|
swallow("runtime.init.transport.clampDuration", err);
|
|
}
|
|
}
|
|
try {
|
|
if (typeof tl.totalTime === "function") {
|
|
tl.totalTime(tlSeekTime, suppressEvents);
|
|
if (!suppressEvents && !hasZeroDurationCallbackTween(tl)) {
|
|
// The first seek is the only eventful one; the re-render only refreshes styles.
|
|
rerenderGsapTimelineAt(
|
|
{
|
|
totalTime: tl.totalTime.bind(tl),
|
|
totalDuration: tl.totalDuration?.bind(tl),
|
|
getChildren: tl.getChildren?.bind(tl),
|
|
},
|
|
tlSeekTime,
|
|
);
|
|
}
|
|
} else {
|
|
tl.seek(tlSeekTime, suppressEvents);
|
|
}
|
|
} catch (err) {
|
|
swallow("runtime.init.transport.seek", err);
|
|
}
|
|
// Root propagation cannot represent an authored child source offset or
|
|
// playback rate. Re-seek registered children below with their host's
|
|
// explicit source-time contract.
|
|
}
|
|
// A second `activateSiblingTimelines` used to follow this call. Dropping it is
|
|
// safe because nothing between frames READS a sibling's paused() — grepped over
|
|
// the deterministic adapters, syncTimedElementVisibility, the hf-timelines-built
|
|
// handler and __hfReseekGpu. It only ever moved the state the seek left behind.
|
|
seekStandaloneRegisteredTimelines(t, opts);
|
|
const pageAnimations = pageAnimationsForOnePass();
|
|
for (const adapter of state.deterministicAdapters) {
|
|
if (adapter.name === "gsap" && tl) continue;
|
|
try {
|
|
adapter.seek({ time: t, suppressEvents, pageAnimations });
|
|
} catch (err) {
|
|
swallow("runtime.init.transport.adapter", err);
|
|
}
|
|
}
|
|
return pageAnimations;
|
|
}
|
|
|
|
// True while the Studio is mid-drag on an element (the gesture marker is
|
|
// stamped on the gestured element for the duration of the drag). During a
|
|
// paused gesture the draft writer owns the element's transform, so the
|
|
// per-frame transport re-seek must yield to it (see transportTick).
|
|
//
|
|
// The watch is document-global (fine for today's single-composition Studio;
|
|
// revisit if a multi-composition editor needs to scope this to one root).
|
|
// It used to be a `document.querySelector` on every paused frame, described
|
|
// here as negligible; measured, it was 1.8 ms/s on a 1689-element project and
|
|
// 3.0 ms/s — 6.4% of the whole paused main-thread budget — on a media-heavy
|
|
// one. An attribute-filtered observer answers the same question in O(edits),
|
|
// and its change callback is also what un-parks the transport when a drag
|
|
// starts or ends.
|
|
const manualEditGestureWatch = createManualEditGestureWatch(document, () => {
|
|
wakeTransport();
|
|
});
|
|
runtimeCleanupCallbacks.push(() => manualEditGestureWatch.disconnect());
|
|
const hasActiveStudioManualEditGesture = (): boolean => {
|
|
try {
|
|
return manualEditGestureWatch.isActive();
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Nothing a tick would discover can change without one of the wake signals
|
|
* firing, so the loop may stand down until one does.
|
|
*/
|
|
const canParkTransport = (): boolean =>
|
|
!clock.isPlaying() &&
|
|
!isExportRenderDrivingFrames() &&
|
|
// Without MutationObserver nothing can PUSH a DOM change: neither the
|
|
// composition-timing watcher nor the gesture watch attaches, and both fall
|
|
// back to answering from the document only when asked. Looking every frame
|
|
// is then the only correct behaviour, so such a host keeps the loop it had.
|
|
//
|
|
// Unreachable today, and the reason is worth recording rather than
|
|
// trusting: `createColorGradingRuntime` constructs a MutationObserver
|
|
// unconditionally earlier in this same init (colorGrading.ts, the
|
|
// `document.body` branch), so a host without one never gets as far as a
|
|
// running transport. This stays as the explicit statement of the
|
|
// invariant, one boolean, for the day that guard is added there.
|
|
manualEditGestureWatch.observing &&
|
|
// A drop/cancel owes one reconciling seek; the gesture watch wakes the loop when it clears.
|
|
(!pausedSeekDeferredByManualGesture || hasActiveStudioManualEditGesture()) &&
|
|
// A composition change is owed a manifest post that the rate limit has
|
|
// deferred. Parking here would strand it until the next unrelated change,
|
|
// so the loop stays awake — for at most one cadence interval — until the
|
|
// post goes out.
|
|
!compositionChangePending &&
|
|
// The playhead and the bound timeline are both where the last seek left
|
|
// them, so re-seeking on the next frame would render the same frame again.
|
|
state.currentTime === lastTransportSeekTime &&
|
|
state.capturedTimeline === lastTransportSeekTimeline;
|
|
|
|
/**
|
|
* The parked loop has two jobs:
|
|
*
|
|
* 1. Keep the control bridge's paused heartbeat on its documented interval
|
|
* (`state.bridgeMaxPostIntervalMs`; a second after `set-idle-heartbeat`,
|
|
* once the whole timeline is bound) so a paused timeline confirms its position.
|
|
* 2. Re-read everything nothing can push (`readParkedPollWitness`) on that
|
|
* same beat: a timer, not a frame loop, is what keeps a paused runtime
|
|
* cheap.
|
|
*/
|
|
/**
|
|
* Everything a parked transport still has to LOOK at, because no observer
|
|
* and no event can tell it.
|
|
*
|
|
* Two inputs qualify, and both are documented at their source:
|
|
* - the timeline registry, a plain object a composition script writes into
|
|
* (see `readCompositionTimingRevision`); and
|
|
* - the adapter duration floor, which adapters infer from live animation
|
|
* objects — CSS, WAAPI, Lottie — that can lengthen with no DOM mutation
|
|
* and no media event (see the comment on `resolveAdapterDurationFloorSeconds`
|
|
* in `getSafeTimelineDurationSeconds`).
|
|
*
|
|
* Calling `readCompositionTimingRevision` here is also what DRAINS the
|
|
* mutation observer, so a record that arrived without its callback running
|
|
* is caught on this path too.
|
|
*/
|
|
const readParkedPollWitness = (): string =>
|
|
`${readCompositionTimingRevision()}|${resolveAdapterDurationFloorSeconds() ?? ""}`;
|
|
|
|
const armParkTimer = () => {
|
|
transportParkTimerId = window.setTimeout(
|
|
parkedTransportHeartbeat,
|
|
slowIdleHeartbeat && state.capturedTimeline && childrenBound
|
|
? SLOW_IDLE_HEARTBEAT_MS
|
|
: state.bridgeMaxPostIntervalMs,
|
|
);
|
|
};
|
|
|
|
const parkedTransportHeartbeat = () => {
|
|
transportParkTimerId = null;
|
|
if (state.tornDown) return;
|
|
// The page can go away with the timer still armed — an embedder discarding
|
|
// the frame, or a test environment torn down around an abandoned runtime.
|
|
// Stop rather than re-arm: there is nothing left to report a state change
|
|
// to, and every read below would throw on a missing global.
|
|
if (typeof window === "undefined" || typeof document === "undefined") return;
|
|
if (readParkedPollWitness() !== parkedPollWitness) {
|
|
// Through wakeTransport, not straight to rAF: reading the witness can
|
|
// itself wake us (the registry compare bumps the revision, and that
|
|
// invalidation hook calls wakeTransport).
|
|
wakeTransport();
|
|
return;
|
|
}
|
|
postState(false);
|
|
armParkTimer();
|
|
};
|
|
|
|
const scheduleNextTransportFrame = () => {
|
|
state.transportRafId = null;
|
|
if (state.tornDown) return;
|
|
const woken = transportWakeRequested;
|
|
transportWakeRequested = false;
|
|
if (woken || !canParkTransport()) {
|
|
state.transportRafId = window.requestAnimationFrame(transportTick);
|
|
return;
|
|
}
|
|
// Read fresh rather than reusing the tick's own `timingRevision`: the tick
|
|
// may have run postTimeline, which stamps authored-timing attributes the
|
|
// observer watches. Parking on the pre-postTimeline revision would make
|
|
// the very first heartbeat see a change and wake for the loop's own writes.
|
|
parkedPollWitness = readParkedPollWitness();
|
|
armParkTimer();
|
|
};
|
|
|
|
wakeTransport = () => {
|
|
if (state.tornDown) return;
|
|
// Inside a tick the tail is the only scheduler; setting the flag there is
|
|
// what stops it parking on work it has just been told about.
|
|
transportWakeRequested = true;
|
|
if (inTransportTick || state.transportRafId != null) return;
|
|
if (transportParkTimerId != null) {
|
|
window.clearTimeout(transportParkTimerId);
|
|
transportParkTimerId = null;
|
|
}
|
|
transportWakeRequested = false;
|
|
state.transportRafId = window.requestAnimationFrame(transportTick);
|
|
};
|
|
|
|
const followedOrLongestRunningAudio = (
|
|
followed: HTMLMediaElement | null,
|
|
): { el: HTMLMediaElement; start: number } | null => {
|
|
const audioEls = document.querySelectorAll("audio[data-start]");
|
|
let longest: { el: HTMLMediaElement; start: number; runsUntil: number } | null = null;
|
|
for (const el of followed ? [followed, ...audioEls] : audioEls) {
|
|
if (!isMediaElement(el) || !el.isConnected) continue;
|
|
if (isSilencedByHidden(el) || isUnplayable(el) || (el.ended && !el.loop)) continue;
|
|
if (!el.hasAttribute("src") && !el.querySelector("source[src]")) continue;
|
|
const start = resolveAbsoluteMediaStartSeconds(el);
|
|
const kept = playedMediaWindow(el, start);
|
|
if (!Number.isFinite(start) || !isInClipWindow(state.currentTime, kept.start, kept.end))
|
|
continue;
|
|
if (el === followed) return { el, start };
|
|
const runsUntil = start + (resolveMediaElementDurationSeconds(el) ?? Infinity);
|
|
if (!longest || runsUntil > longest.runsUntil) longest = { el, start, runsUntil };
|
|
}
|
|
return longest;
|
|
};
|
|
|
|
function refreshTransportClockSource(): void {
|
|
// Audio-master clock: three tiers of timing precision.
|
|
// 1. WebAudio (AudioContext.currentTime) while it plays a decoded buffer: ~21µs, sample-accurate
|
|
// 2. HTMLMediaElement (audio.currentTime): ~33ms, frame-accurate
|
|
// 3. Monotonic (performance.now()): ~1ms, no audio coupling
|
|
if (clock.isPlaying() && !state.mediaOutputMuted) {
|
|
if (
|
|
!state.nativeMediaSyncDisabled &&
|
|
!state.webAudioMediaDisabled &&
|
|
webAudio.ownsClock() &&
|
|
webAudio.context
|
|
) {
|
|
const webAudioTime = webAudio.getTime();
|
|
if (webAudioTime >= 0) {
|
|
clock.attachAudioSource({ currentTimeSeconds: webAudioTime });
|
|
}
|
|
} else {
|
|
const source = followedOrLongestRunningAudio(clock.audioElement());
|
|
if (source && !source.el.paused) {
|
|
clock.attachAudioSource({
|
|
el: source.el,
|
|
compositionStart: source.start,
|
|
mediaStart: readElementPlaybackStart(source.el),
|
|
rate: readElementRateSpec(source.el),
|
|
});
|
|
} else if (source && source.el.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
|
|
// Audio is buffering — freeze visuals at last known position
|
|
// instead of falling through to monotonic (which runs ahead).
|
|
clock.attachAudioSource({ currentTimeSeconds: state.currentTime });
|
|
} else if (clock.hasAudioSource()) {
|
|
clock.detachAudioSource();
|
|
}
|
|
}
|
|
} else if (clock.hasAudioSource()) {
|
|
clock.detachAudioSource();
|
|
}
|
|
}
|
|
|
|
const transportTick = () => {
|
|
if (state.tornDown || inTransportTick) return;
|
|
inTransportTick = true;
|
|
try {
|
|
transportTickCount += 1;
|
|
|
|
// The jobs below run when the composition revision moves (rate-limited). What no
|
|
// observer sees is polled: on the frame counter paused, on a timer playing.
|
|
const timingRevision = readCompositionTimingRevision();
|
|
if (timingRevision !== lastSeenTimingRevision) {
|
|
lastSeenTimingRevision = timingRevision;
|
|
compositionChangePending = true;
|
|
}
|
|
const nowMs = Date.now();
|
|
const playing = clock.isPlaying();
|
|
const playingPollDue = playing && nowMs - lastPlayingPollAtMs >= PLAYING_POLL_INTERVAL_MS;
|
|
const stoppedPlaying = lastTickPlaying && !playing;
|
|
lastTickPlaying = playing;
|
|
if (playingPollDue) {
|
|
lastPlayingPollAtMs = nowMs;
|
|
const witness = readParkedPollWitness();
|
|
if (witness !== playingPollWitness) {
|
|
playingPollWitness = witness;
|
|
compositionChangePending = true;
|
|
}
|
|
}
|
|
const changeDrivenService =
|
|
compositionChangePending &&
|
|
nowMs - lastChangeDrivenServiceAtMs >= CHANGE_DRIVEN_SERVICE_MIN_INTERVAL_MS;
|
|
if (changeDrivenService) lastChangeDrivenServiceAtMs = nowMs;
|
|
const pausedCounterDue = (interval: number) =>
|
|
!playing && transportTickCount % interval === 0;
|
|
|
|
// Slower operations: timeline binding (~every 60 frames / ~1s at 60fps).
|
|
// `compositionChanged` goes IN to the policy, never around it: the policy
|
|
// owns the hold that keeps an async rebind off the first two seconds of
|
|
// playback, and ORing past it removed that hold.
|
|
if (
|
|
shouldAttemptPeriodicTimelineBind({
|
|
tick: transportTickCount,
|
|
isPlaying: playing,
|
|
hasCapturedTimeline: state.capturedTimeline != null,
|
|
currentTimeSeconds: clock.now(),
|
|
compositionChanged: changeDrivenService,
|
|
playingPollDue,
|
|
})
|
|
) {
|
|
const prevTimeline = state.capturedTimeline;
|
|
if (bindRootTimelineIfAvailable()) {
|
|
if (state.capturedTimeline && !player._timeline) {
|
|
player._timeline = state.capturedTimeline;
|
|
}
|
|
if (state.capturedTimeline && state.capturedTimeline !== prevTimeline) {
|
|
pauseTimelineIfPossible(state.capturedTimeline);
|
|
}
|
|
const dur = getSafeTimelineDurationSeconds(state.capturedTimeline, 0, timingRevision);
|
|
if (dur > 0) clock.setDuration(dur);
|
|
postTimeline();
|
|
}
|
|
}
|
|
if (changeDrivenService || pausedCounterDue(TIMELINE_POST_INTERVAL_FRAMES)) {
|
|
// The manifest is what carries a composition change to its consumers,
|
|
// so posting it is what discharges the pending flag — whichever path
|
|
// got here. Clearing it when the change is merely SEEN would drop it
|
|
// whenever the rate limit deferred the post.
|
|
//
|
|
// And clearing it AFTER the post, not before: postTimeline walks author
|
|
// DOM and can throw. The tail scheduler runs in this tick's `finally`
|
|
// either way, so clearing first would let it see nothing owed and park
|
|
// with the change undelivered — permanently, until some unrelated
|
|
// change happens to wake the loop again.
|
|
postTimeline();
|
|
compositionChangePending = false;
|
|
} else if (playingPollDue || stoppedPlaying) {
|
|
postTimelineIfManifestChanged();
|
|
}
|
|
if (changeDrivenService || pausedCounterDue(MEDIA_BIND_INTERVAL_FRAMES)) {
|
|
bindMediaMetadataListeners();
|
|
}
|
|
|
|
// Sync clock duration with the resolved timeline each tick (catches async
|
|
// rebinds, live data-duration edits). Never shrink while playing — transient
|
|
// short reads cause reachedEnd() → playhead jumps to end (#1636).
|
|
if (state.capturedTimeline) {
|
|
const dur = getSafeTimelineDurationSeconds(state.capturedTimeline, 0, timingRevision);
|
|
if (dur > 0 && (!clock.isPlaying() || dur >= clock.getDuration())) {
|
|
clock.setDuration(dur);
|
|
}
|
|
}
|
|
|
|
refreshTransportClockSource();
|
|
|
|
const t = clock.now();
|
|
state.currentTime = t;
|
|
// During a paused Studio manual-edit drag, the draft writer owns the
|
|
// gestured element's transform (e.g. gsap.set for x/y). Re-seeking the
|
|
// timeline every frame re-applies the animated value and clobbers the
|
|
// draft, freezing the element while only the selection box tracks the
|
|
// cursor. The playhead does not advance during a paused gesture, so
|
|
// skipping the re-seek is a no-op for every other element; it resumes
|
|
// the frame the gesture marker clears (drop/cancel). Playback is never
|
|
// affected — the seek runs whenever the clock is playing.
|
|
const isPlaying = clock.isPlaying();
|
|
const manualEditOwnsPausedFrame = !isPlaying && hasActiveStudioManualEditGesture();
|
|
if (manualEditOwnsPausedFrame) {
|
|
// Force one reconciliation after drop/cancel even though the playhead did not move.
|
|
pausedSeekDeferredByManualGesture = true;
|
|
} else if (
|
|
isPlaying ||
|
|
pausedSeekDeferredByManualGesture ||
|
|
t !== lastTransportSeekTime ||
|
|
state.capturedTimeline !== lastTransportSeekTimeline
|
|
) {
|
|
seekTimelineAndAdapters(t);
|
|
if (!isPlaying) pausedSeekDeferredByManualGesture = false;
|
|
}
|
|
if (isPlaying) {
|
|
colorGrading.redrawAnimated();
|
|
}
|
|
|
|
// The clock stops at its end (GSAP's repeat:-1 is bypassed: we drive
|
|
// tl.totalTime(t) directly); the parent loops by seeking to its range
|
|
// start, or 0, and playing again.
|
|
if (clock.isPlaying() && clock.reachedEnd()) {
|
|
webAudio.stopAll();
|
|
clock.detachAudioSource();
|
|
clock.pause();
|
|
state.isPlaying = false;
|
|
const stop = clock.getStopTime();
|
|
if (Number.isFinite(stop)) {
|
|
clock.seek(stop);
|
|
state.currentTime = stop;
|
|
seekTimelineAndAdapters(stop);
|
|
}
|
|
runAdapters("pause");
|
|
syncMediaForCurrentState(timingRevision);
|
|
postState(true);
|
|
return;
|
|
}
|
|
|
|
if (clock.isPlaying()) {
|
|
syncMediaForCurrentState(timingRevision);
|
|
} else if (hasRunningTimedMedia()) {
|
|
// Nothing may run while the clock is paused, and the paused side used to
|
|
// police nothing. Dropping the cursor forces a full visit: the seek-window
|
|
// index skips an element that started outside the current window, which is
|
|
// exactly the one to stop.
|
|
lastSyncedMediaTimeSeconds = null;
|
|
syncMediaForCurrentState(timingRevision);
|
|
}
|
|
postState(false);
|
|
} finally {
|
|
inTransportTick = false;
|
|
// Re-arm here, not at the top: the reached-end branch returns early and
|
|
// must still schedule, and the decision to park can only be made once
|
|
// the tick has settled the playhead.
|
|
scheduleNextTransportFrame();
|
|
}
|
|
};
|
|
|
|
const hardSyncAllMedia = (timeSeconds: number) => {
|
|
const mediaEls = document.querySelectorAll("video, audio");
|
|
for (const el of mediaEls) {
|
|
if (!isMediaElement(el)) continue;
|
|
if (!el.isConnected) continue;
|
|
if (!el.hasAttribute("data-start")) continue;
|
|
const start = resolveAbsoluteMediaStartSeconds(el);
|
|
if (!Number.isFinite(start)) continue;
|
|
const kept = playedMediaWindow(el, start);
|
|
if (!isInClipWindow(timeSeconds, kept.start, kept.end)) continue;
|
|
const mediaStart = readElementPlaybackStart(el);
|
|
const relTime = timeSeconds - start + mediaStart;
|
|
if (relTime >= 0) {
|
|
try {
|
|
el.currentTime = relTime;
|
|
} catch {
|
|
// ignore seek restrictions
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Player methods route through the TransportClock.
|
|
// Schedule WebAudio playback for every in-window audio clip, bounding each
|
|
// buffer to its clip window (own data-duration AND the remaining host
|
|
// composition window) so trimmed / sub-composition-nested clips stop at the
|
|
// same edge as the HTMLMedia path. Reused by play() and by the rate-change
|
|
// handler (a rate change can't rescale a bounded source in place).
|
|
const scheduleWebAudioForActiveClips = () => {
|
|
if (state.nativeMediaSyncDisabled || state.webAudioMediaDisabled) return;
|
|
const gen = webAudio.startGeneration();
|
|
for (const rawEl of webAudioMediaIn(document)) {
|
|
if (isSilencedByHidden(rawEl)) continue;
|
|
const compStart = resolveAbsoluteMediaStartSeconds(rawEl);
|
|
if (!Number.isFinite(compStart)) continue;
|
|
const mediaStart = readElementPlaybackStart(rawEl);
|
|
const volumeAttr = Number.parseFloat(rawEl.dataset.volume ?? "");
|
|
const vol = Number.isFinite(volumeAttr) ? volumeAttr : 1;
|
|
const durationAttr = parseStrictFiniteTimingNumber(rawEl.dataset.duration);
|
|
let clipDuration =
|
|
durationAttr != null && durationAttr > 0 ? durationAttr : Number.POSITIVE_INFINITY;
|
|
const { inheritedStart, inheritedDuration } = resolveMediaCompositionContext(rawEl);
|
|
if (inheritedStart != null && inheritedDuration != null && inheritedDuration > 0) {
|
|
clipDuration = Math.min(
|
|
clipDuration,
|
|
Math.max(0, inheritedStart + inheritedDuration - compStart),
|
|
);
|
|
}
|
|
const slots = resolveMediaHostSlots(rawEl);
|
|
const kept = cutToHostSlots({ start: compStart, end: compStart + clipDuration }, slots);
|
|
if (slots.length > 0) {
|
|
if (kept.end <= kept.start) continue;
|
|
clipDuration = kept.end - compStart;
|
|
}
|
|
// Decided BEFORE the transport is asked, because the two verdicts want
|
|
// two different fallback chains — and only one of them is the chain
|
|
// that existed before (#3458).
|
|
const route = classifyWebAudioMediaRoute(rawEl);
|
|
reportWebAudioMediaRoute(rawEl, route);
|
|
// The cross-origin verdict's BEST outcome is decode, since a CDN that
|
|
// sends `Access-Control-Allow-Origin` (the author just never wrote the
|
|
// `crossorigin` attribute) decodes fine and keeps the whole FX graph.
|
|
const capture =
|
|
route.kind === "web-audio"
|
|
? webAudio.scheduleMediaElementPlayback(
|
|
rawEl,
|
|
compStart,
|
|
mediaStart,
|
|
clock.now(),
|
|
vol,
|
|
gen,
|
|
state.playbackRate,
|
|
)
|
|
: Promise.resolve(null);
|
|
void capture.then((scheduled) => {
|
|
const replacedByNewerPass = gen !== webAudio.currentGeneration();
|
|
// A video's picture must keep playing from the element, so it has no decode fallback.
|
|
if (scheduled || !isAudioElement(rawEl) || !clock.isPlaying() || replacedByNewerPass)
|
|
return;
|
|
// Decoded buffers cannot follow a rate curve without shifting pitch; the media element can.
|
|
if (typeof readElementRateSpec(rawEl) !== "number") return;
|
|
const effectiveRate = state.playbackRate * readElementPlaybackRate(rawEl);
|
|
// Deliberately the FX/automation pair and NOT
|
|
// `nativeUnexpressibleProcessing()`, which this route's diagnostic uses.
|
|
// The two answer different questions: the diagnostic lists everything
|
|
// native output cannot carry (group bus and above-unity gain included),
|
|
// while this decides whether losing the graph is worse than silence.
|
|
// Widening it here would newly mute tracks that play today — a grouped
|
|
// clip at a non-unit rate among them — which is a behaviour change
|
|
// #3458 does not call for.
|
|
const hasProcessing =
|
|
rawEl.hasAttribute("data-fx-chain") || rawEl.hasAttribute("data-automation");
|
|
// A decoded AudioBufferSourceNode changes pitch whenever its playback
|
|
// rate is non-unit. Bare tracks may safely stay on native output; a
|
|
// processed track must fail closed rather than silently lose its graph.
|
|
if (Math.abs(effectiveRate - 1) > 1e-9) {
|
|
// ...but only when the transport TRIED and failed. On the
|
|
// cross-origin route capture was withheld on purpose, and native
|
|
// output is the fix — muting here would hand back the exact
|
|
// silence #3458 is about, now with the runtime's own blessing. The
|
|
// dropped processing is reported instead (`reportWebAudioMediaRoute`).
|
|
if (route.kind === "web-audio" && hasProcessing) rawEl.muted = true;
|
|
return;
|
|
}
|
|
void webAudio.decodeAudioElement(rawEl).then((buffer) => {
|
|
if (!buffer || !clock.isPlaying()) return;
|
|
void webAudio.schedulePlayback(
|
|
rawEl,
|
|
buffer,
|
|
compStart,
|
|
mediaStart,
|
|
() => clock.now(),
|
|
vol,
|
|
gen,
|
|
state.playbackRate,
|
|
clipDuration,
|
|
kept.start,
|
|
);
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
// Apply a new playback rate to the WebAudio transport. Unbounded sources are
|
|
// rescaled in place; but a bounded source's window was baked into start()'s
|
|
// duration at its prior rate and can't be rescaled, so when one is active we
|
|
// stopAll()+reschedule at the new rate to keep trimmed clips ending on time.
|
|
function applyWebAudioRate() {
|
|
const changed = webAudio.setRate(state.playbackRate);
|
|
if (
|
|
changed &&
|
|
!state.nativeMediaSyncDisabled &&
|
|
!state.webAudioMediaDisabled &&
|
|
webAudioReady &&
|
|
clock.isPlaying() &&
|
|
webAudio.hasBoundedActiveSources()
|
|
) {
|
|
webAudio.stopAll();
|
|
scheduleWebAudioForActiveClips();
|
|
}
|
|
}
|
|
|
|
// Sync clock duration from any captured timeline
|
|
if (state.capturedTimeline) {
|
|
const dur = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
|
|
if (dur > 0) clock.setDuration(dur);
|
|
pauseTimelineIfPossible(state.capturedTimeline);
|
|
}
|
|
|
|
installPositionEditsSeekReapply(window as Window & typeof globalThis);
|
|
|
|
// Start the rAF tick loop
|
|
state.transportRafId = window.requestAnimationFrame(transportTick);
|
|
postTimeline();
|
|
postState(true);
|
|
|
|
// Wire the control bridge LAST — after every transport helper its handlers
|
|
// dispatch to (seekTimelineAndAdapters, applyWebAudioRate, ...) is declared.
|
|
// The runtime's external control surface only goes live once all of its
|
|
// dependencies exist, so a load-time seek / set-playback-rate can never reach
|
|
// a not-yet-initialized helper (the 'before initialization' TDZ this fixes).
|
|
state.controlBridgeHandler = installRuntimeControlBridge({
|
|
onPlay: () => {
|
|
player.play();
|
|
emitAnalyticsEvent("composition_played", { time: player.getTime() });
|
|
},
|
|
onPause: () => {
|
|
player.pause();
|
|
emitAnalyticsEvent("composition_paused", { time: player.getTime() });
|
|
},
|
|
onStopMedia: () => {
|
|
webAudio.stopAll();
|
|
const mediaEls = document.querySelectorAll("video, audio");
|
|
for (const el of mediaEls) {
|
|
if (isMediaElement(el) && !el.paused) el.pause();
|
|
}
|
|
},
|
|
onSeek: (timeSeconds, _seekMode) => {
|
|
player.seek(timeSeconds);
|
|
emitAnalyticsEvent("composition_seeked", { time: timeSeconds });
|
|
},
|
|
onSetMuted: (muted) => {
|
|
state.bridgeMuted = muted;
|
|
const effective = muted || state.mediaOutputMuted;
|
|
webAudio.setMuted(effective);
|
|
const mediaEls = document.querySelectorAll("video, audio");
|
|
for (const el of mediaEls) {
|
|
if (!isMediaElement(el)) continue;
|
|
el.muted = effective || el.defaultMuted;
|
|
}
|
|
},
|
|
onSetVolume: (volume) => {
|
|
state.bridgeVolume = volume;
|
|
webAudio.setVolume(volume);
|
|
const mediaEls = document.querySelectorAll("video, audio");
|
|
for (const el of mediaEls) {
|
|
if (!isMediaElement(el)) continue;
|
|
const parsed = parseFloat(el.dataset.volume ?? "");
|
|
const clipVolume = Number.isFinite(parsed) ? parsed : 1;
|
|
// `data-volume` carries authored gain, which goes above unity now that
|
|
// the ceiling is 12 dB — and `el.volume` is spec-pinned to [0,1], so
|
|
// assigning the product raw THROWS IndexSizeError and takes the rest of
|
|
// the loop with it. The element carries the legal part; the boost above
|
|
// unity belongs to Web Audio, which already has it from `setVolume`.
|
|
//
|
|
// Through `clampNativeMediaVolume` rather than an inline clamp: that
|
|
// helper exists in `audioGain.ts` for exactly this bound and is what
|
|
// `withUnclampedVolume` uses, so the two cannot drift.
|
|
el.volume = clampNativeMediaVolume(clipVolume * volume);
|
|
}
|
|
},
|
|
onSetMediaOutputMuted: (muted) => {
|
|
state.mediaOutputMuted = muted;
|
|
const effective = muted || state.bridgeMuted;
|
|
webAudio.setMuted(effective);
|
|
const mediaEls = document.querySelectorAll("video, audio");
|
|
for (const el of mediaEls) {
|
|
if (!isMediaElement(el)) continue;
|
|
el.muted = effective || el.defaultMuted;
|
|
}
|
|
},
|
|
onSetNativeMediaSyncDisabled: (disabled) => {
|
|
if (state.nativeMediaSyncDisabled === disabled) return;
|
|
state.nativeMediaSyncDisabled = disabled;
|
|
state.mediaForceSyncNextTick = true;
|
|
if (disabled) {
|
|
webAudio.stopAll();
|
|
clock.detachAudioSource();
|
|
} else {
|
|
syncMediaForCurrentState();
|
|
}
|
|
},
|
|
onSetWebAudioMediaDisabled: (disabled) => {
|
|
if (state.webAudioMediaDisabled === disabled) return;
|
|
state.webAudioMediaDisabled = disabled;
|
|
state.mediaForceSyncNextTick = true;
|
|
if (disabled) {
|
|
webAudio.stopAll();
|
|
clock.detachAudioSource();
|
|
syncMediaForCurrentState();
|
|
} else {
|
|
syncMediaForCurrentState();
|
|
}
|
|
},
|
|
onSetPlaybackRate: (rate) => {
|
|
applyPlaybackRate(rate);
|
|
if (state.transportClock) state.transportClock.setRate(state.playbackRate);
|
|
applyWebAudioRate();
|
|
},
|
|
onSetDisplayScale: (scale) => {
|
|
setProxyDisplayScale(scale);
|
|
setPreviewRasterScale(scale);
|
|
},
|
|
onSetIdleHeartbeat: (slow) => {
|
|
slowIdleHeartbeat = slow;
|
|
wakeTransport();
|
|
},
|
|
onSetRootDuration: growRootDurationLive,
|
|
onSetPlayRange: (startSeconds, endSeconds) => {
|
|
clock.setPlayRange(startSeconds, endSeconds, state.canonicalFps);
|
|
const start = clock.getPlayStart();
|
|
if (clock.isPlaying() && (clock.now() < start || clock.reachedEnd())) {
|
|
transport.seek(start, { keepPlaying: true });
|
|
return;
|
|
}
|
|
postState(true);
|
|
},
|
|
onSetColorGrading: (target, grading) => {
|
|
colorGrading.setGrading(target, grading);
|
|
},
|
|
onSetColorGradingCompare: (target, compare) => {
|
|
colorGrading.setCompare(target, compare);
|
|
},
|
|
onTick: () => {
|
|
if (state.tornDown || !clock.isPlaying()) return;
|
|
refreshTransportClockSource();
|
|
const t = clock.now();
|
|
state.currentTime = t;
|
|
if (t !== lastTransportSeekTime || state.capturedTimeline !== lastTransportSeekTimeline) {
|
|
seekTimelineAndAdapters(t);
|
|
}
|
|
if (clock.reachedEnd()) {
|
|
webAudio.stopAll();
|
|
clock.detachAudioSource();
|
|
clock.pause();
|
|
state.isPlaying = false;
|
|
const stop = clock.getStopTime();
|
|
if (Number.isFinite(stop)) {
|
|
clock.seek(stop);
|
|
state.currentTime = stop;
|
|
seekTimelineAndAdapters(stop);
|
|
}
|
|
runAdapters("pause");
|
|
syncMediaForCurrentState();
|
|
postState(true);
|
|
}
|
|
},
|
|
onEnablePickMode: () => picker.enablePickMode(),
|
|
onDisablePickMode: () => picker.disablePickMode(),
|
|
onSetRuntimeData: setRuntimeData,
|
|
onClearRuntimeData: clearRuntimeData,
|
|
getCanonicalFps: () => state.canonicalFps,
|
|
});
|
|
|
|
const teardown = () => {
|
|
if (state.tornDown) return;
|
|
state.tornDown = true;
|
|
if (state.transportRafId != null) {
|
|
window.cancelAnimationFrame(state.transportRafId);
|
|
state.transportRafId = null;
|
|
}
|
|
if (transportParkTimerId != null) {
|
|
window.clearTimeout(transportParkTimerId);
|
|
transportParkTimerId = null;
|
|
}
|
|
state.transportClock = null;
|
|
webAudio.destroy();
|
|
if (metadataRebindDebounceTimerId != null) {
|
|
window.clearTimeout(metadataRebindDebounceTimerId);
|
|
metadataRebindDebounceTimerId = null;
|
|
}
|
|
if (rootStageDiagnosticRafId != null) {
|
|
window.cancelAnimationFrame(rootStageDiagnosticRafId);
|
|
rootStageDiagnosticRafId = null;
|
|
}
|
|
unbindMediaMetadataListeners();
|
|
if (state.controlBridgeHandler) {
|
|
window.removeEventListener("message", state.controlBridgeHandler);
|
|
state.controlBridgeHandler = null;
|
|
}
|
|
if (runtimeErrorListener) {
|
|
window.removeEventListener("error", runtimeErrorListener);
|
|
runtimeErrorListener = null;
|
|
}
|
|
if (runtimeUnhandledRejectionListener) {
|
|
window.removeEventListener("unhandledrejection", runtimeUnhandledRejectionListener);
|
|
runtimeUnhandledRejectionListener = null;
|
|
}
|
|
if (state.beforeUnloadHandler) {
|
|
window.removeEventListener("beforeunload", state.beforeUnloadHandler);
|
|
state.beforeUnloadHandler = null;
|
|
}
|
|
picker.disablePickMode();
|
|
for (const adapter of state.deterministicAdapters) {
|
|
if (!adapter || typeof adapter.revert !== "function") continue;
|
|
try {
|
|
adapter.revert();
|
|
} catch (err) {
|
|
// keep runtime resilient against adapter cleanup failures
|
|
swallow("runtime.init.site12", err);
|
|
}
|
|
}
|
|
state.deterministicAdapters = [];
|
|
// A stale, never-resolved buildReady promise from the torn-down composition
|
|
// would otherwise permanently block render-ready for whatever loads next
|
|
// into this window, since window.__hf itself is never reset.
|
|
if (window.__hf?.buildReady) window.__hf.buildReady = {};
|
|
for (const cleanup of runtimeCleanupCallbacks.splice(0)) {
|
|
try {
|
|
cleanup();
|
|
} catch (err) {
|
|
// ignore cleanup failures
|
|
swallow("runtime.init.site13", err);
|
|
}
|
|
}
|
|
for (const styleEl of state.injectedCompStyles) {
|
|
try {
|
|
styleEl.remove();
|
|
} catch (err) {
|
|
// ignore cleanup failures
|
|
swallow("runtime.init.site14", err);
|
|
}
|
|
}
|
|
state.injectedCompStyles = [];
|
|
for (const linkEl of state.injectedCompLinks) {
|
|
try {
|
|
linkEl.remove();
|
|
} catch (err) {
|
|
// ignore cleanup failures
|
|
swallow("runtime.init.site15", err);
|
|
}
|
|
}
|
|
state.injectedCompLinks = [];
|
|
for (const scriptEl of state.injectedCompScripts) {
|
|
try {
|
|
scriptEl.remove();
|
|
} catch (err) {
|
|
// ignore cleanup failures
|
|
swallow("runtime.init.site16", err);
|
|
}
|
|
}
|
|
state.injectedCompScripts = [];
|
|
state.capturedTimeline = null;
|
|
reconcileTimelineAfterRuntimeData = () => undefined;
|
|
if (window.__hfRuntimeTeardown === teardown) {
|
|
window.__hfRuntimeTeardown = null;
|
|
}
|
|
};
|
|
window.__hfRuntimeTeardown = teardown;
|
|
state.beforeUnloadHandler = teardown;
|
|
window.addEventListener("beforeunload", state.beforeUnloadHandler);
|
|
}
|