@agent-native/core 0.70.3 → 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 +13 -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/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/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/changelog/2026-06-23-dragging-the-desktop-camera-bubble-now-glides-to-a-stop-at-t.md +6 -0
- package/corpus/templates/clips/chrome-extension/src/background.ts +130 -32
- package/corpus/templates/clips/chrome-extension/src/content-script.ts +163 -13
- package/corpus/templates/clips/chrome-extension/src/offscreen.ts +195 -34
- 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 +10 -17
- 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/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
|
@@ -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] : []),
|
|
@@ -444,6 +603,7 @@ async function begin(message: BeginMessage): Promise<{
|
|
|
444
603
|
...(ready.displayStream ? [ready.displayStream] : []),
|
|
445
604
|
...(ready.micStream ? [ready.micStream] : []),
|
|
446
605
|
...(ready.cameraStream ? [ready.cameraStream] : []),
|
|
606
|
+
...(compositor ? [compositor.canvasStream] : []),
|
|
447
607
|
],
|
|
448
608
|
audioContext: mixedAudio.audioContext,
|
|
449
609
|
chunkIndex: 0,
|
|
@@ -452,6 +612,10 @@ async function begin(message: BeginMessage): Promise<{
|
|
|
452
612
|
cancelled: false,
|
|
453
613
|
restarting: false,
|
|
454
614
|
startTimer: null,
|
|
615
|
+
stopCompositor: compositor?.stop ?? null,
|
|
616
|
+
displaySource: ready.displayStream,
|
|
617
|
+
cameraSource: ready.cameraStream,
|
|
618
|
+
micSource: ready.micStream,
|
|
455
619
|
dimensions: { width: ready.width, height: ready.height },
|
|
456
620
|
hasAudio: outputStream.getAudioTracks().length > 0,
|
|
457
621
|
hasCamera:
|
|
@@ -518,6 +682,7 @@ function startRecorderNow(recording: ActiveRecording): void {
|
|
|
518
682
|
try {
|
|
519
683
|
recording.recorder.start(2000);
|
|
520
684
|
recording.startedAtMs = Date.now();
|
|
685
|
+
console.log("[clips-offscreen] recorder.start ok");
|
|
521
686
|
reportStatus(recording.sessionId, "recording", {
|
|
522
687
|
recordingId: recording.recordingId,
|
|
523
688
|
width: recording.dimensions.width,
|
|
@@ -685,22 +850,18 @@ async function restart(
|
|
|
685
850
|
recording.restarting = true;
|
|
686
851
|
recording.cancelled = true;
|
|
687
852
|
if (recording.recorder.state !== "inactive") recording.recorder.stop();
|
|
688
|
-
//
|
|
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?.();
|
|
689
856
|
void recording.audioContext?.close().catch(() => undefined);
|
|
690
857
|
activeRecording = null;
|
|
691
858
|
|
|
692
|
-
const videoStream =
|
|
693
|
-
recording.sourceStreams.find((s) => s.getVideoTracks().length) ?? null;
|
|
694
|
-
const isCamera = recording.mode === "camera";
|
|
695
859
|
prepared = {
|
|
696
860
|
sessionId: recording.sessionId,
|
|
697
861
|
mode: recording.mode,
|
|
698
|
-
displayStream:
|
|
699
|
-
cameraStream:
|
|
700
|
-
micStream:
|
|
701
|
-
recording.sourceStreams.find(
|
|
702
|
-
(s) => s.getAudioTracks().length && s !== videoStream,
|
|
703
|
-
) ?? null,
|
|
862
|
+
displayStream: recording.displaySource,
|
|
863
|
+
cameraStream: recording.cameraSource,
|
|
864
|
+
micStream: recording.micSource,
|
|
704
865
|
width: recording.dimensions.width,
|
|
705
866
|
height: recording.dimensions.height,
|
|
706
867
|
endedListener: null,
|
|
@@ -39,10 +39,9 @@ body {
|
|
|
39
39
|
height: 100%;
|
|
40
40
|
border-radius: 50%;
|
|
41
41
|
overflow: hidden;
|
|
42
|
-
background: #
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
0 0 0 4px rgba(255, 255, 255, 0.92);
|
|
42
|
+
background: #18181b;
|
|
43
|
+
border: 3px solid rgba(255, 255, 255, 0.92);
|
|
44
|
+
box-shadow: 0 6px 18px rgba(9, 9, 11, 0.4);
|
|
46
45
|
display: flex;
|
|
47
46
|
align-items: center;
|
|
48
47
|
justify-content: center;
|
|
@@ -75,7 +74,7 @@ body {
|
|
|
75
74
|
display: flex;
|
|
76
75
|
align-items: center;
|
|
77
76
|
justify-content: center;
|
|
78
|
-
background: rgba(
|
|
77
|
+
background: rgba(9, 9, 11, 0.55);
|
|
79
78
|
backdrop-filter: blur(2px);
|
|
80
79
|
}
|
|
81
80
|
|
|
@@ -126,9 +125,9 @@ body {
|
|
|
126
125
|
font-size: clamp(96px, 22vmin, 240px);
|
|
127
126
|
font-weight: 700;
|
|
128
127
|
color: #fff;
|
|
129
|
-
text-shadow: 0 6px 30px rgba(
|
|
128
|
+
text-shadow: 0 6px 30px rgba(9, 9, 11, 0.6);
|
|
130
129
|
line-height: 1;
|
|
131
|
-
animation: countdown-pop
|
|
130
|
+
animation: countdown-pop 0.45s ease-out forwards;
|
|
132
131
|
}
|
|
133
132
|
|
|
134
133
|
.countdown-go {
|
|
@@ -138,21 +137,17 @@ body {
|
|
|
138
137
|
|
|
139
138
|
@keyframes countdown-pop {
|
|
140
139
|
0% {
|
|
141
|
-
transform: scale(0.
|
|
140
|
+
transform: scale(0.55);
|
|
142
141
|
opacity: 0;
|
|
143
142
|
}
|
|
144
|
-
|
|
145
|
-
transform: scale(1);
|
|
143
|
+
45% {
|
|
144
|
+
transform: scale(1.08);
|
|
146
145
|
opacity: 1;
|
|
147
146
|
}
|
|
148
|
-
|
|
147
|
+
100% {
|
|
149
148
|
transform: scale(1);
|
|
150
149
|
opacity: 1;
|
|
151
150
|
}
|
|
152
|
-
100% {
|
|
153
|
-
transform: scale(1.25);
|
|
154
|
-
opacity: 0;
|
|
155
|
-
}
|
|
156
151
|
}
|
|
157
152
|
|
|
158
153
|
.countdown-hint {
|
|
@@ -163,7 +158,7 @@ body {
|
|
|
163
158
|
color: rgba(255, 255, 255, 0.85);
|
|
164
159
|
font-size: 15px;
|
|
165
160
|
font-weight: 500;
|
|
166
|
-
background: rgba(
|
|
161
|
+
background: rgba(9, 9, 11, 0.45);
|
|
167
162
|
padding: 8px 16px;
|
|
168
163
|
border-radius: 999px;
|
|
169
164
|
}
|
|
@@ -181,19 +176,21 @@ body {
|
|
|
181
176
|
}
|
|
182
177
|
|
|
183
178
|
.toolbar-v {
|
|
179
|
+
/* Top-anchored so Stop/time/pause stay put and the pill grows DOWNWARD on
|
|
180
|
+
hover (centering shifted the buttons up and caused misclicks). */
|
|
184
181
|
position: absolute;
|
|
185
|
-
top:
|
|
182
|
+
top: 0;
|
|
186
183
|
left: 50%;
|
|
187
|
-
transform:
|
|
184
|
+
transform: translateX(-50%);
|
|
188
185
|
display: flex;
|
|
189
186
|
flex-direction: column;
|
|
190
187
|
align-items: center;
|
|
191
188
|
gap: 8px;
|
|
192
189
|
padding: 9px;
|
|
193
190
|
border-radius: 20px;
|
|
194
|
-
background: rgba(
|
|
191
|
+
background: rgba(24, 24, 27, 0.94);
|
|
195
192
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
196
|
-
box-shadow: 0 10px 28px rgba(
|
|
193
|
+
box-shadow: 0 10px 28px rgba(9, 9, 11, 0.45);
|
|
197
194
|
color: #fff;
|
|
198
195
|
-webkit-user-select: none;
|
|
199
196
|
user-select: none;
|
|
@@ -323,9 +320,9 @@ body {
|
|
|
323
320
|
gap: 9px;
|
|
324
321
|
padding: 14px 16px;
|
|
325
322
|
border-radius: 14px;
|
|
326
|
-
background: rgba(
|
|
323
|
+
background: rgba(24, 24, 27, 0.95);
|
|
327
324
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
328
|
-
box-shadow: 0 10px 28px rgba(
|
|
325
|
+
box-shadow: 0 10px 28px rgba(9, 9, 11, 0.45);
|
|
329
326
|
color: #fff;
|
|
330
327
|
}
|
|
331
328
|
|
|
@@ -363,7 +360,7 @@ body {
|
|
|
363
360
|
height: 100%;
|
|
364
361
|
width: 40%;
|
|
365
362
|
border-radius: 999px;
|
|
366
|
-
background: #
|
|
363
|
+
background: #d4d4d8;
|
|
367
364
|
animation: saving-indeterminate 1.1s ease-in-out infinite;
|
|
368
365
|
}
|
|
369
366
|
|
|
@@ -375,3 +372,56 @@ body {
|
|
|
375
372
|
margin-left: 100%;
|
|
376
373
|
}
|
|
377
374
|
}
|
|
375
|
+
|
|
376
|
+
/* Bubble size dots (small / large), revealed on hover — desktop-style. */
|
|
377
|
+
.bubble-sizes {
|
|
378
|
+
position: absolute;
|
|
379
|
+
bottom: 7%;
|
|
380
|
+
left: 50%;
|
|
381
|
+
transform: translateX(-50%);
|
|
382
|
+
display: flex;
|
|
383
|
+
align-items: center;
|
|
384
|
+
gap: 9px;
|
|
385
|
+
padding: 5px 9px;
|
|
386
|
+
border-radius: 999px;
|
|
387
|
+
background: rgba(9, 9, 11, 0.55);
|
|
388
|
+
-webkit-backdrop-filter: blur(4px);
|
|
389
|
+
backdrop-filter: blur(4px);
|
|
390
|
+
opacity: 0;
|
|
391
|
+
transition: opacity 0.15s ease;
|
|
392
|
+
pointer-events: none;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
.bubble:hover .bubble-sizes {
|
|
396
|
+
opacity: 1;
|
|
397
|
+
pointer-events: auto;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
.bubble-size-dot {
|
|
401
|
+
border: none;
|
|
402
|
+
padding: 0;
|
|
403
|
+
border-radius: 50%;
|
|
404
|
+
background: rgba(255, 255, 255, 0.55);
|
|
405
|
+
cursor: pointer;
|
|
406
|
+
transition:
|
|
407
|
+
background 0.12s ease,
|
|
408
|
+
transform 0.1s ease;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
.bubble-size-dot:hover {
|
|
412
|
+
background: #fff;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
.bubble-size-dot:active {
|
|
416
|
+
transform: scale(0.9);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
.bubble-size-sm {
|
|
420
|
+
width: 8px;
|
|
421
|
+
height: 8px;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
.bubble-size-lg {
|
|
425
|
+
width: 13px;
|
|
426
|
+
height: 13px;
|
|
427
|
+
}
|
|
@@ -75,21 +75,82 @@ function send(type: string, extra: Record<string, unknown> = {}): void {
|
|
|
75
75
|
|
|
76
76
|
/* ---------------------------------------------------------------- bubble --- */
|
|
77
77
|
|
|
78
|
+
function postBubble(kind: string, extra: Record<string, unknown> = {}): void {
|
|
79
|
+
try {
|
|
80
|
+
window.parent.postMessage(
|
|
81
|
+
{ source: "clips-overlay", kind, part: "bubble", ...extra },
|
|
82
|
+
"*",
|
|
83
|
+
);
|
|
84
|
+
} catch {
|
|
85
|
+
/* parent gone */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
78
89
|
async function initBubble(): Promise<void> {
|
|
79
90
|
const bubble = document.createElement("div");
|
|
80
91
|
bubble.className = "bubble";
|
|
81
92
|
const ring = document.createElement("div");
|
|
82
93
|
ring.className = "bubble-ring";
|
|
83
94
|
bubble.appendChild(ring);
|
|
95
|
+
|
|
96
|
+
// Drag: the content script owns the iframe position, so we just signal the
|
|
97
|
+
// start of a drag and it captures the pointer page-wide.
|
|
98
|
+
bubble.style.cursor = "grab";
|
|
99
|
+
bubble.addEventListener("pointerdown", (e) => {
|
|
100
|
+
if (e.button !== 0) return;
|
|
101
|
+
if ((e.target as HTMLElement).closest("[data-no-drag]")) return;
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
bubble.style.cursor = "grabbing";
|
|
104
|
+
postBubble("bubble-drag-start");
|
|
105
|
+
const restore = (): void => {
|
|
106
|
+
bubble.style.cursor = "grab";
|
|
107
|
+
window.removeEventListener("pointerup", restore);
|
|
108
|
+
};
|
|
109
|
+
window.addEventListener("pointerup", restore);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Size dots (small / large), revealed on hover — like the desktop bubble.
|
|
113
|
+
const sizes = document.createElement("div");
|
|
114
|
+
sizes.className = "bubble-sizes";
|
|
115
|
+
sizes.setAttribute("data-no-drag", "");
|
|
116
|
+
for (const key of ["sm", "lg"] as const) {
|
|
117
|
+
const dot = document.createElement("button");
|
|
118
|
+
dot.type = "button";
|
|
119
|
+
dot.className = `bubble-size-dot bubble-size-${key}`;
|
|
120
|
+
dot.title = key === "sm" ? "Small" : "Large";
|
|
121
|
+
dot.setAttribute(
|
|
122
|
+
"aria-label",
|
|
123
|
+
key === "sm" ? "Small bubble" : "Large bubble",
|
|
124
|
+
);
|
|
125
|
+
dot.setAttribute("data-no-drag", "");
|
|
126
|
+
dot.addEventListener("click", (e) => {
|
|
127
|
+
e.stopPropagation();
|
|
128
|
+
postBubble("bubble-size", { size: key });
|
|
129
|
+
});
|
|
130
|
+
sizes.appendChild(dot);
|
|
131
|
+
}
|
|
132
|
+
bubble.appendChild(sizes);
|
|
133
|
+
|
|
84
134
|
root.appendChild(bubble);
|
|
85
135
|
|
|
86
136
|
try {
|
|
137
|
+
const videoDeviceId = await new Promise<string>((resolve) => {
|
|
138
|
+
try {
|
|
139
|
+
chrome.storage.sync.get("videoDeviceId", (v) =>
|
|
140
|
+
resolve(typeof v.videoDeviceId === "string" ? v.videoDeviceId : ""),
|
|
141
|
+
);
|
|
142
|
+
} catch {
|
|
143
|
+
resolve("");
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
const videoConstraint: MediaTrackConstraints = {
|
|
147
|
+
width: { ideal: 640 },
|
|
148
|
+
height: { ideal: 640 },
|
|
149
|
+
};
|
|
150
|
+
if (videoDeviceId) videoConstraint.deviceId = { exact: videoDeviceId };
|
|
151
|
+
else videoConstraint.facingMode = "user";
|
|
87
152
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
88
|
-
video:
|
|
89
|
-
width: { ideal: 640 },
|
|
90
|
-
height: { ideal: 640 },
|
|
91
|
-
facingMode: "user",
|
|
92
|
-
},
|
|
153
|
+
video: videoConstraint,
|
|
93
154
|
audio: false,
|
|
94
155
|
});
|
|
95
156
|
const video = document.createElement("video");
|
|
@@ -99,7 +160,9 @@ async function initBubble(): Promise<void> {
|
|
|
99
160
|
video.srcObject = stream;
|
|
100
161
|
ring.appendChild(video);
|
|
101
162
|
await video.play().catch(() => undefined);
|
|
102
|
-
|
|
163
|
+
console.log("[clips-overlay] camera bubble live");
|
|
164
|
+
} catch (err) {
|
|
165
|
+
console.warn("[clips-overlay] camera getUserMedia failed:", err);
|
|
103
166
|
const empty = document.createElement("div");
|
|
104
167
|
empty.className = "bubble-empty";
|
|
105
168
|
empty.innerHTML = ICONS.cameraOff;
|
|
@@ -148,14 +211,20 @@ function initCountdown(): void {
|
|
|
148
211
|
root.appendChild(wrap);
|
|
149
212
|
|
|
150
213
|
let lastShown = "";
|
|
214
|
+
// Compute the fallback end-time ONCE (if the worker's countdownEndsAtMs never
|
|
215
|
+
// arrives) — recomputing it every frame froze the number on "3".
|
|
216
|
+
let fallbackEndsAt = 0;
|
|
151
217
|
const render = (): void => {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
218
|
+
let endsAt = state.countdownEndsAtMs;
|
|
219
|
+
if (endsAt <= 0) {
|
|
220
|
+
if (fallbackEndsAt === 0) {
|
|
221
|
+
const fallback =
|
|
222
|
+
Number(params.get("seconds") || String(COUNTDOWN_FALLBACK)) ||
|
|
223
|
+
COUNTDOWN_FALLBACK;
|
|
224
|
+
fallbackEndsAt = Date.now() + fallback * 1000;
|
|
225
|
+
}
|
|
226
|
+
endsAt = fallbackEndsAt;
|
|
227
|
+
}
|
|
159
228
|
const remainingMs = endsAt - Date.now();
|
|
160
229
|
if (state.phase !== "countdown" || remainingMs <= 0) {
|
|
161
230
|
if (lastShown !== "Go") {
|