Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,11 @@ interface Window {
startDelayMsByPath?: Record<string, number>;
error?: string;
}>;
listNativeMicrophones: () => Promise<{
success: boolean;
devices: Array<{ id: string; name: string }>;
error?: string;
}>;
setRecordingState: (recording: boolean) => Promise<void>;
getCursorTelemetry: (videoPath?: string) => Promise<{
success: boolean;
Expand Down
29 changes: 29 additions & 0 deletions electron/ipc/register/recording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} };
Expand Down
42 changes: 37 additions & 5 deletions electron/native/ScreenCaptureKitRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
Expand Down
55 changes: 1 addition & 54 deletions src/components/video-editor/videoPlayback/zoomRegionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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,
Expand Down
32 changes: 31 additions & 1 deletion src/hooks/useScreenRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -1451,14 +1452,43 @@ 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(
selectedSource,
{
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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
);
Expand Down