fix(video): hold final frame through composition

This commit is contained in:
Miguel Ángel
2026-07-16 02:15:01 +00:00
parent 71d7be7921
commit 2e8f871bc8
15 changed files with 352 additions and 90 deletions
@@ -389,7 +389,7 @@ describe("FrameLookupTable", () => {
expect(table.getActiveFramePayloads(4.5).get("hero")?.frameIndex).toBe(15);
});
it("does not hold stale frames for non-looping clips after extracted frames end", () => {
it("holds the last frame for a non-looping clip until its authored slot ends", () => {
const table = createFrameLookupTable(
[
{
@@ -406,7 +406,31 @@ describe("FrameLookupTable", () => {
);
expect(table.getActiveFramePayloads(0.5).has("hero")).toBe(true);
expect(table.getActiveFramePayloads(1.5).has("hero")).toBe(false);
expect(table.getActiveFramePayloads(1.5).get("hero")?.frameIndex).toBe(29);
expect(table.getActiveFramePayloads(4.5).get("hero")?.frameIndex).toBe(29);
expect(table.getFrame("hero", 4.5)).toBeTruthy();
expect(table.getActiveFramePayloads(5.1).has("hero")).toBe(false);
expect(table.getFrame("hero", 5.1)).toBeNull();
});
it("does not invent a held frame when extraction produced no frames", () => {
const table = createFrameLookupTable(
[
{
id: "hero",
src: "clip.webm",
start: 0,
end: 5,
mediaStart: 0,
loop: false,
hasAudio: false,
},
],
[fakeExtracted(0, 30)],
);
expect(table.getActiveFramePayloads(4.5).has("hero")).toBe(false);
expect(table.getFrame("hero", 4.5)).toBeNull();
});
it("places a relative-reference video in its resolved window end-to-end (was blank)", () => {
@@ -448,10 +472,9 @@ describe("FrameLookupTable", () => {
expect(table.getActiveFramePayloads(2.5).get("hero")?.frameIndex).toBe(45);
});
it("holds the last frame at the clip end even when the source is shorter than the window", () => {
// clip [0,5] with only 1s of source (30 @ 30fps). The mid-clip tail stays
// blank (source exhausted), but t === end still holds the last frame to
// match the runtime's inclusive visibility.
it("holds the last frame across the tail when the source is shorter than the window", () => {
// clip [0,5] with only 1s of source (30 @ 30fps). The authored slot is the
// visibility contract, so the final source frame fills its remaining tail.
const table = createFrameLookupTable(
[
{
@@ -466,7 +489,7 @@ describe("FrameLookupTable", () => {
],
[fakeExtracted(30, 30)],
);
expect(table.getActiveFramePayloads(1.5).has("hero")).toBe(false);
expect(table.getActiveFramePayloads(1.5).get("hero")?.frameIndex).toBe(29);
expect(table.getActiveFramePayloads(5.0).get("hero")?.frameIndex).toBe(29);
});
@@ -1529,4 +1552,9 @@ describe("getFrameAtTime — IEEE 754 boundary precision", () => {
const frame = getFrameAtTime(extracted, 0, 0, false, 1.0);
expect(frame).toBe("frame-0.jpg");
});
it("returns null after source exhaustion without an authored slot boundary", () => {
const extracted = makeExtracted(25, 25);
expect(getFrameAtTime(extracted, 3, 0)).toBeNull();
});
});