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