diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index a3f663f2..00f3dd6c 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -557,6 +557,11 @@ interface Window { startDelayMsByPath?: Record; error?: string; }>; + listNativeMicrophones: () => Promise<{ + success: boolean; + devices: Array<{ id: string; name: string }>; + error?: string; + }>; setRecordingState: (recording: boolean) => Promise; getCursorTelemetry: (videoPath?: string) => Promise<{ success: boolean; diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index b13453c7..79bb7e6b 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -1384,6 +1384,35 @@ export function registerRecordingHandlers( return { success: true, diagnostics: lastNativeCaptureDiagnostics }; }); + ipcMain.handle("list-native-microphones", async () => { + if (process.platform !== "darwin") { + return { success: true, devices: [] as Array<{ id: string; name: string }> }; + } + try { + const helperPath = await ensureNativeCaptureHelperBinary(); + const { stdout } = await execFileAsync(helperPath, ["--list-microphones"], { + timeout: 10000, + }); + const parsed = JSON.parse(stdout.trim()); + const devices = Array.isArray(parsed) + ? parsed.filter( + (entry): entry is { id: string; name: string } => + Boolean(entry) && + typeof entry.id === "string" && + typeof entry.name === "string", + ) + : []; + return { success: true, devices }; + } catch (error) { + console.error("Failed to enumerate native microphones:", error); + return { + success: false, + devices: [] as Array<{ id: string; name: string }>, + error: String(error), + }; + } + }); + ipcMain.handle("get-video-audio-fallback-paths", async (_event, videoPath: string) => { if (!videoPath) { return { success: true, paths: [], startDelayMsByPath: {} }; diff --git a/electron/native/ScreenCaptureKitRecorder.swift b/electron/native/ScreenCaptureKitRecorder.swift index 1e2a397a..044c65f5 100644 --- a/electron/native/ScreenCaptureKitRecorder.swift +++ b/electron/native/ScreenCaptureKitRecorder.swift @@ -528,15 +528,31 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { private static func resolveMicrophoneCaptureDeviceID(config: CaptureConfig) -> String? { let audioDevices = AVCaptureDevice.devices(for: .audio) + // Prefer an exact Core Audio uniqueID — the app resolves the selected + // device to its uniqueID via the --list-microphones enumeration and passes + // it here, so this is the authoritative match. + if let microphoneDeviceId = config.microphoneDeviceId?.trimmingCharacters(in: .whitespacesAndNewlines), !microphoneDeviceId.isEmpty { + if audioDevices.contains(where: { $0.uniqueID == microphoneDeviceId }) { + return microphoneDeviceId + } + } + + // Fall back to matching by name. Web device labels don't always equal the + // Core Audio localizedName verbatim, so match exact, then case-insensitive, + // then a containment match in either direction. if let microphoneLabel = config.microphoneLabel?.trimmingCharacters(in: .whitespacesAndNewlines), !microphoneLabel.isEmpty { if let matchedDevice = audioDevices.first(where: { $0.localizedName == microphoneLabel }) { return matchedDevice.uniqueID } - } - - if let microphoneDeviceId = config.microphoneDeviceId?.trimmingCharacters(in: .whitespacesAndNewlines), !microphoneDeviceId.isEmpty { - if audioDevices.contains(where: { $0.uniqueID == microphoneDeviceId }) { - return microphoneDeviceId + let target = microphoneLabel.lowercased() + if let matchedDevice = audioDevices.first(where: { $0.localizedName.lowercased() == target }) { + return matchedDevice.uniqueID + } + if let matchedDevice = audioDevices.first(where: { + let name = $0.localizedName.lowercased() + return !name.isEmpty && (name.contains(target) || target.contains(name)) + }) { + return matchedDevice.uniqueID } } @@ -651,6 +667,22 @@ guard CommandLine.arguments.count >= 2 else { exit(1) } +// Microphone enumeration mode: print the real Core Audio input devices as JSON +// (uniqueID + localizedName) so the app can let the user pick a device and pass +// back its uniqueID. Runs before any screen-capture preflight so it never +// triggers screen-recording permission prompts. +if CommandLine.arguments[1] == "--list-microphones" { + let audioDevices = AVCaptureDevice.devices(for: .audio) + let payload = audioDevices.map { ["id": $0.uniqueID, "name": $0.localizedName] } + if let data = try? JSONSerialization.data(withJSONObject: payload), + let json = String(data: data, encoding: .utf8) { + print(json) + } else { + print("[]") + } + exit(0) +} + // Force CoreGraphics Services initialization on the main thread. // Without this, SCContentFilter(desktopIndependentWindow:) crashes with // CGS_REQUIRE_INIT because CGS is never initialised in a CLI tool. diff --git a/electron/preload.ts b/electron/preload.ts index 04695d6b..9663abd5 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -465,6 +465,9 @@ contextBridge.exposeInMainWorld("electronAPI", { getVideoAudioFallbackPaths: (videoPath: string) => { return ipcRenderer.invoke("get-video-audio-fallback-paths", videoPath); }, + listNativeMicrophones: () => { + return ipcRenderer.invoke("list-native-microphones"); + }, getSources: async (opts: Electron.SourcesOptions) => { return await ipcRenderer.invoke("get-sources", opts); }, diff --git a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts index 8e1cc6bf..ab1af945 100644 --- a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts +++ b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts @@ -6,7 +6,7 @@ import { ZOOM_OUT_EARLY_START_MS, } from "./constants"; import { clampFocusToScale } from "./focusUtils"; -import { clamp01, cubicBezier, easeOutZoom } from "./mathUtils"; +import { clamp01, easeOutZoom } from "./mathUtils"; const CHAINED_ZOOM_PAN_GAP_MS = 1350; const CONNECTED_ZOOM_PAN_DURATION_MS = 1000; @@ -34,14 +34,6 @@ type ConnectedPanTransition = { endScale: number; }; -function lerp(start: number, end: number, amount: number) { - return start + (end - start) * amount; -} - -function easeConnectedPan(value: number) { - return cubicBezier(0.1, 0.0, 0.2, 1.0, value); -} - export function computeRegionStrength( region: ZoomRegion, timeMs: number, @@ -79,13 +71,6 @@ export function computeRegionStrength( return 1 - easeOutZoom(progress); } -function getLinearFocus(start: ZoomFocus, end: ZoomFocus, amount: number): ZoomFocus { - return { - cx: lerp(start.cx, end.cx, amount), - cy: lerp(start.cy, end.cy, amount), - }; -} - function getResolvedFocus(region: ZoomRegion, zoomScale: number): ZoomFocus { return clampFocusToScale(region.focus, zoomScale); } @@ -199,44 +184,6 @@ function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionP return null; } -function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], timeMs: number) { - for (const pair of connectedPairs) { - const { currentRegion, nextRegion, transitionStart, transitionEnd } = pair; - - if (timeMs < transitionStart || timeMs > transitionEnd) { - continue; - } - - const transitionProgress = easeConnectedPan( - clamp01((timeMs - transitionStart) / Math.max(1, transitionEnd - transitionStart)), - ); - const currentScale = ZOOM_DEPTH_SCALES[currentRegion.depth]; - const nextScale = ZOOM_DEPTH_SCALES[nextRegion.depth]; - const transitionScale = lerp(currentScale, nextScale, transitionProgress); - const currentFocus = getResolvedFocus(currentRegion, currentScale); - const nextFocus = getResolvedFocus(nextRegion, nextScale); - const transitionFocus = getLinearFocus(currentFocus, nextFocus, transitionProgress); - - return { - region: { - ...nextRegion, - focus: transitionFocus, - }, - strength: 1, - blendedScale: transitionScale, - transition: { - progress: transitionProgress, - startFocus: currentFocus, - endFocus: nextFocus, - startScale: currentScale, - endScale: nextScale, - }, - }; - } - - return null; -} - export function findDominantRegion( regions: ZoomRegion[], timeMs: number, diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index c5cd7005..823b8251 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1441,6 +1441,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (useNativeMacScreenCapture || useNativeWindowsCapture) { // Resolve the selected mic label for native capture backends. let micLabel: string | undefined; + let nativeMicId: string | undefined; if (microphoneEnabled) { try { const devices = await navigator.mediaDevices.enumerateDevices(); @@ -1451,6 +1452,30 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } catch { // Fall through — native process will use the default mic } + + // The web deviceId is an opaque per-origin hash that never matches a + // Core Audio uniqueID, so the native recorder can't honor it directly. + // Map the selected mic to its real native uniqueID via the helper's + // enumeration so ScreenCaptureKit records the chosen device instead of + // silently falling back to the system default. + if (useNativeMacScreenCapture && micLabel) { + try { + const nativeMics = await window.electronAPI.listNativeMicrophones?.(); + if (nativeMics?.success && nativeMics.devices.length > 0) { + const normalize = (value: string) => value.trim().toLowerCase(); + const target = normalize(micLabel); + const match = + nativeMics.devices.find((d) => normalize(d.name) === target) ?? + nativeMics.devices.find((d) => { + const name = normalize(d.name); + return name.length > 0 && (name.includes(target) || target.includes(name)); + }); + nativeMicId = match?.id; + } + } catch { + // Fall through — native side still attempts label matching. + } + } } const nativeResult = await window.electronAPI.startNativeScreenRecording( @@ -1458,7 +1483,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { { capturesSystemAudio: systemAudioEnabled, capturesMicrophone: microphoneEnabled, - microphoneDeviceId, + // On macOS, only the resolved Core Audio uniqueID is usable; the web + // deviceId can't match, so pass the resolved id (or nothing and let + // the helper fall back to label matching) rather than a web id. + microphoneDeviceId: useNativeMacScreenCapture + ? nativeMicId + : microphoneDeviceId, microphoneLabel: micLabel, }, );