mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user