From e5becc30a3bd3f1dab2f165f150ecec1d70aec3f Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Thu, 26 Mar 2026 21:52:35 +1100 Subject: [PATCH] Fix WGC display selection and webcam sync --- electron/electron-env.d.ts | 3 +- electron/ipc/handlers.ts | 31 ++++++++++++++++--- electron/native/wgc-capture/src/main.cpp | 4 +-- .../native/wgc-capture/src/monitor_utils.cpp | 10 ++---- .../native/wgc-capture/src/monitor_utils.h | 3 +- electron/preload.ts | 2 +- src/components/video-editor/VideoEditor.tsx | 11 +++++-- src/components/video-editor/VideoPlayback.tsx | 9 ++++-- .../video-editor/projectPersistence.ts | 4 +++ src/components/video-editor/types.ts | 3 ++ src/hooks/useScreenRecorder.ts | 27 ++++++++++++++-- 11 files changed, 82 insertions(+), 25 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index c51a5fe0..b012a85c 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -114,10 +114,11 @@ interface Window { setCurrentRecordingSession: (session: { videoPath: string; webcamPath?: string | null; + timeOffsetMs?: number; }) => Promise<{ success: boolean }>; getCurrentRecordingSession: () => Promise<{ success: boolean; - session?: { videoPath: string; webcamPath?: string | null }; + session?: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number }; }>; getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>; clearCurrentVideoPath: () => Promise<{ success: boolean }>; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 0a12b9de..2564aad6 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -57,12 +57,14 @@ type WindowBounds = { type RecordingSessionData = { videoPath: string webcamPath?: string | null + timeOffsetMs?: number } type RecordingSessionManifest = { - version: 1 + version: 1 | 2 videoFileName: string webcamFileName?: string | null + timeOffsetMs?: number } let selectedSource: SelectedSource | null = null @@ -223,6 +225,12 @@ function normalizeVideoSourcePath(videoPath?: string | null): string | null { return trimmed } +function normalizeRecordingTimeOffsetMs(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) + ? Math.round(value) + : 0 +} + function getRecordingSessionManifestPath(videoPath: string) { const extension = path.extname(videoPath) const baseName = path.basename(videoPath, extension) @@ -244,9 +252,10 @@ async function persistRecordingSessionManifest(session: RecordingSessionData): P } const manifest: RecordingSessionManifest = { - version: 1, + version: 2, videoFileName: path.basename(normalizedVideoPath), webcamFileName: path.basename(normalizedWebcamPath), + timeOffsetMs: normalizeRecordingTimeOffsetMs(session.timeOffsetMs), } await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8') @@ -263,7 +272,7 @@ async function resolveRecordingSessionManifest(videoPath?: string | null): Promi try { const content = await fs.readFile(manifestPath, 'utf-8') const parsed = JSON.parse(content) as Partial - if (parsed.version !== 1) { + if (parsed.version !== 1 && parsed.version !== 2) { return null } @@ -275,6 +284,7 @@ async function resolveRecordingSessionManifest(videoPath?: string | null): Promi return { videoPath: normalizedVideoPath, webcamPath: null, + timeOffsetMs: 0, } } @@ -284,6 +294,7 @@ async function resolveRecordingSessionManifest(videoPath?: string | null): Promi return { videoPath: normalizedVideoPath, webcamPath, + timeOffsetMs: normalizeRecordingTimeOffsetMs(parsed.timeOffsetMs), } } catch { return null @@ -338,6 +349,7 @@ async function resolveRecordingSession(videoPath?: string | null): Promise { + ipcMain.handle('set-current-recording-session', async (_, session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number }) => { const normalizedVideoPath = normalizeVideoSourcePath(session.videoPath) ?? session.videoPath currentVideoPath = normalizedVideoPath currentRecordingSession = { videoPath: normalizedVideoPath, webcamPath: normalizeVideoSourcePath(session.webcamPath ?? null), + timeOffsetMs: normalizeRecordingTimeOffsetMs(session.timeOffsetMs), } currentProjectPath = null await persistRecordingSessionManifest(currentRecordingSession) diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index 3f5da69b..f475ace4 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -18,7 +18,7 @@ static std::mutex g_stopMutex; static std::condition_variable g_stopCv; struct CaptureConfig { - int displayId = 0; + int64_t displayId = 0; int64_t windowHandle = 0; std::string outputPath; std::string audioOutputPath; @@ -90,7 +90,7 @@ static bool parseSimpleJson(const std::string& json, CaptureConfig& config) { config.outputPath = findString("outputPath"); if (config.outputPath.empty()) return false; - int displayId = findInt("displayId"); + int64_t displayId = findInt64("displayId"); if (displayId >= 0) config.displayId = displayId; int64_t windowHandle = findInt64("windowHandle"); diff --git a/electron/native/wgc-capture/src/monitor_utils.cpp b/electron/native/wgc-capture/src/monitor_utils.cpp index 25203ebf..e6b0d03d 100644 --- a/electron/native/wgc-capture/src/monitor_utils.cpp +++ b/electron/native/wgc-capture/src/monitor_utils.cpp @@ -27,20 +27,16 @@ std::vector enumerateMonitors() { } // Electron uses the HMONITOR handle value cast to a number as the display ID. -HMONITOR findMonitorByDisplayId(int displayId) { +HMONITOR findMonitorByDisplayId(int64_t displayId) { auto monitors = enumerateMonitors(); for (const auto& m : monitors) { - if (static_cast(reinterpret_cast(m.handle)) == displayId) { + if (static_cast(reinterpret_cast(m.handle)) == displayId) { return m.handle; } } - if (!monitors.empty()) { - return monitors[0].handle; - } - - return MonitorFromPoint({0, 0}, MONITOR_DEFAULTTOPRIMARY); + return nullptr; } MonitorInfo getMonitorInfo(HMONITOR monitor) { diff --git a/electron/native/wgc-capture/src/monitor_utils.h b/electron/native/wgc-capture/src/monitor_utils.h index 513e3105..8889410d 100644 --- a/electron/native/wgc-capture/src/monitor_utils.h +++ b/electron/native/wgc-capture/src/monitor_utils.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -14,5 +15,5 @@ struct MonitorInfo { }; std::vector enumerateMonitors(); -HMONITOR findMonitorByDisplayId(int displayId); +HMONITOR findMonitorByDisplayId(int64_t displayId); MonitorInfo getMonitorInfo(HMONITOR monitor); diff --git a/electron/preload.ts b/electron/preload.ts index 55fd26b1..b5054f38 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -138,7 +138,7 @@ contextBridge.exposeInMainWorld("electronAPI", { setCurrentVideoPath: (path: string) => { return ipcRenderer.invoke("set-current-video-path", path); }, - setCurrentRecordingSession: (session: { videoPath: string; webcamPath?: string | null }) => { + setCurrentRecordingSession: (session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number }) => { return ipcRenderer.invoke("set-current-recording-session", session); }, getCurrentRecordingSession: () => { diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 1627c2e8..e614a74b 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -456,7 +456,7 @@ export default function VideoEditor() { ]); const syncRecordingSessionWebcam = useCallback( - async (webcamPath: string | null) => { + async (webcamPath: string | null, timeOffsetMs = 0) => { const sourcePath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null); if (!sourcePath || !window.electronAPI.setCurrentRecordingSession) { return; @@ -465,6 +465,7 @@ export default function VideoEditor() { await window.electronAPI.setCurrentRecordingSession({ videoPath: sourcePath, webcamPath, + timeOffsetMs, }); }, [videoPath, videoSourcePath], @@ -480,9 +481,10 @@ export default function VideoEditor() { ...prev, enabled: true, sourcePath: result.path ?? null, + timeOffsetMs: 0, })); - await syncRecordingSessionWebcam(result.path); + await syncRecordingSessionWebcam(result.path, 0); toast.success(t("settings.effects.webcamFootageAdded")); }, [syncRecordingSessionWebcam, t]); @@ -491,9 +493,10 @@ export default function VideoEditor() { ...prev, enabled: false, sourcePath: null, + timeOffsetMs: 0, })); - await syncRecordingSessionWebcam(null); + await syncRecordingSessionWebcam(null, 0); toast.success(t("settings.effects.webcamFootageRemoved")); }, [syncRecordingSessionWebcam, t]); @@ -557,6 +560,7 @@ export default function VideoEditor() { ...prev, enabled: Boolean(sessionResult.session?.webcamPath), sourcePath: sessionResult.session?.webcamPath ?? null, + timeOffsetMs: sessionResult.session?.timeOffsetMs ?? 0, })); return; } @@ -572,6 +576,7 @@ export default function VideoEditor() { ...prev, enabled: false, sourcePath: null, + timeOffsetMs: 0, })); } else { setError("No video to load. Please record or select a video."); diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index de3c58a0..b3ce2dca 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -706,10 +706,13 @@ const VideoPlayback = forwardRef( return; } - const targetTime = Math.max(0, currentTime); - if (Math.abs(webcamVideo.currentTime - targetTime) > (isPlaying ? 0.1 : 0.01)) { + const targetTime = Math.max(0, currentTime - (webcam.timeOffsetMs ?? 0) / 1000); + const clampedTargetTime = Number.isFinite(webcamVideo.duration) + ? Math.min(targetTime, Math.max(0, webcamVideo.duration)) + : targetTime; + if (Math.abs(webcamVideo.currentTime - clampedTargetTime) > (isPlaying ? 0.1 : 0.01)) { try { - webcamVideo.currentTime = targetTime; + webcamVideo.currentTime = clampedTargetTime; } catch { // no-op } diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 64eb9d91..19271db7 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -20,6 +20,7 @@ import { DEFAULT_WEBCAM_REACT_TO_ZOOM, DEFAULT_WEBCAM_SHADOW, DEFAULT_WEBCAM_SIZE, + DEFAULT_WEBCAM_TIME_OFFSET_MS, DEFAULT_FIGURE_DATA, DEFAULT_PLAYBACK_SPEED, DEFAULT_ZOOM_DEPTH, @@ -403,6 +404,9 @@ export function normalizeProjectEditor(editor: Partial): Pro enabled: typeof webcam.enabled === "boolean" ? webcam.enabled : DEFAULT_WEBCAM_OVERLAY.enabled, sourcePath: webcamSourcePath, + timeOffsetMs: isFiniteNumber(webcam.timeOffsetMs) + ? Math.round(clamp(webcam.timeOffsetMs, -30_000, 30_000)) + : DEFAULT_WEBCAM_TIME_OFFSET_MS, mirror: typeof webcam.mirror === "boolean" ? webcam.mirror : DEFAULT_WEBCAM_OVERLAY.mirror, corner: webcam.corner === "top-left" || diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 6a071f7b..a1341c52 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -49,6 +49,7 @@ export type WebcamCorner = "top-left" | "top-right" | "bottom-left" | "bottom-ri export interface WebcamOverlaySettings { enabled: boolean; sourcePath: string | null; + timeOffsetMs: number; mirror: boolean; corner: WebcamCorner; size: number; @@ -69,10 +70,12 @@ export const DEFAULT_WEBCAM_REACT_TO_ZOOM = true; export const DEFAULT_WEBCAM_CORNER_RADIUS = 18; export const DEFAULT_WEBCAM_SHADOW = 0.35; export const DEFAULT_WEBCAM_MARGIN = 24; +export const DEFAULT_WEBCAM_TIME_OFFSET_MS = 0; export const DEFAULT_WEBCAM_OVERLAY: WebcamOverlaySettings = { enabled: false, sourcePath: null, + timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS, mirror: true, corner: "bottom-right", size: DEFAULT_WEBCAM_SIZE, diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 10905d11..8953f3d9 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -78,6 +78,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const chunks = useRef([]); const webcamChunks = useRef([]); const startTime = useRef(0); + const webcamStartTime = useRef(null); + const webcamTimeOffsetMs = useRef(0); const recordingSessionTimestamp = useRef(null); const nativeScreenRecording = useRef(false); const wgcRecording = useRef(false); @@ -190,6 +192,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { await window.electronAPI.setCurrentRecordingSession({ videoPath, webcamPath, + timeOffsetMs: webcamTimeOffsetMs.current, }); } else { await window.electronAPI.setCurrentVideoPath(videoPath); @@ -218,6 +221,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const startWebcamRecorder = useCallback(async () => { if (!webcamEnabled) { pendingWebcamPathPromise.current = Promise.resolve(null); + webcamStartTime.current = null; + webcamTimeOffsetMs.current = 0; return; } @@ -270,7 +275,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return; } - const duration = Date.now() - startTime.current; + const duration = Math.max(0, Date.now() - (webcamStartTime.current ?? Date.now())); const webcamBlob = new Blob(webcamChunks.current, { type: mimeType }); webcamChunks.current = []; const fixedBlob = await fixWebmDuration(webcamBlob, duration); @@ -283,6 +288,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } finally { webcamStopResolver.current = null; webcamRecorder.current = null; + webcamStartTime.current = null; if (webcamStream.current) { webcamStream.current.getTracks().forEach((track) => track.stop()); webcamStream.current = null; @@ -290,12 +296,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } }; + webcamStartTime.current = Date.now(); recorder.start(RECORDER_TIMESLICE_MS); } catch (error) { console.warn("Failed to start webcam recording; continuing without webcam layer:", error); pendingWebcamPathPromise.current = Promise.resolve(null); webcamStopPromise.current = Promise.resolve(null); webcamRecorder.current = null; + webcamStartTime.current = null; + webcamTimeOffsetMs.current = 0; if (webcamStream.current) { webcamStream.current.getTracks().forEach((track) => track.stop()); webcamStream.current = null; @@ -441,6 +450,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { recordingSessionTimestamp.current = Date.now(); startTime.current = recordingSessionTimestamp.current; + webcamStartTime.current = null; + webcamTimeOffsetMs.current = 0; await startWebcamRecorder(); const platform = await window.electronAPI.getPlatform(); @@ -513,9 +524,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } if (nativeResult.success) { + const mainStartedAt = Date.now(); nativeScreenRecording.current = true; wgcRecording.current = useWgcCapture; - startTime.current = Date.now(); + startTime.current = mainStartedAt; + webcamTimeOffsetMs.current = webcamStartTime.current === null + ? 0 + : webcamStartTime.current - mainStartedAt; setRecording(true); window.electronAPI?.setRecordingState(true); @@ -732,8 +747,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { recorder.onerror = () => { setRecording(false); }; + const mainStartedAt = Date.now(); + startTime.current = mainStartedAt; + webcamTimeOffsetMs.current = webcamStartTime.current === null + ? 0 + : webcamStartTime.current - mainStartedAt; recorder.start(RECORDER_TIMESLICE_MS); - startTime.current = Date.now(); setRecording(true); window.electronAPI?.setRecordingState(true); } catch (error) { @@ -797,6 +816,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { webcamRecorder.current = null; webcamStream.current?.getTracks().forEach((t) => t.stop()); webcamStream.current = null; + webcamStartTime.current = null; + webcamTimeOffsetMs.current = 0; pendingWebcamPathPromise.current = null; if (nativeScreenRecording.current) {