Fix Windows app and webcam lifecycle

This commit is contained in:
webadderall committed 2026-09-02 20:27:24 +10:00
1 parent d90b3f67ec
commit 7dffa98ae6
6 files changed
+260 -71

No files matched your search

+57 -23
View File
@@ -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();
+73 -33
View File
@@ -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<VideoPlaybackRef, VideoPlaybackProps>(
width: number;
height: number;
} | null>(null);
const webcamSynchronizedPathRef = useRef<string | null>(null);
const [webcamSynchronizedPath, setWebcamSynchronizedPath] = useState<string | null>(null);
const webcamMediaSynchronized =
Boolean(webcamVideoPath) && webcamSynchronizedPath === webcamVideoPath;
const captionBoxRef = useRef<HTMLDivElement | null>(null);
const captionEditInputRef = useRef<HTMLTextAreaElement | null>(null);
const captionEditSessionRef = useRef<CaptionEditSession | null>(null);
@@ -1641,7 +1646,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
? webcamVideo.duration
: null;
const targetTime = getWebcamMediaTargetTimeSeconds({
currentTime,
currentTime: currentTimeRef.current / 1000,
webcamDuration,
timeOffsetMs: webcamTimeOffsetMs,
});
@@ -1650,6 +1655,36 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
? 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<VideoPlaybackRef, VideoPlaybackProps>(
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
style={{
opacity: webcamVideoDimensions ? 1 : 0,
opacity:
webcamVideoDimensions && webcamMediaSynchronized
? 1
: 0,
transform: webcamMirror ? "scaleX(-1)" : undefined,
}}
>
@@ -2498,6 +2536,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
aria-hidden="true"
onLoadedMetadata={handleWebcamMediaReady}
onLoadedData={handleWebcamMediaReady}
onCanPlay={handleWebcamMediaReady}
onSeeked={handleWebcamMediaReady}
/>
</div>
</div>
@@ -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<CropHandle | null>(null);
const [draftVisualCrop, setDraftVisualCrop] = useState<CropRegion | null>(null);
const [previewFrame, setPreviewFrame] = useState<PreviewFrame | null>(null);
const synchronizedPreviewSrcRef = useRef<string | null>(null);
const [synchronizedPreviewSrc, setSynchronizedPreviewSrc] = useState<string | null>(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}
@@ -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);
});
});
@@ -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.
+29 -13
View File
@@ -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:",