From d9d38df24df5efbf666cd0b657e4a12d7441eca7 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Sat, 25 Apr 2026 22:43:08 +0700 Subject: [PATCH] fix(editor): align preview timing with presented video frames --- src/components/video-editor/VideoEditor.tsx | 42 +++-- src/components/video-editor/VideoPlayback.tsx | 32 ++-- .../videoPlayback/videoEventHandlers.test.ts | 154 ++++++++++++++++++ .../videoPlayback/videoEventHandlers.ts | 79 +++++++-- .../videoPlayback/webcamSync.test.ts | 44 +++++ .../video-editor/videoPlayback/webcamSync.ts | 15 ++ 6 files changed, 326 insertions(+), 40 deletions(-) create mode 100644 src/components/video-editor/videoPlayback/videoEventHandlers.test.ts create mode 100644 src/components/video-editor/videoPlayback/webcamSync.test.ts create mode 100644 src/components/video-editor/videoPlayback/webcamSync.ts diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 794dba45..bc2c01ef 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -149,6 +149,7 @@ import { DEFAULT_FIGURE_DATA, DEFAULT_PLAYBACK_SPEED, DEFAULT_WEBCAM_OVERLAY, + DEFAULT_WEBCAM_TIME_OFFSET_MS, DEFAULT_ZOOM_DEPTH, DEFAULT_ZOOM_IN_DURATION_MS, DEFAULT_ZOOM_IN_EASING, @@ -1569,6 +1570,7 @@ export default function VideoEditor() { await window.electronAPI.setCurrentRecordingSession?.({ videoPath: sourcePath, webcamPath: normalizedEditor.webcam.sourcePath, + timeOffsetMs: normalizedEditor.webcam.timeOffsetMs, }); } else { await window.electronAPI.setCurrentVideoPath(sourcePath); @@ -1695,7 +1697,7 @@ export default function VideoEditor() { }, [currentPersistedEditorState, currentSourcePath, lastSavedSnapshot?.projectId]); const syncRecordingSessionWebcam = useCallback( - async (webcamPath: string | null) => { + async (webcamPath: string | null, timeOffsetMs?: number) => { if (!currentSourcePath || !window.electronAPI.setCurrentRecordingSession) { return; } @@ -1703,9 +1705,15 @@ export default function VideoEditor() { await window.electronAPI.setCurrentRecordingSession({ videoPath: currentSourcePath, webcamPath, + timeOffsetMs: + webcamPath && Number.isFinite(timeOffsetMs) + ? (timeOffsetMs ?? DEFAULT_WEBCAM_TIME_OFFSET_MS) + : webcamPath + ? webcam.timeOffsetMs + : DEFAULT_WEBCAM_TIME_OFFSET_MS, }); }, - [currentSourcePath], + [currentSourcePath, webcam.timeOffsetMs], ); const syncActiveVideoSource = useCallback( @@ -1714,13 +1722,14 @@ export default function VideoEditor() { await window.electronAPI.setCurrentRecordingSession?.({ videoPath: sourcePath, webcamPath, + timeOffsetMs: webcam.timeOffsetMs, }); return; } await window.electronAPI.setCurrentVideoPath(sourcePath); }, - [], + [webcam.timeOffsetMs], ); const handleUploadWebcam = useCallback(async () => { @@ -1733,9 +1742,10 @@ export default function VideoEditor() { ...prev, enabled: true, sourcePath: result.path ?? null, + timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS, })); - await syncRecordingSessionWebcam(result.path); + await syncRecordingSessionWebcam(result.path, DEFAULT_WEBCAM_TIME_OFFSET_MS); toast.success(t("settings.effects.webcamFootageAdded")); }, [syncRecordingSessionWebcam, t]); @@ -1744,6 +1754,7 @@ export default function VideoEditor() { ...prev, enabled: false, sourcePath: null, + timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS, })); await syncRecordingSessionWebcam(null); @@ -1838,6 +1849,7 @@ export default function VideoEditor() { ...prev, enabled: !!smokeWebcamSourcePath, sourcePath: smokeWebcamSourcePath, + timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS, shadow: smokeExportConfig.webcamShadow === undefined ? prev.shadow @@ -1896,6 +1908,8 @@ export default function VideoEditor() { ...prev, enabled: Boolean(sessionResult.session?.webcamPath), sourcePath: sessionResult.session?.webcamPath ?? null, + timeOffsetMs: + sessionResult.session?.timeOffsetMs ?? DEFAULT_WEBCAM_TIME_OFFSET_MS, })); return; } @@ -1913,6 +1927,7 @@ export default function VideoEditor() { ...prev, enabled: false, sourcePath: null, + timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS, })); } else { setError("No video to load. Please record or select a video."); @@ -3594,14 +3609,14 @@ export default function VideoEditor() { } } - for (const audioPath of previewSourceAudioFallbackPaths) { - let audio = existing.get(audioPath); - if (!audio) { - audio = new Audio(); - audio.preload = "auto"; - existing.set(audioPath, audio); - } - audio.dataset.sourceAudioPath = audioPath; + for (const audioPath of previewSourceAudioFallbackPaths) { + let audio = existing.get(audioPath); + if (!audio) { + audio = new Audio(); + audio.preload = "auto"; + existing.set(audioPath, audio); + } + audio.dataset.sourceAudioPath = audioPath; if (sourceAudioElementResourcesRef.current.get(audioPath) !== audioPath) { audio.pause(); @@ -5476,7 +5491,8 @@ export default function VideoEditor() { audioRegions.length > 0 ? Math.max( ...audioRegions.map( - (region) => region.trackIndex ?? 0, + (region) => + region.trackIndex ?? 0, ), ) + 1 : 0; diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 3cee42fb..edab1ff2 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -101,6 +101,7 @@ import { DEFAULT_CURSOR_SIZE, DEFAULT_CURSOR_SMOOTHING, DEFAULT_CURSOR_SWAY, + DEFAULT_PADDING, DEFAULT_WEBCAM_CORNER_RADIUS, DEFAULT_WEBCAM_REACT_TO_ZOOM, DEFAULT_WEBCAM_SHADOW, @@ -110,7 +111,6 @@ import { DEFAULT_ZOOM_IN_OVERLAP_MS, DEFAULT_ZOOM_OUT_DURATION_MS, DEFAULT_ZOOM_OUT_EASING, - DEFAULT_PADDING, getDefaultCaptionFontFamily, } from "./types"; import { @@ -124,6 +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 { findDominantRegion } from "./videoPlayback/zoomRegionUtils"; import { applyZoomTransform, @@ -1232,10 +1233,11 @@ const VideoPlayback = forwardRef( return; } - const targetTime = clampMediaTimeToDuration( + const targetTime = getWebcamPreviewTargetTimeSeconds({ currentTime, - Number.isFinite(webcamVideo.duration) ? webcamVideo.duration : null, - ); + webcamDuration: Number.isFinite(webcamVideo.duration) ? webcamVideo.duration : null, + timeOffsetMs: webcam.timeOffsetMs, + }); const activeSpeedRegion = speedRegionsRef.current.find( (region) => targetTime * 1000 >= region.startMs && targetTime * 1000 < region.endMs, @@ -1470,7 +1472,7 @@ const VideoPlayback = forwardRef( layoutVideoContent(); video.pause(); - const { handlePlay, handlePause, handleSeeked, handleSeeking } = + const { handlePlay, handlePause, handleSeeked, handleSeeking, dispose } = createVideoEventHandlers({ video, isSeekingRef, @@ -1496,10 +1498,7 @@ const VideoPlayback = forwardRef( video.removeEventListener("ended", handlePause); video.removeEventListener("seeked", handleSeeked); video.removeEventListener("seeking", handleSeeking); - - if (timeUpdateAnimationRef.current) { - cancelAnimationFrame(timeUpdateAnimationRef.current); - } + dispose(); if (videoSprite) { videoContainer.removeChild(videoSprite); @@ -1749,9 +1748,13 @@ const VideoPlayback = forwardRef( // through an intermediate RenderTexture at renderer resolution, which // downsamples the native video and degrades preview quality. // Hysteresis prevents flickering when motionIntensity oscillates near threshold. - const filtersActive = Array.isArray(videoContainer.filters) && videoContainer.filters.length > 0; - const cameraIsMoving = filtersActive ? motionIntensity > 0.002 : motionIntensity > 0.008; - const needsFilters = zoomMotionBlurRef.current > 0 && isPlayingRef.current && cameraIsMoving; + const filtersActive = + Array.isArray(videoContainer.filters) && videoContainer.filters.length > 0; + const cameraIsMoving = filtersActive + ? motionIntensity > 0.002 + : motionIntensity > 0.008; + const needsFilters = + zoomMotionBlurRef.current > 0 && isPlayingRef.current && cameraIsMoving; if (needsFilters && !filtersActive && motionBlurFilterRef.current) { videoContainer.filters = [motionBlurFilterRef.current]; } else if (!needsFilters && filtersActive) { @@ -2451,12 +2454,15 @@ const VideoPlayback = forwardRef( })()} )} + {/* Keep the source video off-screen instead of display:none so the + browser continues producing presented frames for Pixi and preview sync. */}