@agent-native/core 0.76.12 → 0.76.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +12 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/production-agent.ts +7 -4
- package/corpus/templates/clips/app/components/recorder/camera-visualizer.tsx +88 -10
- package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +64 -0
- package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +94 -9
- package/corpus/templates/clips/app/lib/camera-blur.ts +341 -0
- package/corpus/templates/clips/app/lib/camera-composite.ts +6 -1
- package/corpus/templates/clips/app/lib/recorder-preferences.ts +15 -0
- package/corpus/templates/clips/app/routes/record.tsx +13 -0
- package/corpus/templates/clips/desktop/src/app.tsx +101 -69
- package/corpus/templates/clips/desktop/src/lib/voice-dictation.ts +109 -11
- package/corpus/templates/clips/desktop/src/styles.css +19 -4
- package/corpus/templates/clips/desktop/src-tauri/Cargo.toml +1 -1
- package/corpus/templates/clips/desktop/src-tauri/src/native_speech.rs +143 -86
- package/corpus/templates/clips/package.json +1 -0
- package/corpus/templates/clips/vite.config.ts +38 -1
- package/dist/agent/production-agent.d.ts +7 -4
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +7 -4
- package/dist/agent/production-agent.js.map +1 -1
- package/package.json +1 -1
package/corpus/README.md
CHANGED
package/corpus/core/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @agent-native/core
|
|
2
2
|
|
|
3
|
+
## 0.76.13
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- d12772c: Durable background agent-chat: raise the foreground circuit-breaker's claim grace
|
|
8
|
+
from 8s to 15s (`BACKGROUND_CLAIM_GRACE_MS`). Heavy apps (observed on analytics
|
|
9
|
+
in prod) take longer than 8s to cold-start the background function and reach
|
|
10
|
+
`claimBackgroundRun`, so the foreground recovered inline every time — adding ~8s
|
|
11
|
+
latency per turn and never using the 15-minute background budget. 15s lets the
|
|
12
|
+
slow-but-alive workers win the claim while staying well within the foreground's
|
|
13
|
+
~40s soft-timeout; a genuinely dead worker still falls back to inline.
|
|
14
|
+
|
|
3
15
|
## 0.76.12
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/corpus/core/package.json
CHANGED
|
@@ -153,11 +153,14 @@ export { PROVIDER_TO_ENV };
|
|
|
153
153
|
/**
|
|
154
154
|
* Grace window + poll interval for the foreground circuit-breaker that confirms
|
|
155
155
|
* a background worker actually CLAIMED a 202-dispatched run before recovering
|
|
156
|
-
* inline. The grace
|
|
157
|
-
*
|
|
158
|
-
*
|
|
156
|
+
* inline. The grace must cover the worker's cold-start + per-request init before
|
|
157
|
+
* it reaches `claimBackgroundRun`: light apps win the claim in ~1-2s, but heavy
|
|
158
|
+
* apps (e.g. analytics) were observed in prod taking >8s, so an 8s grace made
|
|
159
|
+
* their worker lose the race every time and always fall back to inline (adding
|
|
160
|
+
* ~8s latency with no background budget). 15s covers the slow apps while staying
|
|
161
|
+
* well within the foreground's ~40s soft-timeout.
|
|
159
162
|
*/
|
|
160
|
-
export const BACKGROUND_CLAIM_GRACE_MS =
|
|
163
|
+
export const BACKGROUND_CLAIM_GRACE_MS = 15_000;
|
|
161
164
|
export const BACKGROUND_CLAIM_POLL_MS = 400;
|
|
162
165
|
|
|
163
166
|
export type BackgroundDispatchOutcome =
|
|
@@ -2,6 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
2
2
|
|
|
3
3
|
import { Button } from "@/components/ui/button";
|
|
4
4
|
import { cn } from "@/lib/utils";
|
|
5
|
+
import {
|
|
6
|
+
createBackgroundBlurStream,
|
|
7
|
+
DEFAULT_BLUR_PX,
|
|
8
|
+
type CameraBlurHandle,
|
|
9
|
+
} from "@/lib/camera-blur";
|
|
5
10
|
import type { CameraBubbleSize } from "./camera-bubble";
|
|
6
11
|
|
|
7
12
|
export type CameraTestStatus = "idle" | "starting" | "live" | "error";
|
|
@@ -10,6 +15,10 @@ export interface CameraVisualizerProps {
|
|
|
10
15
|
deviceId: string | null;
|
|
11
16
|
disabled?: boolean;
|
|
12
17
|
className?: string;
|
|
18
|
+
/** Mirror the recording's background-blur setting in the live test preview. */
|
|
19
|
+
blur?: boolean;
|
|
20
|
+
/** Background blur radius (px) reflected live in the test preview. */
|
|
21
|
+
blurRadius?: number;
|
|
13
22
|
size?: CameraBubbleSize;
|
|
14
23
|
onSizeChange?: (size: CameraBubbleSize) => void;
|
|
15
24
|
onStatusChange?: (
|
|
@@ -119,6 +128,8 @@ export function CameraVisualizer({
|
|
|
119
128
|
deviceId,
|
|
120
129
|
disabled,
|
|
121
130
|
className,
|
|
131
|
+
blur = false,
|
|
132
|
+
blurRadius = DEFAULT_BLUR_PX,
|
|
122
133
|
size = "md",
|
|
123
134
|
onSizeChange,
|
|
124
135
|
onStatusChange,
|
|
@@ -126,8 +137,14 @@ export function CameraVisualizer({
|
|
|
126
137
|
}: CameraVisualizerProps) {
|
|
127
138
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
128
139
|
const streamRef = useRef<MediaStream | null>(null);
|
|
140
|
+
const blurHandleRef = useRef<CameraBlurHandle | null>(null);
|
|
141
|
+
// Bumped per attachPreview() so a stale segmenter build (blur toggled mid-load)
|
|
142
|
+
// bails instead of clobbering the preview.
|
|
143
|
+
const attachGenRef = useRef(0);
|
|
144
|
+
const blurRadiusRef = useRef(blurRadius);
|
|
129
145
|
const runIdRef = useRef(0);
|
|
130
146
|
const previousDeviceIdRef = useRef(deviceId);
|
|
147
|
+
const previousBlurRef = useRef(blur);
|
|
131
148
|
|
|
132
149
|
const [status, setStatus] = useState<CameraTestStatus>("idle");
|
|
133
150
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -144,11 +161,45 @@ export function CameraVisualizer({
|
|
|
144
161
|
}, [onPreviewChange]);
|
|
145
162
|
|
|
146
163
|
const stopCurrent = useCallback(() => {
|
|
164
|
+
blurHandleRef.current?.cleanup();
|
|
165
|
+
blurHandleRef.current = null;
|
|
147
166
|
stopStream(streamRef.current);
|
|
148
167
|
streamRef.current = null;
|
|
149
168
|
clearVideo();
|
|
150
169
|
}, [clearVideo]);
|
|
151
170
|
|
|
171
|
+
// Bind the raw camera or its blurred derivative to the <video> per the current
|
|
172
|
+
// `blur` setting, so the preview matches what recording bakes in. Each call
|
|
173
|
+
// claims a generation and bails if a newer attach superseded it during an await.
|
|
174
|
+
const attachPreview = useCallback(async () => {
|
|
175
|
+
const gen = ++attachGenRef.current;
|
|
176
|
+
const raw = streamRef.current;
|
|
177
|
+
const video = videoRef.current;
|
|
178
|
+
if (!raw || !video) return;
|
|
179
|
+
|
|
180
|
+
let display: MediaStream = raw;
|
|
181
|
+
if (blur) {
|
|
182
|
+
blurHandleRef.current?.cleanup();
|
|
183
|
+
blurHandleRef.current = null;
|
|
184
|
+
const handle = await createBackgroundBlurStream(raw, {
|
|
185
|
+
blurPx: blurRadiusRef.current,
|
|
186
|
+
});
|
|
187
|
+
if (gen !== attachGenRef.current || streamRef.current !== raw) {
|
|
188
|
+
handle.cleanup();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
blurHandleRef.current = handle;
|
|
192
|
+
display = handle.stream;
|
|
193
|
+
} else {
|
|
194
|
+
blurHandleRef.current?.cleanup();
|
|
195
|
+
blurHandleRef.current = null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (gen !== attachGenRef.current) return;
|
|
199
|
+
if (video.srcObject !== display) video.srcObject = display;
|
|
200
|
+
await video.play().catch(() => {});
|
|
201
|
+
}, [blur]);
|
|
202
|
+
|
|
152
203
|
const stopTest = useCallback(() => {
|
|
153
204
|
runIdRef.current += 1;
|
|
154
205
|
stopCurrent();
|
|
@@ -216,12 +267,15 @@ export function CameraVisualizer({
|
|
|
216
267
|
}
|
|
217
268
|
|
|
218
269
|
streamRef.current = stream;
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
270
|
+
// Webcam unplugged mid-test: tear down so the preview + blur pipeline
|
|
271
|
+
// don't keep running frozen. runId guard skips our own stop().
|
|
272
|
+
for (const track of stream.getVideoTracks()) {
|
|
273
|
+
track.addEventListener("ended", () => {
|
|
274
|
+
if (runIdRef.current === runId) stopTest();
|
|
275
|
+
});
|
|
223
276
|
}
|
|
224
|
-
|
|
277
|
+
await attachPreview();
|
|
278
|
+
// Re-check after the async attach so a newer startTest can't be clobbered.
|
|
225
279
|
if (runIdRef.current !== runId) {
|
|
226
280
|
stopCurrent();
|
|
227
281
|
return;
|
|
@@ -239,7 +293,15 @@ export function CameraVisualizer({
|
|
|
239
293
|
onStatusChange?.("error", { error: message });
|
|
240
294
|
clearVideo();
|
|
241
295
|
}
|
|
242
|
-
}, [
|
|
296
|
+
}, [
|
|
297
|
+
attachPreview,
|
|
298
|
+
clearVideo,
|
|
299
|
+
deviceId,
|
|
300
|
+
disabled,
|
|
301
|
+
onStatusChange,
|
|
302
|
+
stopCurrent,
|
|
303
|
+
stopTest,
|
|
304
|
+
]);
|
|
243
305
|
|
|
244
306
|
useEffect(() => {
|
|
245
307
|
if (disabled) {
|
|
@@ -270,10 +332,10 @@ export function CameraVisualizer({
|
|
|
270
332
|
useEffect(() => {
|
|
271
333
|
if (status !== "live" && status !== "starting") return;
|
|
272
334
|
const video = videoRef.current;
|
|
273
|
-
const
|
|
274
|
-
if (!video || !
|
|
275
|
-
if (video.srcObject !==
|
|
276
|
-
video.srcObject =
|
|
335
|
+
const display = blurHandleRef.current?.stream ?? streamRef.current;
|
|
336
|
+
if (!video || !display) return;
|
|
337
|
+
if (video.srcObject !== display) {
|
|
338
|
+
video.srcObject = display;
|
|
277
339
|
}
|
|
278
340
|
const tryPlay = () => {
|
|
279
341
|
video.play().catch(() => undefined);
|
|
@@ -285,6 +347,22 @@ export function CameraVisualizer({
|
|
|
285
347
|
};
|
|
286
348
|
}, [status]);
|
|
287
349
|
|
|
350
|
+
// Toggle blur while live: swap the preview source in place (startTest already
|
|
351
|
+
// binds the initial value, so skip mount).
|
|
352
|
+
useEffect(() => {
|
|
353
|
+
if (previousBlurRef.current === blur) return;
|
|
354
|
+
previousBlurRef.current = blur;
|
|
355
|
+
if (status !== "live" && status !== "starting") return;
|
|
356
|
+
if (!streamRef.current) return;
|
|
357
|
+
void attachPreview();
|
|
358
|
+
}, [blur, status, attachPreview]);
|
|
359
|
+
|
|
360
|
+
// Slider drags adjust the live pipeline without rebuilding the segmenter.
|
|
361
|
+
useEffect(() => {
|
|
362
|
+
blurRadiusRef.current = blurRadius;
|
|
363
|
+
blurHandleRef.current?.setBlurPx(blurRadius);
|
|
364
|
+
}, [blurRadius]);
|
|
365
|
+
|
|
288
366
|
const live = status === "live";
|
|
289
367
|
const starting = status === "starting";
|
|
290
368
|
const showBubble = live || starting;
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type FormEvent,
|
|
8
8
|
} from "react";
|
|
9
9
|
import {
|
|
10
|
+
IconBlur,
|
|
10
11
|
IconBrowser,
|
|
11
12
|
IconCamera,
|
|
12
13
|
IconChevronDown,
|
|
@@ -40,6 +41,7 @@ import {
|
|
|
40
41
|
SelectValue,
|
|
41
42
|
} from "@/components/ui/select";
|
|
42
43
|
import { Switch } from "@/components/ui/switch";
|
|
44
|
+
import { Slider } from "@/components/ui/slider";
|
|
43
45
|
import { cn } from "@/lib/utils";
|
|
44
46
|
import {
|
|
45
47
|
NO_CAMERA_DEVICE_ID,
|
|
@@ -48,6 +50,7 @@ import {
|
|
|
48
50
|
type RecordingMode,
|
|
49
51
|
} from "./recorder-engine";
|
|
50
52
|
import type { CameraBubbleSize } from "./camera-bubble";
|
|
53
|
+
import { DEFAULT_BLUR_PX, MAX_BLUR_PX, MIN_BLUR_PX } from "@/lib/camera-blur";
|
|
51
54
|
import {
|
|
52
55
|
loadRecorderPreferences,
|
|
53
56
|
saveRecorderPreferences,
|
|
@@ -65,6 +68,8 @@ export interface PreRecordPanelProps {
|
|
|
65
68
|
displaySurface: DisplaySurface;
|
|
66
69
|
micDeviceId: string | null;
|
|
67
70
|
cameraDeviceId: string | null;
|
|
71
|
+
cameraBlur: boolean;
|
|
72
|
+
cameraBlurRadius: number;
|
|
68
73
|
}) => void;
|
|
69
74
|
initialMode?: RecordingMode | null;
|
|
70
75
|
initialDisplaySurface?: DisplaySurface | null;
|
|
@@ -210,6 +215,12 @@ export function PreRecordPanel({
|
|
|
210
215
|
const [cameraId, setCameraId] = useState<string>(
|
|
211
216
|
() => savedPrefs.cameraId ?? "default",
|
|
212
217
|
);
|
|
218
|
+
const [cameraBlur, setCameraBlur] = useState(
|
|
219
|
+
() => savedPrefs.cameraBlur ?? false,
|
|
220
|
+
);
|
|
221
|
+
const [cameraBlurRadius, setCameraBlurRadius] = useState(
|
|
222
|
+
() => savedPrefs.cameraBlurRadius ?? DEFAULT_BLUR_PX,
|
|
223
|
+
);
|
|
213
224
|
const [enumError, setEnumError] = useState<string | null>(null);
|
|
214
225
|
const [micAccessStatus, setMicAccessStatus] =
|
|
215
226
|
useState<DeviceAccessStatus>("idle");
|
|
@@ -335,6 +346,10 @@ export function PreRecordPanel({
|
|
|
335
346
|
setCameraId(value);
|
|
336
347
|
saveRecorderPreferences({ cameraId: value });
|
|
337
348
|
}, []);
|
|
349
|
+
const chooseCameraBlur = useCallback((value: boolean) => {
|
|
350
|
+
setCameraBlur(value);
|
|
351
|
+
saveRecorderPreferences({ cameraBlur: value });
|
|
352
|
+
}, []);
|
|
338
353
|
|
|
339
354
|
const requestMicrophoneChoices = useCallback(async () => {
|
|
340
355
|
if (!navigator.mediaDevices?.getUserMedia) {
|
|
@@ -826,12 +841,59 @@ export function PreRecordPanel({
|
|
|
826
841
|
<CameraVisualizer
|
|
827
842
|
deviceId={cameraId === "default" ? null : cameraId}
|
|
828
843
|
disabled={busy}
|
|
844
|
+
blur={cameraBlur}
|
|
845
|
+
blurRadius={cameraBlurRadius}
|
|
829
846
|
size={cameraSize}
|
|
830
847
|
onSizeChange={onCameraSizeChange}
|
|
831
848
|
onStatusChange={handleCameraStatusChange}
|
|
832
849
|
onPreviewChange={handleCameraPreviewChange}
|
|
833
850
|
/>
|
|
834
851
|
) : null}
|
|
852
|
+
|
|
853
|
+
{needsCamera ? (
|
|
854
|
+
<label className="flex cursor-pointer items-center gap-2 rounded-lg px-1 py-1.5 hover:bg-muted/45">
|
|
855
|
+
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
|
856
|
+
<IconBlur className="h-4 w-4" />
|
|
857
|
+
</div>
|
|
858
|
+
<div className="min-w-0 flex-1">
|
|
859
|
+
<p className="text-sm">Blur background</p>
|
|
860
|
+
<p className="text-[11px] text-muted-foreground">
|
|
861
|
+
Keep yourself sharp, blur what's behind you
|
|
862
|
+
</p>
|
|
863
|
+
</div>
|
|
864
|
+
<Switch
|
|
865
|
+
checked={cameraBlur}
|
|
866
|
+
onCheckedChange={chooseCameraBlur}
|
|
867
|
+
disabled={busy}
|
|
868
|
+
aria-label="Blur the camera background"
|
|
869
|
+
/>
|
|
870
|
+
</label>
|
|
871
|
+
) : null}
|
|
872
|
+
|
|
873
|
+
{needsCamera && cameraBlur ? (
|
|
874
|
+
<div className="flex items-center gap-3 px-1 pb-1">
|
|
875
|
+
<span className="w-14 shrink-0 text-[11px] text-muted-foreground">
|
|
876
|
+
Intensity
|
|
877
|
+
</span>
|
|
878
|
+
<Slider
|
|
879
|
+
value={[cameraBlurRadius]}
|
|
880
|
+
min={MIN_BLUR_PX}
|
|
881
|
+
max={MAX_BLUR_PX}
|
|
882
|
+
step={1}
|
|
883
|
+
disabled={busy}
|
|
884
|
+
onValueChange={(value) =>
|
|
885
|
+
setCameraBlurRadius(value[0] ?? DEFAULT_BLUR_PX)
|
|
886
|
+
}
|
|
887
|
+
onValueCommit={(value) =>
|
|
888
|
+
saveRecorderPreferences({
|
|
889
|
+
cameraBlurRadius: value[0] ?? DEFAULT_BLUR_PX,
|
|
890
|
+
})
|
|
891
|
+
}
|
|
892
|
+
aria-label="Background blur intensity"
|
|
893
|
+
className="flex-1"
|
|
894
|
+
/>
|
|
895
|
+
</div>
|
|
896
|
+
) : null}
|
|
835
897
|
</div>
|
|
836
898
|
) : null}
|
|
837
899
|
|
|
@@ -870,6 +932,8 @@ export function PreRecordPanel({
|
|
|
870
932
|
micDeviceId: micId === "default" ? null : micId,
|
|
871
933
|
cameraDeviceId:
|
|
872
934
|
needsCamera && cameraId !== "default" ? cameraId : null,
|
|
935
|
+
cameraBlur: needsCamera ? cameraBlur : false,
|
|
936
|
+
cameraBlurRadius,
|
|
873
937
|
})
|
|
874
938
|
}
|
|
875
939
|
className={cn("h-12 gap-2", onCancel ? "flex-1" : "w-full")}
|
|
@@ -19,6 +19,10 @@ import {
|
|
|
19
19
|
createCameraCompositeStream,
|
|
20
20
|
type CameraCompositeHandle,
|
|
21
21
|
} from "@/lib/camera-composite";
|
|
22
|
+
import {
|
|
23
|
+
createBackgroundBlurStream,
|
|
24
|
+
type CameraBlurHandle,
|
|
25
|
+
} from "@/lib/camera-blur";
|
|
22
26
|
import {
|
|
23
27
|
chunkUploadUrl,
|
|
24
28
|
pickMimeType,
|
|
@@ -75,6 +79,15 @@ export interface RecorderEngineOptions {
|
|
|
75
79
|
cameraDeviceId?: string | null;
|
|
76
80
|
/** Camera bubble size selected in the pre-record UI. */
|
|
77
81
|
cameraBubbleSize?: "sm" | "md" | "lg";
|
|
82
|
+
/**
|
|
83
|
+
* Blur the camera background (sharp person, blurred surroundings) for both the
|
|
84
|
+
* live preview bubble and the baked-in recording composite. Resolved at
|
|
85
|
+
* `acquire()` time. Silently no-ops (records un-blurred) if segmentation is
|
|
86
|
+
* unavailable in the browser.
|
|
87
|
+
*/
|
|
88
|
+
cameraBlur?: boolean;
|
|
89
|
+
/** Background blur radius in px when `cameraBlur` is on. Defaults to ~12. */
|
|
90
|
+
cameraBlurRadius?: number;
|
|
78
91
|
/** Chunk size in ms (MediaRecorder timeslice). Default 2000. */
|
|
79
92
|
chunkIntervalMs?: number;
|
|
80
93
|
/** Base URL for the chunk upload endpoint. Default `/api/uploads/:id/chunk`. */
|
|
@@ -99,6 +112,11 @@ export interface RecorderEngineOptions {
|
|
|
99
112
|
* `onError`, this does NOT transition the engine into the `error` state.
|
|
100
113
|
*/
|
|
101
114
|
onWarning?: (message: string) => void;
|
|
115
|
+
/**
|
|
116
|
+
* Fired when the camera ends (unplugged / revoked) any time after acquire,
|
|
117
|
+
* so the UI can drop the on-page camera bubble to match the recorded output.
|
|
118
|
+
*/
|
|
119
|
+
onCameraEnded?: () => void;
|
|
102
120
|
/**
|
|
103
121
|
* Called when the display stream's video track ends because the user clicked
|
|
104
122
|
* the browser's native "Stop sharing" button. When provided, the engine
|
|
@@ -360,6 +378,17 @@ export class RecorderEngine {
|
|
|
360
378
|
|
|
361
379
|
private displayStream: MediaStream | null = null;
|
|
362
380
|
private cameraStream: MediaStream | null = null;
|
|
381
|
+
/**
|
|
382
|
+
* Raw getUserMedia camera stream. When background blur is active,
|
|
383
|
+
* `cameraStream` points at the processed (blurred) derivative and this field
|
|
384
|
+
* keeps the original so teardown stops the real camera tracks and the
|
|
385
|
+
* disconnect handler can observe the hardware ending.
|
|
386
|
+
*/
|
|
387
|
+
private rawCameraStream: MediaStream | null = null;
|
|
388
|
+
private cameraBlur: CameraBlurHandle | null = null;
|
|
389
|
+
// True once the camera is acquired, through preview/countdown/recording, until
|
|
390
|
+
// teardown — gates disconnect handling outside the recording state.
|
|
391
|
+
private cameraLive = false;
|
|
363
392
|
private micStream: MediaStream | null = null;
|
|
364
393
|
private combinedStream: MediaStream | null = null;
|
|
365
394
|
private previewStream: MediaStream | null = null;
|
|
@@ -697,13 +726,10 @@ export class RecorderEngine {
|
|
|
697
726
|
}
|
|
698
727
|
}
|
|
699
728
|
|
|
700
|
-
// Camera / mic disconnects
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
//
|
|
704
|
-
// display handler so the `ended` events fired by `cleanupTracks()` during
|
|
705
|
-
// a normal stop()/cancel() (state is `stopping`/`idle` by then) are
|
|
706
|
-
// ignored rather than treated as a disconnect.
|
|
729
|
+
// Camera / mic disconnects (USB webcam unplugged, permission revoked,
|
|
730
|
+
// Bluetooth dropped) are NON-fatal: keep whatever inputs remain and warn.
|
|
731
|
+
// The handlers gate on `cameraLive`/state so the `ended` events fired by
|
|
732
|
+
// `cleanupTracks()` during a normal stop/cancel are ignored.
|
|
707
733
|
if (this.cameraStream) {
|
|
708
734
|
for (const track of this.cameraStream.getVideoTracks()) {
|
|
709
735
|
track.addEventListener("ended", () => {
|
|
@@ -720,6 +746,37 @@ export class RecorderEngine {
|
|
|
720
746
|
}
|
|
721
747
|
}
|
|
722
748
|
|
|
749
|
+
// Camera is live from here through preview/countdown/recording until
|
|
750
|
+
// teardown — set before the async blur setup so a disconnect during that
|
|
751
|
+
// window is handled, not inherited as a dead stream.
|
|
752
|
+
if (this.cameraStream) this.cameraLive = true;
|
|
753
|
+
|
|
754
|
+
// Swap the raw camera for its blurred derivative, which both the preview
|
|
755
|
+
// bubble and the recording composite read ("what you see is what's
|
|
756
|
+
// recorded"). createBackgroundBlurStream never throws — it falls back to
|
|
757
|
+
// the raw stream on failure.
|
|
758
|
+
if (this.opts.cameraBlur && this.cameraStream) {
|
|
759
|
+
this.rawCameraStream = this.cameraStream;
|
|
760
|
+
const handle = await createBackgroundBlurStream(this.cameraStream, {
|
|
761
|
+
blurPx: this.opts.cameraBlurRadius,
|
|
762
|
+
});
|
|
763
|
+
if (this.cameraDisconnectNotified) {
|
|
764
|
+
// Webcam ended while the pipeline was loading: discard it and drop the
|
|
765
|
+
// dead camera rather than inheriting a frozen processed stream.
|
|
766
|
+
handle.cleanup();
|
|
767
|
+
this.cameraStream = null;
|
|
768
|
+
} else {
|
|
769
|
+
this.cameraBlur = handle;
|
|
770
|
+
this.cameraStream = this.cameraBlur.stream;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (this.opts.mode === "camera" && !this.cameraStream) {
|
|
775
|
+
throw new Error(
|
|
776
|
+
"Camera disconnected before recording could start. Reconnect it and try again.",
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
|
|
723
780
|
this.previewStream =
|
|
724
781
|
this.opts.mode === "camera" ? this.cameraStream! : this.displayStream!;
|
|
725
782
|
|
|
@@ -1414,6 +1471,12 @@ export class RecorderEngine {
|
|
|
1414
1471
|
return combined;
|
|
1415
1472
|
}
|
|
1416
1473
|
|
|
1474
|
+
// Camera dropped before start (disconnected during setup/countdown):
|
|
1475
|
+
// record screen-only rather than compositing a dead stream.
|
|
1476
|
+
if (!this.cameraStream) {
|
|
1477
|
+
return this.buildDisplayRecordingStream();
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1417
1480
|
// Screen + camera: display capture does not reliably include our separate
|
|
1418
1481
|
// DOM bubble once the user records another app/window, so the saved
|
|
1419
1482
|
// recording must composite the camera feed before MediaRecorder sees it.
|
|
@@ -1705,14 +1768,23 @@ export class RecorderEngine {
|
|
|
1705
1768
|
}
|
|
1706
1769
|
|
|
1707
1770
|
private cleanupTracks(): void {
|
|
1771
|
+
// Clear before stopping tracks so the `ended` events our own stop() fires
|
|
1772
|
+
// aren't mistaken for a disconnect.
|
|
1773
|
+
this.cameraLive = false;
|
|
1708
1774
|
this.audioMixSources = [];
|
|
1709
1775
|
this.audioMixCtx?.close().catch(() => {});
|
|
1710
1776
|
this.audioMixCtx = null;
|
|
1711
1777
|
this.cameraComposite?.cleanup();
|
|
1712
1778
|
this.cameraComposite = null;
|
|
1779
|
+
// Tear down the blur pipeline (segmenter, hidden video, processed capture)
|
|
1780
|
+
// before stopping streams. `rawCameraStream` holds the real hardware tracks
|
|
1781
|
+
// when blur is active; stop those so the camera indicator clears.
|
|
1782
|
+
this.cameraBlur?.cleanup();
|
|
1783
|
+
this.cameraBlur = null;
|
|
1713
1784
|
for (const s of [
|
|
1714
1785
|
this.displayStream,
|
|
1715
1786
|
this.cameraStream,
|
|
1787
|
+
this.rawCameraStream,
|
|
1716
1788
|
this.micStream,
|
|
1717
1789
|
this.combinedStream,
|
|
1718
1790
|
]) {
|
|
@@ -1727,6 +1799,7 @@ export class RecorderEngine {
|
|
|
1727
1799
|
}
|
|
1728
1800
|
this.displayStream = null;
|
|
1729
1801
|
this.cameraStream = null;
|
|
1802
|
+
this.rawCameraStream = null;
|
|
1730
1803
|
this.micStream = null;
|
|
1731
1804
|
this.combinedStream = null;
|
|
1732
1805
|
this.previewStream = null;
|
|
@@ -1764,16 +1837,28 @@ export class RecorderEngine {
|
|
|
1764
1837
|
* warn the user.
|
|
1765
1838
|
*/
|
|
1766
1839
|
private onCameraTrackEnded() {
|
|
1767
|
-
if (this.
|
|
1840
|
+
if (!this.cameraLive) return;
|
|
1768
1841
|
if (this.cameraDisconnectNotified) return;
|
|
1769
1842
|
this.cameraDisconnectNotified = true;
|
|
1770
|
-
|
|
1843
|
+
this.cameraLive = false;
|
|
1844
|
+
// Tear down the blur pipeline so the composite's camera <video> sees its
|
|
1845
|
+
// (blurred) track end and self-hides, instead of freezing on a stale frame.
|
|
1846
|
+
if (this.cameraBlur) {
|
|
1847
|
+
this.cameraBlur.cleanup();
|
|
1848
|
+
this.cameraBlur = null;
|
|
1849
|
+
}
|
|
1850
|
+
for (const track of [
|
|
1851
|
+
...(this.rawCameraStream?.getVideoTracks() ?? []),
|
|
1852
|
+
...(this.cameraStream?.getVideoTracks() ?? []),
|
|
1853
|
+
]) {
|
|
1771
1854
|
try {
|
|
1772
1855
|
track.stop();
|
|
1773
1856
|
} catch {
|
|
1774
1857
|
// ignore — the track has already ended.
|
|
1775
1858
|
}
|
|
1776
1859
|
}
|
|
1860
|
+
// Drop the on-page bubble to match the recorded output (screen-only now).
|
|
1861
|
+
this.opts.onCameraEnded?.();
|
|
1777
1862
|
this.emitWarning(
|
|
1778
1863
|
"Camera disconnected — recording continues without webcam.",
|
|
1779
1864
|
);
|