@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
|
@@ -7,6 +7,13 @@ type ExtensionSettings = {
|
|
|
7
7
|
includeCamera: boolean;
|
|
8
8
|
includeMicrophone: boolean;
|
|
9
9
|
includeDeveloperLogs: boolean;
|
|
10
|
+
videoDeviceId: string;
|
|
11
|
+
audioDeviceId: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type InputDevice = {
|
|
15
|
+
deviceId: string;
|
|
16
|
+
label: string;
|
|
10
17
|
};
|
|
11
18
|
|
|
12
19
|
type PopupStartResponse = {
|
|
@@ -67,6 +74,8 @@ const DEFAULT_SETTINGS: ExtensionSettings = {
|
|
|
67
74
|
includeCamera: true,
|
|
68
75
|
includeMicrophone: true,
|
|
69
76
|
includeDeveloperLogs: true,
|
|
77
|
+
videoDeviceId: "",
|
|
78
|
+
audioDeviceId: "",
|
|
70
79
|
};
|
|
71
80
|
|
|
72
81
|
const SOURCE_LABELS: Record<Exclude<CaptureSurface, "camera">, string> = {
|
|
@@ -167,6 +176,37 @@ async function loadFeedbackSchema(
|
|
|
167
176
|
return pending;
|
|
168
177
|
}
|
|
169
178
|
|
|
179
|
+
// Enumerate the user's input devices for the camera/mic pickers. Labels only
|
|
180
|
+
// populate after camera/mic permission is granted (the extension's permission
|
|
181
|
+
// onboarding page handles that), so fall back to a generic label otherwise.
|
|
182
|
+
async function enumerateInputDevices(): Promise<{
|
|
183
|
+
cameras: InputDevice[];
|
|
184
|
+
microphones: InputDevice[];
|
|
185
|
+
}> {
|
|
186
|
+
try {
|
|
187
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
188
|
+
const cameras: InputDevice[] = [];
|
|
189
|
+
const microphones: InputDevice[] = [];
|
|
190
|
+
for (const device of devices) {
|
|
191
|
+
if (!device.deviceId) continue;
|
|
192
|
+
if (device.kind === "videoinput") {
|
|
193
|
+
cameras.push({
|
|
194
|
+
deviceId: device.deviceId,
|
|
195
|
+
label: device.label.trim() || `Camera ${cameras.length + 1}`,
|
|
196
|
+
});
|
|
197
|
+
} else if (device.kind === "audioinput") {
|
|
198
|
+
microphones.push({
|
|
199
|
+
deviceId: device.deviceId,
|
|
200
|
+
label: device.label.trim() || `Microphone ${microphones.length + 1}`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return { cameras, microphones };
|
|
205
|
+
} catch {
|
|
206
|
+
return { cameras: [], microphones: [] };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
170
210
|
function readSettings(): Promise<ExtensionSettings> {
|
|
171
211
|
return new Promise((resolve) => {
|
|
172
212
|
chrome.storage.sync.get(DEFAULT_SETTINGS, (value) => {
|
|
@@ -188,6 +228,14 @@ function readSettings(): Promise<ExtensionSettings> {
|
|
|
188
228
|
typeof value.includeDeveloperLogs === "boolean"
|
|
189
229
|
? value.includeDeveloperLogs
|
|
190
230
|
: DEFAULT_SETTINGS.includeDeveloperLogs,
|
|
231
|
+
videoDeviceId:
|
|
232
|
+
typeof value.videoDeviceId === "string"
|
|
233
|
+
? value.videoDeviceId
|
|
234
|
+
: DEFAULT_SETTINGS.videoDeviceId,
|
|
235
|
+
audioDeviceId:
|
|
236
|
+
typeof value.audioDeviceId === "string"
|
|
237
|
+
? value.audioDeviceId
|
|
238
|
+
: DEFAULT_SETTINGS.audioDeviceId,
|
|
191
239
|
});
|
|
192
240
|
});
|
|
193
241
|
});
|
|
@@ -401,6 +449,28 @@ function comparableLabel(value: string): string {
|
|
|
401
449
|
.replace(/[^a-z0-9]+/g, "");
|
|
402
450
|
}
|
|
403
451
|
|
|
452
|
+
// Pages where Chrome forbids content-script / overlay injection, so the on-page
|
|
453
|
+
// countdown + controls can't render. Recording the screen still works; we just
|
|
454
|
+
// warn the user in the popup instead of letting it fail silently.
|
|
455
|
+
function isUnsupportedPage(url: string | undefined | null): boolean {
|
|
456
|
+
if (!url) return true;
|
|
457
|
+
const u = url.toLowerCase();
|
|
458
|
+
return (
|
|
459
|
+
u.startsWith("chrome://") ||
|
|
460
|
+
u.startsWith("chrome-extension://") ||
|
|
461
|
+
u.startsWith("edge://") ||
|
|
462
|
+
u.startsWith("brave://") ||
|
|
463
|
+
u.startsWith("arc://") ||
|
|
464
|
+
u.startsWith("about:") ||
|
|
465
|
+
u.startsWith("view-source:") ||
|
|
466
|
+
u.startsWith("devtools://") ||
|
|
467
|
+
u.startsWith("chrome-search://") ||
|
|
468
|
+
u.startsWith("chrome-untrusted://") ||
|
|
469
|
+
u.startsWith("https://chromewebstore.google.com") ||
|
|
470
|
+
u.startsWith("https://chrome.google.com/webstore")
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
404
474
|
function targetCopy(tab: chrome.tabs.Tab | null): {
|
|
405
475
|
title: string;
|
|
406
476
|
subtitle: string;
|
|
@@ -464,9 +534,111 @@ function renderSource(settings: ExtensionSettings): void {
|
|
|
464
534
|
}
|
|
465
535
|
}
|
|
466
536
|
|
|
537
|
+
// Holds the most recent device enumeration so the menus and labels can render
|
|
538
|
+
// without re-querying on every paint. Refreshed by refreshDevices().
|
|
539
|
+
let inputDevices: { cameras: InputDevice[]; microphones: InputDevice[] } = {
|
|
540
|
+
cameras: [],
|
|
541
|
+
microphones: [],
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
function deviceLabel(
|
|
545
|
+
devices: InputDevice[],
|
|
546
|
+
deviceId: string,
|
|
547
|
+
fallback: string,
|
|
548
|
+
): string {
|
|
549
|
+
if (!deviceId) return fallback;
|
|
550
|
+
const match = devices.find((device) => device.deviceId === deviceId);
|
|
551
|
+
return match ? match.label : fallback;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function renderDeviceMenu(
|
|
555
|
+
menu: HTMLDivElement,
|
|
556
|
+
devices: InputDevice[],
|
|
557
|
+
selectedId: string,
|
|
558
|
+
defaultLabel: string,
|
|
559
|
+
): void {
|
|
560
|
+
menu.replaceChildren();
|
|
561
|
+
const options: InputDevice[] = [
|
|
562
|
+
{ deviceId: "", label: defaultLabel },
|
|
563
|
+
...devices,
|
|
564
|
+
];
|
|
565
|
+
for (const option of options) {
|
|
566
|
+
const item = document.createElement("button");
|
|
567
|
+
item.type = "button";
|
|
568
|
+
item.className = "row-menu-item";
|
|
569
|
+
item.dataset.deviceId = option.deviceId;
|
|
570
|
+
const selected = option.deviceId === selectedId;
|
|
571
|
+
if (selected) {
|
|
572
|
+
item.classList.add("selected");
|
|
573
|
+
item.setAttribute("aria-checked", "true");
|
|
574
|
+
} else {
|
|
575
|
+
item.setAttribute("aria-checked", "false");
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const check = document.createElement("span");
|
|
579
|
+
check.className = "row-menu-check";
|
|
580
|
+
check.setAttribute("aria-hidden", "true");
|
|
581
|
+
check.textContent = selected ? "✓" : "";
|
|
582
|
+
|
|
583
|
+
const label = document.createElement("span");
|
|
584
|
+
label.className = "row-menu-label";
|
|
585
|
+
label.textContent = option.label;
|
|
586
|
+
|
|
587
|
+
item.append(check, label);
|
|
588
|
+
menu.append(item);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function renderDevicePickers(settings: ExtensionSettings): void {
|
|
593
|
+
const cameraButton = byId<HTMLButtonElement>("camera-device-button");
|
|
594
|
+
const cameraLabel = byId<HTMLSpanElement>("camera-device-label");
|
|
595
|
+
const cameraMenu = byId<HTMLDivElement>("camera-device-menu");
|
|
596
|
+
const showCamera =
|
|
597
|
+
settings.captureSurface === "camera" || settings.includeCamera;
|
|
598
|
+
cameraButton.hidden = !showCamera;
|
|
599
|
+
if (showCamera) {
|
|
600
|
+
cameraLabel.textContent = deviceLabel(
|
|
601
|
+
inputDevices.cameras,
|
|
602
|
+
settings.videoDeviceId,
|
|
603
|
+
"Default camera",
|
|
604
|
+
);
|
|
605
|
+
renderDeviceMenu(
|
|
606
|
+
cameraMenu,
|
|
607
|
+
inputDevices.cameras,
|
|
608
|
+
settings.videoDeviceId,
|
|
609
|
+
"Default camera",
|
|
610
|
+
);
|
|
611
|
+
} else {
|
|
612
|
+
cameraMenu.hidden = true;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const micRow = byId<HTMLDivElement>("microphone-row");
|
|
616
|
+
const micButton = byId<HTMLButtonElement>("microphone-device-button");
|
|
617
|
+
const micLabel = byId<HTMLSpanElement>("microphone-device-label");
|
|
618
|
+
const micMenu = byId<HTMLDivElement>("microphone-device-menu");
|
|
619
|
+
micButton.hidden = !settings.includeMicrophone;
|
|
620
|
+
if (settings.includeMicrophone) {
|
|
621
|
+
micLabel.textContent = deviceLabel(
|
|
622
|
+
inputDevices.microphones,
|
|
623
|
+
settings.audioDeviceId,
|
|
624
|
+
"Default mic",
|
|
625
|
+
);
|
|
626
|
+
renderDeviceMenu(
|
|
627
|
+
micMenu,
|
|
628
|
+
inputDevices.microphones,
|
|
629
|
+
settings.audioDeviceId,
|
|
630
|
+
"Default mic",
|
|
631
|
+
);
|
|
632
|
+
} else {
|
|
633
|
+
micMenu.hidden = true;
|
|
634
|
+
}
|
|
635
|
+
micRow.hidden = false;
|
|
636
|
+
}
|
|
637
|
+
|
|
467
638
|
function render(settings: ExtensionSettings): void {
|
|
468
639
|
renderMode(settings);
|
|
469
640
|
renderSource(settings);
|
|
641
|
+
renderDevicePickers(settings);
|
|
470
642
|
const includeCamera = byId<HTMLButtonElement>("include-camera");
|
|
471
643
|
const includeMicrophone = byId<HTMLButtonElement>("include-microphone");
|
|
472
644
|
const cameraRow = byId<HTMLDivElement>("camera-row");
|
|
@@ -550,6 +722,12 @@ async function init(): Promise<void> {
|
|
|
550
722
|
}
|
|
551
723
|
const sourceButton = byId<HTMLButtonElement>("source-button");
|
|
552
724
|
const sourceMenu = byId<HTMLDivElement>("source-menu");
|
|
725
|
+
const cameraDeviceButton = byId<HTMLButtonElement>("camera-device-button");
|
|
726
|
+
const cameraDeviceMenu = byId<HTMLDivElement>("camera-device-menu");
|
|
727
|
+
const microphoneDeviceButton = byId<HTMLButtonElement>(
|
|
728
|
+
"microphone-device-button",
|
|
729
|
+
);
|
|
730
|
+
const microphoneDeviceMenu = byId<HTMLDivElement>("microphone-device-menu");
|
|
553
731
|
const includeCamera = byId<HTMLButtonElement>("include-camera");
|
|
554
732
|
const includeMicrophone = byId<HTMLButtonElement>("include-microphone");
|
|
555
733
|
const start = byId<HTMLButtonElement>("start");
|
|
@@ -624,8 +802,30 @@ async function init(): Promise<void> {
|
|
|
624
802
|
window.setTimeout(() => feedbackTextarea.focus(), 30);
|
|
625
803
|
};
|
|
626
804
|
|
|
627
|
-
|
|
805
|
+
// Re-enumerate devices and repaint the pickers. Labels only appear once the
|
|
806
|
+
// user has granted camera/mic access (via the permission onboarding page), so
|
|
807
|
+
// this is also re-run when the device list changes.
|
|
808
|
+
const refreshDevices = async (): Promise<void> => {
|
|
809
|
+
inputDevices = await enumerateInputDevices();
|
|
810
|
+
renderDevicePickers(settings);
|
|
811
|
+
};
|
|
812
|
+
|
|
813
|
+
const closeDeviceMenus = (): void => {
|
|
814
|
+
cameraDeviceMenu.hidden = true;
|
|
815
|
+
microphoneDeviceMenu.hidden = true;
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
const activeTab = await queryActiveTab();
|
|
819
|
+
byId<HTMLDivElement>("unsupported-notice").hidden = !isUnsupportedPage(
|
|
820
|
+
activeTab?.url,
|
|
821
|
+
);
|
|
628
822
|
render(settings);
|
|
823
|
+
void refreshDevices();
|
|
824
|
+
if (navigator.mediaDevices) {
|
|
825
|
+
navigator.mediaDevices.addEventListener("devicechange", () => {
|
|
826
|
+
void refreshDevices();
|
|
827
|
+
});
|
|
828
|
+
}
|
|
629
829
|
const status =
|
|
630
830
|
await sendSimpleMessage<PopupStatusResponse>("CLIPS_POPUP_STATUS");
|
|
631
831
|
activeRecording = status.activeRecording ?? null;
|
|
@@ -634,17 +834,42 @@ async function init(): Promise<void> {
|
|
|
634
834
|
window.setInterval(() => renderActiveRecording(activeRecording), 1000);
|
|
635
835
|
}
|
|
636
836
|
|
|
837
|
+
// Pre-record preview: while this popup is open and idle, show the live face
|
|
838
|
+
// bubble on the active tab (desktop-app style). The port stays open for the
|
|
839
|
+
// popup's lifetime; its disconnect (popup closed) tells the worker to remove
|
|
840
|
+
// the preview. Keep the reference so the port isn't garbage-collected early.
|
|
841
|
+
const previewPort = chrome.runtime.connect({ name: "clips-preview" });
|
|
842
|
+
const syncPreview = (): void => {
|
|
843
|
+
const wantsCamera =
|
|
844
|
+
!activeRecording &&
|
|
845
|
+
!isUnsupportedPage(activeTab?.url) &&
|
|
846
|
+
(settings.captureSurface === "camera" || settings.includeCamera);
|
|
847
|
+
try {
|
|
848
|
+
chrome.runtime.sendMessage(
|
|
849
|
+
{ type: "CLIPS_PREVIEW", camera: wantsCamera },
|
|
850
|
+
() => void chrome.runtime.lastError,
|
|
851
|
+
);
|
|
852
|
+
} catch {
|
|
853
|
+
/* worker asleep; will re-sync on next interaction */
|
|
854
|
+
}
|
|
855
|
+
};
|
|
856
|
+
void previewPort;
|
|
857
|
+
syncPreview();
|
|
858
|
+
|
|
637
859
|
for (const button of document.querySelectorAll<HTMLButtonElement>(
|
|
638
860
|
".mode-option",
|
|
639
861
|
)) {
|
|
640
862
|
button.addEventListener("click", () => {
|
|
641
863
|
applyMode(settings, button.dataset.mode as RecordingModeChoice);
|
|
864
|
+
closeDeviceMenus();
|
|
642
865
|
render(settings);
|
|
643
866
|
void saveSettings(settings);
|
|
867
|
+
syncPreview();
|
|
644
868
|
});
|
|
645
869
|
}
|
|
646
870
|
|
|
647
871
|
sourceButton.addEventListener("click", () => {
|
|
872
|
+
closeDeviceMenus();
|
|
648
873
|
sourceMenu.hidden = !sourceMenu.hidden;
|
|
649
874
|
});
|
|
650
875
|
|
|
@@ -656,9 +881,70 @@ async function init(): Promise<void> {
|
|
|
656
881
|
sourceMenu.hidden = true;
|
|
657
882
|
render(settings);
|
|
658
883
|
void saveSettings(settings);
|
|
884
|
+
syncPreview();
|
|
659
885
|
});
|
|
660
886
|
}
|
|
661
887
|
|
|
888
|
+
cameraDeviceButton.addEventListener("click", () => {
|
|
889
|
+
sourceMenu.hidden = true;
|
|
890
|
+
microphoneDeviceMenu.hidden = true;
|
|
891
|
+
cameraDeviceMenu.hidden = !cameraDeviceMenu.hidden;
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
cameraDeviceMenu.addEventListener("click", (event) => {
|
|
895
|
+
const item = (event.target as HTMLElement).closest<HTMLButtonElement>(
|
|
896
|
+
".row-menu-item",
|
|
897
|
+
);
|
|
898
|
+
if (!item) return;
|
|
899
|
+
settings.videoDeviceId = item.dataset.deviceId ?? "";
|
|
900
|
+
cameraDeviceMenu.hidden = true;
|
|
901
|
+
renderDevicePickers(settings);
|
|
902
|
+
void saveSettings(settings);
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
microphoneDeviceButton.addEventListener("click", () => {
|
|
906
|
+
sourceMenu.hidden = true;
|
|
907
|
+
cameraDeviceMenu.hidden = true;
|
|
908
|
+
microphoneDeviceMenu.hidden = !microphoneDeviceMenu.hidden;
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
microphoneDeviceMenu.addEventListener("click", (event) => {
|
|
912
|
+
const item = (event.target as HTMLElement).closest<HTMLButtonElement>(
|
|
913
|
+
".row-menu-item",
|
|
914
|
+
);
|
|
915
|
+
if (!item) return;
|
|
916
|
+
settings.audioDeviceId = item.dataset.deviceId ?? "";
|
|
917
|
+
microphoneDeviceMenu.hidden = true;
|
|
918
|
+
renderDevicePickers(settings);
|
|
919
|
+
void saveSettings(settings);
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
document.addEventListener("click", (event) => {
|
|
923
|
+
const target = event.target;
|
|
924
|
+
if (!(target instanceof Node)) return;
|
|
925
|
+
if (
|
|
926
|
+
!sourceMenu.hidden &&
|
|
927
|
+
!sourceMenu.contains(target) &&
|
|
928
|
+
!sourceButton.contains(target)
|
|
929
|
+
) {
|
|
930
|
+
sourceMenu.hidden = true;
|
|
931
|
+
}
|
|
932
|
+
if (
|
|
933
|
+
!cameraDeviceMenu.hidden &&
|
|
934
|
+
!cameraDeviceMenu.contains(target) &&
|
|
935
|
+
!cameraDeviceButton.contains(target)
|
|
936
|
+
) {
|
|
937
|
+
cameraDeviceMenu.hidden = true;
|
|
938
|
+
}
|
|
939
|
+
if (
|
|
940
|
+
!microphoneDeviceMenu.hidden &&
|
|
941
|
+
!microphoneDeviceMenu.contains(target) &&
|
|
942
|
+
!microphoneDeviceButton.contains(target)
|
|
943
|
+
) {
|
|
944
|
+
microphoneDeviceMenu.hidden = true;
|
|
945
|
+
}
|
|
946
|
+
});
|
|
947
|
+
|
|
662
948
|
includeCamera.addEventListener("click", () => {
|
|
663
949
|
settings.includeCamera = !settings.includeCamera;
|
|
664
950
|
if (settings.includeCamera && settings.captureSurface === "monitor") {
|
|
@@ -667,12 +953,15 @@ async function init(): Promise<void> {
|
|
|
667
953
|
if (!settings.includeCamera && settings.captureSurface === "camera") {
|
|
668
954
|
settings.captureSurface = "browser";
|
|
669
955
|
}
|
|
956
|
+
closeDeviceMenus();
|
|
670
957
|
render(settings);
|
|
671
958
|
void saveSettings(settings);
|
|
959
|
+
syncPreview();
|
|
672
960
|
});
|
|
673
961
|
|
|
674
962
|
includeMicrophone.addEventListener("click", () => {
|
|
675
963
|
settings.includeMicrophone = !settings.includeMicrophone;
|
|
964
|
+
closeDeviceMenus();
|
|
676
965
|
render(settings);
|
|
677
966
|
void saveSettings(settings);
|
|
678
967
|
});
|
|
@@ -855,3 +855,37 @@ h1 {
|
|
|
855
855
|
.primary-action:hover {
|
|
856
856
|
opacity: 0.92;
|
|
857
857
|
}
|
|
858
|
+
|
|
859
|
+
/* Unsupported-page notice (shown in the popup when the active tab can't host
|
|
860
|
+
the on-page overlay, e.g. chrome:// / New Tab / Web Store pages). */
|
|
861
|
+
.notice {
|
|
862
|
+
display: flex;
|
|
863
|
+
gap: 10px;
|
|
864
|
+
align-items: flex-start;
|
|
865
|
+
margin: 0 0 12px;
|
|
866
|
+
padding: 11px 12px;
|
|
867
|
+
border: 1px solid var(--border);
|
|
868
|
+
border-radius: var(--radius-sm);
|
|
869
|
+
background: var(--surface);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
.notice-icon {
|
|
873
|
+
width: 18px;
|
|
874
|
+
height: 18px;
|
|
875
|
+
flex: none;
|
|
876
|
+
margin-top: 1px;
|
|
877
|
+
color: var(--status-warn);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
.notice-title {
|
|
881
|
+
font-size: 12.5px;
|
|
882
|
+
font-weight: 600;
|
|
883
|
+
color: var(--fg);
|
|
884
|
+
margin-bottom: 2px;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
.notice-text {
|
|
888
|
+
font-size: 11.5px;
|
|
889
|
+
line-height: 1.45;
|
|
890
|
+
color: var(--fg-muted);
|
|
891
|
+
}
|
|
@@ -14,12 +14,11 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
export interface AudioCue {
|
|
17
|
-
/** Play the cue during the countdown once recording is about one second out. */
|
|
18
|
-
playCountdownCue(): Promise<void>;
|
|
19
17
|
/**
|
|
20
18
|
* Play the cue (bounded by a timeout) and wait a short settle so the tone
|
|
21
|
-
* sits just before capture rather than inside the recording.
|
|
22
|
-
*
|
|
19
|
+
* sits just before capture rather than inside the recording. Call this at the
|
|
20
|
+
* exact moment recording starts (right before the recorder/native capture is
|
|
21
|
+
* kicked off) so the chime lines up with the real start, not the countdown.
|
|
23
22
|
*/
|
|
24
23
|
playBeforeCapture(): Promise<void>;
|
|
25
24
|
cleanup(): void;
|
|
@@ -38,7 +37,6 @@ function wait(ms: number): Promise<void> {
|
|
|
38
37
|
|
|
39
38
|
/** A cue that does nothing — used when Web Audio is unavailable. */
|
|
40
39
|
const noopAudioCue: AudioCue = {
|
|
41
|
-
async playCountdownCue() {},
|
|
42
40
|
async playBeforeCapture() {},
|
|
43
41
|
cleanup() {},
|
|
44
42
|
};
|
|
@@ -68,19 +66,26 @@ async function playBeforeCapture(
|
|
|
68
66
|
}
|
|
69
67
|
|
|
70
68
|
/**
|
|
71
|
-
* Schedule the recording-start chime on an already-running context
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
69
|
+
* Schedule the recording-start chime on an already-running context.
|
|
70
|
+
*
|
|
71
|
+
* A warm, bell-like rising two-note "ding-dong" (G5 → C6, a perfect fourth)
|
|
72
|
+
* voiced on triangle oscillators for a rounder, less electronic timbre, with a
|
|
73
|
+
* quiet octave overtone (C7) sparkling on top of the second note. Each note has
|
|
74
|
+
* a soft 18ms attack and a long exponential decay so it rings out gently — a
|
|
75
|
+
* confident "you're rolling" confirmation that sounds nothing like a flat
|
|
76
|
+
* countdown blip. Kept under ~420ms so it still fits inside
|
|
77
|
+
* `CUE_PLAY_TIMEOUT_MS` and lands right as capture begins.
|
|
75
78
|
*/
|
|
76
79
|
function scheduleTone(ctx: AudioContext): Promise<void> {
|
|
77
80
|
return new Promise<void>((resolve) => {
|
|
78
81
|
const t0 = ctx.currentTime + 0.005;
|
|
79
82
|
const voices = [
|
|
80
|
-
|
|
81
|
-
{ freq:
|
|
82
|
-
{ freq:
|
|
83
|
-
|
|
83
|
+
// Rising perfect fourth: a gentle "ding … dong".
|
|
84
|
+
{ freq: 783.99, at: 0.0, dur: 0.26, peak: 0.07, type: "triangle" }, // G5
|
|
85
|
+
{ freq: 1046.5, at: 0.11, dur: 0.34, peak: 0.085, type: "triangle" }, // C6
|
|
86
|
+
// Faint octave overtone gives the second note a soft bell shimmer.
|
|
87
|
+
{ freq: 2093.0, at: 0.115, dur: 0.18, peak: 0.022, type: "sine" }, // C7
|
|
88
|
+
] as const;
|
|
84
89
|
|
|
85
90
|
let lastStop = t0;
|
|
86
91
|
for (const voice of voices) {
|
|
@@ -89,12 +94,13 @@ function scheduleTone(ctx: AudioContext): Promise<void> {
|
|
|
89
94
|
lastStop = Math.max(lastStop, stopAt);
|
|
90
95
|
|
|
91
96
|
const oscillator = ctx.createOscillator();
|
|
92
|
-
oscillator.type =
|
|
97
|
+
oscillator.type = voice.type;
|
|
93
98
|
oscillator.frequency.setValueAtTime(voice.freq, startAt);
|
|
94
99
|
|
|
95
100
|
const gain = ctx.createGain();
|
|
101
|
+
// Smooth attack/decay envelope — ramps avoid the click of a hard gate.
|
|
96
102
|
gain.gain.setValueAtTime(0.0001, startAt);
|
|
97
|
-
gain.gain.exponentialRampToValueAtTime(voice.peak, startAt + 0.
|
|
103
|
+
gain.gain.exponentialRampToValueAtTime(voice.peak, startAt + 0.018);
|
|
98
104
|
gain.gain.exponentialRampToValueAtTime(0.0001, stopAt);
|
|
99
105
|
|
|
100
106
|
oscillator.connect(gain);
|
|
@@ -165,7 +171,6 @@ export function createAudioCue(): AudioCue {
|
|
|
165
171
|
idleTimer = window.setTimeout(cleanup, CUE_IDLE_CLEANUP_MS);
|
|
166
172
|
|
|
167
173
|
return {
|
|
168
|
-
playCountdownCue: play,
|
|
169
174
|
playBeforeCapture: () => playBeforeCapture(play, cleanup),
|
|
170
175
|
cleanup,
|
|
171
176
|
};
|
|
@@ -88,9 +88,13 @@ const LIVE_UPLOAD_CHUNK_MS = 1_000;
|
|
|
88
88
|
const CLOUD_CAPTURE_FRAME_RATE = 24;
|
|
89
89
|
const CLOUD_CAPTURE_MAX_WIDTH = 1920;
|
|
90
90
|
const CLOUD_CAPTURE_MAX_HEIGHT = 1080;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
91
|
+
// Crisp capture for the desktop browser MediaRecorder fallback. Files are no
|
|
92
|
+
// longer shrunk client-side and the upload provider streams large files, so we
|
|
93
|
+
// keep full 1080p (was downscaled to a 1280 long edge) and a sharp bitrate (was
|
|
94
|
+
// 900 kbps, which left UI and text fuzzy). Dial down if file size matters.
|
|
95
|
+
const CLOUD_RECORDING_MAX_LONG_EDGE = 1920;
|
|
96
|
+
const CLOUD_RECORDING_VIDEO_BITRATE_BPS = 8_000_000;
|
|
97
|
+
const CLOUD_RECORDING_AUDIO_BITRATE_BPS = 128_000;
|
|
94
98
|
|
|
95
99
|
function isMacPlatform(): boolean {
|
|
96
100
|
if (typeof navigator === "undefined") return false;
|
|
@@ -1394,10 +1398,7 @@ async function selectRegionForRecording(): Promise<RegionCaptureRect> {
|
|
|
1394
1398
|
}
|
|
1395
1399
|
}
|
|
1396
1400
|
|
|
1397
|
-
function waitForCountdownEvent(
|
|
1398
|
-
timeoutMs = 4000,
|
|
1399
|
-
onOneSecond?: () => void,
|
|
1400
|
-
): Promise<void> {
|
|
1401
|
+
function waitForCountdownEvent(timeoutMs = 4000): Promise<void> {
|
|
1401
1402
|
return new Promise((resolve, reject) => {
|
|
1402
1403
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
1403
1404
|
const unlistens: UnlistenFn[] = [];
|
|
@@ -1453,9 +1454,6 @@ function waitForCountdownEvent(
|
|
|
1453
1454
|
|
|
1454
1455
|
track(listen("clips:countdown-done", () => finish("done")));
|
|
1455
1456
|
track(listen("clips:countdown-cancel", () => finish("cancel")));
|
|
1456
|
-
if (onOneSecond) {
|
|
1457
|
-
track(listen("clips:countdown-one", () => onOneSecond()));
|
|
1458
|
-
}
|
|
1459
1457
|
|
|
1460
1458
|
timer = setTimeout(() => {
|
|
1461
1459
|
if (done) return;
|
|
@@ -1489,13 +1487,12 @@ async function showRegionRecordBorder(region: RegionCaptureRect | null) {
|
|
|
1489
1487
|
});
|
|
1490
1488
|
}
|
|
1491
1489
|
|
|
1492
|
-
async function runRecordingCountdown(wantsScreen: boolean
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
);
|
|
1490
|
+
async function runRecordingCountdown(wantsScreen: boolean) {
|
|
1491
|
+
// The recording-start chime is intentionally NOT played here. It fires from
|
|
1492
|
+
// `audioCue.playBeforeCapture()` at the real capture-start (right before the
|
|
1493
|
+
// recorder/native capture is kicked off) so the beep lines up with the moment
|
|
1494
|
+
// recording actually begins — not one second early on the countdown's "1".
|
|
1495
|
+
const countdownEvent = waitForCountdownEvent(COUNTDOWN_EVENT_TIMEOUT_MS);
|
|
1499
1496
|
await showRegionGuidesForRecording(wantsScreen);
|
|
1500
1497
|
try {
|
|
1501
1498
|
await invoke("show_countdown");
|
|
@@ -1676,7 +1673,7 @@ async function startNativeFullscreenRecording(
|
|
|
1676
1673
|
}).catch((err) => {
|
|
1677
1674
|
console.warn("[clips-recorder] mic warm failed:", err);
|
|
1678
1675
|
});
|
|
1679
|
-
const countdownPromise = runRecordingCountdown(true
|
|
1676
|
+
const countdownPromise = runRecordingCountdown(true);
|
|
1680
1677
|
if (localOnly) {
|
|
1681
1678
|
id = localFolderName;
|
|
1682
1679
|
await Promise.all([countdownPromise, warmMic(id)]);
|
|
@@ -2456,7 +2453,7 @@ async function startRecordingInner(
|
|
|
2456
2453
|
combined,
|
|
2457
2454
|
});
|
|
2458
2455
|
|
|
2459
|
-
const countdownPromise = runRecordingCountdown(wantsScreen
|
|
2456
|
+
const countdownPromise = runRecordingCountdown(wantsScreen);
|
|
2460
2457
|
const localExportPromise = prepareLocalRecordingExport(targets);
|
|
2461
2458
|
let localExport: Awaited<ReturnType<typeof prepareLocalRecordingExport>>;
|
|
2462
2459
|
try {
|
|
@@ -2632,7 +2629,7 @@ async function startRecordingInner(
|
|
|
2632
2629
|
console.log(
|
|
2633
2630
|
"[clips-recorder] invoking show_countdown + createServerRecording",
|
|
2634
2631
|
);
|
|
2635
|
-
const countdownPromise = runRecordingCountdown(wantsScreen
|
|
2632
|
+
const countdownPromise = runRecordingCountdown(wantsScreen);
|
|
2636
2633
|
console.time("[clips-recorder] createServerRecording duration");
|
|
2637
2634
|
const recordingPromise = createServerRecording(
|
|
2638
2635
|
params.serverUrl,
|