From 3a72aa528dfa742209c6565654b23c96bb81536a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 10 Jun 2026 16:37:03 -0400 Subject: [PATCH] fix(engine): add epsilon to frame index floor to prevent IEEE 754 boundary duplicates (#1318) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .../src/services/videoFrameExtractor.test.ts | 59 +++++++++++++++++++ .../src/services/videoFrameExtractor.ts | 6 +- .../results.json | 1 - 3 files changed, 63 insertions(+), 3 deletions(-) delete mode 100644 packages/producer/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json diff --git a/packages/engine/src/services/videoFrameExtractor.test.ts b/packages/engine/src/services/videoFrameExtractor.test.ts index 22b56e119..49d92103c 100644 --- a/packages/engine/src/services/videoFrameExtractor.test.ts +++ b/packages/engine/src/services/videoFrameExtractor.test.ts @@ -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(); + 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"); + }); +}); diff --git a/packages/engine/src/services/videoFrameExtractor.ts b/packages/engine/src/services/videoFrameExtractor.ts index 096dd6b5c..22a9c7658 100644 --- a/packages/engine/src/services/videoFrameExtractor.ts +++ b/packages/engine/src/services/videoFrameExtractor.ts @@ -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) { diff --git a/packages/producer/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json b/packages/producer/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json deleted file mode 100644 index 06e7b51d0..000000000 --- a/packages/producer/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +++ /dev/null @@ -1 +0,0 @@ -{"version":"4.1.4","results":[[":src/utils/paths.test.ts",{"duration":10.026583000000016,"failed":false}],[":src/services/renderOrchestrator.test.ts",{"duration":12.886541999999963,"failed":false}],[":src/services/htmlCompiler.test.ts",{"duration":0,"failed":true}],[":src/services/hyperframeRuntimeLoader.test.ts",{"duration":27.842709000000013,"failed":false}]]} \ No newline at end of file