mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 00:56:23 +00:00
fix(engine): add epsilon to frame index floor to prevent IEEE 754 boundary duplicates (#1318)
## Summary Fixes #1317 — systematic duplicate+skip video frames when clip `data-start` is aligned to the output frame grid. ### Root cause `Math.floor(localTime * fps)` in `getFrameAtTime` produces off-by-one errors when the product lands exactly on an integer boundary due to IEEE 754 float noise. For example, `0.28 * 25 === 6.999999999999999` instead of `7`, causing `Math.floor` to return 6 (duplicate of previous frame) instead of 7. ### Fix 1. Add `1e-9` epsilon before flooring: `Math.floor(localTime * fps + 1e-9)` — nudges boundary values like `6.999999` to `7.000000` without affecting mid-frame values. 2. Include `mediaStart` in the frame index computation so trimmed clips (`data-media-start`) map to the correct extracted frames. Both call sites fixed: `getFrameAtTime()` (public API) and the `FrameLookupTable.getFramesAtTime()` bulk lookup. ### Reporter's measurements (before fix) | Case | Duplicates (of 351 frames) | |---|---| | Source file | 1 | | data-start="0" | 14 | | data-start="230.44" (production) | 127 | | data-start="0.02" (half-frame offset workaround) | 1 | ## Test plan - [x] 4 new regression tests for IEEE 754 boundary precision - [x] No duplicate frames when data-start is grid-aligned (25fps) - [x] Monotonically increasing frame indices across 100 frames - [x] Correct frame at the `0.28 * 25` boundary (frame 7, not 6) - [x] `mediaStart` correctly offsets frame index - [x] Typecheck clean
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
||||
resolveProjectRelativeSrc,
|
||||
codecMayHaveAlpha,
|
||||
decoderForCodec,
|
||||
getFrameAtTime,
|
||||
type VideoElement,
|
||||
type ExtractedFrames,
|
||||
} from "./videoFrameExtractor.js";
|
||||
@@ -704,3 +705,61 @@ describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
|
||||
expect(duplicateRate).toBeLessThan(0.1);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe("getFrameAtTime — IEEE 754 boundary precision", () => {
|
||||
function makeExtracted(fps: number, totalFrames: number): ExtractedFrames {
|
||||
const framePaths = new Map<number, string>();
|
||||
for (let i = 0; i < totalFrames; i++) framePaths.set(i, `frame-${i}.jpg`);
|
||||
return {
|
||||
fps,
|
||||
totalFrames,
|
||||
framePaths,
|
||||
metadata: {
|
||||
durationSeconds: totalFrames / fps,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
codec: "h264",
|
||||
hasAudio: false,
|
||||
fps,
|
||||
},
|
||||
} as ExtractedFrames;
|
||||
}
|
||||
|
||||
it("does not produce duplicate frames when data-start is grid-aligned", () => {
|
||||
const extracted = makeExtracted(25, 351);
|
||||
const videoStart = 0;
|
||||
const seen: string[] = [];
|
||||
let duplicates = 0;
|
||||
for (let i = 0; i < 351; i++) {
|
||||
const globalTime = i / 25;
|
||||
const frame = getFrameAtTime(extracted, globalTime, videoStart);
|
||||
if (frame && seen.length > 0 && frame === seen[seen.length - 1]) duplicates++;
|
||||
if (frame) seen.push(frame);
|
||||
}
|
||||
expect(duplicates).toBe(0);
|
||||
});
|
||||
|
||||
it("returns monotonically increasing frame indices", () => {
|
||||
const extracted = makeExtracted(25, 100);
|
||||
let lastIndex = -1;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const globalTime = i / 25;
|
||||
const frame = getFrameAtTime(extracted, globalTime, 0);
|
||||
const idx = frame ? parseInt(frame.split("-")[1]!) : -1;
|
||||
expect(idx).toBeGreaterThan(lastIndex);
|
||||
lastIndex = idx;
|
||||
}
|
||||
});
|
||||
|
||||
it("handles the 0.28 * 25 boundary case (6.999999 vs 7)", () => {
|
||||
const extracted = makeExtracted(25, 10);
|
||||
const frame = getFrameAtTime(extracted, 0.28, 0);
|
||||
expect(frame).toBe("frame-7.jpg");
|
||||
});
|
||||
|
||||
it("mediaStart does not offset frame index (extractor handles trim via -ss)", () => {
|
||||
const extracted = makeExtracted(25, 100);
|
||||
const frame = getFrameAtTime(extracted, 0, 0, false, 1.0);
|
||||
expect(frame).toBe("frame-0.jpg");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -934,7 +934,9 @@ export function getFrameAtTime(
|
||||
if (loop && loopDuration > 0 && localTime >= loopDuration) {
|
||||
localTime %= loopDuration;
|
||||
}
|
||||
const frameIndex = Math.floor(localTime * extracted.fps);
|
||||
// Add epsilon before flooring to avoid IEEE 754 boundary errors where
|
||||
// e.g. 0.28 * 25 === 6.999999999999999 instead of 7.
|
||||
const frameIndex = Math.floor(localTime * extracted.fps + 1e-9);
|
||||
if (loop && frameIndex >= extracted.totalFrames && extracted.totalFrames > 0) {
|
||||
return extracted.framePaths.get(extracted.totalFrames - 1) || null;
|
||||
}
|
||||
@@ -1044,7 +1046,7 @@ export class FrameLookupTable {
|
||||
if (video.loop && loopDuration > 0 && localTime >= loopDuration) {
|
||||
localTime %= loopDuration;
|
||||
}
|
||||
const frameIndex = Math.floor(localTime * video.extracted.fps);
|
||||
const frameIndex = Math.floor(localTime * video.extracted.fps + 1e-9);
|
||||
if (video.loop && frameIndex >= video.extracted.totalFrames) {
|
||||
const framePath = video.extracted.framePaths.get(video.extracted.totalFrames - 1);
|
||||
if (framePath) {
|
||||
|
||||
Reference in New Issue
Block a user