Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions packages/engine/src/services/videoFrameExtractor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,71 @@ describe("FrameLookupTable", () => {
expect(table.getActiveFramePayloads(0.5).has("hero")).toBe(true);
expect(table.getActiveFramePayloads(1.5).has("hero")).toBe(false);
});

it("holds the last frame at the inclusive clip end (t === end)", () => {
// clip [1,3] with exactly 2s of source frames (60 @ 30fps). The frame
// landing on t === end used to deactivate one frame early and render blank,
// while the runtime keeps the element visible on its last frame.
const table = createFrameLookupTable(
[
{
id: "hero",
src: "clip.webm",
start: 1,
end: 3,
mediaStart: 0,
loop: false,
hasAudio: false,
},
],
[fakeExtracted(60, 30)],
);
const atEnd = table.getActiveFramePayloads(3.0).get("hero");
expect(atEnd?.frameIndex).toBe(59);
// mid-clip is unaffected
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.
const table = createFrameLookupTable(
[
{
id: "hero",
src: "clip.webm",
start: 0,
end: 5,
mediaStart: 0,
loop: false,
hasAudio: false,
},
],
[fakeExtracted(30, 30)],
);
expect(table.getActiveFramePayloads(1.5).has("hero")).toBe(false);
expect(table.getActiveFramePayloads(5.0).get("hero")?.frameIndex).toBe(29);
});

it("keeps both clips active at a shared adjacent boundary, matching the runtime", () => {
// clip A ends at 3.0, clip B starts at 3.0. The runtime shows both at the
// shared instant; the active set must too.
const table = createFrameLookupTable(
[
{ id: "a", src: "a.webm", start: 0, end: 3, mediaStart: 0, loop: false, hasAudio: false },
{ id: "b", src: "b.webm", start: 3, end: 6, mediaStart: 0, loop: false, hasAudio: false },
],
// createFrameLookupTable maps each clip to extracted frames by id.
[
{ ...fakeExtracted(90, 30), videoId: "a" },
{ ...fakeExtracted(90, 30), videoId: "b" },
],
);
const payloads = table.getActiveFramePayloads(3.0);
expect(payloads.has("a")).toBe(true);
expect(payloads.has("b")).toBe(true);
});
});

describe("parseImageElements", () => {
Expand Down
24 changes: 20 additions & 4 deletions packages/engine/src/services/videoFrameExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1014,11 +1014,16 @@ export class FrameLookupTable {
}

private refreshActiveSet(globalTime: number): void {
// The active window is [start, end] INCLUSIVE of the end, mirroring the
// runtime's element-visibility contract (core/runtime init.ts keeps an
// element visible through `currentTime <= end`). An exclusive end-bound
// here deactivated the video one frame early, so the frame landing exactly
// on a clip's end rendered blank while the runtime still showed it.
if (this.lastTime == null || globalTime < this.lastTime) {
this.activeVideoIds.clear();
this.startCursor = 0;
for (const entry of this.orderedVideos) {
if (entry.start <= globalTime && globalTime < entry.end) {
if (entry.start <= globalTime && globalTime <= entry.end) {
this.activeVideoIds.add(entry.videoId);
}
if (entry.start <= globalTime) {
Expand All @@ -1037,15 +1042,15 @@ export class FrameLookupTable {
if (candidate.start > globalTime) {
break;
}
if (globalTime < candidate.end) {
if (globalTime <= candidate.end) {
this.activeVideoIds.add(candidate.videoId);
}
this.startCursor += 1;
}

for (const videoId of Array.from(this.activeVideoIds)) {
const video = this.videos.get(videoId);
if (!video || globalTime < video.start || globalTime >= video.end) {
if (!video || globalTime < video.start || globalTime > video.end) {
this.activeVideoIds.delete(videoId);
}
}
Expand Down Expand Up @@ -1073,7 +1078,18 @@ export class FrameLookupTable {
}
continue;
}
if (frameIndex < 0 || frameIndex >= video.extracted.totalFrames) continue;
if (frameIndex < 0 || frameIndex >= video.extracted.totalFrames) {
// At the inclusive clip end (globalTime === end), hold the last
// extracted frame so the render matches the runtime, which keeps the
// element visible on its final frame at `t === end`. Mid-clip source
// exhaustion (globalTime < end) stays blank — unchanged.
if (globalTime >= video.end && video.extracted.totalFrames > 0) {
const lastIndex = video.extracted.totalFrames - 1;
const lastPath = video.extracted.framePaths.get(lastIndex);
if (lastPath) frames.set(videoId, { framePath: lastPath, frameIndex: lastIndex });
}
continue;
}
const framePath = video.extracted.framePaths.get(frameIndex);
if (!framePath) continue;
frames.set(videoId, { framePath, frameIndex });
Expand Down
Loading