fix(engine): hold the last video frame at the inclusive clip end (#1564)

* fix(engine): hold the last video frame at the inclusive clip end

The frame-lookup active set deactivated a video on an exclusive end-bound
(globalTime < end), while the runtime keeps an element visible through
currentTime <= end (core/runtime init.ts). The rendered frame landing
exactly on a clip's end went blank even though the runtime still showed
the element on its final frame: one blank frame at the end of every clip
whose end lands on a frame boundary.

Make the active window inclusive of the end to match the runtime, and at
t === end serve the last extracted frame (the runtime holds the element's
final frame there too). Mid-clip source exhaustion (t < end) stays blank,
unchanged.

* fix(engine): align getFrame boundary to match refreshActiveSet

Make getFrame's end-bound inclusive (> instead of >=) for consistency
with the refreshActiveSet changes. getFrame is currently unused
externally but should match the same contract.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Ángel
2026-06-18 15:37:48 -04:00
committed by GitHub
co-authored by Claude Opus 4.6 Carlos Alcaraz
parent 1bab79ef4c
commit fdb8f33fc0
2 changed files with 86 additions and 5 deletions
@@ -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", () => {
@@ -1003,7 +1003,7 @@ export class FrameLookupTable {
getFrame(videoId: string, globalTime: number): string | null {
const video = this.videos.get(videoId);
if (!video) return null;
if (globalTime < video.start || globalTime >= video.end) return null;
if (globalTime < video.start || globalTime > video.end) return null;
return getFrameAtTime(video.extracted, globalTime, video.start, video.loop, video.mediaStart);
}
@@ -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) {
@@ -1037,7 +1042,7 @@ export class FrameLookupTable {
if (candidate.start > globalTime) {
break;
}
if (globalTime < candidate.end) {
if (globalTime <= candidate.end) {
this.activeVideoIds.add(candidate.videoId);
}
this.startCursor += 1;
@@ -1045,7 +1050,7 @@ export class FrameLookupTable {
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);
}
}
@@ -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 });