diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index edab1ff2..e0f064bd 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -124,7 +124,7 @@ import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focu import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils"; import { updateOverlayIndicator } from "./videoPlayback/overlayUtils"; import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers"; -import { getWebcamPreviewTargetTimeSeconds } from "./videoPlayback/webcamSync"; +import { getWebcamMediaTargetTimeSeconds } from "./videoPlayback/webcamSync"; import { findDominantRegion } from "./videoPlayback/zoomRegionUtils"; import { applyZoomTransform, @@ -1233,7 +1233,7 @@ const VideoPlayback = forwardRef( return; } - const targetTime = getWebcamPreviewTargetTimeSeconds({ + const targetTime = getWebcamMediaTargetTimeSeconds({ currentTime, webcamDuration: Number.isFinite(webcamVideo.duration) ? webcamVideo.duration : null, timeOffsetMs: webcam.timeOffsetMs, diff --git a/src/components/video-editor/videoPlayback/webcamSync.test.ts b/src/components/video-editor/videoPlayback/webcamSync.test.ts index dc8543b0..d8d1f5ac 100644 --- a/src/components/video-editor/videoPlayback/webcamSync.test.ts +++ b/src/components/video-editor/videoPlayback/webcamSync.test.ts @@ -1,25 +1,28 @@ import { describe, expect, it } from "vitest"; -import { getWebcamPreviewTargetTimeSeconds } from "./webcamSync"; +import { + getWebcamMediaTargetTimeSeconds, + getWebcamPreviewTargetTimeSeconds, +} from "./webcamSync"; describe("getWebcamPreviewTargetTimeSeconds", () => { - it("applies positive webcam offsets", () => { + it("subtracts positive webcam offsets when the webcam started after the main capture", () => { expect( getWebcamPreviewTargetTimeSeconds({ currentTime: 10, webcamDuration: 20, timeOffsetMs: 250, }), - ).toBe(10.25); + ).toBe(9.75); }); - it("clamps negative webcam offsets to zero", () => { + it("adds negative webcam offsets when the webcam started before the main capture", () => { expect( getWebcamPreviewTargetTimeSeconds({ currentTime: 0.1, webcamDuration: 20, timeOffsetMs: -250, }), - ).toBe(0); + ).toBe(0.35); }); it("falls back to the unshifted time when the offset is invalid", () => { @@ -37,8 +40,20 @@ describe("getWebcamPreviewTargetTimeSeconds", () => { getWebcamPreviewTargetTimeSeconds({ currentTime: 8.9, webcamDuration: 9, - timeOffsetMs: 500, + timeOffsetMs: -500, }), ).toBe(9); }); }); + +describe("getWebcamMediaTargetTimeSeconds", () => { + it("clamps positive offsets at zero when the main timeline is earlier than the webcam start", () => { + expect( + getWebcamMediaTargetTimeSeconds({ + currentTime: 0.1, + webcamDuration: 20, + timeOffsetMs: 250, + }), + ).toBe(0); + }); +}); diff --git a/src/components/video-editor/videoPlayback/webcamSync.ts b/src/components/video-editor/videoPlayback/webcamSync.ts index b9125a69..cdcd9df4 100644 --- a/src/components/video-editor/videoPlayback/webcamSync.ts +++ b/src/components/video-editor/videoPlayback/webcamSync.ts @@ -1,6 +1,6 @@ import { clampMediaTimeToDuration } from "@/lib/mediaTiming"; -export function getWebcamPreviewTargetTimeSeconds({ +export function getWebcamMediaTargetTimeSeconds({ currentTime, webcamDuration, timeOffsetMs, @@ -10,6 +10,8 @@ export function getWebcamPreviewTargetTimeSeconds({ timeOffsetMs?: number | null; }): number { const safeOffsetMs = Number.isFinite(timeOffsetMs) ? (timeOffsetMs ?? 0) : 0; - const shiftedTime = currentTime + safeOffsetMs / 1000; + const shiftedTime = currentTime - safeOffsetMs / 1000; return clampMediaTimeToDuration(shiftedTime, webcamDuration); } + +export const getWebcamPreviewTargetTimeSeconds = getWebcamMediaTargetTimeSeconds; diff --git a/src/lib/exporter/frameRenderer.test.ts b/src/lib/exporter/frameRenderer.test.ts index 3d6629d7..caf807d7 100644 --- a/src/lib/exporter/frameRenderer.test.ts +++ b/src/lib/exporter/frameRenderer.test.ts @@ -278,6 +278,24 @@ describe("FrameRenderer webcam export path", () => { expect(renderer.webcamSeekPromise).toBeNull(); }); + it("subtracts stored webcam offsets during export sync", async () => { + const renderer = createRenderer() as unknown as FrameRendererTestAccess & { + config: { webcam?: { timeOffsetMs?: number } }; + }; + const webcamVideo = new FakeVideoElement({ duration: 10, currentTime: 0.25 }); + renderer.webcamVideoElement = webcamVideo; + renderer.config.webcam = { + ...(renderer.config.webcam ?? {}), + timeOffsetMs: 250, + }; + + await renderer.syncWebcamFrame(2); + + expect(webcamVideo.currentTime).toBe(1.75); + expect(renderer.lastSyncedWebcamTime).toBe(1.75); + expect(renderer.webcamSeekPromise).toBeNull(); + }); + it("falls back to animation frame when requestVideoFrameCallback does not fire", async () => { const renderer = createRenderer() as unknown as FrameRendererTestAccess; const webcamVideo = new FakeVideoElement({ diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index f3609e1a..aa388d0b 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -50,6 +50,7 @@ import { getWebcamOverlayPosition, getWebcamOverlaySizePx, } from "@/components/video-editor/webcamOverlay"; +import { getWebcamMediaTargetTimeSeconds } from "@/components/video-editor/videoPlayback/webcamSync"; import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath"; import { extensionHost } from "@/lib/extensions"; import { @@ -693,8 +694,16 @@ export class FrameRenderer { } private async syncWebcamFrame(targetTime: number): Promise { + const webcamTargetTime = getWebcamMediaTargetTimeSeconds({ + currentTime: targetTime, + webcamDuration: Number.isFinite(this.webcamVideoElement?.duration) + ? this.webcamVideoElement?.duration + : null, + timeOffsetMs: this.config.webcam?.timeOffsetMs, + }); + if (this.webcamForwardFrameSource) { - const clampedTime = clampMediaTimeToDuration(targetTime, null); + const clampedTime = clampMediaTimeToDuration(webcamTargetTime, null); const decodedFrame = await this.webcamForwardFrameSource.getFrameAtTime(clampedTime); this.closeWebcamDecodedFrame(); this.webcamDecodedFrame = decodedFrame; @@ -710,7 +719,7 @@ export class FrameRenderer { } const clampedTime = clampMediaTimeToDuration( - targetTime, + webcamTargetTime, Number.isFinite(webcamVideo.duration) ? webcamVideo.duration : null, ); diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index bdf4df30..2db3c645 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -56,6 +56,7 @@ import { getWebcamOverlayPosition, getWebcamOverlaySizePx, } from "@/components/video-editor/webcamOverlay"; +import { getWebcamMediaTargetTimeSeconds } from "@/components/video-editor/videoPlayback/webcamSync"; import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath"; import { extensionHost } from "@/lib/extensions"; import { @@ -1850,8 +1851,13 @@ export class FrameRenderer { } private async syncWebcamFrame(targetTime: number): Promise { - const webcamTimeOffsetSec = (this.config.webcam?.timeOffsetMs ?? 0) / 1000; - const webcamTargetTime = Math.max(0, targetTime + webcamTimeOffsetSec); + const webcamTargetTime = getWebcamMediaTargetTimeSeconds({ + currentTime: targetTime, + webcamDuration: Number.isFinite(this.webcamVideoElement?.duration) + ? this.webcamVideoElement?.duration + : null, + timeOffsetMs: this.config.webcam?.timeOffsetMs, + }); if (this.webcamForwardFrameSource) { const clampedTime = clampMediaTimeToDuration(webcamTargetTime, null);