@agent-native/core 0.70.2 → 0.71.0

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.
Files changed (93) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +19 -0
  3. package/corpus/core/docs/content/multi-app-workspace.md +2 -2
  4. package/corpus/core/docs/design/durable-agent-runs.md +458 -4
  5. package/corpus/core/package.json +1 -1
  6. package/corpus/core/src/action.ts +21 -10
  7. package/corpus/core/src/agent/durable-background.ts +192 -0
  8. package/corpus/core/src/agent/production-agent.ts +330 -13
  9. package/corpus/core/src/agent/run-manager.ts +66 -3
  10. package/corpus/core/src/agent/run-store.ts +129 -23
  11. package/corpus/core/src/agent/types.ts +20 -0
  12. package/corpus/core/src/client/AgentPanel.tsx +12 -9
  13. package/corpus/core/src/client/blocks/library/FileTreeBlock.tsx +72 -14
  14. package/corpus/core/src/deploy/build.ts +96 -0
  15. package/corpus/core/src/deploy/workspace-deploy.ts +121 -0
  16. package/corpus/core/src/mcp/build-server.ts +22 -13
  17. package/corpus/core/src/server/agent-chat-plugin.ts +133 -66
  18. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +19 -0
  19. package/corpus/templates/analytics/AGENTS.md +8 -0
  20. package/corpus/templates/analytics/actions/compose-dashboard.ts +317 -0
  21. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +33 -6
  22. package/corpus/templates/analytics/changelog/2026-06-23-build-large-first-party-analytics-dashboards-in-one-fast-cal.md +6 -0
  23. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +574 -0
  24. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +8 -2
  25. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +1 -9
  26. package/corpus/templates/clips/changelog/2026-06-23-dragging-the-desktop-camera-bubble-now-glides-to-a-stop-at-t.md +6 -0
  27. package/corpus/templates/clips/changelog/2026-06-23-screen-recordings-now-capture-at-a-crisp-1080p-bitrate-inste.md +6 -0
  28. package/corpus/templates/clips/chrome-extension/package.json +1 -0
  29. package/corpus/templates/clips/chrome-extension/scripts/dev.ts +102 -0
  30. package/corpus/templates/clips/chrome-extension/src/background.ts +164 -32
  31. package/corpus/templates/clips/chrome-extension/src/content-script.ts +163 -13
  32. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +198 -35
  33. package/corpus/templates/clips/chrome-extension/src/overlay.css +73 -23
  34. package/corpus/templates/clips/chrome-extension/src/overlay.ts +82 -13
  35. package/corpus/templates/clips/chrome-extension/src/popup.html +62 -5
  36. package/corpus/templates/clips/chrome-extension/src/popup.ts +290 -1
  37. package/corpus/templates/clips/chrome-extension/src/styles.css +34 -0
  38. package/corpus/templates/clips/desktop/src/lib/audio-cue.ts +21 -16
  39. package/corpus/templates/clips/desktop/src/lib/recorder.ts +17 -20
  40. package/corpus/templates/clips/desktop/src/overlays/bubble.tsx +73 -23
  41. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +0 -6
  42. package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +56 -46
  43. package/corpus/templates/clips/desktop/src/styles.css +33 -5
  44. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +113 -0
  45. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -0
  46. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +2 -2
  47. package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +9 -1
  48. package/corpus/templates/clips/shared/upload-limits.ts +5 -2
  49. package/corpus/templates/plan/app/pages/PlansPage.tsx +11 -7
  50. package/corpus/templates/plan/changelog/2026-06-23-plan-loading-skeleton-no-longer-cuts-off-the-canvas-preview-.md +6 -0
  51. package/dist/action.js +21 -10
  52. package/dist/action.js.map +1 -1
  53. package/dist/agent/durable-background.d.ts +60 -0
  54. package/dist/agent/durable-background.d.ts.map +1 -0
  55. package/dist/agent/durable-background.js +144 -0
  56. package/dist/agent/durable-background.js.map +1 -0
  57. package/dist/agent/production-agent.d.ts +20 -0
  58. package/dist/agent/production-agent.d.ts.map +1 -1
  59. package/dist/agent/production-agent.js +292 -14
  60. package/dist/agent/production-agent.js.map +1 -1
  61. package/dist/agent/run-manager.d.ts +48 -0
  62. package/dist/agent/run-manager.d.ts.map +1 -1
  63. package/dist/agent/run-manager.js +45 -3
  64. package/dist/agent/run-manager.js.map +1 -1
  65. package/dist/agent/run-store.d.ts +30 -1
  66. package/dist/agent/run-store.d.ts.map +1 -1
  67. package/dist/agent/run-store.js +124 -24
  68. package/dist/agent/run-store.js.map +1 -1
  69. package/dist/agent/types.d.ts +20 -0
  70. package/dist/agent/types.d.ts.map +1 -1
  71. package/dist/agent/types.js.map +1 -1
  72. package/dist/client/AgentPanel.d.ts.map +1 -1
  73. package/dist/client/AgentPanel.js +2 -2
  74. package/dist/client/AgentPanel.js.map +1 -1
  75. package/dist/client/blocks/library/FileTreeBlock.d.ts.map +1 -1
  76. package/dist/client/blocks/library/FileTreeBlock.js +37 -4
  77. package/dist/client/blocks/library/FileTreeBlock.js.map +1 -1
  78. package/dist/deploy/build.d.ts +29 -0
  79. package/dist/deploy/build.d.ts.map +1 -1
  80. package/dist/deploy/build.js +85 -0
  81. package/dist/deploy/build.js.map +1 -1
  82. package/dist/deploy/workspace-deploy.d.ts.map +1 -1
  83. package/dist/deploy/workspace-deploy.js +108 -0
  84. package/dist/deploy/workspace-deploy.js.map +1 -1
  85. package/dist/mcp/build-server.d.ts.map +1 -1
  86. package/dist/mcp/build-server.js +23 -9
  87. package/dist/mcp/build-server.js.map +1 -1
  88. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  89. package/dist/server/agent-chat-plugin.js +68 -13
  90. package/dist/server/agent-chat-plugin.js.map +1 -1
  91. package/docs/content/multi-app-workspace.md +2 -2
  92. package/docs/design/durable-agent-runs.md +458 -4
  93. package/package.json +1 -1
@@ -13,6 +13,128 @@
13
13
  const ALL_PARTS: OverlayPart[] = ["bubble", "countdown", "toolbar", "saving"];
14
14
  const flags = window as unknown as { __clipsOverlayHostReady?: boolean };
15
15
 
16
+ // ----- Draggable, resizable camera bubble ---------------------------------
17
+ // Size + position persist in storage so the bubble stays where the user put it
18
+ // across pages and recordings (like the desktop app). The iframe can't move or
19
+ // resize itself, so the content script owns its geometry.
20
+ const BUBBLE_SIZES: Record<string, number> = { sm: 184, lg: 280 };
21
+ const bubbleGeom: { size: string; left: number | null; top: number | null } =
22
+ { size: "lg", left: null, top: null };
23
+ let bubbleDragLayer: HTMLDivElement | null = null;
24
+ let bubblePersistTimer: ReturnType<typeof setTimeout> | undefined;
25
+
26
+ function bubbleSizePx(): number {
27
+ return BUBBLE_SIZES[bubbleGeom.size] ?? BUBBLE_SIZES.lg;
28
+ }
29
+
30
+ function clampBubble(
31
+ left: number,
32
+ top: number,
33
+ size: number,
34
+ ): { left: number; top: number } {
35
+ return {
36
+ left: Math.max(8, Math.min(left, window.innerWidth - size - 8)),
37
+ top: Math.max(8, Math.min(top, window.innerHeight - size - 8)),
38
+ };
39
+ }
40
+
41
+ function applyBubbleGeom(): void {
42
+ const frame = document.getElementById(
43
+ partFrameId("bubble"),
44
+ ) as HTMLIFrameElement | null;
45
+ if (!frame) return;
46
+ const size = bubbleSizePx();
47
+ const margin = 24;
48
+ const base = clampBubble(
49
+ bubbleGeom.left ?? margin,
50
+ bubbleGeom.top ?? window.innerHeight - size - margin,
51
+ size,
52
+ );
53
+ Object.assign(frame.style, {
54
+ left: `${base.left}px`,
55
+ top: `${base.top}px`,
56
+ bottom: "auto",
57
+ width: `${size}px`,
58
+ height: `${size}px`,
59
+ });
60
+ }
61
+
62
+ function persistBubbleGeom(): void {
63
+ clearTimeout(bubblePersistTimer);
64
+ bubblePersistTimer = setTimeout(() => {
65
+ try {
66
+ chrome.storage.local.set({ bubbleGeom });
67
+ } catch {
68
+ /* ignore */
69
+ }
70
+ }, 200);
71
+ }
72
+
73
+ function startBubbleDrag(): void {
74
+ if (bubbleDragLayer) return;
75
+ const frame = document.getElementById(
76
+ partFrameId("bubble"),
77
+ ) as HTMLIFrameElement | null;
78
+ if (!frame) return;
79
+ // Full-screen capture layer so the pointer keeps tracking after it leaves
80
+ // the small bubble iframe.
81
+ const layer = document.createElement("div");
82
+ Object.assign(layer.style, {
83
+ position: "fixed",
84
+ inset: "0",
85
+ zIndex: "2147483647",
86
+ cursor: "grabbing",
87
+ });
88
+ (document.documentElement || document.body).appendChild(layer);
89
+ bubbleDragLayer = layer;
90
+ const onMove = (e: PointerEvent): void => {
91
+ const size = bubbleSizePx();
92
+ const rect = frame.getBoundingClientRect();
93
+ const next = clampBubble(
94
+ rect.left + e.movementX,
95
+ rect.top + e.movementY,
96
+ size,
97
+ );
98
+ bubbleGeom.left = next.left;
99
+ bubbleGeom.top = next.top;
100
+ Object.assign(frame.style, {
101
+ left: `${next.left}px`,
102
+ top: `${next.top}px`,
103
+ bottom: "auto",
104
+ });
105
+ };
106
+ const onUp = (): void => {
107
+ layer.removeEventListener("pointermove", onMove);
108
+ layer.removeEventListener("pointerup", onUp);
109
+ layer.removeEventListener("pointercancel", onUp);
110
+ layer.remove();
111
+ bubbleDragLayer = null;
112
+ persistBubbleGeom();
113
+ };
114
+ layer.addEventListener("pointermove", onMove);
115
+ layer.addEventListener("pointerup", onUp);
116
+ layer.addEventListener("pointercancel", onUp);
117
+ }
118
+
119
+ try {
120
+ chrome.storage.local.get("bubbleGeom", (value) => {
121
+ if (chrome.runtime.lastError) return;
122
+ const g = value.bubbleGeom as
123
+ | { size?: unknown; left?: unknown; top?: unknown }
124
+ | undefined;
125
+ if (g && typeof g === "object") {
126
+ bubbleGeom.size = g.size === "sm" ? "sm" : "lg";
127
+ bubbleGeom.left = typeof g.left === "number" ? g.left : null;
128
+ bubbleGeom.top = typeof g.top === "number" ? g.top : null;
129
+ }
130
+ applyBubbleGeom();
131
+ });
132
+ } catch {
133
+ /* ignore */
134
+ }
135
+
136
+ window.addEventListener("resize", () => applyBubbleGeom());
137
+
16
138
  function requestState(): void {
17
139
  try {
18
140
  chrome.runtime.sendMessage(
@@ -78,21 +200,25 @@
78
200
  frame.setAttribute("allowtransparency", "true");
79
201
  if (part === "bubble") {
80
202
  frame.allow = "camera; microphone";
203
+ // Above the countdown so the face stays sharp over the dim/blur. Exact
204
+ // size/position are set by applyBubbleGeom() once mounted.
205
+ const size = bubbleSizePx();
81
206
  Object.assign(frame.style, {
82
207
  left: "24px",
83
208
  bottom: "24px",
84
- width: "148px",
85
- height: "148px",
209
+ width: `${size}px`,
210
+ height: `${size}px`,
211
+ zIndex: "3",
86
212
  });
87
213
  } else if (part === "toolbar") {
88
214
  // Left-edge vertical pill (desktop layout). Height grows on hover via the
89
215
  // resize message below.
90
216
  Object.assign(frame.style, {
91
217
  left: "16px",
92
- top: "50%",
93
- transform: "translateY(-50%)",
218
+ top: "calc(50% - 77px)",
94
219
  width: "68px",
95
220
  height: "154px",
221
+ zIndex: "2",
96
222
  });
97
223
  } else if (part === "saving") {
98
224
  Object.assign(frame.style, {
@@ -100,12 +226,15 @@
100
226
  bottom: "24px",
101
227
  width: "264px",
102
228
  height: "96px",
229
+ zIndex: "2",
103
230
  });
104
231
  } else {
232
+ // countdown — full-screen dim/blur, below the bubble.
105
233
  Object.assign(frame.style, {
106
234
  inset: "0",
107
235
  width: "100%",
108
236
  height: "100%",
237
+ zIndex: "1",
109
238
  });
110
239
  }
111
240
  }
@@ -114,15 +243,18 @@
114
243
  if (document.getElementById(partFrameId(part))) return;
115
244
  const frame = document.createElement("iframe");
116
245
  frame.id = partFrameId(part);
246
+ if (part === "bubble") frame.allow = "camera; microphone";
117
247
  const url = new URL(chrome.runtime.getURL("src/overlay.html"));
118
248
  url.searchParams.set("part", part);
119
249
  if (part === "countdown") url.searchParams.set("seconds", "3");
120
250
  frame.src = url.toString();
121
251
  styleFrame(frame, part);
122
252
  container.appendChild(frame);
253
+ if (part === "bubble") applyBubbleGeom();
123
254
  }
124
255
 
125
256
  function reconcile(parts: OverlayPart[]): void {
257
+ console.log("[clips-cs] reconcile parts:", parts, "on", location.href);
126
258
  const wanted = new Set(parts.filter((p) => ALL_PARTS.includes(p)));
127
259
  if (wanted.size === 0) {
128
260
  document.getElementById(CONTAINER_ID)?.remove();
@@ -157,20 +289,38 @@
157
289
  }
158
290
  });
159
291
 
160
- // The toolbar overlay asks to grow/shrink its own iframe on hover (it can't
161
- // resize itself). Only trust messages from our own extension-origin frames.
292
+ // Overlay iframes post layout requests (toolbar hover-resize, bubble drag and
293
+ // size). Only trust messages from our own extension-origin frames.
162
294
  window.addEventListener("message", (event) => {
163
295
  const data = event.data as
164
- | { source?: string; kind?: string; part?: string; height?: number }
296
+ | {
297
+ source?: string;
298
+ kind?: string;
299
+ part?: string;
300
+ height?: number;
301
+ size?: string;
302
+ }
165
303
  | undefined;
166
- if (!data || data.source !== "clips-overlay" || data.kind !== "resize") {
304
+ if (!data || data.source !== "clips-overlay") return;
305
+ if (event.origin !== chrome.runtime.getURL("").replace(/\/$/, "")) return;
306
+
307
+ if (data.kind === "resize" && data.part === "toolbar") {
308
+ const frame = document.getElementById(partFrameId("toolbar"));
309
+ if (frame && typeof data.height === "number") {
310
+ frame.style.height = `${Math.round(data.height)}px`;
311
+ }
312
+ return;
313
+ }
314
+ if (data.kind === "bubble-drag-start") {
315
+ startBubbleDrag();
316
+ return;
317
+ }
318
+ if (data.kind === "bubble-size") {
319
+ bubbleGeom.size = data.size === "sm" ? "sm" : "lg";
320
+ applyBubbleGeom();
321
+ persistBubbleGeom();
167
322
  return;
168
323
  }
169
- if (event.origin !== chrome.runtime.getURL("").replace(/\/$/, "")) return;
170
- const part = data.part === "toolbar" ? "toolbar" : null;
171
- if (!part || typeof data.height !== "number") return;
172
- const frame = document.getElementById(partFrameId(part));
173
- if (frame) frame.style.height = `${Math.round(data.height)}px`;
174
324
  });
175
325
 
176
326
  syncIfRecording();
@@ -3,11 +3,11 @@
3
3
  // This document holds the getDisplayMedia()/getUserMedia() stream and the
4
4
  // MediaRecorder. Living in an offscreen document (reason DISPLAY_MEDIA) is what
5
5
  // lets a recording survive page navigations — the capture is decoupled from any
6
- // tab. The camera bubble and controls are rendered as on-page overlays by the
7
- // content script, so we no longer composite the camera into a canvas here; for
8
- // screen modes we record the display stream directly (+ mixed mic audio), and
9
- // the bubble shows up in the recording naturally when a full screen/window/tab
10
- // surface that contains it is captured.
6
+ // tab. Controls render as on-page overlays. For SCREEN+CAMERA we capture the
7
+ // camera HERE and composite it (canvas) into the recording, because the on-page
8
+ // bubble can't get the camera on pages that send `Permissions-Policy: camera=()`
9
+ // (an iframe can't escape its parent's policy). Screen-only records the display
10
+ // directly; camera-only records the webcam directly.
11
11
  //
12
12
  // Lifecycle: ACQUIRE (show picker, hold stream) → BEGIN (start recorder after
13
13
  // the countdown) → PAUSE/RESUME → STOP/CANCEL, plus RESTART (discard and start
@@ -21,6 +21,9 @@ type AcquireMessage = {
21
21
  mode: CaptureMode;
22
22
  surface: "browser" | "window" | "monitor";
23
23
  includeMicrophone: boolean;
24
+ // Screen+camera: capture the camera here and composite it into the recording
25
+ // (the on-page bubble can be blocked by the page's Permissions-Policy).
26
+ includeCamera: boolean;
24
27
  };
25
28
 
26
29
  type BeginMessage = {
@@ -94,6 +97,13 @@ type ActiveRecording = {
94
97
  restarting: boolean;
95
98
  // Pending pre-roll timer; non-null means the recorder hasn't started yet.
96
99
  startTimer: ReturnType<typeof setTimeout> | null;
100
+ // Canvas compositor draw loop (screen+camera only); stop it on teardown.
101
+ stopCompositor: (() => void) | null;
102
+ // Original capture streams, kept distinct so restart can re-home exactly the
103
+ // right ones (sourceStreams also holds the derived canvas stream).
104
+ displaySource: MediaStream | null;
105
+ cameraSource: MediaStream | null;
106
+ micSource: MediaStream | null;
97
107
  dimensions: { width: number; height: number };
98
108
  hasAudio: boolean;
99
109
  hasCamera: boolean;
@@ -194,22 +204,140 @@ function displayConstraints(
194
204
  } as MediaStreamConstraints;
195
205
  }
196
206
 
197
- async function getMicStream(): Promise<MediaStream | null> {
207
+ // The user's chosen camera/mic devices (set in the popup, saved to storage).
208
+ async function readDeviceIds(): Promise<{ video: string; audio: string }> {
198
209
  try {
199
- return await navigator.mediaDevices.getUserMedia({
200
- audio: {
201
- echoCancellation: true,
202
- noiseSuppression: true,
203
- autoGainControl: true,
204
- channelCount: 1,
205
- },
206
- video: false,
207
- });
210
+ const v = await chrome.storage.sync.get(["videoDeviceId", "audioDeviceId"]);
211
+ return {
212
+ video: typeof v.videoDeviceId === "string" ? v.videoDeviceId : "",
213
+ audio: typeof v.audioDeviceId === "string" ? v.audioDeviceId : "",
214
+ };
215
+ } catch {
216
+ return { video: "", audio: "" };
217
+ }
218
+ }
219
+
220
+ function cameraConstraints(deviceId: string): MediaTrackConstraints {
221
+ const base: MediaTrackConstraints = {
222
+ width: { ideal: 1280 },
223
+ height: { ideal: 720 },
224
+ };
225
+ if (deviceId) base.deviceId = { exact: deviceId };
226
+ else base.facingMode = "user";
227
+ return base;
228
+ }
229
+
230
+ async function getMicStream(deviceId: string): Promise<MediaStream | null> {
231
+ const audio: MediaTrackConstraints = {
232
+ echoCancellation: true,
233
+ noiseSuppression: true,
234
+ autoGainControl: true,
235
+ channelCount: 1,
236
+ };
237
+ if (deviceId) audio.deviceId = { exact: deviceId };
238
+ try {
239
+ return await navigator.mediaDevices.getUserMedia({ audio, video: false });
208
240
  } catch {
209
241
  return null;
210
242
  }
211
243
  }
212
244
 
245
+ /* ----------------------------------------------------- camera compositing --- */
246
+
247
+ // On many pages the on-page camera bubble can't run (the page sets
248
+ // `Permissions-Policy: camera=()`, which an iframe cannot escape). So for
249
+ // screen+camera we capture the camera HERE in the offscreen document (extension
250
+ // origin — always allowed) and draw it into a canvas on top of the screen, then
251
+ // record the canvas. The face ends up in the video on every page.
252
+
253
+ async function readyVideo(stream: MediaStream): Promise<HTMLVideoElement> {
254
+ const video = document.createElement("video");
255
+ video.muted = true;
256
+ video.playsInline = true;
257
+ video.srcObject = stream;
258
+ await waitForMetadata(video).catch(() => undefined);
259
+ await video.play().catch(() => undefined);
260
+ return video;
261
+ }
262
+
263
+ function drawCameraBubble(
264
+ ctx: CanvasRenderingContext2D,
265
+ camera: HTMLVideoElement,
266
+ canvas: HTMLCanvasElement,
267
+ ): void {
268
+ const size = Math.round(Math.min(canvas.width, canvas.height) * 0.22);
269
+ const margin = Math.round(Math.min(canvas.width, canvas.height) * 0.035);
270
+ const cx = margin + size / 2;
271
+ const cy = canvas.height - margin - size / 2;
272
+ const r = size / 2;
273
+ const vw = camera.videoWidth || size;
274
+ const vh = camera.videoHeight || size;
275
+ const scale = Math.max(size / vw, size / vh);
276
+ const dw = vw * scale;
277
+ const dh = vh * scale;
278
+
279
+ ctx.save();
280
+ ctx.beginPath();
281
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
282
+ ctx.closePath();
283
+ ctx.clip();
284
+ // Mirror the camera horizontally to match how people expect to see themselves.
285
+ ctx.translate(cx, cy);
286
+ ctx.scale(-1, 1);
287
+ ctx.drawImage(camera, -dw / 2, -dh / 2, dw, dh);
288
+ ctx.restore();
289
+
290
+ ctx.save();
291
+ ctx.beginPath();
292
+ ctx.arc(cx, cy, r - 1, 0, Math.PI * 2);
293
+ ctx.lineWidth = Math.max(3, Math.round(size * 0.03));
294
+ ctx.strokeStyle = "rgba(255,255,255,0.92)";
295
+ ctx.stroke();
296
+ ctx.restore();
297
+ }
298
+
299
+ type Compositor = {
300
+ videoTrack: MediaStreamTrack;
301
+ canvasStream: MediaStream;
302
+ stop: () => void;
303
+ };
304
+
305
+ async function buildCompositor(
306
+ displayStream: MediaStream,
307
+ cameraStream: MediaStream,
308
+ dims: { width: number; height: number },
309
+ ): Promise<Compositor> {
310
+ const displayVideo = await readyVideo(displayStream);
311
+ const cameraVideo = await readyVideo(cameraStream);
312
+ const canvas = document.createElement("canvas");
313
+ canvas.width = dims.width;
314
+ canvas.height = dims.height;
315
+ const ctx = canvas.getContext("2d");
316
+ if (!ctx) throw new Error("Canvas 2D context unavailable for compositing.");
317
+
318
+ let running = true;
319
+ let raf = 0;
320
+ const draw = () => {
321
+ if (!running) return;
322
+ ctx.drawImage(displayVideo, 0, 0, canvas.width, canvas.height);
323
+ if (cameraVideo.readyState >= 2) drawCameraBubble(ctx, cameraVideo, canvas);
324
+ raf = requestAnimationFrame(draw);
325
+ };
326
+ raf = requestAnimationFrame(draw);
327
+
328
+ const canvasStream = canvas.captureStream(30);
329
+ return {
330
+ videoTrack: canvasStream.getVideoTracks()[0],
331
+ canvasStream,
332
+ stop: () => {
333
+ running = false;
334
+ cancelAnimationFrame(raf);
335
+ displayVideo.srcObject = null;
336
+ cameraVideo.srcObject = null;
337
+ },
338
+ };
339
+ }
340
+
213
341
  async function createMixedAudio(
214
342
  streams: MediaStream[],
215
343
  ): Promise<{ audioContext: AudioContext | null; tracks: MediaStreamTrack[] }> {
@@ -287,6 +415,13 @@ async function uploadChunk(
287
415
  const text = await res.text().catch(() => "");
288
416
  const data = text ? (JSON.parse(text) as UploadResult) : {};
289
417
  if (!res.ok) {
418
+ console.error(
419
+ "[clips-offscreen] chunk upload failed",
420
+ res.status,
421
+ "hadAuth:",
422
+ Boolean(recording.authToken),
423
+ text.slice(0, 200),
424
+ );
290
425
  throw new Error(
291
426
  data?.error || `Upload failed (${res.status}): ${text || res.statusText}`,
292
427
  );
@@ -310,6 +445,7 @@ function disposePrepared(): void {
310
445
  }
311
446
 
312
447
  function cleanup(recording: ActiveRecording): void {
448
+ recording.stopCompositor?.();
313
449
  stopStreams([recording.outputStream, ...recording.sourceStreams]);
314
450
  void recording.audioContext?.close().catch(() => undefined);
315
451
  }
@@ -329,22 +465,29 @@ async function acquire(message: AcquireMessage): Promise<{
329
465
  let displayStream: MediaStream | null = null;
330
466
  let micStream: MediaStream | null = null;
331
467
  let cameraStream: MediaStream | null = null;
468
+ const devices = await readDeviceIds();
332
469
 
333
470
  if (message.mode === "camera") {
334
471
  cameraStream = await navigator.mediaDevices.getUserMedia({
335
- video: {
336
- width: { ideal: 1280 },
337
- height: { ideal: 720 },
338
- facingMode: "user",
339
- },
340
- audio: message.includeMicrophone,
472
+ video: cameraConstraints(devices.video),
473
+ audio: message.includeMicrophone
474
+ ? devices.audio
475
+ ? { deviceId: { exact: devices.audio } }
476
+ : true
477
+ : false,
341
478
  });
342
479
  } else {
343
480
  // Native "Choose what to share" picker. This is the screenshot Steve showed.
344
481
  displayStream = await navigator.mediaDevices.getDisplayMedia(
345
482
  displayConstraints(message.surface),
346
483
  );
347
- if (message.includeMicrophone) micStream = await getMicStream();
484
+ if (message.includeMicrophone)
485
+ micStream = await getMicStream(devices.audio);
486
+ // The screen+camera face comes from the on-page bubble (captured in the
487
+ // display pixels), NOT composited here: canvas/requestAnimationFrame does
488
+ // not run in a hidden offscreen document, so compositing produced an empty
489
+ // recording ("No chunks found"). We record the display stream directly.
490
+ void message.includeCamera;
348
491
  }
349
492
 
350
493
  const videoStream = displayStream ?? cameraStream;
@@ -401,8 +544,24 @@ async function begin(message: BeginMessage): Promise<{
401
544
  if (activeRecording) throw new Error("Clips is already recording.");
402
545
 
403
546
  const videoStream = ready.displayStream ?? ready.cameraStream;
404
- const videoTrack = videoStream?.getVideoTracks()[0];
405
- if (!videoTrack) throw new Error("Capture video track was lost.");
547
+ const directVideoTrack = videoStream?.getVideoTracks()[0];
548
+ if (!directVideoTrack) throw new Error("Capture video track was lost.");
549
+
550
+ // Screen + camera → composite the camera bubble into a canvas and record that,
551
+ // so the face is in the video even on pages that block the on-page bubble.
552
+ let compositor: Compositor | null = null;
553
+ let videoTrack = directVideoTrack;
554
+ if (ready.displayStream && ready.cameraStream) {
555
+ compositor = await buildCompositor(
556
+ ready.displayStream,
557
+ ready.cameraStream,
558
+ {
559
+ width: ready.width,
560
+ height: ready.height,
561
+ },
562
+ );
563
+ videoTrack = compositor.videoTrack;
564
+ }
406
565
 
407
566
  const audioInputs = [
408
567
  ...(ready.displayStream ? [ready.displayStream] : []),
@@ -417,7 +576,9 @@ async function begin(message: BeginMessage): Promise<{
417
576
  const mimeType = chooseMimeType();
418
577
  const recorder = new MediaRecorder(outputStream, {
419
578
  mimeType,
420
- videoBitsPerSecond: 4_000_000,
579
+ // Crisp 1080p capture — matches the web/desktop recorders. Files upload
580
+ // directly (no client-side shrink), so we favor sharpness over a budget.
581
+ videoBitsPerSecond: 8_000_000,
421
582
  audioBitsPerSecond: 128_000,
422
583
  });
423
584
 
@@ -442,6 +603,7 @@ async function begin(message: BeginMessage): Promise<{
442
603
  ...(ready.displayStream ? [ready.displayStream] : []),
443
604
  ...(ready.micStream ? [ready.micStream] : []),
444
605
  ...(ready.cameraStream ? [ready.cameraStream] : []),
606
+ ...(compositor ? [compositor.canvasStream] : []),
445
607
  ],
446
608
  audioContext: mixedAudio.audioContext,
447
609
  chunkIndex: 0,
@@ -450,6 +612,10 @@ async function begin(message: BeginMessage): Promise<{
450
612
  cancelled: false,
451
613
  restarting: false,
452
614
  startTimer: null,
615
+ stopCompositor: compositor?.stop ?? null,
616
+ displaySource: ready.displayStream,
617
+ cameraSource: ready.cameraStream,
618
+ micSource: ready.micStream,
453
619
  dimensions: { width: ready.width, height: ready.height },
454
620
  hasAudio: outputStream.getAudioTracks().length > 0,
455
621
  hasCamera:
@@ -516,6 +682,7 @@ function startRecorderNow(recording: ActiveRecording): void {
516
682
  try {
517
683
  recording.recorder.start(2000);
518
684
  recording.startedAtMs = Date.now();
685
+ console.log("[clips-offscreen] recorder.start ok");
519
686
  reportStatus(recording.sessionId, "recording", {
520
687
  recordingId: recording.recordingId,
521
688
  width: recording.dimensions.width,
@@ -683,22 +850,18 @@ async function restart(
683
850
  recording.restarting = true;
684
851
  recording.cancelled = true;
685
852
  if (recording.recorder.state !== "inactive") recording.recorder.stop();
686
- // Close only the old mixing context; keep the capture tracks alive.
853
+ // Tear down the old compositor + mixing context; keep the capture tracks alive
854
+ // so the next BEGIN can build a fresh compositor/recorder on the same streams.
855
+ recording.stopCompositor?.();
687
856
  void recording.audioContext?.close().catch(() => undefined);
688
857
  activeRecording = null;
689
858
 
690
- const videoStream =
691
- recording.sourceStreams.find((s) => s.getVideoTracks().length) ?? null;
692
- const isCamera = recording.mode === "camera";
693
859
  prepared = {
694
860
  sessionId: recording.sessionId,
695
861
  mode: recording.mode,
696
- displayStream: isCamera ? null : videoStream,
697
- cameraStream: isCamera ? videoStream : null,
698
- micStream:
699
- recording.sourceStreams.find(
700
- (s) => s.getAudioTracks().length && s !== videoStream,
701
- ) ?? null,
862
+ displayStream: recording.displaySource,
863
+ cameraStream: recording.cameraSource,
864
+ micStream: recording.micSource,
702
865
  width: recording.dimensions.width,
703
866
  height: recording.dimensions.height,
704
867
  endedListener: null,