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
25 changes: 8 additions & 17 deletions src/components/launch/hooks/useWebcamPreviewOverlay.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type PointerEvent, useCallback, useEffect, useRef, useState } from "react";
import { acquireSharedWebcamStream, releaseSharedWebcamStream } from "@/lib/sharedWebcamStream";
import { canShowFloatingWebcamPreview } from "../floatingWebcamPreview";

const WEBCAM_PREVIEW_DRAG_THRESHOLD = 6;
Expand Down Expand Up @@ -210,31 +211,19 @@ export function useWebcamPreviewOverlay({

useEffect(() => {
let mounted = true;
let acquisition: Promise<MediaStream> | null = null;

const startPreview = async () => {
if (!shouldStreamWebcamPreview) {
return;
}

try {
const previewStream = await navigator.mediaDevices.getUserMedia({
video: webcamDeviceId
? {
deviceId: { exact: webcamDeviceId },
width: { ideal: 320 },
height: { ideal: 320 },
frameRate: { ideal: 24, max: 30 },
}
: {
width: { ideal: 320 },
height: { ideal: 320 },
frameRate: { ideal: 24, max: 30 },
},
audio: false,
});
acquisition = acquireSharedWebcamStream(webcamDeviceId);
const previewStream = await acquisition;

if (!mounted) {
previewStream.getTracks().forEach((track) => track.stop());
releaseSharedWebcamStream(acquisition);
return;
}

Expand All @@ -260,7 +249,9 @@ export function useWebcamPreviewOverlay({
videoElement.pause();
videoElement.srcObject = null;
});
previewStream?.getTracks().forEach((track) => track.stop());
if (acquisition) {
releaseSharedWebcamStream(acquisition);
}
if (previewStreamRef.current === previewStream) {
previewStreamRef.current = null;
}
Expand Down
6 changes: 5 additions & 1 deletion src/hooks/useMicrophoneDevices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,12 @@ export function useMicrophoneDevices(enabled: boolean = true, preferredDeviceId?
groupId: device.groupId,
}));

// Chromium can report zero audio inputs at all (not just unlabeled ones)
// until getUserMedia() has been called at least once for this app's
// profile. Probe with getUserMedia whenever we don't yet have a labeled
// device list, not only when placeholder entries are present.
const needsLabelPermission =
audioInputs.length > 0 && audioInputs.every((device) => !device.label.trim());
audioInputs.length === 0 || audioInputs.every((device) => !device.label.trim());

if (needsLabelPermission && !hasRequestedMicrophoneLabels) {
hasRequestedMicrophoneLabels = true;
Expand Down
37 changes: 19 additions & 18 deletions src/hooks/useScreenRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { fixWebmDuration } from "@fix-webm-duration/fix";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { getEffectiveRecordingDurationMs } from "@/lib/mediaTiming";
import { acquireSharedWebcamStream, releaseSharedWebcamStream } from "@/lib/sharedWebcamStream";
import {
getVideoExtensionForMimeType,
isWebmMimeType,
Expand Down Expand Up @@ -33,9 +34,6 @@ const AUDIO_BITRATE_VOICE = 128_000;
const AUDIO_BITRATE_SYSTEM = 192_000;
const MIC_GAIN_BOOST = 1.4;
const WEBCAM_BITRATE = 8_000_000;
const WEBCAM_WIDTH = 1280;
const WEBCAM_HEIGHT = 720;
const WEBCAM_FRAME_RATE = 30;
const WEBCAM_SUFFIX = "-webcam";
const MICROPHONE_FALLBACK_ERROR_TOAST_ID = "recording-microphone-fallback-error";
const MICROPHONE_SIDECAR_ERROR_TOAST_ID = "recording-microphone-sidecar-error";
Expand Down Expand Up @@ -1017,21 +1015,24 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}

try {
webcamStream.current = await navigator.mediaDevices.getUserMedia({
video: webcamDeviceId
? {
deviceId: { exact: webcamDeviceId },
width: { ideal: WEBCAM_WIDTH },
height: { ideal: WEBCAM_HEIGHT },
frameRate: { ideal: WEBCAM_FRAME_RATE, max: WEBCAM_FRAME_RATE },
}
: {
width: { ideal: WEBCAM_WIDTH },
height: { ideal: WEBCAM_HEIGHT },
frameRate: { ideal: WEBCAM_FRAME_RATE, max: WEBCAM_FRAME_RATE },
},
audio: false,
});
// Route through the shared webcam coordinator instead of calling
// getUserMedia() directly. Many UVC webcams only allow a single open
// handle at the OS/driver level, so a second concurrent getUserMedia()
// call for the same camera (e.g. while the HUD preview's own acquisition
// is still in flight) can freeze the existing stream and/or silently fail
// to deliver frames to the recorder. Awaiting the coordinator means we
// either join the preview's in-flight/resolved acquisition or, if nothing
// else is using the camera, become the sole owner of a fresh one.
// MediaStreamTrack.clone() lets the recorder keep an independent track
// after releasing our reference to the shared acquisition.
const acquisition = acquireSharedWebcamStream(webcamDeviceId);
try {
const sharedStream = await acquisition;
const sharedTrack = sharedStream.getVideoTracks()[0];
webcamStream.current = sharedTrack ? new MediaStream([sharedTrack.clone()]) : sharedStream;
} finally {
releaseSharedWebcamStream(acquisition);
}

const mimeType = selectWebcamMimeType();
webcamChunks.current = [];
Expand Down
19 changes: 12 additions & 7 deletions src/hooks/useVideoDevices.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { acquireSharedWebcamStream, releaseSharedWebcamStream } from "@/lib/sharedWebcamStream";

export interface VideoDevice {
deviceId: string;
Expand All @@ -24,7 +25,7 @@ export function useVideoDevices(enabled: boolean = true) {

const loadDevices = async () => {
const loadId = ++activeLoadId;
let permissionStream: MediaStream | null = null;
let permissionAcquisition: Promise<MediaStream> | null = null;

try {
if (mounted && loadId === activeLoadId) {
Expand All @@ -41,14 +42,16 @@ export function useVideoDevices(enabled: boolean = true) {
groupId: device.groupId,
}));

// Chromium can report zero video inputs at all (not just unlabeled ones)
// until getUserMedia() has been called at least once for this app's
// profile. Probe with getUserMedia whenever we don't yet have a labeled
// device list, not only when placeholder entries are present.
const needsLabelPermission =
videoInputs.length > 0 && videoInputs.every((device) => !device.label.trim());
videoInputs.length === 0 || videoInputs.every((device) => !device.label.trim());
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (needsLabelPermission && !hasRequestedVideoLabels) {
permissionStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: false,
});
permissionAcquisition = acquireSharedWebcamStream();
await permissionAcquisition;
allDevices = await navigator.mediaDevices.enumerateDevices();
videoInputs = allDevices
.filter((device) => device.kind === "videoinput")
Expand Down Expand Up @@ -87,7 +90,9 @@ export function useVideoDevices(enabled: boolean = true) {
console.error("Error loading video devices:", error);
}
} finally {
permissionStream?.getTracks().forEach((track) => track.stop());
if (permissionAcquisition) {
releaseSharedWebcamStream(permissionAcquisition);
}
if (mounted && loadId === activeLoadId) {
setIsLoading(false);
}
Expand Down
115 changes: 115 additions & 0 deletions src/lib/sharedWebcamStream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
const WEBCAM_WIDTH_IDEAL = 1280;
const WEBCAM_HEIGHT_IDEAL = 720;

interface WebcamAcquisition {
promise: Promise<MediaStream>;
deviceId: string | undefined;
refCount: number;
settled: boolean;
pendingStop: boolean;
}

const acquisitions = new Set<WebcamAcquisition>();

function buildVideoConstraints(deviceId: string | undefined): MediaTrackConstraints {
return deviceId
? {
deviceId: { exact: deviceId },
width: { ideal: WEBCAM_WIDTH_IDEAL },
height: { ideal: WEBCAM_HEIGHT_IDEAL },
}
: {
width: { ideal: WEBCAM_WIDTH_IDEAL },
height: { ideal: WEBCAM_HEIGHT_IDEAL },
};
}

/** An unspecified request may reuse any open camera; a specific device may only reuse a match. */
function findCompatibleAcquisition(deviceId: string | undefined): WebcamAcquisition | undefined {
for (const acquisition of acquisitions) {
if (!deviceId || acquisition.deviceId === deviceId) {
return acquisition;
}
}
return undefined;
}

/**
* Many UVC webcams only support a single open handle at the OS/driver level,
* so two concurrent getUserMedia() calls for the same physical camera can
* freeze one another or silently fail to deliver frames. This coordinator
* dedupes concurrent webcam acquisitions across every consumer (the device
* picker's label-unlock probe, the HUD's live preview, and the recorder) so
* only one open request per distinct device is ever in flight, and a track is
* only stopped once every consumer holding it has released their reference
* *and* its getUserMedia() call has actually settled — a release that lands
* while the call is still pending just marks it for a deferred stop, so a new
* compatible acquire() in the meantime can cancel that and reuse the same
* in-flight request instead of starting a competing one.
*/
export function acquireSharedWebcamStream(deviceId?: string): Promise<MediaStream> {
const existing = findCompatibleAcquisition(deviceId);
if (existing) {
existing.refCount += 1;
existing.pendingStop = false;
return existing.promise;
}

const acquisition: WebcamAcquisition = {
deviceId,
refCount: 1,
settled: false,
pendingStop: false,
promise: null as unknown as Promise<MediaStream>,
};
acquisition.promise = navigator.mediaDevices
.getUserMedia({ video: buildVideoConstraints(deviceId), audio: false })
.then((stream) => {
acquisition.settled = true;
if (acquisition.pendingStop) {
acquisitions.delete(acquisition);
stream.getTracks().forEach((track) => track.stop());
}
return stream;
})
.catch((error: unknown) => {
acquisition.settled = true;
acquisitions.delete(acquisition);
throw error;
});
acquisitions.add(acquisition);
return acquisition.promise;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/** Releases one reference obtained from {@link acquireSharedWebcamStream}. */
export function releaseSharedWebcamStream(acquisitionPromise: Promise<MediaStream>): void {
let target: WebcamAcquisition | undefined;
for (const acquisition of acquisitions) {
if (acquisition.promise === acquisitionPromise) {
target = acquisition;
break;
}
}
if (!target) {
return;
}

target.refCount -= 1;
if (target.refCount > 0) {
return;
}

if (!target.settled) {
target.pendingStop = true;
return;
}

acquisitions.delete(target);
void target.promise
.then((stream) => {
stream.getTracks().forEach((track) => track.stop());
})
.catch(() => {
// Acquisition failed; nothing to stop.
});
}