mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 00:56:23 +00:00
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:
co-authored by
Claude Opus 4.6
Carlos Alcaraz
parent
1bab79ef4c
commit
fdb8f33fc0
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user