diff --git a/electron/main.ts b/electron/main.ts index f46a8ce0..cd609292 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -40,8 +40,8 @@ import { getUpdateStatusSummary, installDownloadedUpdateNow, previewUpdateToast, - setupAutoUpdates, setExperimentalUpdatesEnabled, + setupAutoUpdates, skipAvailableUpdateVersion, } from "./updater"; import { @@ -175,6 +175,7 @@ let trayContextMenu: Menu | null = null; let selectedSourceName = ""; let editorHasUnsavedChanges = false; let isForceClosing = false; +let isAppQuitting = false; let isCreatingMainWindow = false; let isCreatingEditorWindow = false; const shouldEnforceSingleInstanceLock = !IS_DEV; @@ -198,6 +199,22 @@ function closeEditorWindowBypassingUnsavedPrompt(window: BrowserWindow | null) { window.close(); } +function closeEditorWindowToHud(window: BrowserWindow | null) { + if (!window || window.isDestroyed()) { + return; + } + + // The HUD renderer normally remains hidden while the editor is open so + // recording finalization can continue. Restore that HUD before destroying + // the editor, keeping Recordly in its ready-to-record state on the taskbar. + window.hide(); + if (mainWindow === window) { + mainWindow = null; + } + createWindow(); + closeEditorWindowBypassingUnsavedPrompt(window); +} + function restoreWindowSafely(window: BrowserWindow | null) { if (!window || window.isDestroyed()) { return; @@ -414,26 +431,23 @@ function sendEditorMenuAction( function setupApplicationMenu() { const isMac = process.platform === "darwin"; - if (!isMac) { - Menu.setApplicationMenu(null); - return; - } - const template: Electron.MenuItemConstructorOptions[] = []; - template.push({ - label: app.name, - submenu: [ - { role: "about" }, - { type: "separator" }, - { role: "services" }, - { type: "separator" }, - { role: "hide" }, - { role: "hideOthers" }, - { role: "unhide" }, - { type: "separator" }, - { role: "quit" }, - ], - }); + if (isMac) { + template.push({ + label: app.name, + submenu: [ + { role: "about" }, + { type: "separator" }, + { role: "services" }, + { type: "separator" }, + { role: "hide" }, + { role: "hideOthers" }, + { role: "unhide" }, + { type: "separator" }, + { role: "quit" }, + ], + }); + } template.push( { @@ -454,7 +468,12 @@ function setupApplicationMenu() { accelerator: "CmdOrCtrl+Shift+S", click: () => sendEditorMenuAction("menu-save-project-as"), }, - ...(isMac ? [] : [{ type: "separator" as const }, { role: "quit" as const }]), + ...(isMac + ? [] + : [ + { type: "separator" as const }, + { role: "quit" as const, accelerator: "CmdOrCtrl+Q" }, + ]), ], }, { @@ -776,6 +795,10 @@ function createEditorWindowWrapper() { editorWindow.on("close", (event) => { if (isForceClosing || !editorHasUnsavedChanges) { + if (process.platform === "win32" && !isForceClosing && !isAppQuitting) { + event.preventDefault(); + closeEditorWindowToHud(editorWindow); + } return; } @@ -795,11 +818,21 @@ function createEditorWindowWrapper() { editorWindow.webContents.send("request-save-before-close"); ipcMain.once("save-before-close-done", (_event, saved: boolean) => { if (saved) { - closeEditorWindowBypassingUnsavedPrompt(editorWindow); + if (process.platform === "win32" && !isAppQuitting) { + closeEditorWindowToHud(editorWindow); + } else { + closeEditorWindowBypassingUnsavedPrompt(editorWindow); + } } }); } else if (choice === 1) { - closeEditorWindowBypassingUnsavedPrompt(editorWindow); + if (process.platform === "win32" && !isAppQuitting) { + closeEditorWindowToHud(editorWindow); + } else { + closeEditorWindowBypassingUnsavedPrompt(editorWindow); + } + } else { + isAppQuitting = false; } }); @@ -817,6 +850,7 @@ function createSourceSelectorWindowWrapper() { // On macOS, applications and their menu bar stay active until the user quits // explicitly with Cmd + Q. app.on("before-quit", () => { + isAppQuitting = true; killWindowsCaptureProcess(); showCursor(); cleanupNativeVideoExportSessions(); diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 9115d781..473551b3 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -13,6 +13,7 @@ import { } from "react"; import { getAssetPath, getRenderableAssetUrl, getRenderableVideoUrl } from "@/lib/assetPath"; import { getWebcamShadowFilter } from "@/lib/exporter/shadowProfile"; +import { getSquircleSvgPath } from "@/lib/geometry/squircle"; import { clampMediaTimeToDuration, enablePitchPreservingPlayback, @@ -27,6 +28,8 @@ import { DEFAULT_WALLPAPER_RELATIVE_PATH, isVideoWallpaperSource, } from "@/lib/wallpapers"; +import { type AspectRatio, formatAspectRatioForCSS } from "@/utils/aspectRatioUtils"; +import { AnnotationOverlay } from "./AnnotationOverlay"; import { type CaptionEditTarget, normalizeCaptionEditText } from "./captionEditing"; import { buildActiveCaptionLayout } from "./captionLayout"; import { @@ -44,36 +47,6 @@ import { type CaptionCue, type CursorClickEffectStyle, type CursorStyle, - type Padding, - type SpeedRegion, - type TrimRegion, - type WebcamOverlaySettings, - ZOOM_DEPTH_SCALES, - type ZoomDepth, - type ZoomFocus, - type ZoomMotionBlurTuning, - type ZoomRegion, - type ZoomTransitionEasing, -} from "./types"; -import { DEFAULT_FOCUS } from "./videoPlayback/constants"; -import { - DEFAULT_CURSOR_CONFIG, - PixiCursorOverlay, - preloadCursorAssets, -} from "./videoPlayback/cursorRenderer"; -import { clamp01 } from "./videoPlayback/mathUtils"; -import { - createSpringState, - getZoomSpringConfig, - resetSpringState, - type SpringState, - stepSpringValue, -} from "./videoPlayback/motionSmoothing"; - -import { getSquircleSvgPath } from "@/lib/geometry/squircle"; -import { type AspectRatio, formatAspectRatioForCSS } from "@/utils/aspectRatioUtils"; -import { AnnotationOverlay } from "./AnnotationOverlay"; -import { DEFAULT_CONNECTED_ZOOM_DURATION_MS, DEFAULT_CONNECTED_ZOOM_EASING, DEFAULT_CONNECTED_ZOOM_GAP_MS, @@ -102,7 +75,18 @@ import { DEFAULT_ZOOM_OUT_DURATION_MS, DEFAULT_ZOOM_OUT_EASING, getDefaultCaptionFontFamily, + type Padding, + type SpeedRegion, + type TrimRegion, + type WebcamOverlaySettings, + ZOOM_DEPTH_SCALES, + type ZoomDepth, + type ZoomFocus, + type ZoomMotionBlurTuning, + type ZoomRegion, + type ZoomTransitionEasing, } from "./types"; +import { DEFAULT_FOCUS } from "./videoPlayback/constants"; import { type CursorFollowCameraState, computeCursorFollowFocus, @@ -110,11 +94,28 @@ import { resetCursorFollowCamera, SNAP_TO_EDGES_RATIO_AUTO, } from "./videoPlayback/cursorFollowCamera"; +import { + DEFAULT_CURSOR_CONFIG, + PixiCursorOverlay, + preloadCursorAssets, +} from "./videoPlayback/cursorRenderer"; import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils"; import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils"; +import { clamp01 } from "./videoPlayback/mathUtils"; +import { + createSpringState, + getZoomSpringConfig, + resetSpringState, + type SpringState, + stepSpringValue, +} from "./videoPlayback/motionSmoothing"; import { updateOverlayIndicator } from "./videoPlayback/overlayUtils"; import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers"; -import { getWebcamMediaTargetTimeSeconds, shouldSeekWebcamMedia } from "./videoPlayback/webcamSync"; +import { + getWebcamMediaTargetTimeSeconds, + isWebcamMediaSynchronized, + shouldSeekWebcamMedia, +} from "./videoPlayback/webcamSync"; import { findDominantRegion } from "./videoPlayback/zoomRegionUtils"; import { applyZoomTransform, @@ -419,6 +420,10 @@ const VideoPlayback = forwardRef( width: number; height: number; } | null>(null); + const webcamSynchronizedPathRef = useRef(null); + const [webcamSynchronizedPath, setWebcamSynchronizedPath] = useState(null); + const webcamMediaSynchronized = + Boolean(webcamVideoPath) && webcamSynchronizedPath === webcamVideoPath; const captionBoxRef = useRef(null); const captionEditInputRef = useRef(null); const captionEditSessionRef = useRef(null); @@ -1641,7 +1646,7 @@ const VideoPlayback = forwardRef( ? webcamVideo.duration : null; const targetTime = getWebcamMediaTargetTimeSeconds({ - currentTime, + currentTime: currentTimeRef.current / 1000, webcamDuration, timeOffsetMs: webcamTimeOffsetMs, }); @@ -1650,6 +1655,36 @@ const VideoPlayback = forwardRef( ? Math.min(1 / 60, webcamDuration) : targetTime; + if ( + webcamSynchronizedPathRef.current !== webcamVideoPath && + !isWebcamMediaSynchronized({ + currentTime: webcamVideo.currentTime, + targetTime: mediaTargetTime, + readyState: webcamVideo.readyState, + isSeeking: webcamVideo.seeking, + }) + ) { + webcamVideo.pause(); + if ( + webcamVideo.readyState >= HTMLMediaElement.HAVE_METADATA && + !webcamVideo.seeking && + Math.abs(webcamVideo.currentTime - mediaTargetTime) > 0.01 + ) { + try { + webcamVideo.currentTime = mediaTargetTime; + } catch { + // The next media-ready event retries once the source accepts seeks. + } + } + return; + } + + if (webcamSynchronizedPathRef.current !== webcamVideoPath) { + webcamSynchronizedPathRef.current = webcamVideoPath; + setWebcamSynchronizedPath(webcamVideoPath); + lastWebcamSyncTimeRef.current = targetTime; + } + const timelineTimeMs = currentTime * 1000; const activeSpeedRegion = speedRegionsRef.current.find( (region) => timelineTimeMs >= region.startMs && timelineTimeMs < region.endMs, @@ -2480,7 +2515,10 @@ const VideoPlayback = forwardRef(
@@ -2498,6 +2536,8 @@ const VideoPlayback = forwardRef( aria-hidden="true" onLoadedMetadata={handleWebcamMediaReady} onLoadedData={handleWebcamMediaReady} + onCanPlay={handleWebcamMediaReady} + onSeeked={handleWebcamMediaReady} />
diff --git a/src/components/video-editor/WebcamCropControl.tsx b/src/components/video-editor/WebcamCropControl.tsx index 10e5e6ef..4468434f 100644 --- a/src/components/video-editor/WebcamCropControl.tsx +++ b/src/components/video-editor/WebcamCropControl.tsx @@ -1,7 +1,10 @@ import { useCallback, useEffect, useId, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import type { CropRegion } from "./types"; -import { getWebcamPreviewTargetTimeSeconds } from "./videoPlayback/webcamSync"; +import { + getWebcamPreviewTargetTimeSeconds, + isWebcamMediaSynchronized, +} from "./videoPlayback/webcamSync"; import { normalizeWebcamCropRegion } from "./webcamOverlay"; type CropHandle = "move" | "nw" | "ne" | "sw" | "se"; @@ -135,6 +138,9 @@ export function WebcamCropControl({ const [activeHandle, setActiveHandle] = useState(null); const [draftVisualCrop, setDraftVisualCrop] = useState(null); const [previewFrame, setPreviewFrame] = useState(null); + const synchronizedPreviewSrcRef = useRef(null); + const [synchronizedPreviewSrc, setSynchronizedPreviewSrc] = useState(null); + const previewSynchronized = previewSrc !== null && synchronizedPreviewSrc === previewSrc; const hasPreviewFrame = previewSrc !== null && previewFrame?.src === previewSrc; const previewAspectRatio = hasPreviewFrame && previewFrame && previewFrame.width > 0 && previewFrame.height > 0 @@ -178,6 +184,35 @@ export function WebcamCropControl({ targetTime <= 0 && webcamDuration !== null && webcamDuration > 0 ? Math.min(1 / 60, webcamDuration) : targetTime; + + if ( + synchronizedPreviewSrcRef.current !== previewSrc && + !isWebcamMediaSynchronized({ + currentTime: video.currentTime, + targetTime: mediaTargetTime, + readyState: video.readyState, + isSeeking: video.seeking, + }) + ) { + video.pause(); + if ( + video.readyState >= HTMLMediaElement.HAVE_METADATA && + !video.seeking && + Math.abs(video.currentTime - mediaTargetTime) > 0.01 + ) { + try { + video.currentTime = mediaTargetTime; + } catch { + /* The next media-ready event retries once the source accepts seeks. */ + } + } + return; + } + + if (synchronizedPreviewSrcRef.current !== previewSrc) { + synchronizedPreviewSrcRef.current = previewSrc; + setSynchronizedPreviewSrc(previewSrc); + } const driftThreshold = previewPlaying ? 0.35 : 0.01; if (Math.abs(video.currentTime - mediaTargetTime) > driftThreshold) { @@ -355,7 +390,7 @@ export function WebcamCropControl({ src={previewSrc} className="pointer-events-none absolute inset-0 block h-full w-full object-fill" style={{ - opacity: hasPreviewFrame ? 1 : 0, + opacity: hasPreviewFrame && previewSynchronized ? 1 : 0, transform: mirrored ? "scaleX(-1)" : undefined, }} muted @@ -364,6 +399,7 @@ export function WebcamCropControl({ aria-hidden="true" onLoadedMetadata={handlePreviewFrameReady} onLoadedData={handlePreviewFrameReady} + onCanPlay={handlePreviewFrameReady} onSeeked={handlePreviewFrameReady} /> ) : null} diff --git a/src/components/video-editor/videoPlayback/webcamSync.test.ts b/src/components/video-editor/videoPlayback/webcamSync.test.ts index bd51e9c9..492b4764 100644 --- a/src/components/video-editor/videoPlayback/webcamSync.test.ts +++ b/src/components/video-editor/videoPlayback/webcamSync.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { getWebcamMediaTargetTimeSeconds, getWebcamPreviewTargetTimeSeconds, + isWebcamMediaSynchronized, shouldSeekWebcamMedia, } from "./webcamSync"; @@ -99,3 +100,43 @@ describe("shouldSeekWebcamMedia", () => { ).toBe(false); }); }); + +describe("isWebcamMediaSynchronized", () => { + it("waits for current frame data and a completed seek", () => { + expect( + isWebcamMediaSynchronized({ + currentTime: 10, + targetTime: 10, + readyState: 1, + isSeeking: false, + }), + ).toBe(false); + expect( + isWebcamMediaSynchronized({ + currentTime: 10, + targetTime: 10, + readyState: 2, + isSeeking: true, + }), + ).toBe(false); + }); + + it("accepts a loaded webcam only when it is anchored to the timeline", () => { + expect( + isWebcamMediaSynchronized({ + currentTime: 9.95, + targetTime: 10, + readyState: 2, + isSeeking: false, + }), + ).toBe(true); + expect( + isWebcamMediaSynchronized({ + currentTime: 9.5, + targetTime: 10, + readyState: 2, + isSeeking: false, + }), + ).toBe(false); + }); +}); diff --git a/src/components/video-editor/videoPlayback/webcamSync.ts b/src/components/video-editor/videoPlayback/webcamSync.ts index f7cfbfb4..660b882b 100644 --- a/src/components/video-editor/videoPlayback/webcamSync.ts +++ b/src/components/video-editor/videoPlayback/webcamSync.ts @@ -1,5 +1,7 @@ import { clampMediaTimeToDuration } from "@/lib/mediaTiming"; +const HAVE_CURRENT_DATA_READY_STATE = 2; + /** * Maps the editor timeline time to the corresponding webcam media timestamp, * accounting for any recorded webcam start offset and media duration clamps. @@ -20,6 +22,26 @@ export function getWebcamMediaTargetTimeSeconds({ export const getWebcamPreviewTargetTimeSeconds = getWebcamMediaTargetTimeSeconds; +export function isWebcamMediaSynchronized({ + currentTime, + targetTime, + readyState, + isSeeking, + toleranceSeconds = 0.12, +}: { + currentTime: number; + targetTime: number; + readyState: number; + isSeeking: boolean; + toleranceSeconds?: number; +}): boolean { + return ( + readyState >= HAVE_CURRENT_DATA_READY_STATE && + !isSeeking && + Math.abs(currentTime - targetTime) <= toleranceSeconds + ); +} + /** * Decides whether the webcam media element needs a corrective seek for the * current preview frame, while avoiding repeated seeks during active media seeks. diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index ed065f66..0a04b72f 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1365,25 +1365,41 @@ export function useScreenRecorder(): UseScreenRecorderReturn { // We don't await this to keep the UI responsive void (async () => { try { - // Await the webcam path in the background - const webcamPath = await webcamPathPromise; - console.log( - "[useScreenRecorder] Background native processing: webcamPath is", - webcamPath, - ); + // Webcam persistence, microphone sidecars, and Windows muxing are + // independent. Run them concurrently so the editor can keep playing + // the main video and reveal the webcam as soon as only that file is ready. + const webcamReadyPromise = webcamPathPromise.then(async (webcamPath) => { + console.log( + "[useScreenRecorder] Background native processing: webcamPath is", + webcamPath, + ); - // Store sidecars - await storeMicrophoneSidecar( + if (webcamPath) { + await window.electronAPI.setCurrentRecordingSession({ + videoPath: finalPath, + webcamPath, + timeOffsetMs: webcamTimeOffsetMs.current, + hideOverlayCursorByDefault: + hideEditorOverlayCursorByDefault.current, + }); + } + + return webcamPath; + }); + const microphoneReadyPromise = storeMicrophoneSidecar( micFallbackBlobPromise, finalPath, fallbackStartDelayMs, fallbackTrackSettings, ); - - // Perform muxing/renaming if on Windows - if (isNativeWindows) { - await window.electronAPI.muxNativeWindowsRecording(expectedDurationMs); - } + const muxReadyPromise = isNativeWindows + ? window.electronAPI.muxNativeWindowsRecording(expectedDurationMs) + : Promise.resolve(null); + const [webcamPath] = await Promise.all([ + webcamReadyPromise, + microphoneReadyPromise, + muxReadyPromise, + ]); console.log( "[useScreenRecorder] Emitting setCurrentRecordingSession with:",