@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.
@@ -0,0 +1,341 @@
1
+ import { appBasePath } from "@agent-native/core/client";
2
+ import type { ImageSegmenter } from "@mediapipe/tasks-vision";
3
+
4
+ /**
5
+ * A processed camera stream whose background is blurred while the person stays
6
+ * sharp (Zoom / Loom style). Produced once in the recorder engine and shared by
7
+ * both the live preview bubble and the baked-in recording composite, so "what
8
+ * you see is what's recorded".
9
+ */
10
+ export interface CameraBlurHandle {
11
+ /** The processed stream, or the original `source` stream when `active` is false. */
12
+ stream: MediaStream;
13
+ /** False when segmentation was unavailable and we transparently fell back to raw. */
14
+ readonly active: boolean;
15
+ /**
16
+ * Update the background blur radius (px) live, without rebuilding the
17
+ * segmenter. No-op on the passthrough fallback handle.
18
+ */
19
+ setBlurPx(px: number): void;
20
+ cleanup(): void;
21
+ }
22
+
23
+ export interface CameraBlurOptions {
24
+ /** CSS blur radius applied to the background, in px. Default 12. */
25
+ blurPx?: number;
26
+ /**
27
+ * How often segmentation runs, in frames per second. Kept below the 30fps
28
+ * capture rate to bound CPU/GPU cost — the small bubble does not need a fresh
29
+ * mask every captured frame. Default 20.
30
+ */
31
+ segmentationFps?: number;
32
+ }
33
+
34
+ export const DEFAULT_BLUR_PX = 12;
35
+ export const MIN_BLUR_PX = 2;
36
+ export const MAX_BLUR_PX = 30;
37
+ const DEFAULT_SEGMENTATION_FPS = 20;
38
+ const CAPTURE_FPS = 30;
39
+ /**
40
+ * Max dimension (px) of the frame we feed the segmenter. The model resizes to
41
+ * its own 256² input internally and upsamples the mask back to this size, so
42
+ * keeping the input small bounds the per-frame mask readback + alpha loop cost
43
+ * regardless of the camera's native resolution. The soft mask is scaled back up
44
+ * to the camera resolution with bilinear filtering when compositing.
45
+ */
46
+ const SEG_MAX_DIM = 256;
47
+
48
+ const MODEL_PATH = "/mediapipe/selfie_segmenter.tflite";
49
+ const WASM_PATH = "/mediapipe/wasm";
50
+
51
+ function positive(value: unknown): value is number {
52
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
53
+ }
54
+
55
+ function sourceDimensions(
56
+ video: HTMLVideoElement,
57
+ stream: MediaStream,
58
+ ): { width: number; height: number } {
59
+ if (positive(video.videoWidth) && positive(video.videoHeight)) {
60
+ return { width: video.videoWidth, height: video.videoHeight };
61
+ }
62
+ const settings = stream.getVideoTracks()[0]?.getSettings();
63
+ return {
64
+ width: positive(settings?.width) ? Math.round(settings.width) : 640,
65
+ height: positive(settings?.height) ? Math.round(settings.height) : 480,
66
+ };
67
+ }
68
+
69
+ /** Hidden, off-screen `<video>` that plays the source stream for canvas reads. */
70
+ function attachHiddenVideo(stream: MediaStream): {
71
+ video: HTMLVideoElement;
72
+ cleanup(): void;
73
+ } {
74
+ const video = document.createElement("video");
75
+ video.muted = true;
76
+ video.playsInline = true;
77
+ video.autoplay = true;
78
+ video.srcObject = stream;
79
+ video.style.position = "fixed";
80
+ video.style.left = "-10000px";
81
+ video.style.top = "0";
82
+ video.style.width = "1px";
83
+ video.style.height = "1px";
84
+ video.style.opacity = "0";
85
+ video.style.pointerEvents = "none";
86
+
87
+ const tryPlay = () => {
88
+ video.play().catch(() => undefined);
89
+ };
90
+
91
+ document.body.appendChild(video);
92
+ video.addEventListener("loadedmetadata", tryPlay);
93
+ tryPlay();
94
+
95
+ return {
96
+ video,
97
+ cleanup() {
98
+ video.removeEventListener("loadedmetadata", tryPlay);
99
+ video.pause();
100
+ video.srcObject = null;
101
+ video.remove();
102
+ },
103
+ };
104
+ }
105
+
106
+ async function createSegmenter(): Promise<ImageSegmenter> {
107
+ // Lazy-loaded so the ~11MB Wasm runtime and its loader never enter the main
108
+ // bundle or run during SSR — only when a recording actually requests blur.
109
+ const vision = await import("@mediapipe/tasks-vision");
110
+ const base = appBasePath();
111
+ const fileset = await vision.FilesetResolver.forVisionTasks(
112
+ `${base}${WASM_PATH}`,
113
+ );
114
+ const build = (delegate: "GPU" | "CPU") =>
115
+ vision.ImageSegmenter.createFromOptions(fileset, {
116
+ baseOptions: {
117
+ modelAssetPath: `${base}${MODEL_PATH}`,
118
+ delegate,
119
+ },
120
+ runningMode: "VIDEO",
121
+ outputConfidenceMasks: true,
122
+ outputCategoryMask: false,
123
+ });
124
+ try {
125
+ return await build("GPU");
126
+ } catch {
127
+ // Older GPUs / blocked WebGL contexts: fall back to the CPU delegate.
128
+ return await build("CPU");
129
+ }
130
+ }
131
+
132
+ const fallback = (source: MediaStream): CameraBlurHandle => ({
133
+ stream: source,
134
+ active: false,
135
+ setBlurPx() {},
136
+ cleanup() {},
137
+ });
138
+
139
+ /**
140
+ * Build a background-blurred derivative of `source`. Never throws: if MediaPipe,
141
+ * the Wasm fileset, the model, or canvas capture are unavailable, it resolves to
142
+ * a passthrough handle wrapping the raw `source` (`active === false`) so the
143
+ * recording always proceeds. Callers own `source`'s tracks; `cleanup()` here
144
+ * tears down only the processing pipeline, never the source.
145
+ */
146
+ export async function createBackgroundBlurStream(
147
+ source: MediaStream,
148
+ opts: CameraBlurOptions = {},
149
+ ): Promise<CameraBlurHandle> {
150
+ if (typeof document === "undefined") return fallback(source);
151
+ if (!source.getVideoTracks().length) return fallback(source);
152
+
153
+ let blurPx = positive(opts.blurPx) ? opts.blurPx : DEFAULT_BLUR_PX;
154
+ const segFps = positive(opts.segmentationFps)
155
+ ? opts.segmentationFps
156
+ : DEFAULT_SEGMENTATION_FPS;
157
+
158
+ const out = document.createElement("canvas");
159
+ const outCtx = out.getContext("2d", { alpha: false });
160
+ if (!outCtx || typeof out.captureStream !== "function") {
161
+ return fallback(source);
162
+ }
163
+
164
+ let segmenter: ImageSegmenter;
165
+ try {
166
+ segmenter = await createSegmenter();
167
+ } catch (err) {
168
+ console.warn(
169
+ "[camera-blur] Segmentation unavailable — recording without background blur:",
170
+ err,
171
+ );
172
+ return fallback(source);
173
+ }
174
+
175
+ const hidden = attachHiddenVideo(source);
176
+ const { width: w0, height: h0 } = sourceDimensions(hidden.video, source);
177
+ out.width = w0;
178
+ out.height = h0;
179
+
180
+ // Foreground compositing scratch: sharp person punched out by the mask alpha.
181
+ const fg = document.createElement("canvas");
182
+ const fgCtx = fg.getContext("2d");
183
+ // Downscaled frame we actually segment, plus the soft alpha mask it produces.
184
+ const segInput = document.createElement("canvas");
185
+ const segCtx = segInput.getContext("2d");
186
+ const maskCanvas = document.createElement("canvas");
187
+ const maskCtx = maskCanvas.getContext("2d");
188
+ if (!fgCtx || !segCtx || !maskCtx) {
189
+ hidden.cleanup();
190
+ segmenter.close();
191
+ return fallback(source);
192
+ }
193
+
194
+ let lastTimestamp = -1;
195
+ let lastSegAt = -Infinity;
196
+ let haveMask = false;
197
+ const segIntervalMs = 1000 / segFps;
198
+
199
+ // Re-segment into maskCanvas. Runs at segFps; on failure the previous mask is
200
+ // kept rather than dropping to an un-blurred frame.
201
+ const updateMask = (video: HTMLVideoElement) => {
202
+ const scale = SEG_MAX_DIM / Math.max(video.videoWidth, video.videoHeight);
203
+ const segW = Math.max(1, Math.round(video.videoWidth * scale));
204
+ const segH = Math.max(1, Math.round(video.videoHeight * scale));
205
+ if (segInput.width !== segW) segInput.width = segW;
206
+ if (segInput.height !== segH) segInput.height = segH;
207
+ try {
208
+ segCtx.drawImage(video, 0, 0, segW, segH);
209
+ const timestamp = Math.max(
210
+ Math.round(performance.now()),
211
+ lastTimestamp + 1,
212
+ );
213
+ lastTimestamp = timestamp;
214
+ const result = segmenter.segmentForVideo(segInput, timestamp);
215
+ const confidence = result.confidenceMasks?.[0];
216
+ if (confidence) {
217
+ const maskW = confidence.width;
218
+ const maskH = confidence.height;
219
+ const values = confidence.getAsFloat32Array();
220
+ if (maskCanvas.width !== maskW) maskCanvas.width = maskW;
221
+ if (maskCanvas.height !== maskH) maskCanvas.height = maskH;
222
+ const image = maskCtx.createImageData(maskW, maskH);
223
+ for (let i = 0; i < values.length; i++) {
224
+ image.data[i * 4 + 3] = Math.round(values[i] * 255);
225
+ }
226
+ maskCtx.putImageData(image, 0, 0);
227
+ haveMask = true;
228
+ }
229
+ result.close();
230
+ } catch {
231
+ // keep the previous mask
232
+ }
233
+ };
234
+
235
+ // Composite blurred background + sharp foreground (or raw until a first mask).
236
+ const composite = (video: HTMLVideoElement, outW: number, outH: number) => {
237
+ if (!haveMask) {
238
+ outCtx.drawImage(video, 0, 0, outW, outH);
239
+ return;
240
+ }
241
+ if (fg.width !== outW) fg.width = outW;
242
+ if (fg.height !== outH) fg.height = outH;
243
+ fgCtx.globalCompositeOperation = "source-over";
244
+ fgCtx.clearRect(0, 0, outW, outH);
245
+ fgCtx.drawImage(video, 0, 0, outW, outH);
246
+ fgCtx.globalCompositeOperation = "destination-in";
247
+ fgCtx.imageSmoothingEnabled = true;
248
+ fgCtx.drawImage(maskCanvas, 0, 0, outW, outH);
249
+ fgCtx.globalCompositeOperation = "source-over";
250
+
251
+ outCtx.filter = `blur(${blurPx}px)`;
252
+ outCtx.drawImage(video, 0, 0, outW, outH);
253
+ outCtx.filter = "none";
254
+ outCtx.drawImage(fg, 0, 0);
255
+ };
256
+
257
+ // Composite every tick (capture rate) for smooth motion; re-segment only at
258
+ // segFps, reusing the last mask in between.
259
+ const drawFrame = () => {
260
+ const video = hidden.video;
261
+ if (!positive(video.videoWidth) || !positive(video.videoHeight)) return;
262
+ if (out.width !== video.videoWidth) out.width = video.videoWidth;
263
+ if (out.height !== video.videoHeight) out.height = video.videoHeight;
264
+
265
+ const now = performance.now();
266
+ if (now - lastSegAt >= segIntervalMs) {
267
+ updateMask(video);
268
+ lastSegAt = now;
269
+ }
270
+ composite(video, out.width, out.height);
271
+ };
272
+
273
+ const stream = out.captureStream(CAPTURE_FPS);
274
+ const minFrameMs = 1000 / CAPTURE_FPS;
275
+
276
+ // Worker-driven timer so the loop keeps running at full rate in background
277
+ // tabs (rAF is throttled to ~1fps when hidden). Falls back to rAF when blob:
278
+ // workers are blocked by CSP — mirrors camera-composite.ts.
279
+ let worker: Worker | null = null;
280
+ let raf: number | null = null;
281
+
282
+ try {
283
+ const workerBlob = new Blob(
284
+ [
285
+ `let t=null;onmessage=e=>{if(e.data==='start'){clearInterval(t);t=setInterval(()=>postMessage('tick'),${minFrameMs});}else if(e.data==='stop'){clearInterval(t);}};`,
286
+ ],
287
+ { type: "application/javascript" },
288
+ );
289
+ const workerUrl = URL.createObjectURL(workerBlob);
290
+ try {
291
+ worker = new Worker(workerUrl);
292
+ } finally {
293
+ URL.revokeObjectURL(workerUrl);
294
+ }
295
+ worker.onmessage = () => drawFrame();
296
+ worker.postMessage("start");
297
+ } catch (err) {
298
+ console.warn(
299
+ "[camera-blur] Worker timer unavailable, falling back to rAF — blur may glitch on hidden tabs:",
300
+ err,
301
+ );
302
+ let lastFrameAt = 0;
303
+ const tick = (now: number) => {
304
+ if (raf === null) return;
305
+ if (now - lastFrameAt >= minFrameMs) {
306
+ lastFrameAt = now;
307
+ drawFrame();
308
+ }
309
+ raf = window.requestAnimationFrame(tick);
310
+ };
311
+ raf = window.requestAnimationFrame(tick);
312
+ }
313
+
314
+ drawFrame();
315
+
316
+ return {
317
+ stream,
318
+ active: true,
319
+ setBlurPx(px: number) {
320
+ if (positive(px)) blurPx = px;
321
+ },
322
+ cleanup() {
323
+ if (worker) {
324
+ worker.postMessage("stop");
325
+ worker.terminate();
326
+ worker = null;
327
+ }
328
+ if (raf !== null) {
329
+ window.cancelAnimationFrame(raf);
330
+ raf = null;
331
+ }
332
+ stream.getTracks().forEach((track) => track.stop());
333
+ hidden.cleanup();
334
+ try {
335
+ segmenter.close();
336
+ } catch {
337
+ // ignore — already closed.
338
+ }
339
+ },
340
+ };
341
+ }
@@ -244,7 +244,12 @@ export function createCameraCompositeStream(
244
244
  } catch {
245
245
  // The display video can be momentarily unavailable while metadata loads.
246
246
  }
247
- drawCameraBubble(ctx, camera.video, canvas, drawOptions);
247
+ // Hide the bubble once the camera ends (unplugged, or the blur pipeline
248
+ // stopped its captureStream) — the <video> freezes on its last frame rather
249
+ // than zeroing its dimensions, so check the track instead.
250
+ if (cameraTrack.readyState !== "ended") {
251
+ drawCameraBubble(ctx, camera.video, canvas, drawOptions);
252
+ }
248
253
  };
249
254
 
250
255
  // Use a Worker-based timer so the draw loop keeps running at the target
@@ -3,6 +3,7 @@ import type {
3
3
  DisplaySurface,
4
4
  RecordingMode,
5
5
  } from "@/components/recorder/recorder-engine";
6
+ import { MAX_BLUR_PX, MIN_BLUR_PX } from "@/lib/camera-blur";
6
7
 
7
8
  /**
8
9
  * Last-used recorder selections, remembered across `/record` visits via
@@ -21,6 +22,8 @@ export interface RecorderPreferences {
21
22
  micId: string;
22
23
  cameraId: string;
23
24
  cameraSize: CameraBubbleSize;
25
+ cameraBlur: boolean;
26
+ cameraBlurRadius: number;
24
27
  }
25
28
 
26
29
  const STORAGE_KEY = "clips:recorder-preferences";
@@ -47,6 +50,18 @@ export function loadRecorderPreferences(): Partial<RecorderPreferences> {
47
50
  if (VALID_SIZES.includes(parsed.cameraSize as CameraBubbleSize)) {
48
51
  prefs.cameraSize = parsed.cameraSize as CameraBubbleSize;
49
52
  }
53
+ if (typeof parsed.cameraBlur === "boolean") {
54
+ prefs.cameraBlur = parsed.cameraBlur;
55
+ }
56
+ if (
57
+ typeof parsed.cameraBlurRadius === "number" &&
58
+ Number.isFinite(parsed.cameraBlurRadius)
59
+ ) {
60
+ prefs.cameraBlurRadius = Math.min(
61
+ MAX_BLUR_PX,
62
+ Math.max(MIN_BLUR_PX, parsed.cameraBlurRadius),
63
+ );
64
+ }
50
65
  return prefs;
51
66
  } catch {
52
67
  return {};
@@ -874,6 +874,8 @@ export default function RecordRoute() {
874
874
  displaySurface: DisplaySurface;
875
875
  micDeviceId: string | null;
876
876
  cameraDeviceId: string | null;
877
+ cameraBlur: boolean;
878
+ cameraBlurRadius: number;
877
879
  } | null>(null);
878
880
  const tickRef = useRef<number | null>(null);
879
881
  const previewVideoRef = useRef<HTMLVideoElement>(null);
@@ -949,6 +951,8 @@ export default function RecordRoute() {
949
951
  displaySurface: DisplaySurface;
950
952
  micDeviceId: string | null;
951
953
  cameraDeviceId: string | null;
954
+ cameraBlur: boolean;
955
+ cameraBlurRadius: number;
952
956
  }) => {
953
957
  const blockedFeature = isEmbeddedWindow()
954
958
  ? getPolicyBlockedCaptureLabel({
@@ -994,6 +998,8 @@ export default function RecordRoute() {
994
998
  micDeviceId: opts.micDeviceId,
995
999
  cameraDeviceId: opts.cameraDeviceId,
996
1000
  cameraBubbleSize: cameraSize,
1001
+ cameraBlur: opts.cameraBlur,
1002
+ cameraBlurRadius: opts.cameraBlurRadius,
997
1003
  uploadUrl: "",
998
1004
  abortUrl: "",
999
1005
  onError: (err) => {
@@ -1007,6 +1013,13 @@ export default function RecordRoute() {
1007
1013
  onWarning: (message) => {
1008
1014
  toast.warning(message);
1009
1015
  },
1016
+ // Camera ended (unplugged / revoked): drop the on-page bubble, and in
1017
+ // camera-only mode clear the fullscreen preview too (it renders from
1018
+ // previewStream) so neither freezes on the last frame.
1019
+ onCameraEnded: () => {
1020
+ setCameraStream(null);
1021
+ if (opts.mode === "camera") setPreviewStream(null);
1022
+ },
1010
1023
  // Track the surface the user actually chose (and any mid-recording
1011
1024
  // switch) so the live camera bubble is hidden only when the full
1012
1025
  // screen — including this tab's overlay — is being captured.