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:
Miguel Ángel
2026-06-10 16:37:03 -04:00
committed by GitHub
parent 8133d9346e
commit 3a72aa528d
3 changed files with 63 additions and 3 deletions
@@ -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) {