From 1f5b24aa146b640e25f23717640cd0237e240ebf Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 18:55:12 -0400 Subject: [PATCH 1/6] perf(player): scope preview readiness to first frame --- .../core/src/compositionReadiness.test.ts | 59 +++++++++++++ packages/core/src/compositionReadiness.ts | 83 +++++++++++++++++-- packages/core/src/runtime/init.ts | 14 ++-- packages/player/src/hyperframes-player.ts | 6 +- 4 files changed, 149 insertions(+), 13 deletions(-) diff --git a/packages/core/src/compositionReadiness.test.ts b/packages/core/src/compositionReadiness.test.ts index 157a3ceb36..ae92d91343 100644 --- a/packages/core/src/compositionReadiness.test.ts +++ b/packages/core/src/compositionReadiness.test.ts @@ -63,6 +63,25 @@ describe("scanPendingCompositionAssets", () => { const scan = scanPendingCompositionAssets(docWith('')); expect(scan.pendingImages).toHaveLength(1); }); + + it("scopes first-frame scans to assets active at t=0", () => { + const doc = docWith( + '' + + '', + ); + const scan = scanPendingCompositionAssets(doc, { scope: "first-frame" }); + + expect(scan.pendingImages.map((image) => image.id)).toEqual(["first"]); + expect(scan.pendingMedia.map((media) => media.id)).toEqual([]); + }); + + it("keeps untimed media in the first-frame scan", () => { + const doc = docWith(''); + + const scan = scanPendingCompositionAssets(doc, { scope: "first-frame" }); + + expect(scan.pendingMedia.map((media) => media.id)).toEqual(["untimed"]); + }); }); describe("mediaReadinessInput", () => { @@ -96,6 +115,46 @@ describe("mediaReadinessInput", () => { expect(result).toEqual({ timedOut: false }); vi.useRealTimers(); }); + + it("does not wait for a later first-frame video", async () => { + const doc = docWith( + '' + + '', + ); + const image = doc.querySelector("#first")!; + Object.defineProperty(image, "complete", { value: false }); + let resolveImage!: () => void; + image.decode = () => new Promise((resolve) => (resolveImage = resolve)); + const pending = mediaReadinessInput(doc, new AbortController().signal, { + scope: "first-frame", + }); + + expect(pending).not.toBeNull(); + resolveImage(); + await expect(pending).resolves.toBeUndefined(); + }); + + it("waits for later media in the default full scan", async () => { + const doc = docWith( + '' + + '', + ); + const image = doc.querySelector("#first")!; + const video = doc.querySelector("#later")!; + Object.defineProperty(image, "complete", { value: false }); + image.decode = () => Promise.resolve(); + Object.defineProperty(video, "readyState", { value: 0, configurable: true }); + const pending = mediaReadinessInput(doc, new AbortController().signal, { scope: "all" }); + + let settled = false; + pending?.then(() => { + settled = true; + }); + await flushMicrotasks(); + expect(settled).toBe(false); + video.dispatchEvent(new Event("canplay")); + await expect(pending).resolves.toBeUndefined(); + }); }); describe("computeReadinessInput", () => { diff --git a/packages/core/src/compositionReadiness.ts b/packages/core/src/compositionReadiness.ts index cf10e4684f..ba8a8b699a 100644 --- a/packages/core/src/compositionReadiness.ts +++ b/packages/core/src/compositionReadiness.ts @@ -1,3 +1,5 @@ +import { createRuntimeStartTimeResolver } from "./runtime/startResolver.js"; + /** A composition is "ready" once every declared input settles, not just once * its duration is known. Each input returns null (nothing to wait on) or a * promise that resolves once it settles, and must stop its own pending work @@ -14,6 +16,12 @@ export interface PendingCompositionAssets { fontsLoading: boolean; } +export type CompositionReadinessScope = "all" | "first-frame"; + +export interface CompositionReadinessOptions { + scope?: CompositionReadinessScope; +} + // HTMLMediaElement.HAVE_FUTURE_DATA per spec, used as a literal because not // every DOM implementation defines the named static (e.g. happy-dom leaves // it undefined). @@ -37,12 +45,68 @@ export function isRealmHtmlMediaElement(node: Node): node is HTMLMediaElement { return node instanceof HTMLMediaElement; } +function isTimedElement(element: Element): boolean { + return element.hasAttribute("data-start") || element.hasAttribute("data-track-index"); +} + +function isActiveAtFirstFrame( + element: Element, + resolver: ReturnType, +): boolean { + let current: Element | null = element; + while (current) { + if (isTimedElement(current)) { + const tag = current.tagName.toLowerCase(); + const start = + tag === "video" || tag === "audio" + ? resolver.resolveMediaStartForElement(current) + : resolver.resolveStartForElement(current, 0); + const duration = resolver.resolveDurationForElement(current); + const end = duration != null && duration > 0 ? start + duration : Number.POSITIVE_INFINITY; + if (!(0 >= start && 0 < end)) return false; + } + current = current.parentElement; + } + return true; +} + +function shouldIncludeAsset( + element: Element, + scope: CompositionReadinessScope, + resolver: ReturnType, +): boolean { + if (scope === "all") return true; + if ( + !isTimedElement(element) && + !element.parentElement?.closest("[data-start], [data-track-index]") + ) { + return true; + } + return isActiveAtFirstFrame(element, resolver); +} + /** One DOM pass for every declared-media asset not yet ready. */ -export function scanPendingCompositionAssets(doc: Document): PendingCompositionAssets { +export function scanPendingCompositionAssets( + doc: Document, + { scope = "all" }: CompositionReadinessOptions = {}, +): PendingCompositionAssets { + const runtimeWindow = doc.defaultView as + | (Window & { + __timelines?: Record; + }) + | null; + const resolver = createRuntimeStartTimeResolver({ + documentRef: doc, + timelineRegistry: runtimeWindow?.__timelines, + includeAuthoredTimingAttrs: true, + }); const pendingMedia = Array.from(doc.querySelectorAll("video, audio")) .filter(isRealmHtmlMediaElement) + .filter((el) => shouldIncludeAsset(el, scope, resolver)) .filter((el) => el.readyState < HAVE_FUTURE_DATA); - const pendingImages = Array.from(doc.querySelectorAll("img")).filter((img) => !img.complete); + const pendingImages = Array.from(doc.querySelectorAll("img")) + .filter((img) => shouldIncludeAsset(img, scope, resolver)) + .filter((img) => !img.complete); const fontsLoading = doc.fonts?.status === "loading"; return { pendingMedia, pendingImages, fontsLoading }; } @@ -82,8 +146,12 @@ function collectPendingCompositionAssets( /** Declared-media readiness input: waits on the composition's own video, * audio, image and font-face loads. */ -export function mediaReadinessInput(doc: Document, signal: AbortSignal): Promise | null { - const scan = scanPendingCompositionAssets(doc); +export function mediaReadinessInput( + doc: Document, + signal: AbortSignal, + { scope = "all" }: CompositionReadinessOptions = {}, +): Promise | null { + const scan = scanPendingCompositionAssets(doc, { scope }); if (scan.pendingMedia.length === 0 && scan.pendingImages.length === 0 && !scan.fontsLoading) { return null; } @@ -216,10 +284,13 @@ export interface CompositionReadinessResult { export function settleCompositionReadiness( doc: Document, onSettled: (result: CompositionReadinessResult) => void, - opts: { inputs?: CompositionReadinessInput[]; timeoutMs?: number } = {}, + opts: CompositionReadinessOptions & { + inputs?: CompositionReadinessInput[]; + timeoutMs?: number; + } = {}, ): void { const inputs = opts.inputs ?? [ - mediaReadinessInput, + (inputDoc, signal) => mediaReadinessInput(inputDoc, signal, { scope: opts.scope }), computeReadinessInput, paintAndIdleReadinessInput, ]; diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 7dc7173e36..492ae5d268 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -2931,11 +2931,15 @@ export function initSandboxRuntimeModular(): void { postRuntimeMessage({ ...payload, assetsReady: assetsSettled }); if (!assetsReadyStarted) { assetsReadyStarted = true; - settleCompositionReadiness(document, ({ timedOut }) => { - if (state.tornDown) return; - assetsSettled = true; - postRuntimeMessage({ source: "hf-preview", type: "assets-ready", timedOut }); - }); + settleCompositionReadiness( + document, + ({ timedOut }) => { + if (state.tornDown) return; + assetsSettled = true; + postRuntimeMessage({ source: "hf-preview", type: "assets-ready", timedOut }); + }, + { scope: "first-frame" }, + ); } scheduleRootStageLayoutDiagnostics(); }; diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index d68099d344..fee8e533d3 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -1063,7 +1063,7 @@ class HyperframesPlayer extends HTMLElement { if (timedOut) this._warnStuckAssets(doc); this._settleAssetsReady(generation); }, - { timeoutMs: ASSETS_READY_TIMEOUT_MS }, + { scope: "first-frame", timeoutMs: ASSETS_READY_TIMEOUT_MS }, ); if (!this._assetsReady) this._startAssetsLoadingOverlayTimer(generation); } @@ -1082,7 +1082,9 @@ class HyperframesPlayer extends HTMLElement { * document can starve paint-and-idle of frames for the full 8s — that's * reported directly rather than inferred, since it can't be bounded. */ private _warnStuckAssets(doc: Document): void { - const { pendingMedia, pendingImages, fontsLoading } = scanPendingCompositionAssets(doc); + const { pendingMedia, pendingImages, fontsLoading } = scanPendingCompositionAssets(doc, { + scope: "first-frame", + }); const win = doc.defaultView as (Window & { __renderReady?: boolean }) | null; console.warn( `[hyperframes-player] assets-loading timed out after ${ASSETS_READY_TIMEOUT_MS}ms — playing anyway`, From ced54f0e57dc8bc3113a676abf3a25af881e90ac Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 20:05:47 -0400 Subject: [PATCH 2/6] refactor(core): share runtime visibility timing --- .../core/src/compositionReadiness.test.ts | 53 ++++++++++++ packages/core/src/compositionReadiness.ts | 8 +- packages/core/src/runtime/init.ts | 82 ++++++------------- packages/core/src/runtime/timeline.ts | 63 ++++++++++++++ packages/player/src/hyperframes-player.ts | 5 +- 5 files changed, 144 insertions(+), 67 deletions(-) diff --git a/packages/core/src/compositionReadiness.test.ts b/packages/core/src/compositionReadiness.test.ts index ae92d91343..f6cff1feb1 100644 --- a/packages/core/src/compositionReadiness.test.ts +++ b/packages/core/src/compositionReadiness.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { computeReadinessInput, + FIRST_FRAME_READINESS_SCOPE, mediaReadinessInput, paintAndIdleReadinessInput, scanPendingCompositionAssets, @@ -326,6 +327,58 @@ describe("paintAndIdleReadinessInput", () => { }); describe("settleCompositionReadiness", () => { + it("uses the shared first-frame scope for both runtime callsites", () => { + expect(FIRST_FRAME_READINESS_SCOPE).toBe("first-frame"); + }); + + it("does not wait for a later video through the public first-frame path", async () => { + const doc = docWith( + '' + + '', + ); + const image = doc.querySelector("#first")!; + const video = doc.querySelector("#later")!; + Object.defineProperty(image, "complete", { value: false }); + image.decode = () => Promise.resolve(); + Object.defineProperty(video, "readyState", { value: 0, configurable: true }); + + let result: { timedOut: boolean } | undefined; + settleCompositionReadiness( + doc, + (settled) => { + result = settled; + }, + { scope: FIRST_FRAME_READINESS_SCOPE, timeoutMs: 1 }, + ); + + await flushMicrotasks(); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(result).toEqual({ timedOut: false }); + }); + + it("keeps the explicit full-scan path waiting for a later video", async () => { + const doc = docWith( + '' + + '', + ); + const image = doc.querySelector("#first")!; + const video = doc.querySelector("#later")!; + Object.defineProperty(image, "complete", { value: true }); + Object.defineProperty(video, "readyState", { value: 0, configurable: true }); + + let result: { timedOut: boolean } | undefined; + settleCompositionReadiness( + doc, + (settled) => { + result = settled; + }, + { scope: "all", timeoutMs: 1 }, + ); + + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(result).toEqual({ timedOut: true }); + }); + it("defaults to media, compute and paint-and-idle together", async () => { vi.useFakeTimers(); const { win, fireFrame } = docWithFakeWindow(false); diff --git a/packages/core/src/compositionReadiness.ts b/packages/core/src/compositionReadiness.ts index ba8a8b699a..a45e262431 100644 --- a/packages/core/src/compositionReadiness.ts +++ b/packages/core/src/compositionReadiness.ts @@ -18,6 +18,8 @@ export interface PendingCompositionAssets { export type CompositionReadinessScope = "all" | "first-frame"; +export const FIRST_FRAME_READINESS_SCOPE: CompositionReadinessScope = "first-frame"; + export interface CompositionReadinessOptions { scope?: CompositionReadinessScope; } @@ -76,12 +78,6 @@ function shouldIncludeAsset( resolver: ReturnType, ): boolean { if (scope === "all") return true; - if ( - !isTimedElement(element) && - !element.parentElement?.closest("[data-start], [data-track-index]") - ) { - return true; - } return isActiveAtFirstFrame(element, resolver); } diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 492ae5d268..ee96fe7c6a 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1,6 +1,6 @@ // fallow-ignore-file code-duplication complexity import { installRuntimeControlBridge, postRuntimeMessage, setRuntimeProtocolFps } from "./bridge"; -import { isClipVisibleAt, isInClipWindow } from "./clipWindow"; +import { isInClipWindow } from "./clipWindow"; import { initRuntimeAnalytics, emitAnalyticsEvent } from "./analytics"; import { injectCompositionCssVariables } from "./getVariables"; import { createCssAdapter } from "./adapters/css"; @@ -36,7 +36,7 @@ import { probeAndCacheElementVolume, type VolumeKeyframe } from "./mediaVolumeEn import { createPickerModule } from "./picker"; import { createRuntimePlayer, type RuntimePlayerTransport } from "./player"; import { createRuntimeState } from "./state"; -import { collectRuntimeTimelinePayload } from "./timeline"; +import { collectRuntimeTimelinePayload, isRuntimeElementVisibleAt } from "./timeline"; import { resolveCompositionDuration } from "@hyperframes/parsers/composition-duration"; import { createRuntimeStartTimeResolver } from "./startResolver"; import { createClipTree } from "./clipTree"; @@ -58,11 +58,7 @@ import { isMemberGroupHidden, } from "../audioGroups"; import { clampNativeMediaVolume } from "../audioGain"; -import { - quantizeSeekTime, - quantizeTimeToFrame, - snapTimeToFrameBoundary, -} from "../inline-scripts/parityContract"; +import { quantizeSeekTime, quantizeTimeToFrame } from "../inline-scripts/parityContract"; import { createManualEditGestureWatch } from "./manualEditGestureWatch"; import type { RuntimeDeterministicAdapter, @@ -81,7 +77,7 @@ import { import { installStudioCustomEase } from "./customEase"; import { parseStrictFiniteTimingNumber, resolveMediaElementDurationSeconds } from "./playbackRate"; import { MEDIA_START_BASIS_ATTR } from "../mediaTiming"; -import { settleCompositionReadiness } from "../compositionReadiness"; +import { FIRST_FRAME_READINESS_SCOPE, settleCompositionReadiness } from "../compositionReadiness"; import { clearRuntimeData, setRuntimeData, @@ -807,54 +803,6 @@ export function initSandboxRuntimeModular(): void { } }); - const isTimedElementVisibleAt = ( - rawNode: HTMLElement, - currentTime: number, - compositionDuration: number, - ): boolean => { - const tag = rawNode.tagName.toLowerCase(); - if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") { - return false; - } - - const isMedia = tag === "video" || tag === "audio"; - const start = isMedia - ? resolveAbsoluteMediaStartSeconds(rawNode) - : resolveStartForElement(rawNode, 0); - let duration = resolveDurationForElement(rawNode); - const compId = rawNode.getAttribute("data-composition-id"); - if (compId) { - const compTimeline = (window.__timelines ?? {})[compId]; - let liveDuration: number | null = null; - if (compTimeline && typeof compTimeline.duration === "function") { - const compDur = Number(compTimeline.duration()); - if (Number.isFinite(compDur) && compDur > 0) { - liveDuration = compDur; - } - } - - const hasAuthoredTiming = - rawNode.hasAttribute("data-duration") || - rawNode.hasAttribute("data-end") || - rawNode.hasAttribute(AUTHORED_DURATION_ATTR) || - rawNode.hasAttribute(AUTHORED_END_ATTR); - - if (!hasAuthoredTiming && (duration == null || duration <= 0) && liveDuration != null) { - duration = liveDuration; - } - } - const computedEnd = - duration != null && duration > 0 ? start + duration : Number.POSITIVE_INFINITY; - const visibilityStart = window.__HF_EXPORT_RENDER_SEEK_CONFIG - ? snapTimeToFrameBoundary(start, state.canonicalFps) - : start; - const visibilityEnd = - window.__HF_EXPORT_RENDER_SEEK_CONFIG && Number.isFinite(computedEnd) - ? snapTimeToFrameBoundary(computedEnd, state.canonicalFps) - : computedEnd; - return isClipVisibleAt(currentTime, visibilityStart, visibilityEnd, compositionDuration); - }; - const hasExternalCompositions = !!document.querySelector("[data-composition-src]"); let hasInlineTemplateCompositions = false; { @@ -2535,7 +2483,14 @@ export function initSandboxRuntimeModular(): void { groupMuteDirty = true; } - let isVisibleNow = isTimedElementVisibleAt(rawNode, currentTime, compositionDuration); + let isVisibleNow = isRuntimeElementVisibleAt(rawNode, { + currentTime, + compositionDuration, + canonicalFps: state.canonicalFps, + exportRenderSeek: Boolean(window.__HF_EXPORT_RENDER_SEEK_CONFIG), + timelineRegistry: window.__timelines ?? {}, + resolver: timingResolverFor(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. @@ -2544,7 +2499,16 @@ export function initSandboxRuntimeModular(): void { while (ancestor) { if (ancestor === rootComp) break; if (isHtmlElement(ancestor) && ancestor.hasAttribute("data-start")) { - if (!isTimedElementVisibleAt(ancestor, currentTime, compositionDuration)) { + if ( + !isRuntimeElementVisibleAt(ancestor, { + currentTime, + compositionDuration, + canonicalFps: state.canonicalFps, + exportRenderSeek: Boolean(window.__HF_EXPORT_RENDER_SEEK_CONFIG), + timelineRegistry: window.__timelines ?? {}, + resolver: timingResolverFor(true), + }) + ) { isVisibleNow = false; break; } @@ -2938,7 +2902,7 @@ export function initSandboxRuntimeModular(): void { assetsSettled = true; postRuntimeMessage({ source: "hf-preview", type: "assets-ready", timedOut }); }, - { scope: "first-frame" }, + { scope: FIRST_FRAME_READINESS_SCOPE }, ); } scheduleRootStageLayoutDiagnostics(); diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts index 0c75165c71..cbbab93269 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -16,11 +16,74 @@ import { } from "./playbackRate"; import { resolveCssStackingContextId } from "./stackingContext"; import { createRuntimeStartTimeResolver } from "./startResolver"; +import { isClipVisibleAt } from "./clipWindow"; +import { snapTimeToFrameBoundary } from "../inline-scripts/parityContract"; import { isSceneLikeCompositionId } from "../slideshow/index.js"; import { COMPOSITION_CONTRACT_VERSION } from "../compositionContract.js"; import { runtimeProtocolMetadata } from "./protocol.js"; import { isElementNode, isMediaElement } from "./domRealm"; +export function isRuntimeElementVisibleAt( + rawNode: HTMLElement, + options: { + currentTime: number; + compositionDuration: number; + canonicalFps: number; + exportRenderSeek: boolean; + timelineRegistry: Record; + resolver: ReturnType; + }, +): boolean { + const tag = rawNode.tagName.toLowerCase(); + if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") { + return false; + } + + const isMedia = tag === "video" || tag === "audio"; + const start = isMedia + ? options.resolver.resolveMediaStartForElement(rawNode) + : options.resolver.resolveStartForElement(rawNode, 0); + let duration = options.resolver.resolveDurationForElement(rawNode); + const compId = rawNode.getAttribute("data-composition-id"); + if (compId) { + const compTimeline = options.timelineRegistry[compId]; + const liveDuration = + compTimeline && typeof compTimeline.duration === "function" + ? Number(compTimeline.duration()) + : null; + const hasAuthoredTiming = + rawNode.hasAttribute("data-duration") || + rawNode.hasAttribute("data-end") || + rawNode.hasAttribute("data-hf-authored-duration") || + rawNode.hasAttribute("data-hf-authored-end"); + if ( + !hasAuthoredTiming && + (duration == null || duration <= 0) && + liveDuration != null && + Number.isFinite(liveDuration) && + liveDuration > 0 + ) { + duration = liveDuration; + } + } + const computedEnd = + duration != null && duration > 0 ? start + duration : Number.POSITIVE_INFINITY; + // Export seeks snap to frame boundaries; interactive visibility uses authored seconds. + const visibilityStart = options.exportRenderSeek + ? snapTimeToFrameBoundary(start, options.canonicalFps) + : start; + const visibilityEnd = + options.exportRenderSeek && Number.isFinite(computedEnd) + ? snapTimeToFrameBoundary(computedEnd, options.canonicalFps) + : computedEnd; + return isClipVisibleAt( + options.currentTime, + visibilityStart, + visibilityEnd, + options.compositionDuration, + ); +} + function parseNum(value: string | null | undefined): number | null { return parseStrictFiniteTimingNumber(value); } diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index fee8e533d3..1f72a07811 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -21,6 +21,7 @@ import { PLAYER_STYLES } from "./styles.js"; import { type DirectTimelineAdapter } from "./timeline-adapters.js"; import { runtimeProtocolMetadata } from "@hyperframes/core/runtime/protocol"; import { + FIRST_FRAME_READINESS_SCOPE, scanPendingCompositionAssets, settleCompositionReadiness, } from "@hyperframes/core/composition-readiness"; @@ -1063,7 +1064,7 @@ class HyperframesPlayer extends HTMLElement { if (timedOut) this._warnStuckAssets(doc); this._settleAssetsReady(generation); }, - { scope: "first-frame", timeoutMs: ASSETS_READY_TIMEOUT_MS }, + { scope: FIRST_FRAME_READINESS_SCOPE, timeoutMs: ASSETS_READY_TIMEOUT_MS }, ); if (!this._assetsReady) this._startAssetsLoadingOverlayTimer(generation); } @@ -1083,7 +1084,7 @@ class HyperframesPlayer extends HTMLElement { * reported directly rather than inferred, since it can't be bounded. */ private _warnStuckAssets(doc: Document): void { const { pendingMedia, pendingImages, fontsLoading } = scanPendingCompositionAssets(doc, { - scope: "first-frame", + scope: FIRST_FRAME_READINESS_SCOPE, }); const win = doc.defaultView as (Window & { __renderReady?: boolean }) | null; console.warn( From b0dc3dc772d8d6a9553c7b07c5b2354ab1501258 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 20:22:54 -0400 Subject: [PATCH 3/6] refactor(core): share readiness timing owner --- .../core/src/compositionReadiness.test.ts | 5 +- packages/core/src/compositionReadiness.ts | 46 ++++++++++++++----- packages/core/src/runtime/init.ts | 16 +++---- packages/player/src/hyperframes-player.ts | 6 +-- 4 files changed, 47 insertions(+), 26 deletions(-) diff --git a/packages/core/src/compositionReadiness.test.ts b/packages/core/src/compositionReadiness.test.ts index f6cff1feb1..506dcdac5a 100644 --- a/packages/core/src/compositionReadiness.test.ts +++ b/packages/core/src/compositionReadiness.test.ts @@ -6,6 +6,7 @@ import { paintAndIdleReadinessInput, scanPendingCompositionAssets, settleCompositionReadiness, + settleFirstFrameCompositionReadiness, } from "./compositionReadiness.js"; function docWith(bodyHtml: string): Document { @@ -343,12 +344,12 @@ describe("settleCompositionReadiness", () => { Object.defineProperty(video, "readyState", { value: 0, configurable: true }); let result: { timedOut: boolean } | undefined; - settleCompositionReadiness( + settleFirstFrameCompositionReadiness( doc, (settled) => { result = settled; }, - { scope: FIRST_FRAME_READINESS_SCOPE, timeoutMs: 1 }, + { timeoutMs: 1 }, ); await flushMicrotasks(); diff --git a/packages/core/src/compositionReadiness.ts b/packages/core/src/compositionReadiness.ts index a45e262431..f5b22eb6da 100644 --- a/packages/core/src/compositionReadiness.ts +++ b/packages/core/src/compositionReadiness.ts @@ -1,4 +1,6 @@ import { createRuntimeStartTimeResolver } from "./runtime/startResolver.js"; +import { isRuntimeElementVisibleAt } from "./runtime/timeline.js"; +import type { RuntimeTimelineLike } from "./runtime/types.js"; /** A composition is "ready" once every declared input settles, not just once * its duration is known. Each input returns null (nothing to wait on) or a @@ -54,18 +56,22 @@ function isTimedElement(element: Element): boolean { function isActiveAtFirstFrame( element: Element, resolver: ReturnType, + timelineRegistry: Record, ): boolean { let current: Element | null = element; while (current) { if (isTimedElement(current)) { - const tag = current.tagName.toLowerCase(); - const start = - tag === "video" || tag === "audio" - ? resolver.resolveMediaStartForElement(current) - : resolver.resolveStartForElement(current, 0); - const duration = resolver.resolveDurationForElement(current); - const end = duration != null && duration > 0 ? start + duration : Number.POSITIVE_INFINITY; - if (!(0 >= start && 0 < end)) return false; + if ( + !isRuntimeElementVisibleAt(current as HTMLElement, { + currentTime: 0, + compositionDuration: Number.POSITIVE_INFINITY, + canonicalFps: 30, + exportRenderSeek: false, + timelineRegistry, + resolver, + }) + ) + return false; } current = current.parentElement; } @@ -76,9 +82,10 @@ function shouldIncludeAsset( element: Element, scope: CompositionReadinessScope, resolver: ReturnType, + timelineRegistry: Record, ): boolean { if (scope === "all") return true; - return isActiveAtFirstFrame(element, resolver); + return isActiveAtFirstFrame(element, resolver, timelineRegistry); } /** One DOM pass for every declared-media asset not yet ready. */ @@ -98,10 +105,10 @@ export function scanPendingCompositionAssets( }); const pendingMedia = Array.from(doc.querySelectorAll("video, audio")) .filter(isRealmHtmlMediaElement) - .filter((el) => shouldIncludeAsset(el, scope, resolver)) + .filter((el) => shouldIncludeAsset(el, scope, resolver, runtimeWindow?.__timelines ?? {})) .filter((el) => el.readyState < HAVE_FUTURE_DATA); const pendingImages = Array.from(doc.querySelectorAll("img")) - .filter((img) => shouldIncludeAsset(img, scope, resolver)) + .filter((img) => shouldIncludeAsset(img, scope, resolver, runtimeWindow?.__timelines ?? {})) .filter((img) => !img.complete); const fontsLoading = doc.fonts?.status === "loading"; return { pendingMedia, pendingImages, fontsLoading }; @@ -314,3 +321,20 @@ export function settleCompositionReadiness( onSettled({ timedOut: result === "timed-out" }); }); } + +export function settleFirstFrameCompositionReadiness( + doc: Document, + onSettled: (result: CompositionReadinessResult) => void, + opts: Omit< + CompositionReadinessOptions & { + inputs?: CompositionReadinessInput[]; + timeoutMs?: number; + }, + "scope" + > = {}, +): void { + settleCompositionReadiness(doc, onSettled, { + ...opts, + scope: FIRST_FRAME_READINESS_SCOPE, + }); +} diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index ee96fe7c6a..ab9b4f3c58 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -77,7 +77,7 @@ import { import { installStudioCustomEase } from "./customEase"; import { parseStrictFiniteTimingNumber, resolveMediaElementDurationSeconds } from "./playbackRate"; import { MEDIA_START_BASIS_ATTR } from "../mediaTiming"; -import { FIRST_FRAME_READINESS_SCOPE, settleCompositionReadiness } from "../compositionReadiness"; +import { settleFirstFrameCompositionReadiness } from "../compositionReadiness"; import { clearRuntimeData, setRuntimeData, @@ -2895,15 +2895,11 @@ export function initSandboxRuntimeModular(): void { postRuntimeMessage({ ...payload, assetsReady: assetsSettled }); if (!assetsReadyStarted) { assetsReadyStarted = true; - settleCompositionReadiness( - document, - ({ timedOut }) => { - if (state.tornDown) return; - assetsSettled = true; - postRuntimeMessage({ source: "hf-preview", type: "assets-ready", timedOut }); - }, - { scope: FIRST_FRAME_READINESS_SCOPE }, - ); + settleFirstFrameCompositionReadiness(document, ({ timedOut }) => { + if (state.tornDown) return; + assetsSettled = true; + postRuntimeMessage({ source: "hf-preview", type: "assets-ready", timedOut }); + }); } scheduleRootStageLayoutDiagnostics(); }; diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index 1f72a07811..c325118995 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -23,7 +23,7 @@ import { runtimeProtocolMetadata } from "@hyperframes/core/runtime/protocol"; import { FIRST_FRAME_READINESS_SCOPE, scanPendingCompositionAssets, - settleCompositionReadiness, + settleFirstFrameCompositionReadiness, } from "@hyperframes/core/composition-readiness"; // Playback-rate bounds mirror the runtime clamp in @@ -1057,14 +1057,14 @@ class HyperframesPlayer extends HTMLElement { } return; } - settleCompositionReadiness( + settleFirstFrameCompositionReadiness( doc, ({ timedOut }) => { if (generation !== this._assetsGeneration) return; if (timedOut) this._warnStuckAssets(doc); this._settleAssetsReady(generation); }, - { scope: FIRST_FRAME_READINESS_SCOPE, timeoutMs: ASSETS_READY_TIMEOUT_MS }, + { timeoutMs: ASSETS_READY_TIMEOUT_MS }, ); if (!this._assetsReady) this._startAssetsLoadingOverlayTimer(generation); } From f6f9809d8a2b10a09173252e25ed0944e113e289 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 20:34:54 -0400 Subject: [PATCH 4/6] test(core): pin readiness caller wiring --- .../core/src/compositionReadiness.test.ts | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/core/src/compositionReadiness.test.ts b/packages/core/src/compositionReadiness.test.ts index 506dcdac5a..3edfec64f2 100644 --- a/packages/core/src/compositionReadiness.test.ts +++ b/packages/core/src/compositionReadiness.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { computeReadinessInput, FIRST_FRAME_READINESS_SCOPE, @@ -8,6 +10,8 @@ import { settleCompositionReadiness, settleFirstFrameCompositionReadiness, } from "./compositionReadiness.js"; +import { createRuntimeStartTimeResolver } from "./runtime/startResolver.js"; +import { isRuntimeElementVisibleAt } from "./runtime/timeline.js"; function docWith(bodyHtml: string): Document { const doc = document.implementation.createHTMLDocument(""); @@ -84,6 +88,26 @@ describe("scanPendingCompositionAssets", () => { expect(scan.pendingMedia.map((media) => media.id)).toEqual(["untimed"]); }); + + it("keeps nested timing decisions aligned with the runtime visibility owner", () => { + const doc = docWith( + '
', + ); + const nested = doc.querySelector("#nested")!; + const resolver = createRuntimeStartTimeResolver({ documentRef: doc }); + const runtimeDecision = isRuntimeElementVisibleAt(doc.querySelector("section")!, { + currentTime: 0, + compositionDuration: Number.POSITIVE_INFINITY, + canonicalFps: 30, + exportRenderSeek: false, + timelineRegistry: {}, + resolver, + }); + + expect(scanPendingCompositionAssets(doc, { scope: "first-frame" }).pendingMedia).toEqual([]); + expect(runtimeDecision).toBe(false); + expect(nested.closest("[data-start]")).not.toBeNull(); + }); }); describe("mediaReadinessInput", () => { @@ -330,6 +354,13 @@ describe("paintAndIdleReadinessInput", () => { describe("settleCompositionReadiness", () => { it("uses the shared first-frame scope for both runtime callsites", () => { expect(FIRST_FRAME_READINESS_SCOPE).toBe("first-frame"); + const playerSource = readFileSync( + resolve(process.cwd(), "../player/src/hyperframes-player.ts"), + "utf8", + ); + const runtimeSource = readFileSync(resolve(process.cwd(), "src/runtime/init.ts"), "utf8"); + expect(playerSource).toContain("settleFirstFrameCompositionReadiness("); + expect(runtimeSource).toContain("settleFirstFrameCompositionReadiness("); }); it("does not wait for a later video through the public first-frame path", async () => { @@ -340,7 +371,8 @@ describe("settleCompositionReadiness", () => { const image = doc.querySelector("#first")!; const video = doc.querySelector("#later")!; Object.defineProperty(image, "complete", { value: false }); - image.decode = () => Promise.resolve(); + let resolveImage!: () => void; + image.decode = () => new Promise((resolve) => (resolveImage = resolve)); Object.defineProperty(video, "readyState", { value: 0, configurable: true }); let result: { timedOut: boolean } | undefined; @@ -349,11 +381,14 @@ describe("settleCompositionReadiness", () => { (settled) => { result = settled; }, - { timeoutMs: 1 }, + { timeoutMs: 50 }, ); await flushMicrotasks(); await new Promise((resolve) => setTimeout(resolve, 5)); + expect(result).toBeUndefined(); + resolveImage(); + await flushMicrotasks(); expect(result).toEqual({ timedOut: false }); }); From c59dd02563f5b87f914474ca0af2f0294da58d21 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 20:48:07 -0400 Subject: [PATCH 5/6] test(core): cover fonts in first-frame gate --- packages/core/src/compositionReadiness.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/core/src/compositionReadiness.test.ts b/packages/core/src/compositionReadiness.test.ts index 3edfec64f2..2185689a8f 100644 --- a/packages/core/src/compositionReadiness.test.ts +++ b/packages/core/src/compositionReadiness.test.ts @@ -373,6 +373,14 @@ describe("settleCompositionReadiness", () => { Object.defineProperty(image, "complete", { value: false }); let resolveImage!: () => void; image.decode = () => new Promise((resolve) => (resolveImage = resolve)); + let resolveFonts!: () => void; + Object.defineProperty(doc, "fonts", { + configurable: true, + value: { + status: "loading", + ready: new Promise((resolve) => (resolveFonts = resolve)), + }, + }); Object.defineProperty(video, "readyState", { value: 0, configurable: true }); let result: { timedOut: boolean } | undefined; @@ -389,6 +397,9 @@ describe("settleCompositionReadiness", () => { expect(result).toBeUndefined(); resolveImage(); await flushMicrotasks(); + expect(result).toBeUndefined(); + resolveFonts(); + await flushMicrotasks(); expect(result).toEqual({ timedOut: false }); }); From a3c50d0b3e2138a593b3cfb25e3a89ef322246ee Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 21:36:03 -0400 Subject: [PATCH 6/6] fix(core): restore readiness test contract --- packages/core/src/compositionReadiness.test.ts | 14 -------------- packages/core/src/runtime/authoredTiming.ts | 3 +++ packages/core/src/runtime/init.ts | 4 +--- packages/core/src/runtime/timeline.ts | 14 +++++++++----- packages/core/src/runtime/transportPark.test.ts | 9 ++++++++- 5 files changed, 21 insertions(+), 23 deletions(-) diff --git a/packages/core/src/compositionReadiness.test.ts b/packages/core/src/compositionReadiness.test.ts index 2185689a8f..9ab984340b 100644 --- a/packages/core/src/compositionReadiness.test.ts +++ b/packages/core/src/compositionReadiness.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; import { computeReadinessInput, - FIRST_FRAME_READINESS_SCOPE, mediaReadinessInput, paintAndIdleReadinessInput, scanPendingCompositionAssets, @@ -352,17 +349,6 @@ describe("paintAndIdleReadinessInput", () => { }); describe("settleCompositionReadiness", () => { - it("uses the shared first-frame scope for both runtime callsites", () => { - expect(FIRST_FRAME_READINESS_SCOPE).toBe("first-frame"); - const playerSource = readFileSync( - resolve(process.cwd(), "../player/src/hyperframes-player.ts"), - "utf8", - ); - const runtimeSource = readFileSync(resolve(process.cwd(), "src/runtime/init.ts"), "utf8"); - expect(playerSource).toContain("settleFirstFrameCompositionReadiness("); - expect(runtimeSource).toContain("settleFirstFrameCompositionReadiness("); - }); - it("does not wait for a later video through the public first-frame path", async () => { const doc = docWith( '' + diff --git a/packages/core/src/runtime/authoredTiming.ts b/packages/core/src/runtime/authoredTiming.ts index 8d582dd128..2fd238ec95 100644 --- a/packages/core/src/runtime/authoredTiming.ts +++ b/packages/core/src/runtime/authoredTiming.ts @@ -1,5 +1,8 @@ export type AuthoredTimingValue = string | number | null | undefined; +export const AUTHORED_DURATION_ATTR = "data-hf-authored-duration"; +export const AUTHORED_END_ATTR = "data-hf-authored-end"; + export interface RawAuthoredTiming { start?: AuthoredTimingValue; duration?: AuthoredTimingValue; diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index ab9b4f3c58..b12ce654a5 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -78,6 +78,7 @@ import { installStudioCustomEase } from "./customEase"; import { parseStrictFiniteTimingNumber, resolveMediaElementDurationSeconds } from "./playbackRate"; import { MEDIA_START_BASIS_ATTR } from "../mediaTiming"; import { settleFirstFrameCompositionReadiness } from "../compositionReadiness"; +import { AUTHORED_DURATION_ATTR, AUTHORED_END_ATTR } from "./authoredTiming"; import { clearRuntimeData, setRuntimeData, @@ -93,9 +94,6 @@ import { isVideoElement, } from "./domRealm"; -const AUTHORED_DURATION_ATTR = "data-hf-authored-duration"; -const AUTHORED_END_ATTR = "data-hf-authored-end"; - /** * A `window.__timelines` entry is authored content and may be a PARTIAL * RuntimeTimelineLike — e.g. duration/seek only, no `pause()`. Such diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts index cbbab93269..e4fdfbfc00 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -5,7 +5,11 @@ import type { RuntimeTimelineLike, } from "./types"; import { stableClipId } from "./clipTree"; -import { resolveAuthoredTimingWindow } from "./authoredTiming"; +import { + AUTHORED_DURATION_ATTR, + AUTHORED_END_ATTR, + resolveAuthoredTimingWindow, +} from "./authoredTiming"; import { swallow } from "./diagnostics"; import { readElementPlaybackRate, readElementPlaybackStart } from "./media"; import { @@ -54,8 +58,8 @@ export function isRuntimeElementVisibleAt( const hasAuthoredTiming = rawNode.hasAttribute("data-duration") || rawNode.hasAttribute("data-end") || - rawNode.hasAttribute("data-hf-authored-duration") || - rawNode.hasAttribute("data-hf-authored-end"); + rawNode.hasAttribute(AUTHORED_DURATION_ATTR) || + rawNode.hasAttribute(AUTHORED_END_ATTR); if ( !hasAuthoredTiming && (duration == null || duration <= 0) && @@ -90,7 +94,7 @@ function parseNum(value: string | null | undefined): number | null { function parseElementDurationAttr(element: Element): number | null { const publicDuration = element.getAttribute("data-duration"); - const authoredDuration = element.getAttribute("data-hf-authored-duration"); + const authoredDuration = element.getAttribute(AUTHORED_DURATION_ATTR); const resolved = resolveAuthoredTimingWindow({ start: 0, duration: publicDuration, @@ -108,7 +112,7 @@ function parseElementEndAttr(element: Element): number | null { resolveAuthoredTimingWindow({ start: 0, end: element.getAttribute("data-end"), - authoredEnd: element.getAttribute("data-hf-authored-end"), + authoredEnd: element.getAttribute(AUTHORED_END_ATTR), })?.end ?? null ); } diff --git a/packages/core/src/runtime/transportPark.test.ts b/packages/core/src/runtime/transportPark.test.ts index d70a3127c8..fe6bfbe6dd 100644 --- a/packages/core/src/runtime/transportPark.test.ts +++ b/packages/core/src/runtime/transportPark.test.ts @@ -4,7 +4,14 @@ import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "../editing/draftMarkers"; import type { RuntimeTimelineLike } from "./types"; // The readiness gate's 8s timeout is a one-shot timer these transport tests must not count. -vi.mock("../compositionReadiness", () => ({ settleCompositionReadiness: vi.fn() })); +vi.mock("../compositionReadiness", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + settleCompositionReadiness: vi.fn(), + settleFirstFrameCompositionReadiness: vi.fn(), + }; +}); /** * The transport parks itself when the editor is paused and settled. Everything