@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.
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +19 -0
- package/corpus/core/docs/content/multi-app-workspace.md +2 -2
- package/corpus/core/docs/design/durable-agent-runs.md +458 -4
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/action.ts +21 -10
- package/corpus/core/src/agent/durable-background.ts +192 -0
- package/corpus/core/src/agent/production-agent.ts +330 -13
- package/corpus/core/src/agent/run-manager.ts +66 -3
- package/corpus/core/src/agent/run-store.ts +129 -23
- package/corpus/core/src/agent/types.ts +20 -0
- package/corpus/core/src/client/AgentPanel.tsx +12 -9
- package/corpus/core/src/client/blocks/library/FileTreeBlock.tsx +72 -14
- package/corpus/core/src/deploy/build.ts +96 -0
- package/corpus/core/src/deploy/workspace-deploy.ts +121 -0
- package/corpus/core/src/mcp/build-server.ts +22 -13
- package/corpus/core/src/server/agent-chat-plugin.ts +133 -66
- package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +19 -0
- package/corpus/templates/analytics/AGENTS.md +8 -0
- package/corpus/templates/analytics/actions/compose-dashboard.ts +317 -0
- package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +33 -6
- package/corpus/templates/analytics/changelog/2026-06-23-build-large-first-party-analytics-dashboards-in-one-fast-cal.md +6 -0
- package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +574 -0
- package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +8 -2
- package/corpus/templates/clips/app/routes/r.$recordingId.tsx +1 -9
- package/corpus/templates/clips/changelog/2026-06-23-dragging-the-desktop-camera-bubble-now-glides-to-a-stop-at-t.md +6 -0
- package/corpus/templates/clips/changelog/2026-06-23-screen-recordings-now-capture-at-a-crisp-1080p-bitrate-inste.md +6 -0
- package/corpus/templates/clips/chrome-extension/package.json +1 -0
- package/corpus/templates/clips/chrome-extension/scripts/dev.ts +102 -0
- package/corpus/templates/clips/chrome-extension/src/background.ts +164 -32
- package/corpus/templates/clips/chrome-extension/src/content-script.ts +163 -13
- package/corpus/templates/clips/chrome-extension/src/offscreen.ts +198 -35
- package/corpus/templates/clips/chrome-extension/src/overlay.css +73 -23
- package/corpus/templates/clips/chrome-extension/src/overlay.ts +82 -13
- package/corpus/templates/clips/chrome-extension/src/popup.html +62 -5
- package/corpus/templates/clips/chrome-extension/src/popup.ts +290 -1
- package/corpus/templates/clips/chrome-extension/src/styles.css +34 -0
- package/corpus/templates/clips/desktop/src/lib/audio-cue.ts +21 -16
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +17 -20
- package/corpus/templates/clips/desktop/src/overlays/bubble.tsx +73 -23
- package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +0 -6
- package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +56 -46
- package/corpus/templates/clips/desktop/src/styles.css +33 -5
- package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +113 -0
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +2 -2
- package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +9 -1
- package/corpus/templates/clips/shared/upload-limits.ts +5 -2
- package/corpus/templates/plan/app/pages/PlansPage.tsx +11 -7
- package/corpus/templates/plan/changelog/2026-06-23-plan-loading-skeleton-no-longer-cuts-off-the-canvas-preview-.md +6 -0
- package/dist/action.js +21 -10
- package/dist/action.js.map +1 -1
- package/dist/agent/durable-background.d.ts +60 -0
- package/dist/agent/durable-background.d.ts.map +1 -0
- package/dist/agent/durable-background.js +144 -0
- package/dist/agent/durable-background.js.map +1 -0
- package/dist/agent/production-agent.d.ts +20 -0
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +292 -14
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/agent/run-manager.d.ts +48 -0
- package/dist/agent/run-manager.d.ts.map +1 -1
- package/dist/agent/run-manager.js +45 -3
- package/dist/agent/run-manager.js.map +1 -1
- package/dist/agent/run-store.d.ts +30 -1
- package/dist/agent/run-store.d.ts.map +1 -1
- package/dist/agent/run-store.js +124 -24
- package/dist/agent/run-store.js.map +1 -1
- package/dist/agent/types.d.ts +20 -0
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/client/AgentPanel.d.ts.map +1 -1
- package/dist/client/AgentPanel.js +2 -2
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/client/blocks/library/FileTreeBlock.d.ts.map +1 -1
- package/dist/client/blocks/library/FileTreeBlock.js +37 -4
- package/dist/client/blocks/library/FileTreeBlock.js.map +1 -1
- package/dist/deploy/build.d.ts +29 -0
- package/dist/deploy/build.d.ts.map +1 -1
- package/dist/deploy/build.js +85 -0
- package/dist/deploy/build.js.map +1 -1
- package/dist/deploy/workspace-deploy.d.ts.map +1 -1
- package/dist/deploy/workspace-deploy.js +108 -0
- package/dist/deploy/workspace-deploy.js.map +1 -1
- package/dist/mcp/build-server.d.ts.map +1 -1
- package/dist/mcp/build-server.js +23 -9
- package/dist/mcp/build-server.js.map +1 -1
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +68 -13
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/docs/content/multi-app-workspace.md +2 -2
- package/docs/design/durable-agent-runs.md +458 -4
- 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:
|
|
85
|
-
height:
|
|
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
|
-
//
|
|
161
|
-
//
|
|
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
|
-
| {
|
|
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"
|
|
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.
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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)
|
|
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
|
|
405
|
-
if (!
|
|
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
|
-
|
|
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
|
-
//
|
|
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:
|
|
697
|
-
cameraStream:
|
|
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,
|