mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(core): a film stops at its declared length instead of playing its animation past it (#4530)
* fix(core): a film stops at its declared length instead of playing its animation past it * docs(core): the root-length floor comment names what it still does * fix(core): report the real length of a film shorter than one second * test(core): pin sub-second lengths with and without timed clips * fix(core): any positive declared length stops the film, even a single frame * test(core): pin every declared length in one table and read the player test by its fields
This commit is contained in:
@@ -842,12 +842,13 @@ describe("initSandboxRuntimeModular", () => {
|
||||
"visible",
|
||||
]);
|
||||
|
||||
// 79.4 s is now the film's end, which a clip running to the end rests on (the render stops before it).
|
||||
setDuration(79.4);
|
||||
window.__player?.renderSeek(finalSample);
|
||||
expect([root, ctaHost, cta].map((element) => element.style.visibility)).toEqual([
|
||||
"hidden",
|
||||
"hidden",
|
||||
"hidden",
|
||||
"visible",
|
||||
"visible",
|
||||
"visible",
|
||||
]);
|
||||
|
||||
setDuration(79.41666666666667);
|
||||
@@ -905,12 +906,14 @@ describe("initSandboxRuntimeModular", () => {
|
||||
// prepareFrameForCapture -> window.__hf.seek path before reading pixels.
|
||||
// Lock the visibility state at that common pre-capture boundary; the
|
||||
// unavailable private project is still required for buffer/encode proof.
|
||||
for (const { host, child, tailFrame } of seams) {
|
||||
for (const [index, { host, child, tailFrame }] of seams.entries()) {
|
||||
window.__player?.renderSeek(tailFrame / fps);
|
||||
expect([host.style.visibility, child.style.visibility]).toEqual(["visible", "visible"]);
|
||||
|
||||
// Past the last seam is past the film's end, where its final clip rests.
|
||||
const after = index === seams.length - 1 ? "visible" : "hidden";
|
||||
window.__player?.renderSeek((tailFrame + 1) / fps);
|
||||
expect([host.style.visibility, child.style.visibility]).toEqual(["hidden", "hidden"]);
|
||||
expect([host.style.visibility, child.style.visibility]).toEqual([after, after]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1347,7 +1350,7 @@ describe("initSandboxRuntimeModular", () => {
|
||||
expect(window.__player?.getDuration()).toBe(250.5);
|
||||
});
|
||||
|
||||
it("keeps the timeline duration when it exceeds the root's declared data-duration", () => {
|
||||
it("cuts a timeline that runs past the root's declared data-duration to the declared length", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-root", "true");
|
||||
@@ -1363,7 +1366,7 @@ describe("initSandboxRuntimeModular", () => {
|
||||
|
||||
initSandboxRuntimeModular();
|
||||
|
||||
expect(window.__player?.getDuration()).toBe(12);
|
||||
expect(window.__player?.getDuration()).toBe(10);
|
||||
});
|
||||
|
||||
// #6: a single timeline registered under a key that does NOT match the root's
|
||||
|
||||
@@ -876,11 +876,9 @@ export function initSandboxRuntimeModular(): void {
|
||||
timelineRegistry: timelines,
|
||||
includeAuthoredTimingAttrs: true,
|
||||
});
|
||||
// The root's own data-duration is the authored source of truth for
|
||||
// composition length. Without it in the floor, a GSAP timeline that ends
|
||||
// even slightly short of the declared duration shrinks the playable
|
||||
// window — and duration-gated consumers (e.g. the studio's adapter
|
||||
// selection) silently reject the runtime player, losing audio playback.
|
||||
// getSafeTimelineDurationSeconds returns the declared length first; here it only sizes the
|
||||
// stand-in timeline that resolveRootTimelineFromDocument builds for a root timeline with no
|
||||
// length.
|
||||
const rootDeclaredSeconds = parseStrictFiniteTimingNumber(rootEl.getAttribute("data-duration"));
|
||||
const subCompositionEnds: number[] = [];
|
||||
const compositionNodes = Array.from(
|
||||
@@ -1223,6 +1221,16 @@ export function initSandboxRuntimeModular(): void {
|
||||
fallback = 0,
|
||||
timingRevision?: number,
|
||||
): number => {
|
||||
// The root's declared length is the film's length, as in the render: a longer timeline is cut
|
||||
// off.
|
||||
const declaredDuration = parseStrictFiniteTimingNumber(
|
||||
resolveRootCompositionElement()?.getAttribute("data-duration"),
|
||||
);
|
||||
// Any positive length counts, even one frame: the render accepts it too.
|
||||
if (declaredDuration !== null && declaredDuration > 0) {
|
||||
if (window.__hf?.durationSource) delete window.__hf.durationSource;
|
||||
return declaredDuration;
|
||||
}
|
||||
const timelineDuration = getTimelineDurationSeconds(timeline);
|
||||
const { media: mediaFloor, authoredComposition: authoredCompositionFloor } =
|
||||
resolveDurationFloors(timingRevision);
|
||||
|
||||
@@ -520,6 +520,22 @@ describe("collectRuntimeTimelinePayload", () => {
|
||||
expect(result.durationInFrames).toBe(210);
|
||||
});
|
||||
|
||||
it("reports the real length of a film shorter than one second", () => {
|
||||
document.body.innerHTML = `<div data-composition-id="main" data-duration="0.2"><div id="clip" data-start="0" data-duration="0.2"></div></div>`;
|
||||
(window as TimelineTestWindow).__timelines = { main: { duration: () => 0.2 } };
|
||||
|
||||
const result = collectRuntimeTimelinePayload(defaultParams);
|
||||
expect(result.durationSeconds).toBe(0.2);
|
||||
expect(result.durationInFrames).toBe(6);
|
||||
});
|
||||
|
||||
it("reports the real length of a sub-second root with no timed clips", () => {
|
||||
document.body.innerHTML = `<div data-composition-id="main" data-duration="0.2"></div>`;
|
||||
|
||||
const result = collectRuntimeTimelinePayload(defaultParams);
|
||||
expect(result.durationSeconds).toBe(0.2);
|
||||
});
|
||||
|
||||
it("defaults composition dimensions to 1920x1080", () => {
|
||||
const result = collectRuntimeTimelinePayload(defaultParams);
|
||||
expect(result.compositionWidth).toBe(1920);
|
||||
|
||||
@@ -695,7 +695,8 @@ export function collectRuntimeTimelinePayload(params: {
|
||||
// hide structural/background tracks from the timeline UI; if we collapse the
|
||||
// payload duration down to the last visible clip end, the controls jump even
|
||||
// though playback still runs for the full authored root duration.
|
||||
const safeDuration = Math.max(1, maxEnd || 1, rootCompositionDuration ?? 0);
|
||||
const knownDuration = Math.max(maxEnd || 0, rootCompositionDuration ?? 0);
|
||||
const safeDuration = knownDuration > 0 ? knownDuration : 1;
|
||||
const durationInFrames = Math.max(1, Math.ceil(safeDuration * Math.max(1, params.canonicalFps)));
|
||||
return {
|
||||
...runtimeProtocolMetadata(params.canonicalFps),
|
||||
|
||||
@@ -200,6 +200,33 @@ describe("parked transport loop", () => {
|
||||
expect(states.at(-1)).toMatchObject({ isPlaying: false, ended: true, frame: 149 });
|
||||
});
|
||||
|
||||
// The render stops at the root's declared length too; a longer animation is cut off.
|
||||
it.each([
|
||||
["3 s, shorter than its animation", "3", 90],
|
||||
["under a second", "0.2", 6],
|
||||
["one 60 fps frame", String(1 / 60), 1],
|
||||
])("stops a film declared %s at that length, and says so", (_label, declared, frame) => {
|
||||
const seconds = Number(declared);
|
||||
let nowMs = 1000;
|
||||
vi.spyOn(performance, "now").mockImplementation(() => nowMs);
|
||||
document.body.innerHTML = `<div id="root" data-composition-id="main" data-root="true" data-start="0" data-duration="${declared}"></div>`;
|
||||
window.__timelines = { main: createMockTimeline(5) };
|
||||
initSandboxRuntimeModular();
|
||||
quiesce();
|
||||
|
||||
window.__player!.play();
|
||||
for (let step = 0; step < 400 && window.__player!.isPlaying(); step += 1) {
|
||||
nowMs += 10;
|
||||
raf.step(10);
|
||||
}
|
||||
|
||||
const states = posted.filter((m) => m["type"] === "state");
|
||||
expect(states.at(-1)).toMatchObject({ isPlaying: false, ended: true, frame });
|
||||
expect(window.__player!.getDuration()).toBeCloseTo(seconds, 9);
|
||||
const timeline = posted.filter((m) => m["type"] === "timeline").at(-1);
|
||||
expect(timeline?.["durationSeconds"]).toBeCloseTo(seconds, 9);
|
||||
});
|
||||
|
||||
it("keeps asking for animation frames while playing", () => {
|
||||
mount();
|
||||
initSandboxRuntimeModular();
|
||||
@@ -330,6 +357,8 @@ describe("parked transport loop", () => {
|
||||
|
||||
it("delivers media metadata that arrives after the loop parked", async () => {
|
||||
mount(`<video id="v" data-start="0" src="a.mp4"></video>`);
|
||||
// A declared length would hold the film at 5 s; only an inferred one can grow.
|
||||
document.getElementById("root")!.removeAttribute("data-duration");
|
||||
initSandboxRuntimeModular();
|
||||
quiesce();
|
||||
|
||||
@@ -513,6 +542,7 @@ describe("parked transport loop", () => {
|
||||
|
||||
it("delivers an adapter duration that grows while parked, with no DOM mutation and no event", () => {
|
||||
mount();
|
||||
document.getElementById("root")!.removeAttribute("data-duration");
|
||||
initSandboxRuntimeModular();
|
||||
quiesce();
|
||||
const before = window.__player!.getDuration();
|
||||
|
||||
@@ -1464,6 +1464,25 @@ describe("HyperframesPlayer loop end-state handling", () => {
|
||||
expect(player._currentTime).toBe(player._duration);
|
||||
});
|
||||
|
||||
it("takes a length under a second from the runtime and ends there", () => {
|
||||
const ended = vi.fn();
|
||||
player.addEventListener("ended", ended);
|
||||
player.loop = false;
|
||||
player._onMessage(
|
||||
new MessageEvent("message", {
|
||||
source: frameWindow,
|
||||
data: { source: "hf-preview", type: "timeline", durationSeconds: 0.2, durationInFrames: 6 },
|
||||
}),
|
||||
);
|
||||
expect(player._duration).toBe(0.2);
|
||||
player._paused = false;
|
||||
|
||||
postState(6, false, 0.2, true);
|
||||
|
||||
expect(ended).toHaveBeenCalledTimes(1);
|
||||
expect(player._currentTime).toBe(0.2);
|
||||
});
|
||||
|
||||
it("loops a film whose length falls between two frames", () => {
|
||||
const seek = vi.spyOn(player, "seek");
|
||||
player.loop = true;
|
||||
|
||||
Reference in New Issue
Block a user