diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 7b8876e0..1f570cd8 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -183,10 +183,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 0a3d6d8c..e422fc47 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -81,12 +81,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 } type ProjectLibraryEntry = { @@ -384,6 +386,7 @@ async function loadProjectFromPath(projectPath: string) { currentRecordingSession = { videoPath: mediaSources.videoPath, webcamPath: mediaSources.webcamPath, + timeOffsetMs: mediaSources.timeOffsetMs, } await rememberRecentProject(normalizedPath) @@ -420,6 +423,7 @@ async function resolveProjectMediaSources(project: unknown): Promise< success: true videoPath: string webcamPath: string | null + timeOffsetMs: number } | { success: false @@ -453,6 +457,9 @@ async function resolveProjectMediaSources(project: unknown): Promise< typeof (project as { editor?: { webcam?: { sourcePath?: unknown } } }).editor?.webcam?.sourcePath === 'string' ? ((project as { editor?: { webcam?: { sourcePath?: string } } }).editor?.webcam?.sourcePath ?? null) : null + const timeOffsetMs = normalizeRecordingTimeOffsetMs( + (project as { editor?: { webcam?: { timeOffsetMs?: unknown } } }).editor?.webcam?.timeOffsetMs, + ) const normalizedWebcamPath = normalizeVideoSourcePath(rawWebcamPath) if (!normalizedWebcamPath) { @@ -460,6 +467,7 @@ async function resolveProjectMediaSources(project: unknown): Promise< success: true, videoPath: normalizedVideoPath, webcamPath: null, + timeOffsetMs, } } @@ -469,16 +477,24 @@ async function resolveProjectMediaSources(project: unknown): Promise< success: true, videoPath: normalizedVideoPath, webcamPath: normalizedWebcamPath, + timeOffsetMs, } } catch { return { success: true, videoPath: normalizedVideoPath, webcamPath: null, + timeOffsetMs, } } } +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) @@ -500,9 +516,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') @@ -519,7 +536,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 } @@ -531,6 +548,7 @@ async function resolveRecordingSessionManifest(videoPath?: string | null): Promi return { videoPath: normalizedVideoPath, webcamPath: null, + timeOffsetMs: 0, } } @@ -540,6 +558,7 @@ async function resolveRecordingSessionManifest(videoPath?: string | null): Promi return { videoPath: normalizedVideoPath, webcamPath, + timeOffsetMs: normalizeRecordingTimeOffsetMs(parsed.timeOffsetMs), } } catch { return null @@ -594,6 +613,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/monitor_utils.cpp b/electron/native/wgc-capture/src/monitor_utils.cpp index 56962aeb..e6b0d03d 100644 --- a/electron/native/wgc-capture/src/monitor_utils.cpp +++ b/electron/native/wgc-capture/src/monitor_utils.cpp @@ -36,11 +36,7 @@ HMONITOR findMonitorByDisplayId(int64_t displayId) { } } - if (!monitors.empty()) { - return monitors[0].handle; - } - - return MonitorFromPoint({0, 0}, MONITOR_DEFAULTTOPRIMARY); + return nullptr; } MonitorInfo getMonitorInfo(HMONITOR monitor) { diff --git a/electron/preload.ts b/electron/preload.ts index cfe9d8f4..638511d0 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -194,7 +194,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 dc11eec5..77da916d 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1175,7 +1175,7 @@ export default function VideoEditor() { }, [currentPersistedEditorState, currentSourcePath]); const syncRecordingSessionWebcam = useCallback( - async (webcamPath: string | null) => { + async (webcamPath: string | null, timeOffsetMs = 0) => { if (!currentSourcePath || !window.electronAPI.setCurrentRecordingSession) { return; } @@ -1183,6 +1183,7 @@ export default function VideoEditor() { await window.electronAPI.setCurrentRecordingSession({ videoPath: currentSourcePath, webcamPath, + timeOffsetMs, }); }, [currentSourcePath], @@ -1213,9 +1214,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]); @@ -1224,9 +1226,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]); @@ -1295,6 +1298,7 @@ export default function VideoEditor() { ...prev, enabled: Boolean(sessionResult.session?.webcamPath), sourcePath: sessionResult.session?.webcamPath ?? null, + timeOffsetMs: sessionResult.session?.timeOffsetMs ?? 0, })); return; } @@ -1310,6 +1314,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 0e8fe195..6ba75ac0 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -945,7 +945,7 @@ const VideoPlayback = forwardRef( } const targetTime = clampMediaTimeToDuration( - currentTime, + Math.max(0, currentTime - (webcam.timeOffsetMs ?? 0) / 1000), Number.isFinite(webcamVideo.duration) ? webcamVideo.duration : null, ); diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 7720b975..b8a1c500 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -35,6 +35,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, @@ -585,6 +586,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, positionPreset: webcam.positionPreset === "top-left" || diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 4eedb513..2c5eaf76 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -69,6 +69,7 @@ export type WebcamPositionPreset = export interface WebcamOverlaySettings { enabled: boolean; sourcePath: string | null; + timeOffsetMs: number; mirror: boolean; corner: WebcamCorner; positionPreset: WebcamPositionPreset; @@ -104,10 +105,12 @@ export const DEFAULT_WEBCAM_MARGIN = 24; export const DEFAULT_WEBCAM_POSITION_PRESET: WebcamPositionPreset = "bottom-right"; export const DEFAULT_WEBCAM_POSITION_X = 1; export const DEFAULT_WEBCAM_POSITION_Y = 1; +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", positionPreset: DEFAULT_WEBCAM_POSITION_PRESET, diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 0120a732..a47c41ab 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -79,6 +79,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 nativeWindowsRecording = useRef(false); @@ -265,6 +267,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { await window.electronAPI.setCurrentRecordingSession({ videoPath, webcamPath, + timeOffsetMs: webcamTimeOffsetMs.current, }); } else { await window.electronAPI.setCurrentVideoPath(videoPath); @@ -293,6 +296,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const startWebcamRecorder = useCallback(async () => { if (!webcamEnabled) { pendingWebcamPathPromise.current = Promise.resolve(null); + webcamStartTime.current = null; + webcamTimeOffsetMs.current = 0; return; } @@ -345,7 +350,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return; } - const duration = getRecordingDurationMs(Date.now()); + const duration = Math.max(0, getRecordingDurationMs(Date.now()) - webcamTimeOffsetMs.current); const webcamBlob = new Blob(webcamChunks.current, { type: mimeType }); webcamChunks.current = []; const fixedBlob = await fixWebmDuration(webcamBlob, duration); @@ -358,6 +363,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; @@ -365,12 +371,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; @@ -524,6 +533,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { recordingSessionTimestamp.current = Date.now(); resetRecordingClock(recordingSessionTimestamp.current); + webcamStartTime.current = null; + webcamTimeOffsetMs.current = 0; await startWebcamRecorder(); const platform = await window.electronAPI.getPlatform(); @@ -596,9 +607,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } if (nativeResult.success) { + const mainStartedAt = Date.now(); nativeScreenRecording.current = true; nativeWindowsRecording.current = useNativeWindowsCapture; - resetRecordingClock(Date.now()); + webcamTimeOffsetMs.current = webcamStartTime.current === null + ? 0 + : webcamStartTime.current - mainStartedAt; + resetRecordingClock(mainStartedAt); setRecording(true); window.electronAPI?.setRecordingState(true); @@ -824,8 +839,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { recorder.onerror = () => { setRecording(false); }; + const mainStartedAt = Date.now(); + resetRecordingClock(mainStartedAt); + webcamTimeOffsetMs.current = webcamStartTime.current === null + ? 0 + : webcamStartTime.current - mainStartedAt; recorder.start(RECORDER_TIMESLICE_MS); - resetRecordingClock(Date.now()); setRecording(true); window.electronAPI?.setRecordingState(true); } catch (error) { @@ -909,6 +928,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) {