fix(runtime): a finished film rests on its last frame instead of a blank one (#4158)

* fix(runtime): a finished film rests on its last frame instead of a blank one

The clip-visibility check was half-open (start <= t < end), so a clip whose
authored end reached the composition's total duration hid at exactly that
instant. The player parks there when playback ends, so a finished film
went blank instead of holding its last frame, in both the published
player and Studio's scrub-to-end.

Extracts the shared half-open comparison used in seven places into
isInClipWindow (packages/core/src/runtime/clipWindow.ts) and adds
isClipVisibleAt, which additionally treats a clip reaching the
composition's terminal instant as visible. Only the DOM-visibility gate
uses the terminal rule; playback and media-activation windows keep the
plain half-open check, so two back-to-back clips still never both show
at their shared boundary and audio never re-activates past the end.

* fix(runtime): hold a terminal video on its last frame at the composition end

* refactor(player): route the proxy clip-window checks through the shared predicate

Makes the composition duration required on syncRuntimeMedia so a caller
cannot drop the terminal rule, exposes the predicate as a core subpath,
and adds a nested-timeline float-noise test.

* build(core): declare the runtime/clip-window subpath in the package-subpaths descriptor

* test(runtime): a speed-ramped video that runs to the composition end holds its last frame

Covers the composition of the terminal hold with a rate lane: at a time
past the clip end the video sits at the source time of its own end.
This commit is contained in:
Miguel Ángel
2026-09-19 10:35:26 -04:00
committed by GitHub
parent 3f3f3b7f10
commit f4e00ce2b5
12 changed files with 315 additions and 42 deletions
+6
View File
@@ -248,6 +248,12 @@
"types": "./dist/runtime/protocol.d.ts",
"environments": ["browser", "bun", "node"]
},
"./runtime/clip-window": {
"source": "./src/runtime/clipWindow.ts",
"runtime": "./dist/runtime/clipWindow.js",
"types": "./dist/runtime/clipWindow.d.ts",
"environments": ["browser", "bun", "node"]
},
"./runtime/stacking-context": {
"source": "./src/runtime/stackingContext.ts",
"runtime": "./dist/runtime/stackingContext.js",
+10
View File
@@ -257,6 +257,12 @@
"import": "./src/runtime/protocol.ts",
"types": "./src/runtime/protocol.ts"
},
"./runtime/clip-window": {
"bun": "./src/runtime/clipWindow.ts",
"node": "./dist/runtime/clipWindow.js",
"import": "./src/runtime/clipWindow.ts",
"types": "./src/runtime/clipWindow.ts"
},
"./runtime/stacking-context": {
"bun": "./src/runtime/stackingContext.ts",
"node": "./dist/runtime/stackingContext.js",
@@ -571,6 +577,10 @@
"import": "./dist/runtime/protocol.js",
"types": "./dist/runtime/protocol.d.ts"
},
"./runtime/clip-window": {
"import": "./dist/runtime/clipWindow.js",
"types": "./dist/runtime/clipWindow.d.ts"
},
"./runtime/stacking-context": {
"import": "./dist/runtime/stackingContext.js",
"types": "./dist/runtime/stackingContext.d.ts"
+2
View File
@@ -319,6 +319,8 @@ export {
// publishConfig entry points at a file the pack doesn't contain
// (verify:packed-manifests catches exactly that).
export { createRuntimeStartTimeResolver } from "./runtime/startResolver.js";
// Also exposed via the ./runtime/clip-window subpath; re-exported here for the same dist-emit reason.
export { isClipVisibleAt, isInClipWindow } from "./runtime/clipWindow.js";
export {
normalizePlaybackRate,
normalizeRateSpec,
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { isClipVisibleAt, isInClipWindow } from "./clipWindow";
describe("isInClipWindow", () => {
it("includes the start and excludes the end", () => {
expect([1, 1.5, 2].map((t) => isInClipWindow(t, 1, 2))).toEqual([true, true, false]);
});
});
describe("isClipVisibleAt", () => {
it("hides a clip that ends before the composition does, from its end on", () => {
expect([1.99, 2, 5].map((t) => isClipVisibleAt(t, 1, 2, 5))).toEqual([true, false, false]);
});
it("keeps a clip that runs to the composition end visible at and past it", () => {
expect([4.99, 5, 6].map((t) => isClipVisibleAt(t, 4, 5, 5))).toEqual([true, true, true]);
});
it("does not show a clip before its start", () => {
expect(isClipVisibleAt(3.99, 4, 5, 5)).toBe(false);
});
it("treats float noise in the summed end as reaching the composition end", () => {
expect(isClipVisibleAt(0.35, 0.1, 0.3, 0.1 + 0.2)).toBe(true);
});
it("gives no terminal grace when the composition duration is unknown", () => {
expect(isClipVisibleAt(5, 4, 5, 0)).toBe(false);
});
});
+20
View File
@@ -0,0 +1,20 @@
/** Half-open: two back-to-back clips never both hold the shared boundary instant. */
export const isInClipWindow = (time: number, start: number, end: number): boolean =>
time >= start && time < end;
const TERMINAL_EPSILON_SECONDS = 1e-6;
/**
* Visibility window: half-open, except a clip that runs to the composition's end also owns
* the terminal instant, so a finished film rests on its last frame instead of going blank.
*/
export const isClipVisibleAt = (
time: number,
start: number,
end: number,
compositionDuration: number,
): boolean =>
isInClipWindow(time, start, end) ||
(time >= start &&
compositionDuration > 0 &&
end >= compositionDuration - TERMINAL_EPSILON_SECONDS);
+89
View File
@@ -949,6 +949,95 @@ describe("initSandboxRuntimeModular", () => {
expect(clip.style.visibility).toBe("hidden");
});
describe("at the composition's terminal time", () => {
const buildRoot = () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
return root;
};
const addClip = (root: HTMLElement, start: number, duration: number) => {
const clip = document.createElement("div");
clip.setAttribute("data-start", String(start));
clip.setAttribute("data-duration", String(duration));
root.appendChild(clip);
return clip;
};
it("keeps a clip that runs to the composition duration visible at and past the duration", () => {
const root = buildRoot();
const lastClip = addClip(root, 2.5, 2.5);
window.__timelines = { main: createMockTimeline(5) };
initSandboxRuntimeModular();
window.__player?.renderSeek(5 - 1e-9);
expect(lastClip.style.visibility).toBe("visible");
window.__player?.renderSeek(5);
expect(lastClip.style.visibility).toBe("visible");
window.__player?.renderSeek(5.5);
expect(lastClip.style.visibility).toBe("visible");
});
it("seeks a terminal video to its last authored frame and keeps it paused on a direct seek", () => {
const root = buildRoot();
const video = document.createElement("video");
video.setAttribute("data-start", "2.5");
video.setAttribute("data-duration", "2.5");
root.appendChild(video);
Object.defineProperty(video, "duration", { value: 10, configurable: true });
Object.defineProperty(video, "currentTime", { value: 0, writable: true, configurable: true });
video.play = vi.fn(() => Promise.resolve());
window.__timelines = { main: createMockTimeline(5) };
initSandboxRuntimeModular();
window.__player?.renderSeek(5);
expect(video.style.visibility).toBe("visible");
expect(video.currentTime).toBe(2.5);
expect(video.paused).toBe(true);
expect(video.play).not.toHaveBeenCalled();
});
it("keeps a nested clip visible when its summed end falls one ulp short of the timeline duration", () => {
const root = buildRoot();
// GSAP reports 0.8 for a 0.7s tween followed by a 0.1s tween; the authored end sums to 0.7999999999999999.
const nested = addClip(root, 0.7, 0.1);
window.__timelines = { main: createMockTimeline(0.8) };
initSandboxRuntimeModular();
window.__player?.renderSeek(0.8);
expect(nested.style.visibility).toBe("visible");
});
it("still hides a clip that ended before the composition duration", () => {
const root = buildRoot();
const earlyClip = addClip(root, 0, 2.5);
addClip(root, 2.5, 2.5);
window.__timelines = { main: createMockTimeline(5) };
initSandboxRuntimeModular();
window.__player?.renderSeek(5);
expect(earlyClip.style.visibility).toBe("hidden");
});
it("never shows two back-to-back clips at their shared boundary", () => {
const root = buildRoot();
const first = addClip(root, 0, 2.5);
const second = addClip(root, 2.5, 2.5);
window.__timelines = { main: createMockTimeline(5) };
initSandboxRuntimeModular();
window.__player?.renderSeek(2.5);
expect([first.style.visibility, second.style.visibility]).toEqual(["hidden", "visible"]);
window.__player?.renderSeek(5);
expect([first.style.visibility, second.style.visibility]).toEqual(["hidden", "visible"]);
});
});
it("keeps external composition hosts visible through their authored duration", async () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
+35 -18
View File
@@ -1,5 +1,6 @@
// fallow-ignore-file code-duplication complexity
import { installRuntimeControlBridge, postRuntimeMessage, setRuntimeProtocolFps } from "./bridge";
import { isClipVisibleAt, isInClipWindow } from "./clipWindow";
import { initRuntimeAnalytics, emitAnalyticsEvent } from "./analytics";
import { injectCompositionCssVariables } from "./getVariables";
import { createCssAdapter } from "./adapters/css";
@@ -805,7 +806,11 @@ export function initSandboxRuntimeModular(): void {
}
});
const isTimedElementVisibleAt = (rawNode: HTMLElement, currentTime: number): boolean => {
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;
@@ -846,10 +851,7 @@ export function initSandboxRuntimeModular(): void {
window.__HF_EXPORT_RENDER_SEEK_CONFIG && Number.isFinite(computedEnd)
? snapTimeToFrameBoundary(computedEnd, state.canonicalFps)
: computedEnd;
return (
currentTime >= visibilityStart &&
(Number.isFinite(visibilityEnd) ? currentTime < visibilityEnd : true)
);
return isClipVisibleAt(currentTime, visibilityStart, visibilityEnd, compositionDuration);
};
const hasExternalCompositions = !!document.querySelector("[data-composition-src]");
@@ -2394,8 +2396,17 @@ export function initSandboxRuntimeModular(): void {
}
};
const applyTimedElementVisibility = (currentTime: number, visibilityNodes: Element[]) => {
const applyTimedElementVisibility = (
currentTime: number,
visibilityNodes: Element[],
timingRevision?: number,
) => {
const rootComp = resolveRootCompositionElement();
const compositionDuration = getSafeTimelineDurationSeconds(
state.capturedTimeline,
0,
timingRevision,
);
for (const rawNode of visibilityNodes) {
if (!isHtmlElement(rawNode)) continue;
@@ -2426,7 +2437,7 @@ export function initSandboxRuntimeModular(): void {
groupMuteDirty = true;
}
let isVisibleNow = isTimedElementVisibleAt(rawNode, currentTime);
let isVisibleNow = isTimedElementVisibleAt(rawNode, currentTime, compositionDuration);
// 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.
@@ -2435,7 +2446,7 @@ export function initSandboxRuntimeModular(): void {
while (ancestor) {
if (ancestor === rootComp) break;
if (isHtmlElement(ancestor) && ancestor.hasAttribute("data-start")) {
if (!isTimedElementVisibleAt(ancestor, currentTime)) {
if (!isTimedElementVisibleAt(ancestor, currentTime, compositionDuration)) {
isVisibleNow = false;
break;
}
@@ -2476,7 +2487,11 @@ export function initSandboxRuntimeModular(): void {
const syncTimedElementVisibility = (
currentTime: number,
visibilityNodes: Element[] = Array.from(document.querySelectorAll("[data-start]")),
) => withTimingResolver(() => applyTimedElementVisibility(currentTime, visibilityNodes));
timingRevision?: number,
) =>
withTimingResolver(() =>
applyTimedElementVisibility(currentTime, visibilityNodes, timingRevision),
);
/**
* Clip windows sorted by each endpoint, so a seek can ask which windows it
@@ -2657,7 +2672,7 @@ export function initSandboxRuntimeModular(): void {
document.removeEventListener("play", onMediaPlayWakeTransport, true);
});
const syncMediaForCurrentState = () => {
const syncMediaForCurrentState = (timingRevision?: number) => {
// Scope 1 of 3 (see `withTimingResolver`). Closes before `syncRuntimeMedia`,
// which may call `el.load()` and invalidate every cached duration.
//
@@ -2703,6 +2718,8 @@ export function initSandboxRuntimeModular(): void {
userMuted: state.bridgeMuted,
userVolume: state.bridgeVolume,
forceSync,
getCompositionDuration: () =>
getSafeTimelineDurationSeconds(state.capturedTimeline, 0, timingRevision),
onElementVolume: (el, _effectiveVolume, authorVolume) =>
webAudio.setElementVolume(el, authorVolume),
isWebAudioOwned: (el) => webAudio.ownsElement(el),
@@ -2717,15 +2734,15 @@ export function initSandboxRuntimeModular(): void {
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) => state.currentTime >= clip.start && state.currentTime < clip.end,
mediaClipsInWindow = mediaClips.filter((clip) =>
isInClipWindow(state.currentTime, clip.start, clip.end),
);
} else {
lastSyncedMediaTimeSeconds = null;
mediaClipsInWindow = [];
}
}
syncTimedElementVisibility(state.currentTime);
syncTimedElementVisibility(state.currentTime, undefined, timingRevision);
};
const postState = (force: boolean) => {
@@ -3801,7 +3818,7 @@ export function initSandboxRuntimeModular(): void {
const durAttr = parseStrictFiniteTimingNumber(rawEl.dataset.duration);
const end = durAttr != null && durAttr > 0 ? start + durAttr : Infinity;
const mediaStart = readElementPlaybackStart(rawEl);
if (Number.isFinite(start) && state.currentTime >= start && state.currentTime < end) {
if (Number.isFinite(start) && isInClipWindow(state.currentTime, start, end)) {
if (!rawEl.paused) {
clock.attachAudioSource({
el: rawEl,
@@ -3874,20 +3891,20 @@ export function initSandboxRuntimeModular(): void {
seekTimelineAndAdapters(dur);
}
runAdapters("pause");
syncMediaForCurrentState();
syncMediaForCurrentState(timingRevision);
postState(true);
return;
}
if (clock.isPlaying()) {
syncMediaForCurrentState();
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();
syncMediaForCurrentState(timingRevision);
}
postState(false);
} finally {
@@ -3909,7 +3926,7 @@ export function initSandboxRuntimeModular(): void {
if (!Number.isFinite(start)) continue;
const durAttr = parseStrictFiniteTimingNumber(el.dataset.duration);
const end = durAttr != null && durAttr > 0 ? start + durAttr : Infinity;
if (timeSeconds < start || timeSeconds >= end) continue;
if (!isInClipWindow(timeSeconds, start, end)) continue;
const mediaStart = readElementPlaybackStart(el);
const relTime = timeSeconds - start + mediaStart;
if (relTime >= 0) {
+68 -1
View File
@@ -4,10 +4,18 @@ import {
readElementPlaybackStart,
refreshRuntimeMediaCache,
resolveRuntimeMediaClipDuration,
syncRuntimeMedia,
syncRuntimeMedia as syncRuntimeMediaWithDuration,
} from "./media";
import type { RuntimeMediaClip } from "./media";
import { resolveNaturalMediaTimelineDuration } from "./playbackRate";
import { sourceTimeAt } from "../speedRamp";
import type { HfAutomationLane } from "../audioAutomation";
// Most cases predate the terminal rule and run with no composition end to hold at.
const syncRuntimeMedia = (
params: Omit<Parameters<typeof syncRuntimeMediaWithDuration>[0], "getCompositionDuration"> &
Partial<Pick<Parameters<typeof syncRuntimeMediaWithDuration>[0], "getCompositionDuration">>,
) => syncRuntimeMediaWithDuration({ getCompositionDuration: () => 0, ...params });
function createVideo(attrs: Record<string, string>): HTMLVideoElement {
const el = document.createElement("video");
@@ -808,6 +816,65 @@ describe("syncRuntimeMedia", () => {
expect(clip.el.play).not.toHaveBeenCalled();
});
it("holds a video that runs to the composition end on its last frame at the terminal time", () => {
const clip = createMockClip({ start: 2.5, end: 5, duration: 2.5, sourceDuration: 10 });
const seek = {
clips: [clip],
playing: false,
playbackRate: 1,
getCompositionDuration: () => 5,
};
syncRuntimeMedia({ ...seek, timeSeconds: 5 });
expect(clip.el.currentTime).toBe(2.5);
expect(clip.el.play).not.toHaveBeenCalled();
});
it("holds a speed-ramped video that runs to the composition end at the source time of its own end", () => {
const lane: HfAutomationLane = {
target: "rate",
points: [
{ t: 0, v: 1 },
{ t: 5, v: 2 },
],
};
const clip = createMockClip({ start: 0, end: 5, duration: 5, sourceDuration: 100, rate: lane });
syncRuntimeMedia({
clips: [clip],
timeSeconds: 6,
playing: false,
playbackRate: 1,
getCompositionDuration: () => 5,
});
expect(clip.el.currentTime).toBeCloseTo(sourceTimeAt(lane, 5), 5);
expect(clip.el.currentTime).toBeLessThan(sourceTimeAt(lane, 6));
expect(clip.el.play).not.toHaveBeenCalled();
});
it("clamps a terminal video hold to a shorter source tail", () => {
const clip = createMockClip({ start: 0, end: 5, duration: 5, sourceDuration: 0.25 });
syncRuntimeMedia({
clips: [clip],
timeSeconds: 5,
playing: true,
playbackRate: 1,
getCompositionDuration: () => 5,
});
expect(clip.el.currentTime).toBe(0.25);
expect(clip.el.play).not.toHaveBeenCalled();
});
it("does not hold a video that ended before the composition did", () => {
const clip = createMockClip({ start: 0, end: 2.5, duration: 2.5, sourceDuration: 10 });
syncRuntimeMedia({
clips: [clip],
timeSeconds: 5,
playing: false,
playbackRate: 1,
getCompositionDuration: () => 5,
});
expect(clip.el.currentTime).toBe(0);
});
it("seeks an ended video backward into its playable source", () => {
const clip = createMockClip({ start: 0, end: 5, duration: 5, sourceDuration: 1 });
Object.defineProperty(clip.el, "currentTime", { value: 1, writable: true, configurable: true });
+19 -11
View File
@@ -1,4 +1,5 @@
import { swallow } from "./diagnostics";
import { isClipVisibleAt, isInClipWindow } from "./clipWindow";
import { interpolateVolumeGain, type VolumeKeyframe } from "./mediaVolumeEnvelope.js";
import { elementVolumeLaneGain } from "./audioAutomationVolume.js";
import { readElementPlaybackRate, readElementRateSpec, readMediaStart } from "./playbackRate.js";
@@ -247,22 +248,31 @@ export function syncRuntimeMedia(params: {
* unity; do not mistake that transport write for an authored volume edit. */
isWebAudioRouted?: (el: HTMLMediaElement) => boolean;
forceSync?: boolean;
/** Lets a video clip that runs to the composition end hold its last frame at the terminal time.
* A thunk, because deriving the duration is only worth it for a clip past its own end. */
getCompositionDuration: () => number;
}): void {
const forceMuteAll = !!(params.outputMuted || params.userMuted);
for (const clip of params.clips) {
const { el } = clip;
if (!el.isConnected) continue;
const clipRate = clip.rate ?? clip.playbackRate;
let relTime = sourceTimeAt(clipRate, params.timeSeconds - clip.start) + clip.mediaStart;
const isNonLoopVideo = el.tagName === "VIDEO" && !clip.loop;
const isHeldVideoTail =
const inWindow = isInClipWindow(params.timeSeconds, clip.start, clip.end);
// A video that runs to the composition end stays the visible frame at and past it, so it
// is held on the frame it shows at its own end rather than left on a stale one.
const isTerminalVideo =
isNonLoopVideo &&
clip.sourceDuration != null &&
relTime >= clip.sourceDuration &&
params.timeSeconds >= clip.start &&
params.timeSeconds < clip.end;
!inWindow &&
params.timeSeconds >= clip.end &&
isClipVisibleAt(params.timeSeconds, clip.start, clip.end, params.getCompositionDuration());
let relTime =
sourceTimeAt(clipRate, Math.min(params.timeSeconds, clip.end) - clip.start) + clip.mediaStart;
const isHeldVideoTail =
isTerminalVideo ||
(isNonLoopVideo && clip.sourceDuration != null && relTime >= clip.sourceDuration && inWindow);
if (isHeldVideoTail && clip.sourceDuration != null) {
relTime = clip.sourceDuration;
relTime = Math.min(relTime, clip.sourceDuration);
}
const previousRelativeTime = lastRelativeTime.get(el);
const audioReenteredAfterBackwardSeek =
@@ -281,8 +291,7 @@ export function syncRuntimeMedia(params: {
// video additionally remains an active visual through
// its authored window, with tail seeks clamped to the final frame.
const isActive =
params.timeSeconds >= clip.start &&
params.timeSeconds < clip.end &&
(inWindow || isTerminalVideo) &&
relTime >= 0 &&
(!el.ended || clip.loop || isHeldVideoTail || canSeekEndedMediaBackward);
if (isActive) {
@@ -527,8 +536,7 @@ export function syncRuntimeMedia(params: {
// the next poll would mistake the cleared baseline for a fresh activation
// and replay the tail. A real backward seek still decreases relTime, and a
// true outside-window transition clears every baseline as before.
const remainsInsideAuthoredWindow =
params.timeSeconds >= clip.start && params.timeSeconds < clip.end;
const remainsInsideAuthoredWindow = isInClipWindow(params.timeSeconds, clip.start, clip.end);
evictMediaSyncState(el);
if (remainsInsideAuthoredWindow) lastRelativeTime.set(el, relTime);
if (!el.paused) el.pause();
+10 -1
View File
@@ -1,5 +1,14 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { hasMediaSyncStateForTest, syncRuntimeMedia } from "./media";
import {
hasMediaSyncStateForTest,
syncRuntimeMedia as syncRuntimeMediaWithDuration,
} from "./media";
// Most cases predate the terminal rule and run with no composition end to hold at.
const syncRuntimeMedia = (
params: Omit<Parameters<typeof syncRuntimeMediaWithDuration>[0], "getCompositionDuration"> &
Partial<Pick<Parameters<typeof syncRuntimeMediaWithDuration>[0], "getCompositionDuration">>,
) => syncRuntimeMediaWithDuration({ getCompositionDuration: () => 0, ...params });
import {
deriveCodecMapKey,
handleErrorForProxy,
+13
View File
@@ -162,3 +162,16 @@ describe("ParentMediaManager audio-src proxy lifecycle", () => {
expect(mgr.entries[0]).toBe(adopted);
});
});
describe("ParentMediaManager clip window", () => {
it("plays a proxy inside its clip window and pauses it at the clip end instant", () => {
const mgr = makeManager({ isPaused: false, owner: "parent" });
const el = makeFakeAudio(false);
mgr.entries.push({ el, start: 1, duration: 2, driftSamples: 0 });
mgr.mirrorTime(2.999);
expect(el.paused).toBe(false);
mgr.mirrorTime(3);
expect(el.paused).toBe(true);
});
});
+13 -11
View File
@@ -11,6 +11,7 @@
import { selectMediaObserverTargets } from "./mediaObserverScope.js";
import { isRealmElement, isRealmHtmlMediaElement } from "./media-element-guards.js";
import { isInClipWindow } from "@hyperframes/core/runtime/clip-window";
import { readClipTiming } from "@hyperframes/core/composition-contract";
/** Minimum absolute drift before a currentTime correction is attempted. */
@@ -138,8 +139,7 @@ export class ParentMediaManager {
// window until the next mirrorTime tick gates them off.
private _playEntryIfActive(m: ProxyEntry): void {
this._refreshEntryBounds(m);
const relTime = this._getCurrentTime() - m.start;
if (relTime < 0 || relTime >= m.duration) return;
if (!this._inWindow(m, this._getCurrentTime())) return;
this._playEntry(m);
}
@@ -148,7 +148,7 @@ export class ParentMediaManager {
private _refreshEntryBounds(m: ProxyEntry): void {
if (!m.source?.isConnected) return;
// Guard against a malformed (non-numeric) attribute parsing to NaN: an NaN
// duration makes every `relTime >= m.duration` window check false, so the
// duration makes every window check pass, so the
// gate never closes and the proxy plays past its clip end.
const timing = readClipTiming(m.source);
m.start = timing.start ?? 0;
@@ -158,8 +158,12 @@ export class ParentMediaManager {
// Pause the proxy outside its clip window; resume it on re-entry during
// parent-owned playback. Returns whether the proxy is within the window.
private _gateEntryPlayback(m: ProxyEntry, relTime: number): boolean {
if (relTime < 0 || relTime >= m.duration) {
private _inWindow(m: ProxyEntry, timeSeconds: number): boolean {
return isInClipWindow(timeSeconds, m.start, m.start + m.duration);
}
private _gateEntryPlayback(m: ProxyEntry, timeSeconds: number): boolean {
if (!this._inWindow(m, timeSeconds)) {
if (!m.el.paused) m.el.pause();
m.driftSamples = 0;
return false;
@@ -187,8 +191,7 @@ export class ParentMediaManager {
// Re-read live bounds so a trim/move just before a paused scrub gates and
// positions against the current clip window, not the adopt-time one.
this._refreshEntryBounds(m);
const relTime = timeInSeconds - m.start;
if (relTime >= 0 && relTime < m.duration) m.el.currentTime = relTime;
if (this._inWindow(m, timeInSeconds)) m.el.currentTime = timeInSeconds - m.start;
}
}
@@ -202,9 +205,8 @@ export class ParentMediaManager {
scrubAll(timeInSeconds: number): void {
for (const m of this._entries) {
this._refreshEntryBounds(m);
const relTime = timeInSeconds - m.start;
if (relTime >= 0 && relTime < m.duration) {
m.el.currentTime = relTime;
if (this._inWindow(m, timeInSeconds)) {
m.el.currentTime = timeInSeconds - m.start;
this._playEntry(m);
} else if (!m.el.paused) {
m.el.pause();
@@ -222,8 +224,8 @@ export class ParentMediaManager {
const force = options?.force === true;
for (const m of this._entries) {
this._refreshEntryBounds(m);
if (!this._gateEntryPlayback(m, timelineSeconds)) continue;
const relTime = timelineSeconds - m.start;
if (!this._gateEntryPlayback(m, relTime)) continue;
if (Math.abs(m.el.currentTime - relTime) > MIRROR_DRIFT_THRESHOLD_SECONDS) {
m.driftSamples += 1;
if (force || m.driftSamples >= MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES) {