@agent-native/core 0.114.6 → 0.114.7
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 +6 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/cli/mcp-config-writers.ts +6 -4
- package/corpus/core/src/cli/mcp.ts +124 -0
- package/corpus/core/src/cli/skills-content/help.ts +4 -3
- package/corpus/core/src/cli/skills-content/index.ts +1 -0
- package/corpus/core/src/cli/skills-content/rewind-skill.ts +52 -0
- package/corpus/core/src/cli/skills.ts +115 -13
- package/corpus/core/src/mcp/screen-memory-stdio.ts +1360 -104
- package/corpus/core/src/mcp-client/index.ts +9 -0
- package/corpus/core/src/mcp-client/screen-memory-local.ts +847 -13
- package/corpus/templates/clips/actions/apply-rewind-extension.ts +195 -0
- package/corpus/templates/clips/actions/delete-agent-recording-if-unpromoted.ts +64 -0
- package/corpus/templates/clips/actions/get-rewind-extension-request.ts +22 -0
- package/corpus/templates/clips/actions/get-screen-memory-status.ts +18 -2
- package/corpus/templates/clips/actions/list-rewind-extension-requests.ts +52 -0
- package/corpus/templates/clips/actions/make-recording-private-for-rewind.ts +55 -0
- package/corpus/templates/clips/actions/query-screen-memory-context.ts +4 -4
- package/corpus/templates/clips/actions/request-rewind-extension.ts +81 -0
- package/corpus/templates/clips/actions/update-rewind-extension-request.ts +47 -0
- package/corpus/templates/clips/app/components/editor/editor-layout.tsx +25 -0
- package/corpus/templates/clips/app/components/editor/editor-toolbar.tsx +36 -0
- package/corpus/templates/clips/app/components/editor/rewind-extension-dialog.tsx +338 -0
- package/corpus/templates/clips/app/components/editor/stitch-manager.tsx +1 -16
- package/corpus/templates/clips/app/components/editor/timeline.tsx +26 -0
- package/corpus/templates/clips/app/i18n/en-US.ts +18 -0
- package/corpus/templates/clips/app/lib/ffmpeg-export.ts +19 -12
- package/corpus/templates/clips/app/lib/rewind-visibility.ts +31 -0
- package/corpus/templates/clips/app/lib/timestamp-mapping.ts +7 -0
- package/corpus/templates/clips/app/lib/upload-file-client.ts +17 -0
- package/corpus/templates/clips/changelog/2026-07-14-rewind-keeps-recent-screen-context-local-and-turns-it-into-clips.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-active-clips-keep-rewind-stable-and-settings-clear.md +12 -0
- package/corpus/templates/clips/changelog/2026-07-17-clicking-the-clips-icon-during-a-recording-now-opens-the-app.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-default-mic-now-uses-the-mac-default-and-transcription-shares-one-capture.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-full-screen-clips-finalize-even-when-screencapturekit-stop-hangs.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-normal-full-screen-clips-no-longer-stop-immediately-after-th.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-rewind-can-hand-one-bounded-private-clip-to-your-agent.md +12 -0
- package/corpus/templates/clips/changelog/2026-07-17-rewind-home-can-copy-a-local-agent-prompt.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-rewind-now-has-a-calm-home-status-focused-first-run-consent-.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-rewind-settings-are-smaller-clearer-and-agent-first.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-the-full-clips-interface-stays-available-while-recording.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-18-rewind-organizes-recent-work-into-local-searchable-chapters.md +8 -0
- package/corpus/templates/clips/changelog/2026-07-18-searchable-rewind-chapters.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-19-rewind-backed-recordings-now-start-immediately-after-the-cou.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-20-rewind-can-set-up-your-local-agent-once-so-future-requests-w.md +6 -0
- package/corpus/templates/clips/desktop/package.json +1 -0
- package/corpus/templates/clips/desktop/scripts/sign-macos-local.ts +12 -1
- package/corpus/templates/clips/desktop/src/app.tsx +2498 -239
- package/corpus/templates/clips/desktop/src/components/ReadinessPanel.tsx +21 -1
- package/corpus/templates/clips/desktop/src/components/Switch.tsx +3 -0
- package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +156 -24
- package/corpus/templates/clips/desktop/src/lib/native-recording-warm.ts +6 -7
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +1763 -1160
- package/corpus/templates/clips/desktop/src/lib/rewind-agent-prompt.ts +9 -0
- package/corpus/templates/clips/desktop/src/lib/rewind-recording-start.ts +20 -0
- package/corpus/templates/clips/desktop/src/main.tsx +3 -0
- package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +14 -14
- package/corpus/templates/clips/desktop/src/overlays/onboarding.tsx +4 -4
- package/corpus/templates/clips/desktop/src/overlays/preparing.tsx +13 -0
- package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +27 -2
- package/corpus/templates/clips/desktop/src/shared/config.ts +16 -0
- package/corpus/templates/clips/desktop/src/styles.css +956 -134
- package/corpus/templates/clips/desktop/src-tauri/Cargo.toml +5 -0
- package/corpus/templates/clips/desktop/src-tauri/build.rs +69 -1
- package/corpus/templates/clips/desktop/src-tauri/src/accessibility.rs +386 -12
- package/corpus/templates/clips/desktop/src-tauri/src/capture_audio_bus.rs +382 -0
- package/corpus/templates/clips/desktop/src-tauri/src/capture_graph.rs +777 -0
- package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +249 -22
- package/corpus/templates/clips/desktop/src-tauri/src/config.rs +148 -1
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +58 -4
- package/corpus/templates/clips/desktop/src-tauri/src/logfile.rs +13 -1
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +1505 -51
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +124 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +1006 -95
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_capture_suspension.rs +165 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_chapters.rs +2434 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +1650 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_egress.rs +643 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_local_ask.rs +803 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_meeting_history.rs +595 -0
- package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +2900 -91
- package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_ocr.rs +360 -0
- package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_transcript.rs +408 -0
- package/corpus/templates/clips/desktop/src-tauri/src/shortcuts.rs +114 -33
- package/corpus/templates/clips/desktop/src-tauri/src/state.rs +2 -2
- package/corpus/templates/clips/desktop/src-tauri/src/system_audio.rs +13 -3
- package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +4 -1
- package/corpus/templates/clips/desktop/src-tauri/src/util.rs +7 -13
- package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +198 -11
- package/corpus/templates/clips/desktop/src-tauri/tauri.alpha.conf.json +17 -0
- package/dist/cli/mcp-config-writers.d.ts +2 -2
- package/dist/cli/mcp-config-writers.d.ts.map +1 -1
- package/dist/cli/mcp-config-writers.js +6 -6
- package/dist/cli/mcp-config-writers.js.map +1 -1
- package/dist/cli/mcp.d.ts +14 -0
- package/dist/cli/mcp.d.ts.map +1 -1
- package/dist/cli/mcp.js +79 -0
- package/dist/cli/mcp.js.map +1 -1
- package/dist/cli/skills-content/help.d.ts +1 -1
- package/dist/cli/skills-content/help.d.ts.map +1 -1
- package/dist/cli/skills-content/help.js +4 -3
- package/dist/cli/skills-content/help.js.map +1 -1
- package/dist/cli/skills-content/index.d.ts +1 -0
- package/dist/cli/skills-content/index.d.ts.map +1 -1
- package/dist/cli/skills-content/index.js +1 -0
- package/dist/cli/skills-content/index.js.map +1 -1
- package/dist/cli/skills-content/rewind-skill.d.ts +2 -0
- package/dist/cli/skills-content/rewind-skill.d.ts.map +1 -0
- package/dist/cli/skills-content/rewind-skill.js +53 -0
- package/dist/cli/skills-content/rewind-skill.js.map +1 -0
- package/dist/cli/skills.d.ts +6 -0
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +90 -14
- package/dist/cli/skills.js.map +1 -1
- package/dist/mcp/screen-memory-stdio.d.ts +365 -0
- package/dist/mcp/screen-memory-stdio.d.ts.map +1 -1
- package/dist/mcp/screen-memory-stdio.js +1014 -91
- package/dist/mcp/screen-memory-stdio.js.map +1 -1
- package/dist/mcp-client/index.d.ts +1 -1
- package/dist/mcp-client/index.d.ts.map +1 -1
- package/dist/mcp-client/index.js +1 -1
- package/dist/mcp-client/index.js.map +1 -1
- package/dist/mcp-client/screen-memory-local.d.ts +90 -0
- package/dist/mcp-client/screen-memory-local.d.ts.map +1 -1
- package/dist/mcp-client/screen-memory-local.js +590 -12
- package/dist/mcp-client/screen-memory-local.js.map +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +1 -1
- package/dist/provider-api/actions/provider-api.d.ts +4 -4
- package/dist/resources/handlers.d.ts +1 -1
- package/package.json +1 -1
- package/src/cli/mcp-config-writers.ts +6 -4
- package/src/cli/mcp.ts +124 -0
- package/src/cli/skills-content/help.ts +4 -3
- package/src/cli/skills-content/index.ts +1 -0
- package/src/cli/skills-content/rewind-skill.ts +52 -0
- package/src/cli/skills.ts +115 -13
- package/src/mcp/screen-memory-stdio.ts +1360 -104
- package/src/mcp-client/index.ts +9 -0
- package/src/mcp-client/screen-memory-local.ts +847 -13
|
@@ -80,6 +80,7 @@ import {
|
|
|
80
80
|
} from "./pause-transition";
|
|
81
81
|
import { reconcileProcessingBackup } from "./processing-backup-recovery";
|
|
82
82
|
import { buildCaptureTitle, type CaptureTitleResult } from "./recording-title";
|
|
83
|
+
import { prepareRewindRecordingStart } from "./rewind-recording-start";
|
|
83
84
|
import { singleFlight } from "./single-flight";
|
|
84
85
|
import {
|
|
85
86
|
startTranscriptionCapture,
|
|
@@ -219,6 +220,61 @@ export interface StartParams {
|
|
|
219
220
|
preAcquiredCameraStream?: MediaStream | null;
|
|
220
221
|
}
|
|
221
222
|
|
|
223
|
+
const REWIND_CLIP_ORIGINS_KEY = "clips.rewindClipOrigins.v1";
|
|
224
|
+
|
|
225
|
+
export interface RewindClipOrigin {
|
|
226
|
+
recordingId: string;
|
|
227
|
+
startedAt: string;
|
|
228
|
+
includeMicrophone: boolean;
|
|
229
|
+
includeSystemAudio: boolean;
|
|
230
|
+
rememberedAt: string;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function readRewindClipOrigins(): Record<string, RewindClipOrigin> {
|
|
234
|
+
try {
|
|
235
|
+
const parsed = JSON.parse(
|
|
236
|
+
localStorage.getItem(REWIND_CLIP_ORIGINS_KEY) || "{}",
|
|
237
|
+
);
|
|
238
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
239
|
+
} catch {
|
|
240
|
+
return {};
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function writeRewindClipOrigins(
|
|
245
|
+
origins: Record<string, RewindClipOrigin>,
|
|
246
|
+
): void {
|
|
247
|
+
try {
|
|
248
|
+
localStorage.setItem(REWIND_CLIP_ORIGINS_KEY, JSON.stringify(origins));
|
|
249
|
+
} catch {
|
|
250
|
+
// Losing optional retrospective metadata must never cancel a live Clip.
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function rememberRewindClipOrigin(origin: RewindClipOrigin): void {
|
|
255
|
+
const origins = readRewindClipOrigins();
|
|
256
|
+
origins[origin.recordingId] = origin;
|
|
257
|
+
const newest = Object.values(origins)
|
|
258
|
+
.sort((left, right) => right.rememberedAt.localeCompare(left.rememberedAt))
|
|
259
|
+
.slice(0, 50);
|
|
260
|
+
writeRewindClipOrigins(
|
|
261
|
+
Object.fromEntries(newest.map((entry) => [entry.recordingId, entry])),
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function getRewindClipOrigin(
|
|
266
|
+
recordingId: string,
|
|
267
|
+
): RewindClipOrigin | null {
|
|
268
|
+
return readRewindClipOrigins()[recordingId] ?? null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function forgetRewindClipOrigin(recordingId: string): void {
|
|
272
|
+
const origins = readRewindClipOrigins();
|
|
273
|
+
if (!origins[recordingId]) return;
|
|
274
|
+
delete origins[recordingId];
|
|
275
|
+
writeRewindClipOrigins(origins);
|
|
276
|
+
}
|
|
277
|
+
|
|
222
278
|
export interface RecorderHandle {
|
|
223
279
|
/** Stop the recording and resolve once the server has finalized. */
|
|
224
280
|
stop(): Promise<RecorderStopResult>;
|
|
@@ -819,7 +875,7 @@ export function scheduleNativeBackupCleanupAfterProcessing(args: {
|
|
|
819
875
|
timeoutMs: 12 * 60 * 1000,
|
|
820
876
|
}),
|
|
821
877
|
onReady: () =>
|
|
822
|
-
invoke("
|
|
878
|
+
invoke("native_fullscreen_recording_clear_upload", {
|
|
823
879
|
recordingId: args.recordingId,
|
|
824
880
|
}),
|
|
825
881
|
onUnresolved: () => flagNativeBackupAfterProcessing(args.recordingId),
|
|
@@ -862,6 +918,38 @@ async function recoverAcceptedRecordingAfterFinalizeError({
|
|
|
862
918
|
return "ready";
|
|
863
919
|
}
|
|
864
920
|
|
|
921
|
+
async function recoverAcceptedNativeRecordingAfterFinalizeError({
|
|
922
|
+
serverUrl,
|
|
923
|
+
recordingId,
|
|
924
|
+
authToken,
|
|
925
|
+
}: {
|
|
926
|
+
serverUrl: string;
|
|
927
|
+
recordingId: string;
|
|
928
|
+
authToken?: string;
|
|
929
|
+
}): Promise<"processing" | "ready" | null> {
|
|
930
|
+
const recovered = await waitForAcceptedRecordingAfterFinalizeError({
|
|
931
|
+
uploadUrl: chunkUrl(serverUrl, recordingId, 0, false),
|
|
932
|
+
recordingId,
|
|
933
|
+
authToken,
|
|
934
|
+
preferAuthenticated: true,
|
|
935
|
+
});
|
|
936
|
+
if (!recovered) return null;
|
|
937
|
+
if (recovered.status === "processing") {
|
|
938
|
+
scheduleNativeBackupCleanupAfterProcessing({
|
|
939
|
+
serverUrl,
|
|
940
|
+
recordingId,
|
|
941
|
+
authToken,
|
|
942
|
+
});
|
|
943
|
+
return "processing";
|
|
944
|
+
}
|
|
945
|
+
await invoke("native_fullscreen_recording_clear_upload", {
|
|
946
|
+
recordingId,
|
|
947
|
+
}).catch((error) => {
|
|
948
|
+
console.warn("[clips-recorder] native backup cleanup failed:", error);
|
|
949
|
+
});
|
|
950
|
+
return "ready";
|
|
951
|
+
}
|
|
952
|
+
|
|
865
953
|
export async function listBrowserRecordingBackups(): Promise<
|
|
866
954
|
PendingBrowserRecordingUpload[]
|
|
867
955
|
> {
|
|
@@ -1153,6 +1241,7 @@ async function createServerRecording(
|
|
|
1153
1241
|
mimeType?: string;
|
|
1154
1242
|
requestStreaming?: boolean;
|
|
1155
1243
|
streamingUploadClient?: StreamingUploadClient;
|
|
1244
|
+
visibility?: "public" | "private";
|
|
1156
1245
|
},
|
|
1157
1246
|
) {
|
|
1158
1247
|
const url = `${serverUrl.replace(/\/+$/, "")}/_agent-native/actions/create-recording`;
|
|
@@ -1176,6 +1265,7 @@ async function createServerRecording(
|
|
|
1176
1265
|
hasCamera,
|
|
1177
1266
|
hasAudio,
|
|
1178
1267
|
spaceIds: [],
|
|
1268
|
+
visibility: options?.visibility ?? "public",
|
|
1179
1269
|
...(options?.requestStreaming
|
|
1180
1270
|
? {
|
|
1181
1271
|
requestStreaming: true,
|
|
@@ -1219,6 +1309,30 @@ async function createServerRecording(
|
|
|
1219
1309
|
return { id: result.id, uploadMode };
|
|
1220
1310
|
}
|
|
1221
1311
|
|
|
1312
|
+
export async function createPrivateAgentRewindRecording(
|
|
1313
|
+
serverUrl: string,
|
|
1314
|
+
hasAudio: boolean,
|
|
1315
|
+
startedAt: string,
|
|
1316
|
+
): Promise<{ id: string; uploadMode: UploadMode }> {
|
|
1317
|
+
return createServerRecording(
|
|
1318
|
+
serverUrl,
|
|
1319
|
+
false,
|
|
1320
|
+
hasAudio,
|
|
1321
|
+
{
|
|
1322
|
+
title: `Rewind · ${new Date(startedAt).toLocaleString()}`,
|
|
1323
|
+
titleSource: "context",
|
|
1324
|
+
sourceAppName: "Clips Rewind",
|
|
1325
|
+
sourceWindowTitle: null,
|
|
1326
|
+
},
|
|
1327
|
+
{
|
|
1328
|
+
mimeType: NATIVE_FULLSCREEN_MIME_TYPE,
|
|
1329
|
+
requestStreaming: true,
|
|
1330
|
+
streamingUploadClient: "desktop-native",
|
|
1331
|
+
visibility: "private",
|
|
1332
|
+
},
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1222
1336
|
interface ActiveWindowContext {
|
|
1223
1337
|
appName?: string | null;
|
|
1224
1338
|
windowTitle?: string | null;
|
|
@@ -1799,70 +1913,84 @@ async function selectRegionForRecording(): Promise<RegionCaptureRect> {
|
|
|
1799
1913
|
}
|
|
1800
1914
|
}
|
|
1801
1915
|
|
|
1802
|
-
function
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
if (kind === "cancel") {
|
|
1830
|
-
reject(new CountdownCancelledError());
|
|
1831
|
-
} else {
|
|
1832
|
-
resolve();
|
|
1833
|
-
}
|
|
1834
|
-
};
|
|
1835
|
-
const track = (listener: Promise<UnlistenFn>) => {
|
|
1836
|
-
listener
|
|
1837
|
-
.then((u) => {
|
|
1838
|
-
if (done) {
|
|
1839
|
-
try {
|
|
1840
|
-
u();
|
|
1841
|
-
} catch {
|
|
1842
|
-
// ignore
|
|
1843
|
-
}
|
|
1844
|
-
return;
|
|
1845
|
-
}
|
|
1846
|
-
unlistens.push(u);
|
|
1847
|
-
})
|
|
1848
|
-
.catch((err) => {
|
|
1849
|
-
if (done) return;
|
|
1850
|
-
done = true;
|
|
1851
|
-
cleanup();
|
|
1852
|
-
reject(err);
|
|
1853
|
-
});
|
|
1854
|
-
};
|
|
1916
|
+
async function prepareCountdownEventWaiter(timeoutMs = 4000): Promise<{
|
|
1917
|
+
event: Promise<string>;
|
|
1918
|
+
cleanup: () => void;
|
|
1919
|
+
}> {
|
|
1920
|
+
let resolveEvent!: (cause: string) => void;
|
|
1921
|
+
let rejectEvent!: (error: Error) => void;
|
|
1922
|
+
const event = new Promise<string>((resolve, reject) => {
|
|
1923
|
+
resolveEvent = resolve;
|
|
1924
|
+
rejectEvent = reject;
|
|
1925
|
+
});
|
|
1926
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
1927
|
+
const unlistens: UnlistenFn[] = [];
|
|
1928
|
+
let done = false;
|
|
1929
|
+
const onKeyDown = (keyboardEvent: KeyboardEvent) => {
|
|
1930
|
+
if (keyboardEvent.key !== "Enter" && keyboardEvent.key !== "Escape") {
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
keyboardEvent.preventDefault();
|
|
1934
|
+
const cancelled = keyboardEvent.key === "Escape";
|
|
1935
|
+
void emit(cancelled ? "clips:countdown-cancel" : "clips:countdown-done", {
|
|
1936
|
+
cause: cancelled ? "escape" : "return",
|
|
1937
|
+
});
|
|
1938
|
+
};
|
|
1939
|
+
// The parked popover deliberately keeps focus while the non-activating
|
|
1940
|
+
// countdown is visible. Listen here as well as in the overlay/global-hotkey
|
|
1941
|
+
// paths so Return is reliable even when macOS declines a bare global key.
|
|
1942
|
+
window.addEventListener("keydown", onKeyDown);
|
|
1855
1943
|
|
|
1856
|
-
|
|
1857
|
-
|
|
1944
|
+
const cleanup = () => {
|
|
1945
|
+
window.removeEventListener("keydown", onKeyDown);
|
|
1946
|
+
if (timer) {
|
|
1947
|
+
clearTimeout(timer);
|
|
1948
|
+
timer = null;
|
|
1949
|
+
}
|
|
1950
|
+
for (const unlisten of unlistens.splice(0)) {
|
|
1951
|
+
try {
|
|
1952
|
+
unlisten();
|
|
1953
|
+
} catch {
|
|
1954
|
+
// ignore
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
};
|
|
1958
|
+
const finish = (kind: "done" | "cancel", cause = "unknown") => {
|
|
1959
|
+
if (done) return;
|
|
1960
|
+
done = true;
|
|
1961
|
+
cleanup();
|
|
1962
|
+
if (kind === "cancel") {
|
|
1963
|
+
rejectEvent(new CountdownCancelledError());
|
|
1964
|
+
} else {
|
|
1965
|
+
resolveEvent(cause);
|
|
1966
|
+
}
|
|
1967
|
+
};
|
|
1858
1968
|
|
|
1969
|
+
// Await both registrations before the countdown can become visible. A fast
|
|
1970
|
+
// Return previously closed the overlay before these asynchronous listeners
|
|
1971
|
+
// were ready, so the UI disappeared while the recorder waited for timeout.
|
|
1972
|
+
const [doneUnlisten, cancelUnlisten] = await Promise.all([
|
|
1973
|
+
listen<{ cause?: string }>("clips:countdown-done", (event) =>
|
|
1974
|
+
finish("done", event.payload?.cause),
|
|
1975
|
+
),
|
|
1976
|
+
listen<{ cause?: string }>("clips:countdown-cancel", (event) =>
|
|
1977
|
+
finish("cancel", event.payload?.cause),
|
|
1978
|
+
),
|
|
1979
|
+
]);
|
|
1980
|
+
if (done) {
|
|
1981
|
+
doneUnlisten();
|
|
1982
|
+
cancelUnlisten();
|
|
1983
|
+
} else {
|
|
1984
|
+
unlistens.push(doneUnlisten, cancelUnlisten);
|
|
1859
1985
|
timer = setTimeout(() => {
|
|
1860
1986
|
if (done) return;
|
|
1861
1987
|
done = true;
|
|
1862
1988
|
cleanup();
|
|
1863
|
-
|
|
1989
|
+
rejectEvent(new Error("timeout waiting for clips:countdown-done"));
|
|
1864
1990
|
}, timeoutMs);
|
|
1865
|
-
}
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
return { event, cleanup };
|
|
1866
1994
|
}
|
|
1867
1995
|
|
|
1868
1996
|
async function showRegionGuidesForRecording(wantsScreen: boolean) {
|
|
@@ -1893,15 +2021,21 @@ async function runRecordingCountdown(wantsScreen: boolean) {
|
|
|
1893
2021
|
// `audioCue.playBeforeCapture()` at the real capture-start (right before the
|
|
1894
2022
|
// recorder/native capture is kicked off) so the beep lines up with the moment
|
|
1895
2023
|
// recording actually begins — not one second early on the countdown's "1".
|
|
1896
|
-
const
|
|
2024
|
+
const countdown = await prepareCountdownEventWaiter(
|
|
2025
|
+
COUNTDOWN_EVENT_TIMEOUT_MS,
|
|
2026
|
+
);
|
|
1897
2027
|
await showRegionGuidesForRecording(wantsScreen);
|
|
2028
|
+
let countdownGeneration: number;
|
|
1898
2029
|
try {
|
|
1899
|
-
await invoke("show_countdown");
|
|
2030
|
+
countdownGeneration = await invoke<number>("show_countdown");
|
|
1900
2031
|
} catch (err) {
|
|
1901
2032
|
console.error("[clips-recorder] show_countdown failed:", err);
|
|
2033
|
+
countdown.cleanup();
|
|
2034
|
+
throw err;
|
|
1902
2035
|
}
|
|
1903
2036
|
try {
|
|
1904
|
-
await
|
|
2037
|
+
const cause = await countdown.event;
|
|
2038
|
+
console.log(`[rewind-latency] countdown completion cause=${cause}`);
|
|
1905
2039
|
} catch (err) {
|
|
1906
2040
|
if (isCountdownCancelledError(err)) {
|
|
1907
2041
|
await invoke("hide_recording_chrome").catch(() => {});
|
|
@@ -1909,6 +2043,11 @@ async function runRecordingCountdown(wantsScreen: boolean) {
|
|
|
1909
2043
|
}
|
|
1910
2044
|
console.warn("[clips-recorder] countdown timed out — proceeding");
|
|
1911
2045
|
return;
|
|
2046
|
+
} finally {
|
|
2047
|
+
countdown.cleanup();
|
|
2048
|
+
await invoke("finish_countdown_shortcuts", {
|
|
2049
|
+
generation: countdownGeneration,
|
|
2050
|
+
}).catch(() => {});
|
|
1912
2051
|
}
|
|
1913
2052
|
}
|
|
1914
2053
|
|
|
@@ -2005,88 +2144,488 @@ function abortCreatedRecordingOnCountdownCancel(
|
|
|
2005
2144
|
.catch(() => {});
|
|
2006
2145
|
}
|
|
2007
2146
|
|
|
2008
|
-
|
|
2147
|
+
interface RewindClipBackendStatus {
|
|
2148
|
+
compatibility: "compatible" | "not-compatible";
|
|
2149
|
+
active: boolean;
|
|
2150
|
+
paused: boolean;
|
|
2151
|
+
retrospectiveSeconds: number;
|
|
2152
|
+
sources: Array<"screen" | "system-audio" | "microphone" | "camera">;
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
interface RewindCaptureSuspensionLease {
|
|
2156
|
+
leaseId: string | null;
|
|
2157
|
+
suspendedRewind: boolean;
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
function acquireRewindCaptureSuspension(options: {
|
|
2161
|
+
requiresScreen: boolean;
|
|
2162
|
+
requiresMicrophone: boolean;
|
|
2163
|
+
}): Promise<RewindCaptureSuspensionLease> {
|
|
2164
|
+
return invoke<RewindCaptureSuspensionLease>(
|
|
2165
|
+
"rewind_capture_suspension_acquire",
|
|
2166
|
+
options,
|
|
2167
|
+
);
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
function captureSuspensionReleaser(
|
|
2171
|
+
lease: RewindCaptureSuspensionLease,
|
|
2172
|
+
): () => Promise<void> {
|
|
2173
|
+
let releasePromise: Promise<void> | null = null;
|
|
2174
|
+
return () => {
|
|
2175
|
+
if (!lease.leaseId) return Promise.resolve();
|
|
2176
|
+
releasePromise ??= invoke("rewind_capture_suspension_release", {
|
|
2177
|
+
leaseId: lease.leaseId,
|
|
2178
|
+
});
|
|
2179
|
+
return releasePromise;
|
|
2180
|
+
};
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
function recorderWithCaptureSuspension(
|
|
2184
|
+
handle: RecorderHandle,
|
|
2185
|
+
release: () => Promise<void>,
|
|
2186
|
+
): RecorderHandle {
|
|
2187
|
+
// Mutate the same handle object instead of returning a separate wrapper.
|
|
2188
|
+
// Toolbar listeners close over `handle` inside each recorder backend; using
|
|
2189
|
+
// the same object ensures those event-driven stop/cancel paths also release
|
|
2190
|
+
// the suspension after the physical recorder has actually torn down.
|
|
2191
|
+
const stop = handle.stop.bind(handle);
|
|
2192
|
+
const cancel = handle.cancel.bind(handle);
|
|
2193
|
+
let stopPromise: Promise<RecorderStopResult> | null = null;
|
|
2194
|
+
let cancelPromise: Promise<void> | null = null;
|
|
2195
|
+
handle.stop = async () => {
|
|
2196
|
+
if (stopPromise) return stopPromise;
|
|
2197
|
+
if (cancelPromise) {
|
|
2198
|
+
await cancelPromise;
|
|
2199
|
+
throw new Error("Recording was already cancelled");
|
|
2200
|
+
}
|
|
2201
|
+
stopPromise = (async () => {
|
|
2202
|
+
try {
|
|
2203
|
+
return await stop();
|
|
2204
|
+
} finally {
|
|
2205
|
+
await release();
|
|
2206
|
+
}
|
|
2207
|
+
})();
|
|
2208
|
+
return stopPromise;
|
|
2209
|
+
};
|
|
2210
|
+
handle.cancel = async () => {
|
|
2211
|
+
if (cancelPromise) return cancelPromise;
|
|
2212
|
+
if (stopPromise) {
|
|
2213
|
+
await stopPromise;
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2216
|
+
cancelPromise = (async () => {
|
|
2217
|
+
try {
|
|
2218
|
+
await cancel();
|
|
2219
|
+
} finally {
|
|
2220
|
+
await release();
|
|
2221
|
+
}
|
|
2222
|
+
})();
|
|
2223
|
+
return cancelPromise;
|
|
2224
|
+
};
|
|
2225
|
+
return handle;
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
/**
|
|
2229
|
+
* Reuse the active local Rewind producer for a plain full-screen Clip. A
|
|
2230
|
+
* `null` result is a pre-countdown compatibility decision; after countdown
|
|
2231
|
+
* zero we fail closed instead of silently starting an ordinary second stream.
|
|
2232
|
+
*/
|
|
2233
|
+
async function tryStartRewindFullscreenRecording(
|
|
2009
2234
|
params: StartParams,
|
|
2010
2235
|
wantsCamera: boolean,
|
|
2011
2236
|
wantsAudio: boolean,
|
|
2012
2237
|
audioCue: AudioCue,
|
|
2013
|
-
): Promise<RecorderHandle> {
|
|
2014
|
-
|
|
2238
|
+
): Promise<RecorderHandle | null> {
|
|
2239
|
+
if (
|
|
2240
|
+
params.mode === "camera" ||
|
|
2241
|
+
(wantsCamera && params.localRecordingMode === "separate") ||
|
|
2242
|
+
(params.source ?? "window") !== "full-screen"
|
|
2243
|
+
) {
|
|
2244
|
+
return null;
|
|
2245
|
+
}
|
|
2246
|
+
const status = await invoke<RewindClipBackendStatus>(
|
|
2247
|
+
"rewind_clip_status",
|
|
2248
|
+
).catch(() => null);
|
|
2249
|
+
if (!status || status.compatibility !== "compatible" || status.active) {
|
|
2250
|
+
return null;
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2015
2253
|
const localRecordingMode = params.localRecordingMode ?? "off";
|
|
2016
2254
|
const localOnly = localRecordingMode !== "off";
|
|
2017
|
-
const
|
|
2018
|
-
const
|
|
2019
|
-
|
|
2255
|
+
const folderName = localOnly ? createLocalRecordingFolderName() : "";
|
|
2256
|
+
const includeMic = wantsAudio;
|
|
2257
|
+
const includeSystemAudio = params.systemAudioOn !== false;
|
|
2258
|
+
const hasAudio = includeMic || includeSystemAudio;
|
|
2259
|
+
let id = folderName;
|
|
2020
2260
|
let uploadMode: UploadMode = "buffered";
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2261
|
+
await invoke("show_preparing");
|
|
2262
|
+
const recordingPromise = localOnly
|
|
2263
|
+
? Promise.resolve<{ id: string; uploadMode: UploadMode }>({
|
|
2264
|
+
id: folderName,
|
|
2265
|
+
uploadMode: "buffered",
|
|
2266
|
+
})
|
|
2267
|
+
: (async () => {
|
|
2268
|
+
const title = await captureTitleForRecording({
|
|
2269
|
+
mode: params.mode,
|
|
2270
|
+
source: params.source,
|
|
2271
|
+
});
|
|
2272
|
+
return createServerRecording(
|
|
2273
|
+
params.serverUrl,
|
|
2274
|
+
wantsCamera,
|
|
2275
|
+
hasAudio,
|
|
2276
|
+
title,
|
|
2277
|
+
{
|
|
2278
|
+
mimeType: NATIVE_FULLSCREEN_MIME_TYPE,
|
|
2279
|
+
// Hosted storage requires the resumable upload session created by
|
|
2280
|
+
// the streaming contract even though Rewind materializes one final
|
|
2281
|
+
// MP4 at Stop. Without this, finalization reaches the server only to
|
|
2282
|
+
// fail with 409 after the local encode has completed.
|
|
2283
|
+
requestStreaming: true,
|
|
2284
|
+
streamingUploadClient: "desktop-native",
|
|
2285
|
+
},
|
|
2286
|
+
);
|
|
2287
|
+
})();
|
|
2288
|
+
try {
|
|
2289
|
+
const recording = await prepareRewindRecordingStart({
|
|
2290
|
+
async prepare() {
|
|
2291
|
+
const preparedRecording = await recordingPromise;
|
|
2292
|
+
id = preparedRecording.id;
|
|
2293
|
+
uploadMode = preparedRecording.uploadMode ?? "buffered";
|
|
2294
|
+
await invoke<RewindClipBackendStatus>("rewind_clip_prepare", {
|
|
2295
|
+
artifactLabel: id,
|
|
2296
|
+
serverUrl: localOnly ? null : params.serverUrl,
|
|
2297
|
+
recordingId: localOnly ? null : id,
|
|
2298
|
+
authToken: localOnly ? null : (params.authToken ?? ""),
|
|
2299
|
+
cookie: localOnly ? null : (params.cookie ?? ""),
|
|
2300
|
+
includeMic,
|
|
2301
|
+
includeSystemAudio,
|
|
2302
|
+
hasCamera: wantsCamera,
|
|
2303
|
+
});
|
|
2304
|
+
return preparedRecording;
|
|
2055
2305
|
},
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2306
|
+
async countdown() {
|
|
2307
|
+
console.log("[rewind-latency] countdown shown after preparation");
|
|
2308
|
+
await invoke("hide_preparing").catch(() => {});
|
|
2309
|
+
await runRecordingCountdown(true);
|
|
2310
|
+
console.log("[rewind-latency] countdown completed");
|
|
2311
|
+
},
|
|
2312
|
+
async activate(preparedRecording) {
|
|
2313
|
+
const activationStarted = performance.now();
|
|
2314
|
+
await invoke<RewindClipBackendStatus>("rewind_clip_start");
|
|
2315
|
+
console.log(
|
|
2316
|
+
`[rewind-latency] countdown completion to start acknowledgement ${Math.round(performance.now() - activationStarted)}ms`,
|
|
2317
|
+
);
|
|
2318
|
+
return preparedRecording;
|
|
2319
|
+
},
|
|
2320
|
+
onActivated() {
|
|
2321
|
+
// The capture boundary must never wait for an audible affordance.
|
|
2322
|
+
void audioCue.playBeforeCapture();
|
|
2323
|
+
},
|
|
2324
|
+
});
|
|
2325
|
+
id = recording.id;
|
|
2326
|
+
const originalStartedAt = new Date().toISOString();
|
|
2327
|
+
if (!localOnly) {
|
|
2328
|
+
rememberRewindClipOrigin({
|
|
2329
|
+
recordingId: id,
|
|
2330
|
+
startedAt: originalStartedAt,
|
|
2331
|
+
includeMicrophone: includeMic,
|
|
2332
|
+
includeSystemAudio,
|
|
2333
|
+
rememberedAt: originalStartedAt,
|
|
2334
|
+
});
|
|
2335
|
+
}
|
|
2336
|
+
} catch (err) {
|
|
2337
|
+
await invoke("hide_preparing").catch(() => {});
|
|
2338
|
+
if (id) forgetRewindClipOrigin(id);
|
|
2339
|
+
await invoke("rewind_clip_cancel").catch(() => {});
|
|
2340
|
+
audioCue.cleanup();
|
|
2341
|
+
if (!localOnly) {
|
|
2342
|
+
abortCreatedRecordingOnCountdownCancel(
|
|
2343
|
+
err,
|
|
2344
|
+
recordingPromise,
|
|
2345
|
+
params.serverUrl,
|
|
2066
2346
|
);
|
|
2067
2347
|
}
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
if (params.source === "region") {
|
|
2075
|
-
captureRegion = await selectRegionForRecording();
|
|
2076
|
-
// Frame the selected area now so it stays visible through the countdown
|
|
2077
|
-
// and the whole recording. hide_recording_chrome / hide_overlays tear it
|
|
2078
|
-
// down on stop, cancel, and the error paths below.
|
|
2079
|
-
await showRegionRecordBorder(captureRegion);
|
|
2348
|
+
if (!localOnly && id) {
|
|
2349
|
+
await abortRecordingUpload(
|
|
2350
|
+
params.serverUrl,
|
|
2351
|
+
id,
|
|
2352
|
+
err instanceof Error ? err.message : String(err),
|
|
2353
|
+
);
|
|
2080
2354
|
}
|
|
2355
|
+
throw err;
|
|
2356
|
+
}
|
|
2081
2357
|
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2358
|
+
let stopped = false;
|
|
2359
|
+
let stopPromise: Promise<RecorderStopResult> | null = null;
|
|
2360
|
+
let cancelPromise: Promise<void> | null = null;
|
|
2361
|
+
let stateUnlistens: UnlistenFn[] = [];
|
|
2362
|
+
let tickHandle: ReturnType<typeof setInterval> | null = null;
|
|
2363
|
+
let pausedAt: number | null = null;
|
|
2364
|
+
let pauseRequestedAt: number | null = null;
|
|
2365
|
+
let accumulatedPauseMs = 0;
|
|
2366
|
+
const startedAt = Date.now();
|
|
2367
|
+
let pauseQueue: PauseTransitionQueue | null = null;
|
|
2368
|
+
|
|
2369
|
+
const emitState = (
|
|
2370
|
+
paused = pauseQueue?.getDesiredPaused() ?? pausedAt !== null,
|
|
2371
|
+
) => {
|
|
2372
|
+
const now = Date.now();
|
|
2373
|
+
const pauseStart = pausedAt ?? (paused ? pauseRequestedAt : null);
|
|
2374
|
+
emit("clips:recorder-state", {
|
|
2375
|
+
paused,
|
|
2376
|
+
elapsedMs: Math.max(
|
|
2377
|
+
0,
|
|
2378
|
+
now -
|
|
2379
|
+
startedAt -
|
|
2380
|
+
accumulatedPauseMs -
|
|
2381
|
+
(paused && pauseStart !== null ? now - pauseStart : 0),
|
|
2382
|
+
),
|
|
2383
|
+
}).catch(() => {});
|
|
2384
|
+
};
|
|
2385
|
+
const cleanupUi = () => {
|
|
2386
|
+
pauseQueue?.dispose();
|
|
2387
|
+
if (tickHandle) window.clearInterval(tickHandle);
|
|
2388
|
+
tickHandle = null;
|
|
2389
|
+
stateUnlistens.forEach((unlisten) => unlisten());
|
|
2390
|
+
stateUnlistens = [];
|
|
2391
|
+
};
|
|
2392
|
+
|
|
2393
|
+
const handle: RecorderHandle = {
|
|
2394
|
+
async stop() {
|
|
2395
|
+
if (stopPromise) return stopPromise;
|
|
2396
|
+
if (stopped) return { recordingId: id, viewUrl: `/r/${id}` };
|
|
2397
|
+
stopPromise = (async () => {
|
|
2398
|
+
stopped = true;
|
|
2399
|
+
cleanupUi();
|
|
2400
|
+
if (!localOnly) showFinalizingFeedback();
|
|
2401
|
+
try {
|
|
2402
|
+
if (localOnly) {
|
|
2403
|
+
const savePromise = invoke<NativeFullscreenSaveResult>(
|
|
2404
|
+
"rewind_clip_stop_and_save",
|
|
2405
|
+
{
|
|
2406
|
+
folderName,
|
|
2407
|
+
fileRole: "desktop",
|
|
2408
|
+
includeMic,
|
|
2409
|
+
includeSystemAudio,
|
|
2410
|
+
},
|
|
2411
|
+
);
|
|
2412
|
+
savePromise.catch(() => {});
|
|
2413
|
+
await invoke("hide_recording_chrome").catch(() => {});
|
|
2414
|
+
if (wantsCamera) await invoke("close_bubble").catch(() => {});
|
|
2415
|
+
const saved = await savePromise;
|
|
2416
|
+
return {
|
|
2417
|
+
recordingId: saved.recordingId,
|
|
2418
|
+
viewUrl: "",
|
|
2419
|
+
localOnly: true,
|
|
2420
|
+
localFolder: saved.folderPath,
|
|
2421
|
+
localFiles: [saved.file],
|
|
2422
|
+
};
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
const viewUrl = `/r/${id}`;
|
|
2426
|
+
const stopStarted = performance.now();
|
|
2427
|
+
const uploadPromise = invoke<NativeFullscreenUploadResult>(
|
|
2428
|
+
"rewind_clip_stop_and_upload",
|
|
2429
|
+
{
|
|
2430
|
+
serverUrl: params.serverUrl,
|
|
2431
|
+
recordingId: id,
|
|
2432
|
+
authToken: params.authToken ?? "",
|
|
2433
|
+
cookie: params.cookie ?? "",
|
|
2434
|
+
uploadMode,
|
|
2435
|
+
includeMic,
|
|
2436
|
+
includeSystemAudio,
|
|
2437
|
+
hasCamera: wantsCamera,
|
|
2438
|
+
},
|
|
2439
|
+
);
|
|
2440
|
+
console.log(
|
|
2441
|
+
`[rewind-latency] stop command dispatched in ${Math.round(performance.now() - stopStarted)}ms`,
|
|
2442
|
+
);
|
|
2443
|
+
uploadPromise.catch(() => {});
|
|
2444
|
+
await invoke("hide_recording_chrome").catch(() => {});
|
|
2445
|
+
if (wantsCamera) await invoke("close_bubble").catch(() => {});
|
|
2446
|
+
await openNativeUploadUrl(
|
|
2447
|
+
id,
|
|
2448
|
+
`${params.serverUrl.replace(/\/+$/, "")}${viewUrl}`,
|
|
2449
|
+
);
|
|
2450
|
+
try {
|
|
2451
|
+
const uploaded = await uploadPromise;
|
|
2452
|
+
if (uploaded.verificationPending) {
|
|
2453
|
+
scheduleNativeBackupCleanupAfterProcessing({
|
|
2454
|
+
serverUrl: params.serverUrl,
|
|
2455
|
+
recordingId: id,
|
|
2456
|
+
authToken: params.authToken,
|
|
2457
|
+
});
|
|
2458
|
+
}
|
|
2459
|
+
return { recordingId: uploaded.recordingId, viewUrl };
|
|
2460
|
+
} catch (err) {
|
|
2461
|
+
if (
|
|
2462
|
+
await recoverAcceptedNativeRecordingAfterFinalizeError({
|
|
2463
|
+
serverUrl: params.serverUrl,
|
|
2464
|
+
recordingId: id,
|
|
2465
|
+
authToken: params.authToken,
|
|
2466
|
+
})
|
|
2467
|
+
) {
|
|
2468
|
+
return { recordingId: id, viewUrl };
|
|
2469
|
+
}
|
|
2470
|
+
await abortRecordingUpload(
|
|
2471
|
+
params.serverUrl,
|
|
2472
|
+
id,
|
|
2473
|
+
err instanceof Error ? err.message : String(err),
|
|
2474
|
+
);
|
|
2475
|
+
throw err;
|
|
2476
|
+
}
|
|
2477
|
+
} finally {
|
|
2478
|
+
audioCue.cleanup();
|
|
2479
|
+
await clearRecordingState();
|
|
2480
|
+
}
|
|
2481
|
+
})();
|
|
2482
|
+
return stopPromise;
|
|
2483
|
+
},
|
|
2484
|
+
async cancel() {
|
|
2485
|
+
if (cancelPromise) return cancelPromise;
|
|
2486
|
+
if (stopped) return;
|
|
2487
|
+
cancelPromise = (async () => {
|
|
2488
|
+
stopped = true;
|
|
2489
|
+
cleanupUi();
|
|
2490
|
+
await invoke("rewind_clip_cancel").catch(() => {});
|
|
2491
|
+
audioCue.cleanup();
|
|
2492
|
+
await invoke("hide_overlays").catch(() => {});
|
|
2493
|
+
await clearRecordingState();
|
|
2494
|
+
if (!localOnly && id) {
|
|
2495
|
+
forgetRewindClipOrigin(id);
|
|
2496
|
+
await cleanupCancelledRemoteRecording(params.serverUrl, id).catch(
|
|
2497
|
+
() => {},
|
|
2498
|
+
);
|
|
2499
|
+
}
|
|
2500
|
+
})();
|
|
2501
|
+
return cancelPromise;
|
|
2502
|
+
},
|
|
2503
|
+
};
|
|
2504
|
+
|
|
2505
|
+
pauseQueue = createPauseTransitionQueue({
|
|
2506
|
+
apply: (paused) =>
|
|
2507
|
+
invoke(paused ? "rewind_clip_pause" : "rewind_clip_resume"),
|
|
2508
|
+
onRequested(paused) {
|
|
2509
|
+
if (paused && pausedAt === null) pauseRequestedAt = Date.now();
|
|
2510
|
+
emitState(paused);
|
|
2511
|
+
},
|
|
2512
|
+
onApplied(paused) {
|
|
2513
|
+
if (paused) {
|
|
2514
|
+
pausedAt = pauseRequestedAt ?? Date.now();
|
|
2515
|
+
} else {
|
|
2516
|
+
if (pausedAt !== null) accumulatedPauseMs += Date.now() - pausedAt;
|
|
2517
|
+
pausedAt = null;
|
|
2518
|
+
}
|
|
2519
|
+
pauseRequestedAt = null;
|
|
2520
|
+
emitState(paused);
|
|
2521
|
+
},
|
|
2522
|
+
onError(_err, _attemptedPaused) {
|
|
2523
|
+
pauseRequestedAt = null;
|
|
2524
|
+
emitState(pauseQueue?.getAppliedPaused() ?? pausedAt !== null);
|
|
2525
|
+
},
|
|
2526
|
+
});
|
|
2527
|
+
stateUnlistens = await Promise.all([
|
|
2528
|
+
listen("clips:recorder-pause", () => pauseQueue?.request(true)),
|
|
2529
|
+
listen("clips:recorder-resume", () => pauseQueue?.request(false)),
|
|
2530
|
+
listen("clips:recorder-stop", () => {
|
|
2531
|
+
void handle.stop().catch((error) => {
|
|
2532
|
+
console.error("[clips-recorder] Rewind handle.stop() threw:", error);
|
|
2533
|
+
});
|
|
2534
|
+
}),
|
|
2535
|
+
listen("clips:recorder-cancel", () => {
|
|
2536
|
+
void handle.cancel().catch((error) => {
|
|
2537
|
+
console.error("[clips-recorder] Rewind handle.cancel() threw:", error);
|
|
2538
|
+
});
|
|
2539
|
+
}),
|
|
2540
|
+
]);
|
|
2541
|
+
tickHandle = window.setInterval(emitState, 500);
|
|
2542
|
+
emit("clips:toolbar-enabled", true).catch(() => {});
|
|
2543
|
+
emitState();
|
|
2544
|
+
return handle;
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
async function startNativeFullscreenRecording(
|
|
2548
|
+
params: StartParams,
|
|
2549
|
+
wantsCamera: boolean,
|
|
2550
|
+
wantsAudio: boolean,
|
|
2551
|
+
audioCue: AudioCue,
|
|
2552
|
+
): Promise<RecorderHandle> {
|
|
2553
|
+
console.log("[clips-recorder] using native full-screen capture");
|
|
2554
|
+
const localRecordingMode = params.localRecordingMode ?? "off";
|
|
2555
|
+
const localOnly = localRecordingMode !== "off";
|
|
2556
|
+
const localFolderName = localOnly ? createLocalRecordingFolderName() : "";
|
|
2557
|
+
const streamCleanups: Array<() => void> = [audioCue.cleanup];
|
|
2558
|
+
let id = "";
|
|
2559
|
+
let uploadMode: UploadMode = "buffered";
|
|
2560
|
+
let localCameraExport: LocalRecordingExportHandle | null = null;
|
|
2561
|
+
let localCameraStream: MediaStream | null = null;
|
|
2562
|
+
let localOwnsCameraStream = false;
|
|
2563
|
+
let bubbleCaptureExcluded = false;
|
|
2564
|
+
let captureRegion: RegionCaptureRect | null = null;
|
|
2565
|
+
let transcriptionCapture: TranscriptionCapture | null = null;
|
|
2566
|
+
// Timer baseline for the toolbar/pill elapsed clock. Stamped the instant
|
|
2567
|
+
// native capture goes live (right after the start invoke resolves), not after
|
|
2568
|
+
// the region-guide / transcription spin-up — which would push the displayed
|
|
2569
|
+
// clock and the toolbar-enable behind the real recording start.
|
|
2570
|
+
let startedAt = 0;
|
|
2571
|
+
let nativeTranscriptFailureSaved = false;
|
|
2572
|
+
const wantsSystemAudio = params.systemAudioOn !== false;
|
|
2573
|
+
const wantsRecordedAudio = wantsAudio || wantsSystemAudio;
|
|
2574
|
+
let micDeviceLabel: string | null = params.micLabel || null;
|
|
2575
|
+
const saveTranscriptFailure = async (
|
|
2576
|
+
failureReason: string,
|
|
2577
|
+
): Promise<boolean> => {
|
|
2578
|
+
if (!wantsRecordedAudio || nativeTranscriptFailureSaved || !id)
|
|
2579
|
+
return false;
|
|
2580
|
+
nativeTranscriptFailureSaved = true;
|
|
2581
|
+
return saveRecordingTranscriptFailure(
|
|
2582
|
+
params.serverUrl,
|
|
2583
|
+
id,
|
|
2584
|
+
failureReason,
|
|
2585
|
+
params.authToken,
|
|
2586
|
+
);
|
|
2587
|
+
};
|
|
2588
|
+
const startNativeTranscriptionBeforeRecording = async () => {
|
|
2589
|
+
if (localOnly || !wantsRecordedAudio || transcriptionCapture) return;
|
|
2590
|
+
transcriptionCapture = await startTranscriptionCapture(
|
|
2591
|
+
{
|
|
2592
|
+
deviceId: params.micId,
|
|
2593
|
+
label: micDeviceLabel,
|
|
2594
|
+
},
|
|
2595
|
+
wantsSystemAudio,
|
|
2596
|
+
{ voiceProcessing: false },
|
|
2597
|
+
);
|
|
2598
|
+
if (
|
|
2599
|
+
wantsRecordedAudio &&
|
|
2600
|
+
!transcriptionCapture &&
|
|
2601
|
+
shouldSaveLocalTranscriptionStartupFailure()
|
|
2602
|
+
) {
|
|
2603
|
+
void saveTranscriptFailure(
|
|
2604
|
+
"macOS Speech recognition could not start for this recording. Check Speech Recognition, System Audio, and Microphone permissions, then retry transcription.",
|
|
2605
|
+
);
|
|
2606
|
+
}
|
|
2607
|
+
};
|
|
2608
|
+
|
|
2609
|
+
try {
|
|
2610
|
+
await invoke("park_popover_offscreen").catch(() => {});
|
|
2611
|
+
emit("clips:popover-visible", false).catch(() => {});
|
|
2612
|
+
|
|
2613
|
+
if (params.source === "region") {
|
|
2614
|
+
captureRegion = await selectRegionForRecording();
|
|
2615
|
+
// Frame the selected area now so it stays visible through the countdown
|
|
2616
|
+
// and the whole recording. hide_recording_chrome / hide_overlays tear it
|
|
2617
|
+
// down on stop, cancel, and the error paths below.
|
|
2618
|
+
await showRegionRecordBorder(captureRegion);
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
if (localOnly && localRecordingMode === "separate" && wantsCamera) {
|
|
2622
|
+
localCameraStream =
|
|
2623
|
+
params.preAcquiredCameraStream ??
|
|
2624
|
+
(await getCameraStreamWithFallback(params.cameraId));
|
|
2625
|
+
localOwnsCameraStream =
|
|
2626
|
+
localCameraStream !== params.preAcquiredCameraStream;
|
|
2627
|
+
localCameraExport = await prepareLocalRecordingExport(
|
|
2628
|
+
[
|
|
2090
2629
|
{
|
|
2091
2630
|
role: "camera",
|
|
2092
2631
|
stream: streamFromTracks(localCameraStream.getVideoTracks()),
|
|
@@ -2167,7 +2706,6 @@ async function startNativeFullscreenRecording(
|
|
|
2167
2706
|
// Whisper startup with countdown + deferred SCK warm.
|
|
2168
2707
|
const countdownPromise = runRecordingCountdown(true);
|
|
2169
2708
|
id = localFolderName;
|
|
2170
|
-
const transcriptionPromise = startNativeTranscriptionBeforeRecording();
|
|
2171
2709
|
const warmPromise = (async () => {
|
|
2172
2710
|
const warmStartedAt = Date.now();
|
|
2173
2711
|
await warmMic(id);
|
|
@@ -2178,11 +2716,12 @@ async function startNativeFullscreenRecording(
|
|
|
2178
2716
|
try {
|
|
2179
2717
|
await Promise.all([
|
|
2180
2718
|
countdownPromise,
|
|
2181
|
-
|
|
2182
|
-
|
|
2719
|
+
(async () => {
|
|
2720
|
+
await warmPromise;
|
|
2721
|
+
await startNativeTranscriptionBeforeRecording();
|
|
2722
|
+
})(),
|
|
2183
2723
|
]);
|
|
2184
2724
|
} catch (err) {
|
|
2185
|
-
await transcriptionPromise.catch(() => {});
|
|
2186
2725
|
throw err;
|
|
2187
2726
|
}
|
|
2188
2727
|
} else {
|
|
@@ -2191,9 +2730,6 @@ async function startNativeFullscreenRecording(
|
|
|
2191
2730
|
source: params.source,
|
|
2192
2731
|
});
|
|
2193
2732
|
const countdownPromise = runRecordingCountdown(true);
|
|
2194
|
-
// Kick Whisper off immediately (no recording id needed) so Skip no longer
|
|
2195
|
-
// serializes create → Whisper → SCK warm on the critical path.
|
|
2196
|
-
const transcriptionPromise = startNativeTranscriptionBeforeRecording();
|
|
2197
2733
|
const recordingPromise = (async () => {
|
|
2198
2734
|
const captureTitle = await captureTitlePromise;
|
|
2199
2735
|
const createStartedAt = Date.now();
|
|
@@ -2229,7 +2765,7 @@ async function startNativeFullscreenRecording(
|
|
|
2229
2765
|
startTranscription: async () => {
|
|
2230
2766
|
const transcriptionStartedAt = Date.now();
|
|
2231
2767
|
try {
|
|
2232
|
-
await
|
|
2768
|
+
await startNativeTranscriptionBeforeRecording();
|
|
2233
2769
|
} finally {
|
|
2234
2770
|
console.log(
|
|
2235
2771
|
`[clips-recorder] transcription warm durationMs=${Date.now() - transcriptionStartedAt}`,
|
|
@@ -2600,7 +3136,7 @@ async function startNativeFullscreenRecording(
|
|
|
2600
3136
|
uploadResult = await uploadPromise;
|
|
2601
3137
|
} catch (err) {
|
|
2602
3138
|
if (
|
|
2603
|
-
await
|
|
3139
|
+
await recoverAcceptedNativeRecordingAfterFinalizeError({
|
|
2604
3140
|
serverUrl: params.serverUrl,
|
|
2605
3141
|
recordingId: id,
|
|
2606
3142
|
authToken: params.authToken,
|
|
@@ -2912,12 +3448,30 @@ async function startRecordingInner(
|
|
|
2912
3448
|
});
|
|
2913
3449
|
|
|
2914
3450
|
if (wantsScreen && shouldUseNativeFullscreenRecording(captureSource)) {
|
|
2915
|
-
|
|
3451
|
+
const rewindRecorder = await tryStartRewindFullscreenRecording(
|
|
2916
3452
|
params,
|
|
2917
3453
|
wantsCamera,
|
|
2918
3454
|
wantsAudio,
|
|
2919
3455
|
audioCue,
|
|
2920
3456
|
);
|
|
3457
|
+
if (rewindRecorder) return rewindRecorder;
|
|
3458
|
+
const suspension = await acquireRewindCaptureSuspension({
|
|
3459
|
+
requiresScreen: true,
|
|
3460
|
+
requiresMicrophone: wantsAudio,
|
|
3461
|
+
});
|
|
3462
|
+
const releaseSuspension = captureSuspensionReleaser(suspension);
|
|
3463
|
+
try {
|
|
3464
|
+
const recorder = await startNativeFullscreenRecording(
|
|
3465
|
+
params,
|
|
3466
|
+
wantsCamera,
|
|
3467
|
+
wantsAudio,
|
|
3468
|
+
audioCue,
|
|
3469
|
+
);
|
|
3470
|
+
return recorderWithCaptureSuspension(recorder, releaseSuspension);
|
|
3471
|
+
} catch (err) {
|
|
3472
|
+
await releaseSuspension();
|
|
3473
|
+
throw err;
|
|
3474
|
+
}
|
|
2921
3475
|
}
|
|
2922
3476
|
|
|
2923
3477
|
// 1. Acquire streams BEFORE the countdown so the user gets the permission
|
|
@@ -2951,6 +3505,15 @@ async function startRecordingInner(
|
|
|
2951
3505
|
const streamCleanups: Array<() => void> = [audioCue.cleanup];
|
|
2952
3506
|
const devSyntheticCapture = shouldUseDevSyntheticCapture();
|
|
2953
3507
|
|
|
3508
|
+
// Queue the native suspension before dispatching any WebKit capture calls.
|
|
3509
|
+
// `getDisplayMedia` must stay in this user-activation turn, so awaiting IPC
|
|
3510
|
+
// first would make the OS picker fail. The command is initiated first and
|
|
3511
|
+
// must resolve before we accept any acquired stream below.
|
|
3512
|
+
const suspensionPromise = acquireRewindCaptureSuspension({
|
|
3513
|
+
requiresScreen: wantsScreen,
|
|
3514
|
+
requiresMicrophone: wantsAudio,
|
|
3515
|
+
});
|
|
3516
|
+
|
|
2954
3517
|
const displayStreamPromise: Promise<MediaStream> | null = wantsScreen
|
|
2955
3518
|
? (() => {
|
|
2956
3519
|
if (!devSyntheticCapture) {
|
|
@@ -2997,213 +3560,611 @@ async function startRecordingInner(
|
|
|
2997
3560
|
? getAudioStreamWithFallback(params.micId, params.micLabel)
|
|
2998
3561
|
: null;
|
|
2999
3562
|
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3563
|
+
let captureSuspension: RewindCaptureSuspensionLease;
|
|
3564
|
+
try {
|
|
3565
|
+
captureSuspension = await suspensionPromise;
|
|
3566
|
+
} catch (err) {
|
|
3567
|
+
// A picker may already have resolved while IPC was in flight. Tear down
|
|
3568
|
+
// every possible stream before surfacing the fail-closed suspension error.
|
|
3569
|
+
const streams = await Promise.allSettled([
|
|
3570
|
+
displayStreamPromise,
|
|
3571
|
+
bubbleCameraStreamPromise,
|
|
3572
|
+
audioStreamPromise,
|
|
3573
|
+
]);
|
|
3574
|
+
streams.forEach((result) => {
|
|
3575
|
+
if (result.status === "fulfilled") {
|
|
3576
|
+
result.value?.getTracks().forEach((track) => track.stop());
|
|
3577
|
+
}
|
|
3578
|
+
});
|
|
3579
|
+
throw err;
|
|
3580
|
+
}
|
|
3581
|
+
const releaseCaptureSuspension = captureSuspensionReleaser(captureSuspension);
|
|
3582
|
+
|
|
3583
|
+
try {
|
|
3584
|
+
// Use allSettled so a single rejection (e.g. user cancels the macOS screen
|
|
3585
|
+
// picker → `NotAllowedError`) doesn't leave the OTHER resolved streams
|
|
3586
|
+
// orphaned with live tracks. If ANY of the three rejected, we stop every
|
|
3587
|
+
// track that DID resolve, then re-throw the original error so the caller's
|
|
3588
|
+
// catch still sees `NotAllowedError` / `AbortError` as before.
|
|
3589
|
+
console.log("[clips-recorder] allSettled IN — streams dispatched");
|
|
3590
|
+
const settled = await Promise.allSettled([
|
|
3591
|
+
displayStreamPromise,
|
|
3592
|
+
bubbleCameraStreamPromise,
|
|
3593
|
+
audioStreamPromise,
|
|
3594
|
+
]);
|
|
3595
|
+
console.log(
|
|
3596
|
+
"[clips-recorder] allSettled OUT — settled statuses:",
|
|
3597
|
+
settled.map((s) => s.status),
|
|
3598
|
+
);
|
|
3599
|
+
const firstRejectionIndex = settled.findIndex(
|
|
3600
|
+
(s) => s.status === "rejected",
|
|
3601
|
+
);
|
|
3602
|
+
const firstRejection =
|
|
3603
|
+
firstRejectionIndex >= 0
|
|
3604
|
+
? (settled[firstRejectionIndex] as PromiseRejectedResult)
|
|
3605
|
+
: null;
|
|
3606
|
+
if (firstRejection) {
|
|
3607
|
+
const canUseSyntheticScreen =
|
|
3608
|
+
devSyntheticCapture &&
|
|
3609
|
+
wantsScreen &&
|
|
3610
|
+
displayStreamPromise != null &&
|
|
3611
|
+
firstRejectionIndex === 0;
|
|
3612
|
+
if (!canUseSyntheticScreen) {
|
|
3613
|
+
for (const s of settled) {
|
|
3614
|
+
if (s.status === "fulfilled" && s.value) {
|
|
3615
|
+
try {
|
|
3616
|
+
s.value.getTracks().forEach((t) => t.stop());
|
|
3617
|
+
} catch {
|
|
3618
|
+
// ignore — best-effort cleanup
|
|
3619
|
+
}
|
|
3033
3620
|
}
|
|
3034
3621
|
}
|
|
3622
|
+
// NOTE: we do NOT stop `reusedCameraStream` tracks here. The popover
|
|
3623
|
+
// owns the camera for the entire session (see top-of-file comment +
|
|
3624
|
+
// `preAcquiredCameraStream` doc) — it keeps the stream alive so the
|
|
3625
|
+
// bubble stays live while the user retries.
|
|
3626
|
+
const rejErr = firstRejection.reason;
|
|
3627
|
+
console.error(
|
|
3628
|
+
"[clips-recorder] stream acquisition failed:",
|
|
3629
|
+
(rejErr as { name?: string })?.name,
|
|
3630
|
+
(rejErr as { message?: string })?.message,
|
|
3631
|
+
rejErr,
|
|
3632
|
+
);
|
|
3633
|
+
throw firstRejection.reason;
|
|
3035
3634
|
}
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
// bubble stays live while the user retries.
|
|
3040
|
-
const rejErr = firstRejection.reason;
|
|
3041
|
-
console.error(
|
|
3042
|
-
"[clips-recorder] stream acquisition failed:",
|
|
3043
|
-
(rejErr as { name?: string })?.name,
|
|
3044
|
-
(rejErr as { message?: string })?.message,
|
|
3045
|
-
rejErr,
|
|
3635
|
+
console.warn(
|
|
3636
|
+
"[clips-recorder] continuing with opt-in dev synthetic capture after stream acquisition failed:",
|
|
3637
|
+
firstRejection.reason,
|
|
3046
3638
|
);
|
|
3047
|
-
throw firstRejection.reason;
|
|
3048
3639
|
}
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
const syntheticAudio = createSyntheticAudioStream();
|
|
3081
|
-
if (syntheticAudio) {
|
|
3082
|
-
audioStream = syntheticAudio.stream;
|
|
3083
|
-
streamCleanups.push(syntheticAudio.cleanup);
|
|
3640
|
+
let displayStream =
|
|
3641
|
+
settled[0].status === "fulfilled"
|
|
3642
|
+
? (settled[0].value as MediaStream | null)
|
|
3643
|
+
: null;
|
|
3644
|
+
let freshlyAcquiredCameraStream =
|
|
3645
|
+
settled[1].status === "fulfilled"
|
|
3646
|
+
? (settled[1].value as MediaStream | null)
|
|
3647
|
+
: null;
|
|
3648
|
+
let audioStream =
|
|
3649
|
+
settled[2].status === "fulfilled"
|
|
3650
|
+
? (settled[2].value as MediaStream | null)
|
|
3651
|
+
: null;
|
|
3652
|
+
if (
|
|
3653
|
+
firstRejection &&
|
|
3654
|
+
firstRejectionIndex === 0 &&
|
|
3655
|
+
devSyntheticCapture &&
|
|
3656
|
+
wantsScreen &&
|
|
3657
|
+
!displayStream
|
|
3658
|
+
) {
|
|
3659
|
+
[displayStream, freshlyAcquiredCameraStream, audioStream].forEach((s) =>
|
|
3660
|
+
s?.getTracks().forEach((track) => track.stop()),
|
|
3661
|
+
);
|
|
3662
|
+
const syntheticDisplay = createSyntheticScreenStream();
|
|
3663
|
+
displayStream = syntheticDisplay.stream;
|
|
3664
|
+
streamCleanups.push(syntheticDisplay.cleanup);
|
|
3665
|
+
if (wantsAudio && !audioStream) {
|
|
3666
|
+
const syntheticAudio = createSyntheticAudioStream();
|
|
3667
|
+
if (syntheticAudio) {
|
|
3668
|
+
audioStream = syntheticAudio.stream;
|
|
3669
|
+
streamCleanups.push(syntheticAudio.cleanup);
|
|
3670
|
+
}
|
|
3084
3671
|
}
|
|
3672
|
+
freshlyAcquiredCameraStream = null;
|
|
3085
3673
|
}
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
const bubbleCameraStream =
|
|
3091
|
-
reusedCameraStream ?? freshlyAcquiredCameraStream ?? null;
|
|
3674
|
+
// Reused (from preview) XOR freshly acquired — `bubbleCameraStreamPromise`
|
|
3675
|
+
// was null when we reused, so only one of the two can be non-null.
|
|
3676
|
+
const bubbleCameraStream =
|
|
3677
|
+
reusedCameraStream ?? freshlyAcquiredCameraStream ?? null;
|
|
3092
3678
|
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3679
|
+
if (displayStream) {
|
|
3680
|
+
console.log(
|
|
3681
|
+
"[clips-recorder] display media acquired",
|
|
3682
|
+
displayStream.getTracks().map((t) => t.kind),
|
|
3683
|
+
);
|
|
3684
|
+
}
|
|
3685
|
+
if (bubbleCameraStream) {
|
|
3686
|
+
const vtrack = bubbleCameraStream.getVideoTracks()[0];
|
|
3687
|
+
console.log("[clips-recorder] camera acquired", {
|
|
3688
|
+
label: vtrack?.label,
|
|
3689
|
+
readyState: vtrack?.readyState,
|
|
3690
|
+
muted: vtrack?.muted,
|
|
3691
|
+
});
|
|
3692
|
+
}
|
|
3693
|
+
if (audioStream) {
|
|
3694
|
+
console.log(
|
|
3695
|
+
"[clips-recorder] audioStream acquired",
|
|
3696
|
+
audioStream.getAudioTracks().map((t) => ({
|
|
3697
|
+
label: t.label,
|
|
3698
|
+
readyState: t.readyState,
|
|
3699
|
+
})),
|
|
3700
|
+
);
|
|
3701
|
+
}
|
|
3702
|
+
|
|
3703
|
+
await invoke("park_popover_offscreen").catch(() => {});
|
|
3704
|
+
emit("clips:popover-visible", false).catch(() => {});
|
|
3705
|
+
const captureTitle = await captureTitleForRecording({
|
|
3706
|
+
mode: params.mode,
|
|
3707
|
+
source: captureSource,
|
|
3708
|
+
});
|
|
3709
|
+
let transcriptionCapture: TranscriptionCapture | null = null;
|
|
3710
|
+
|
|
3711
|
+
const recordedScreenCameraStream =
|
|
3712
|
+
localRecordingMode !== "separate" &&
|
|
3713
|
+
params.mode === "screen-camera" &&
|
|
3714
|
+
displayStream &&
|
|
3715
|
+
bubbleCameraStream
|
|
3716
|
+
? createCameraCompositeStream({
|
|
3717
|
+
displayStream,
|
|
3718
|
+
cameraStream: bubbleCameraStream,
|
|
3719
|
+
bubbleSizeRatio: bubbleSizeRatioForName(
|
|
3720
|
+
await invoke<string>("load_bubble_size").catch(() => "small"),
|
|
3721
|
+
),
|
|
3722
|
+
})
|
|
3723
|
+
: null;
|
|
3724
|
+
if (recordedScreenCameraStream) {
|
|
3725
|
+
streamCleanups.push(recordedScreenCameraStream.cleanup);
|
|
3726
|
+
console.log("[clips-recorder] compositing camera into recorded video");
|
|
3727
|
+
}
|
|
3728
|
+
|
|
3729
|
+
// Choose the primary video track for MediaRecorder:
|
|
3730
|
+
// - screen mode → display
|
|
3731
|
+
// - screen-camera mode → composited display + camera
|
|
3732
|
+
// - camera mode → camera
|
|
3733
|
+
const primaryVideo =
|
|
3734
|
+
recordedScreenCameraStream?.stream ??
|
|
3735
|
+
displayStream ??
|
|
3736
|
+
(params.mode === "camera" ? bubbleCameraStream : null);
|
|
3737
|
+
if (!primaryVideo) throw new Error("No video stream available");
|
|
3738
|
+
|
|
3739
|
+
const combined = new MediaStream();
|
|
3740
|
+
primaryVideo.getVideoTracks().forEach((t) => combined.addTrack(t));
|
|
3741
|
+
// Mic + system audio (mixed when both present); see buildRecordingAudio.
|
|
3742
|
+
const recordingAudio = buildRecordingAudio(
|
|
3743
|
+
audioStream?.getAudioTracks() ?? [],
|
|
3744
|
+
displayStream?.getAudioTracks() ?? [],
|
|
3114
3745
|
);
|
|
3115
|
-
|
|
3746
|
+
streamCleanups.push(recordingAudio.cleanup);
|
|
3747
|
+
recordingAudio.tracks.forEach((t) => combined.addTrack(t));
|
|
3748
|
+
|
|
3749
|
+
// The popover owns the camera stream whenever we reused its pre-acquired
|
|
3750
|
+
// preview stream — its session effect decides when to close the stream +
|
|
3751
|
+
// hide the bubble + stop the pump, so the recorder must NOT stop those
|
|
3752
|
+
// tracks on stop/cancel. The rare exception is the fresh-acquire fallback
|
|
3753
|
+
// (preview stream wasn't ready at record start, so we opened the camera
|
|
3754
|
+
// ourselves above) — there we own the tracks and must stop them, or the
|
|
3755
|
+
// camera + macOS recording indicator leak after the recording ends.
|
|
3756
|
+
const popoverOwnsCamera = bubbleCameraStream === reusedCameraStream;
|
|
3757
|
+
|
|
3758
|
+
if (localRecordingMode !== "off") {
|
|
3759
|
+
console.log("[clips-recorder] starting local-only recording", {
|
|
3760
|
+
localRecordingMode,
|
|
3761
|
+
});
|
|
3762
|
+
const targets = localRecordingTargetsForMode({
|
|
3763
|
+
localRecordingMode,
|
|
3764
|
+
displayStream,
|
|
3765
|
+
bubbleCameraStream,
|
|
3766
|
+
recordingAudio,
|
|
3767
|
+
combined,
|
|
3768
|
+
});
|
|
3116
3769
|
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3770
|
+
const countdownPromise = runRecordingCountdown(wantsScreen);
|
|
3771
|
+
const localExportPromise = prepareLocalRecordingExport(targets);
|
|
3772
|
+
let localExport: Awaited<ReturnType<typeof prepareLocalRecordingExport>>;
|
|
3773
|
+
try {
|
|
3774
|
+
[, localExport] = await Promise.all([
|
|
3775
|
+
countdownPromise,
|
|
3776
|
+
localExportPromise,
|
|
3777
|
+
]);
|
|
3778
|
+
} catch (err) {
|
|
3779
|
+
[displayStream, audioStream].forEach((stream) =>
|
|
3780
|
+
stream?.getTracks().forEach((track) => track.stop()),
|
|
3781
|
+
);
|
|
3782
|
+
streamCleanups.forEach((cleanup) => cleanup());
|
|
3783
|
+
if (!popoverOwnsCamera) {
|
|
3784
|
+
bubbleCameraStream?.getTracks().forEach((track) => track.stop());
|
|
3785
|
+
}
|
|
3786
|
+
throw err;
|
|
3787
|
+
}
|
|
3124
3788
|
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3789
|
+
const id = `local-${Date.now().toString(36)}`;
|
|
3790
|
+
// Stamped at the real capture start below — kept 0 until then so the tick
|
|
3791
|
+
// never reports an elapsed time against a stale baseline (which showed the
|
|
3792
|
+
// clock counting up and then resetting to 0 when start finally fired).
|
|
3793
|
+
let startedAt = 0;
|
|
3794
|
+
let pausedAt: number | null = null;
|
|
3795
|
+
let accumulatedPauseMs = 0;
|
|
3796
|
+
let stopped = false;
|
|
3797
|
+
let stateUnlistens: UnlistenFn[] = [];
|
|
3798
|
+
let tickHandle: ReturnType<typeof setInterval> | null = null;
|
|
3799
|
+
|
|
3800
|
+
function emitState(paused: boolean) {
|
|
3801
|
+
const now = Date.now();
|
|
3802
|
+
const pausedNowMs = paused && pausedAt ? now - pausedAt : 0;
|
|
3803
|
+
const elapsedMs = now - startedAt - accumulatedPauseMs - pausedNowMs;
|
|
3804
|
+
emit("clips:recorder-state", {
|
|
3805
|
+
paused,
|
|
3806
|
+
elapsedMs,
|
|
3807
|
+
}).catch(() => {});
|
|
3808
|
+
}
|
|
3142
3809
|
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3810
|
+
const toolbarUnlistens = await Promise.all([
|
|
3811
|
+
listen("clips:recorder-pause", () => {
|
|
3812
|
+
localExport.pause();
|
|
3813
|
+
pausedAt = Date.now();
|
|
3814
|
+
emitState(true);
|
|
3815
|
+
}),
|
|
3816
|
+
listen("clips:recorder-resume", () => {
|
|
3817
|
+
localExport.resume();
|
|
3818
|
+
if (pausedAt) accumulatedPauseMs += Date.now() - pausedAt;
|
|
3819
|
+
pausedAt = null;
|
|
3820
|
+
emitState(false);
|
|
3821
|
+
}),
|
|
3822
|
+
listen("clips:recorder-stop", () => {
|
|
3823
|
+
console.log("[clips-recorder] local stop event received");
|
|
3824
|
+
handle.stop().catch((err) => {
|
|
3825
|
+
console.error("[clips-recorder] local handle.stop() threw:", err);
|
|
3826
|
+
});
|
|
3827
|
+
}),
|
|
3828
|
+
listen("clips:recorder-cancel", () => {
|
|
3829
|
+
console.log("[clips-recorder] local cancel event received");
|
|
3830
|
+
handle.cancel().catch((err) => {
|
|
3831
|
+
console.error("[clips-recorder] local handle.cancel() threw:", err);
|
|
3832
|
+
});
|
|
3833
|
+
}),
|
|
3834
|
+
]);
|
|
3835
|
+
stateUnlistens = toolbarUnlistens;
|
|
3836
|
+
|
|
3837
|
+
await showRegionGuidesForRecording(wantsScreen);
|
|
3838
|
+
await audioCue.playBeforeCapture();
|
|
3839
|
+
localExport.start(2_000);
|
|
3840
|
+
startedAt = Date.now();
|
|
3841
|
+
tickHandle = setInterval(() => emitState(pausedAt != null), 500);
|
|
3842
|
+
emit("clips:toolbar-enabled", true).catch(() => {});
|
|
3843
|
+
emitState(false);
|
|
3844
|
+
|
|
3845
|
+
const detachCombinedStream = () => {
|
|
3846
|
+
try {
|
|
3847
|
+
combined.getTracks().forEach((track) => combined.removeTrack(track));
|
|
3848
|
+
} catch {
|
|
3849
|
+
// ignore — best-effort
|
|
3850
|
+
}
|
|
3851
|
+
for (const target of targets) {
|
|
3852
|
+
try {
|
|
3853
|
+
target.stream
|
|
3854
|
+
.getTracks()
|
|
3855
|
+
.forEach((track) => target.stream.removeTrack(track));
|
|
3856
|
+
} catch {
|
|
3857
|
+
// ignore — best-effort
|
|
3858
|
+
}
|
|
3859
|
+
}
|
|
3860
|
+
};
|
|
3861
|
+
|
|
3862
|
+
const stopOwnedStreams = () => {
|
|
3863
|
+
[displayStream, audioStream].forEach((stream) =>
|
|
3864
|
+
stream?.getTracks().forEach((track) => track.stop()),
|
|
3865
|
+
);
|
|
3866
|
+
streamCleanups.forEach((cleanup) => cleanup());
|
|
3867
|
+
if (!popoverOwnsCamera) {
|
|
3868
|
+
bubbleCameraStream?.getTracks().forEach((track) => track.stop());
|
|
3869
|
+
}
|
|
3870
|
+
};
|
|
3871
|
+
|
|
3872
|
+
const hideChrome = async () => {
|
|
3873
|
+
await invoke("hide_recording_chrome").catch((err) =>
|
|
3874
|
+
console.error(`[clips-recorder] hide_recording_chrome failed:`, err),
|
|
3875
|
+
);
|
|
3876
|
+
};
|
|
3877
|
+
|
|
3878
|
+
const handle: RecorderHandle = {
|
|
3879
|
+
async stop() {
|
|
3880
|
+
if (stopped) {
|
|
3881
|
+
return {
|
|
3882
|
+
recordingId: id,
|
|
3883
|
+
viewUrl: "",
|
|
3884
|
+
localOnly: true,
|
|
3885
|
+
localFolder: localExport.folderPath,
|
|
3886
|
+
localFiles: [],
|
|
3887
|
+
};
|
|
3888
|
+
}
|
|
3889
|
+
stopped = true;
|
|
3890
|
+
if (tickHandle) clearInterval(tickHandle);
|
|
3891
|
+
stateUnlistens.forEach((unlisten) => unlisten());
|
|
3892
|
+
stateUnlistens = [];
|
|
3893
|
+
const durationMs = Math.max(
|
|
3894
|
+
0,
|
|
3895
|
+
Math.round(Date.now() - startedAt - accumulatedPauseMs),
|
|
3896
|
+
);
|
|
3897
|
+
let files: LocalExportedFile[] = [];
|
|
3898
|
+
try {
|
|
3899
|
+
files = await localExport.stop(durationMs);
|
|
3900
|
+
} finally {
|
|
3901
|
+
detachCombinedStream();
|
|
3902
|
+
stopOwnedStreams();
|
|
3903
|
+
await hideChrome();
|
|
3904
|
+
}
|
|
3905
|
+
return {
|
|
3906
|
+
recordingId: id,
|
|
3907
|
+
viewUrl: "",
|
|
3908
|
+
localOnly: true,
|
|
3909
|
+
localFolder: localExport.folderPath,
|
|
3910
|
+
localFiles: files,
|
|
3911
|
+
};
|
|
3912
|
+
},
|
|
3913
|
+
|
|
3914
|
+
async cancel() {
|
|
3915
|
+
if (stopped) return;
|
|
3916
|
+
stopped = true;
|
|
3917
|
+
if (tickHandle) clearInterval(tickHandle);
|
|
3918
|
+
stateUnlistens.forEach((unlisten) => unlisten());
|
|
3919
|
+
stateUnlistens = [];
|
|
3920
|
+
await localExport.cancel();
|
|
3921
|
+
detachCombinedStream();
|
|
3922
|
+
stopOwnedStreams();
|
|
3923
|
+
await hideChrome();
|
|
3924
|
+
},
|
|
3925
|
+
};
|
|
3926
|
+
|
|
3927
|
+
return recorderWithCaptureSuspension(handle, releaseCaptureSuspension);
|
|
3928
|
+
}
|
|
3183
3929
|
|
|
3930
|
+
const uploadPrimaryVideo = createUploadOptimizedVideoStream(primaryVideo);
|
|
3931
|
+
streamCleanups.push(uploadPrimaryVideo.cleanup);
|
|
3932
|
+
|
|
3933
|
+
const uploadCombined = new MediaStream();
|
|
3934
|
+
uploadPrimaryVideo.stream
|
|
3935
|
+
.getVideoTracks()
|
|
3936
|
+
.forEach((track) => uploadCombined.addTrack(track));
|
|
3937
|
+
recordingAudio.tracks.forEach((track) => uploadCombined.addTrack(track));
|
|
3938
|
+
|
|
3939
|
+
// MIME type is resolved up front so create-recording can initialize the
|
|
3940
|
+
// resumable session with the correct content type when the server supports
|
|
3941
|
+
// streaming uploads.
|
|
3942
|
+
const mimeCandidates = [
|
|
3943
|
+
"video/webm;codecs=vp8,opus",
|
|
3944
|
+
"video/webm;codecs=vp9,opus",
|
|
3945
|
+
"video/webm",
|
|
3946
|
+
];
|
|
3947
|
+
const mimeType =
|
|
3948
|
+
mimeCandidates.find((m) => MediaRecorder.isTypeSupported(m)) ?? "";
|
|
3949
|
+
|
|
3950
|
+
// 2+3. Countdown + create-recording happen IN PARALLEL. The countdown is
|
|
3951
|
+
// pure visual feedback — gating it on a network round-trip makes the
|
|
3952
|
+
// 3-2-1 feel laggy after the user picks a screen. Kick both off and
|
|
3953
|
+
// wait at the end before starting the MediaRecorder.
|
|
3954
|
+
console.log(
|
|
3955
|
+
"[clips-recorder] invoking show_countdown + createServerRecording",
|
|
3956
|
+
);
|
|
3184
3957
|
const countdownPromise = runRecordingCountdown(wantsScreen);
|
|
3185
|
-
|
|
3186
|
-
|
|
3958
|
+
console.time("[clips-recorder] createServerRecording duration");
|
|
3959
|
+
const recordingPromise = createServerRecording(
|
|
3960
|
+
params.serverUrl,
|
|
3961
|
+
wantsCamera,
|
|
3962
|
+
recordingAudio.tracks.length > 0,
|
|
3963
|
+
captureTitle,
|
|
3964
|
+
{ mimeType: mimeType || "video/webm", requestStreaming: true },
|
|
3965
|
+
).finally(() => {
|
|
3966
|
+
console.timeEnd("[clips-recorder] createServerRecording duration");
|
|
3967
|
+
});
|
|
3968
|
+
console.log("[clips-recorder] awaiting countdown + createServerRecording");
|
|
3969
|
+
let createRes: Awaited<ReturnType<typeof createServerRecording>>;
|
|
3187
3970
|
try {
|
|
3188
|
-
[,
|
|
3189
|
-
countdownPromise,
|
|
3190
|
-
localExportPromise,
|
|
3191
|
-
]);
|
|
3971
|
+
[, createRes] = await Promise.all([countdownPromise, recordingPromise]);
|
|
3192
3972
|
} catch (err) {
|
|
3193
|
-
|
|
3194
|
-
|
|
3973
|
+
abortCreatedRecordingOnCountdownCancel(
|
|
3974
|
+
err,
|
|
3975
|
+
recordingPromise,
|
|
3976
|
+
params.serverUrl,
|
|
3195
3977
|
);
|
|
3196
|
-
streamCleanups.forEach((cleanup) => cleanup());
|
|
3197
|
-
if (!popoverOwnsCamera) {
|
|
3198
|
-
bubbleCameraStream?.getTracks().forEach((track) => track.stop());
|
|
3199
|
-
}
|
|
3200
3978
|
throw err;
|
|
3201
3979
|
}
|
|
3980
|
+
const { id, uploadMode } = createRes;
|
|
3981
|
+
console.log(
|
|
3982
|
+
"[clips-recorder] countdown + createServerRecording both resolved, id=",
|
|
3983
|
+
id,
|
|
3984
|
+
);
|
|
3985
|
+
console.log("[clips-recorder] recording row created", { id, uploadMode });
|
|
3986
|
+
let nativeTranscriptFailureSaved = false;
|
|
3987
|
+
const saveTranscriptFailure = async (
|
|
3988
|
+
failureReason: string,
|
|
3989
|
+
): Promise<boolean> => {
|
|
3990
|
+
if (!wantsRecordedAudio || nativeTranscriptFailureSaved) return false;
|
|
3991
|
+
nativeTranscriptFailureSaved = true;
|
|
3992
|
+
return saveRecordingTranscriptFailure(
|
|
3993
|
+
params.serverUrl,
|
|
3994
|
+
id,
|
|
3995
|
+
failureReason,
|
|
3996
|
+
params.authToken,
|
|
3997
|
+
);
|
|
3998
|
+
};
|
|
3999
|
+
|
|
4000
|
+
// 4. Start MediaRecorder with a 2-second timeslice — each `ondataavailable`
|
|
4001
|
+
// streams a chunk to the server, so we don't hold 5-min buffers in memory.
|
|
4002
|
+
const recorder = createCloudMediaRecorder(uploadCombined, mimeType);
|
|
4003
|
+
let chunkIndex = 0;
|
|
4004
|
+
let failed: Error | null = null;
|
|
4005
|
+
let backupBytes = 0;
|
|
4006
|
+
// Backup chunks are indexed by raw MediaRecorder blob (one per
|
|
4007
|
+
// `ondataavailable`), independent of `chunkIndex` — on the streaming path
|
|
4008
|
+
// `chunkIndex` counts aligned upload slices, not raw blobs.
|
|
4009
|
+
let backupChunkCount = 0;
|
|
4010
|
+
const streamMimeType = mimeType || "video/webm";
|
|
4011
|
+
let backupMeta: BrowserRecordingBackupMeta = {
|
|
4012
|
+
recordingId: id,
|
|
4013
|
+
serverUrl: params.serverUrl.replace(/\/+$/, ""),
|
|
4014
|
+
durationMs: 0,
|
|
4015
|
+
width: null,
|
|
4016
|
+
height: null,
|
|
4017
|
+
bytes: 0,
|
|
4018
|
+
hasAudio: uploadCombined.getAudioTracks().length > 0,
|
|
4019
|
+
hasCamera: wantsCamera,
|
|
4020
|
+
savedAt: new Date().toISOString(),
|
|
4021
|
+
lastAttemptAt: null,
|
|
4022
|
+
lastError: null,
|
|
4023
|
+
retryCount: 0,
|
|
4024
|
+
chunkCount: 0,
|
|
4025
|
+
mimeType: mimeType || "video/webm",
|
|
4026
|
+
};
|
|
4027
|
+
const persistBackupMeta = async (
|
|
4028
|
+
patch: Partial<BrowserRecordingBackupMeta> = {},
|
|
4029
|
+
) => {
|
|
4030
|
+
backupMeta = { ...backupMeta, ...patch };
|
|
4031
|
+
await putBrowserRecordingBackupMeta(backupMeta);
|
|
4032
|
+
};
|
|
4033
|
+
let backupFailure: Error | null = null;
|
|
4034
|
+
const backupWrites = new Set<Promise<void>>();
|
|
4035
|
+
let initialBackupWrite: Promise<void>;
|
|
4036
|
+
initialBackupWrite = persistBackupMeta()
|
|
4037
|
+
.catch((err) => {
|
|
4038
|
+
backupFailure = err instanceof Error ? err : new Error(String(err));
|
|
4039
|
+
console.warn("[clips-recorder] local backup metadata failed:", err);
|
|
4040
|
+
})
|
|
4041
|
+
.finally(() => {
|
|
4042
|
+
backupWrites.delete(initialBackupWrite);
|
|
4043
|
+
});
|
|
4044
|
+
backupWrites.add(initialBackupWrite);
|
|
4045
|
+
|
|
4046
|
+
// Every raw MediaRecorder blob is mirrored to IndexedDB on both upload paths.
|
|
4047
|
+
// If uploads fail the recording can still be recovered locally — replayed to
|
|
4048
|
+
// the server (the retry first resets any resumable session so replay routes
|
|
4049
|
+
// through the buffered chunk path) or exported to a local file.
|
|
4050
|
+
const backupChunkLocally = (blob: Blob): Promise<void> => {
|
|
4051
|
+
const backupIdx = backupChunkCount++;
|
|
4052
|
+
backupBytes += blob.size;
|
|
4053
|
+
const chunkMimeType = blob.type || streamMimeType;
|
|
4054
|
+
let w: Promise<void>;
|
|
4055
|
+
w = (async () => {
|
|
4056
|
+
try {
|
|
4057
|
+
await putBrowserRecordingBackupChunk({
|
|
4058
|
+
recordingId: id,
|
|
4059
|
+
index: backupIdx,
|
|
4060
|
+
blob,
|
|
4061
|
+
bytes: blob.size,
|
|
4062
|
+
mimeType: chunkMimeType,
|
|
4063
|
+
createdAt: new Date().toISOString(),
|
|
4064
|
+
});
|
|
4065
|
+
await persistBackupMeta({
|
|
4066
|
+
bytes: backupBytes,
|
|
4067
|
+
chunkCount: backupChunkCount,
|
|
4068
|
+
mimeType: chunkMimeType,
|
|
4069
|
+
});
|
|
4070
|
+
} catch (err) {
|
|
4071
|
+
backupFailure = err instanceof Error ? err : new Error(String(err));
|
|
4072
|
+
console.warn("[clips-recorder] local chunk backup failed:", err);
|
|
4073
|
+
}
|
|
4074
|
+
})().finally(() => {
|
|
4075
|
+
backupWrites.delete(w);
|
|
4076
|
+
});
|
|
4077
|
+
backupWrites.add(w);
|
|
4078
|
+
return w;
|
|
4079
|
+
};
|
|
4080
|
+
// In-flight chunk uploads. We use a Set (not an array) so entries can be
|
|
4081
|
+
// removed as soon as each fetch settles — otherwise, for a 30-minute
|
|
4082
|
+
// recording the array grows to 900 Promises, and EACH promise closes over
|
|
4083
|
+
// the Blob it's uploading. MediaRecorder Blobs are the raw encoded video
|
|
4084
|
+
// chunk — ~500KB to ~1MB each. Holding 900 of them is a ~700MB leak per
|
|
4085
|
+
// recording, and cumulative across recordings in a long-lived process.
|
|
4086
|
+
// See `uploadChunk()` — it removes its own entry in `.finally()`.
|
|
4087
|
+
const inflight = new Set<Promise<void>>();
|
|
4088
|
+
|
|
4089
|
+
// Streaming-path state. When the server opened a resumable session, blobs
|
|
4090
|
+
// accumulate here until at least STREAM_CHUNK_BYTES is available, then a
|
|
4091
|
+
// 256 KiB-aligned slice is uploaded as a non-final chunk. Resumable sessions
|
|
4092
|
+
// append by byte offset server-side, so streamed chunks MUST arrive in order
|
|
4093
|
+
// — uploads are serialized through `streamQueue`. The unaligned remainder is
|
|
4094
|
+
// sent as the final chunk on stop().
|
|
4095
|
+
let pendingStreamBlobs: Blob[] = [];
|
|
4096
|
+
let pendingStreamBytes = 0;
|
|
4097
|
+
let streamQueue: Promise<void> = Promise.resolve();
|
|
4098
|
+
|
|
4099
|
+
const queueStreamChunk = (blob: Blob, idx: number) => {
|
|
4100
|
+
const url = chunkUrl(params.serverUrl, id, idx, false, {
|
|
4101
|
+
mimeType: streamMimeType,
|
|
4102
|
+
});
|
|
4103
|
+
streamQueue = streamQueue.then(async () => {
|
|
4104
|
+
if (failed) return;
|
|
4105
|
+
try {
|
|
4106
|
+
await uploadChunk(url, blob);
|
|
4107
|
+
} catch (err) {
|
|
4108
|
+
failed ??= err instanceof Error ? err : new Error(String(err));
|
|
4109
|
+
}
|
|
4110
|
+
});
|
|
4111
|
+
};
|
|
4112
|
+
|
|
4113
|
+
const flushAlignedStreamChunks = () => {
|
|
4114
|
+
while (pendingStreamBytes >= STREAM_CHUNK_BYTES) {
|
|
4115
|
+
const combined = new Blob(pendingStreamBlobs, { type: streamMimeType });
|
|
4116
|
+
const head = combined.slice(0, STREAM_CHUNK_BYTES, streamMimeType);
|
|
4117
|
+
const tail = combined.slice(
|
|
4118
|
+
STREAM_CHUNK_BYTES,
|
|
4119
|
+
combined.size,
|
|
4120
|
+
streamMimeType,
|
|
4121
|
+
);
|
|
4122
|
+
pendingStreamBlobs = tail.size > 0 ? [tail] : [];
|
|
4123
|
+
pendingStreamBytes = tail.size;
|
|
4124
|
+
queueStreamChunk(head, chunkIndex++);
|
|
4125
|
+
}
|
|
4126
|
+
};
|
|
4127
|
+
|
|
4128
|
+
recorder.ondataavailable = (ev) => {
|
|
4129
|
+
if (!ev.data || ev.data.size === 0) return;
|
|
4130
|
+
|
|
4131
|
+
// Always mirror the raw blob to the local backup first (disaster recovery).
|
|
4132
|
+
void backupChunkLocally(ev.data);
|
|
4133
|
+
|
|
4134
|
+
if (uploadMode === "streaming") {
|
|
4135
|
+
// Resumable session on the server: buffer and flush 256 KiB-aligned
|
|
4136
|
+
// slices, uploaded in order. The unaligned remainder is sent as the
|
|
4137
|
+
// final chunk on stop().
|
|
4138
|
+
pendingStreamBlobs.push(ev.data);
|
|
4139
|
+
pendingStreamBytes += ev.data.size;
|
|
4140
|
+
flushAlignedStreamChunks();
|
|
4141
|
+
return;
|
|
4142
|
+
}
|
|
4143
|
+
|
|
4144
|
+
const idx = chunkIndex++;
|
|
4145
|
+
const chunkMimeType = ev.data.type || mimeType || "video/webm";
|
|
4146
|
+
const url = chunkUrl(params.serverUrl, id, idx, false, {
|
|
4147
|
+
mimeType: chunkMimeType,
|
|
4148
|
+
});
|
|
4149
|
+
// Wrap so `inflight.delete(p)` runs regardless of outcome. The closure
|
|
4150
|
+
// holds the Blob only for the duration of this fetch — once removed,
|
|
4151
|
+
// the Blob (and this promise) become GC-able. Note we assign `p` before
|
|
4152
|
+
// constructing the promise body so `inflight.delete(p)` inside the
|
|
4153
|
+
// `.finally` can reference the same handle we added.
|
|
4154
|
+
let p: Promise<void>;
|
|
4155
|
+
p = uploadChunk(url, ev.data)
|
|
4156
|
+
.catch((err) => {
|
|
4157
|
+
failed ??= err instanceof Error ? err : new Error(String(err));
|
|
4158
|
+
})
|
|
4159
|
+
.finally(() => {
|
|
4160
|
+
inflight.delete(p);
|
|
4161
|
+
});
|
|
4162
|
+
inflight.add(p);
|
|
4163
|
+
};
|
|
3202
4164
|
|
|
3203
|
-
const id = `local-${Date.now().toString(36)}`;
|
|
3204
4165
|
// Stamped at the real capture start below — kept 0 until then so the tick
|
|
3205
|
-
// never reports
|
|
3206
|
-
//
|
|
4166
|
+
// never reports elapsed against a stale baseline (which showed the clock
|
|
4167
|
+
// counting up and then resetting to 0 when recorder.start finally fired).
|
|
3207
4168
|
let startedAt = 0;
|
|
3208
4169
|
let pausedAt: number | null = null;
|
|
3209
4170
|
let accumulatedPauseMs = 0;
|
|
@@ -3221,28 +4182,49 @@ async function startRecordingInner(
|
|
|
3221
4182
|
}).catch(() => {});
|
|
3222
4183
|
}
|
|
3223
4184
|
|
|
4185
|
+
// 5. Wire toolbar events.
|
|
3224
4186
|
const toolbarUnlistens = await Promise.all([
|
|
3225
4187
|
listen("clips:recorder-pause", () => {
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
4188
|
+
if (recorder.state === "recording") {
|
|
4189
|
+
try {
|
|
4190
|
+
recorder.pause();
|
|
4191
|
+
pausedAt = Date.now();
|
|
4192
|
+
emitState(true);
|
|
4193
|
+
console.log(
|
|
4194
|
+
"[clips-recorder] recorder pause: pausing transcription",
|
|
4195
|
+
);
|
|
4196
|
+
void transcriptionCapture?.pause().catch(() => {});
|
|
4197
|
+
} catch {
|
|
4198
|
+
// ignore
|
|
4199
|
+
}
|
|
4200
|
+
}
|
|
3229
4201
|
}),
|
|
3230
4202
|
listen("clips:recorder-resume", () => {
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
4203
|
+
if (recorder.state === "paused") {
|
|
4204
|
+
try {
|
|
4205
|
+
recorder.resume();
|
|
4206
|
+
if (pausedAt) accumulatedPauseMs += Date.now() - pausedAt;
|
|
4207
|
+
pausedAt = null;
|
|
4208
|
+
emitState(false);
|
|
4209
|
+
console.log(
|
|
4210
|
+
"[clips-recorder] recorder resume: resuming transcription",
|
|
4211
|
+
);
|
|
4212
|
+
void transcriptionCapture?.resume().catch(() => {});
|
|
4213
|
+
} catch {
|
|
4214
|
+
// ignore
|
|
4215
|
+
}
|
|
4216
|
+
}
|
|
3235
4217
|
}),
|
|
3236
4218
|
listen("clips:recorder-stop", () => {
|
|
3237
|
-
console.log("[clips-recorder]
|
|
4219
|
+
console.log("[clips-recorder] stop event received");
|
|
3238
4220
|
handle.stop().catch((err) => {
|
|
3239
|
-
console.error("[clips-recorder]
|
|
4221
|
+
console.error("[clips-recorder] handle.stop() threw:", err);
|
|
3240
4222
|
});
|
|
3241
4223
|
}),
|
|
3242
4224
|
listen("clips:recorder-cancel", () => {
|
|
3243
|
-
console.log("[clips-recorder]
|
|
4225
|
+
console.log("[clips-recorder] cancel event received");
|
|
3244
4226
|
handle.cancel().catch((err) => {
|
|
3245
|
-
console.error("[clips-recorder]
|
|
4227
|
+
console.error("[clips-recorder] handle.cancel() threw:", err);
|
|
3246
4228
|
});
|
|
3247
4229
|
}),
|
|
3248
4230
|
]);
|
|
@@ -3250,868 +4232,489 @@ async function startRecordingInner(
|
|
|
3250
4232
|
|
|
3251
4233
|
await showRegionGuidesForRecording(wantsScreen);
|
|
3252
4234
|
await audioCue.playBeforeCapture();
|
|
3253
|
-
|
|
4235
|
+
recorder.start(LIVE_UPLOAD_CHUNK_MS);
|
|
3254
4236
|
startedAt = Date.now();
|
|
3255
4237
|
tickHandle = setInterval(() => emitState(pausedAt != null), 500);
|
|
4238
|
+
// The toolbar is already open (the popover's bubble-session effect
|
|
4239
|
+
// spawns it alongside the bubble in its pre-record, disabled state).
|
|
4240
|
+
// Now that MediaRecorder is actually ticking, flip the toolbar's
|
|
4241
|
+
// Stop / Pause buttons to enabled so the user can drive the recorder.
|
|
3256
4242
|
emit("clips:toolbar-enabled", true).catch(() => {});
|
|
4243
|
+
// Seed the initial recorder-state so the time / paused styling match
|
|
4244
|
+
// MediaRecorder's real state (before the first 500ms tick).
|
|
3257
4245
|
emitState(false);
|
|
3258
4246
|
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
4247
|
+
// Live transcription capture starts AFTER the recorder is live. Its mic +
|
|
4248
|
+
// ScreenCaptureKit spin-up takes ~1s; awaiting it before recorder.start was
|
|
4249
|
+
// delaying capture, so the first ~1s the user expected to record was lost
|
|
4250
|
+
// (and the recording felt cut at the end). It's a separate capture from the
|
|
4251
|
+
// recorded audio tracks, so starting it slightly late is safe.
|
|
4252
|
+
transcriptionCapture = wantsRecordedAudio
|
|
4253
|
+
? await startTranscriptionCapture(
|
|
4254
|
+
{
|
|
4255
|
+
deviceId: params.micId,
|
|
4256
|
+
label: params.micLabel,
|
|
4257
|
+
},
|
|
4258
|
+
wantsSystemAudio,
|
|
4259
|
+
// Match native path: VoiceProcessingIO AEC/ducking on a shared mic
|
|
4260
|
+
// can tank live call volume and attenuate the recorded mic leg.
|
|
4261
|
+
{ voiceProcessing: false },
|
|
4262
|
+
)
|
|
4263
|
+
: null;
|
|
4264
|
+
// Stop/Cancel can fire during the await above — at that point stop()/cancel()
|
|
4265
|
+
// ran while transcriptionCapture was still null, so it never tore this down.
|
|
4266
|
+
// Cancel the freshly-started session here so it doesn't keep running.
|
|
4267
|
+
if (stopped && transcriptionCapture) {
|
|
4268
|
+
void transcriptionCapture.cancel().catch(() => {});
|
|
4269
|
+
transcriptionCapture = null;
|
|
4270
|
+
} else if (pausedAt != null && transcriptionCapture) {
|
|
4271
|
+
// The user paused while the engine was still starting; honor it now.
|
|
4272
|
+
console.log(
|
|
4273
|
+
"[clips-recorder] recorder: paused during startup, pausing transcription",
|
|
4274
|
+
);
|
|
4275
|
+
void transcriptionCapture.pause().catch(() => {});
|
|
4276
|
+
} else if (
|
|
4277
|
+
wantsRecordedAudio &&
|
|
4278
|
+
!transcriptionCapture &&
|
|
4279
|
+
shouldSaveLocalTranscriptionStartupFailure()
|
|
4280
|
+
) {
|
|
4281
|
+
void saveTranscriptFailure(
|
|
4282
|
+
"macOS Speech recognition could not start for this recording. Check Speech Recognition, System Audio, and Microphone permissions, then retry transcription.",
|
|
4283
|
+
);
|
|
4284
|
+
}
|
|
4285
|
+
|
|
4286
|
+
// 6. Bubble + toolbar visibility are owned by the popover's session
|
|
4287
|
+
// effect (see app.tsx + bubble-pump.ts) — not the recorder. Both open
|
|
4288
|
+
// as soon as the user opens the popover in screen-camera / camera mode
|
|
4289
|
+
// with cameraOn. The recorder reuses that camera stream for the saved
|
|
4290
|
+
// video composite and flips the toolbar from disabled → enabled above.
|
|
4291
|
+
|
|
4292
|
+
const performStop = async (): Promise<RecorderStopResult> => {
|
|
4293
|
+
if (stopped) return { recordingId: id, viewUrl: `/r/${id}` };
|
|
4294
|
+
stopped = true;
|
|
4295
|
+
// Stamped right after the recorder fully stops below (see stoppedAt).
|
|
4296
|
+
// Duration must measure recorded content — through the final flushed
|
|
4297
|
+
// chunk — but NOT the transcript-finalize + thumbnail + upload awaits that
|
|
4298
|
+
// follow, which add ~seconds and would overstate the saved duration.
|
|
4299
|
+
let stoppedAt = 0;
|
|
4300
|
+
const viewUrl = `/r/${id}`;
|
|
4301
|
+
const absoluteViewUrl = `${params.serverUrl.replace(/\/+$/, "")}${viewUrl}`;
|
|
4302
|
+
console.log("[clips-recorder] stop requested");
|
|
4303
|
+
showFinalizingFeedback();
|
|
4304
|
+
if (tickHandle) clearInterval(tickHandle);
|
|
4305
|
+
stateUnlistens.forEach((u) => u());
|
|
4306
|
+
stateUnlistens = [];
|
|
4307
|
+
|
|
4308
|
+
// Flush the in-flight recorder buffer, then wait for it to fully stop
|
|
4309
|
+
// so we get the trailing dataavailable event.
|
|
4310
|
+
const recorderStopped = new Promise<void>((resolve) => {
|
|
4311
|
+
if (recorder.state === "inactive") {
|
|
4312
|
+
stoppedAt = Date.now();
|
|
4313
|
+
resolve();
|
|
4314
|
+
return;
|
|
4315
|
+
}
|
|
4316
|
+
recorder.addEventListener(
|
|
4317
|
+
"stop",
|
|
4318
|
+
() => {
|
|
4319
|
+
stoppedAt = Date.now();
|
|
4320
|
+
resolve();
|
|
4321
|
+
},
|
|
4322
|
+
{ once: true },
|
|
4323
|
+
);
|
|
3266
4324
|
try {
|
|
3267
|
-
|
|
3268
|
-
.
|
|
3269
|
-
|
|
4325
|
+
if (recorder.state === "paused") {
|
|
4326
|
+
recorder.resume();
|
|
4327
|
+
if (pausedAt) accumulatedPauseMs += Date.now() - pausedAt;
|
|
4328
|
+
pausedAt = null;
|
|
4329
|
+
}
|
|
3270
4330
|
} catch {
|
|
3271
|
-
// ignore
|
|
4331
|
+
// ignore
|
|
4332
|
+
}
|
|
4333
|
+
try {
|
|
4334
|
+
recorder.requestData();
|
|
4335
|
+
} catch {
|
|
4336
|
+
// ignore
|
|
4337
|
+
}
|
|
4338
|
+
try {
|
|
4339
|
+
recorder.stop();
|
|
4340
|
+
} catch {
|
|
4341
|
+
// ignore
|
|
3272
4342
|
}
|
|
4343
|
+
});
|
|
4344
|
+
// `recorder.stop()` has already been requested, so recorded duration is
|
|
4345
|
+
// fixed even if launching the browser takes a moment. Open the existing
|
|
4346
|
+
// recording row now and let its page poll while upload/finalize continues.
|
|
4347
|
+
//
|
|
4348
|
+
// This must claim the native upload-open slot before launching. The
|
|
4349
|
+
// Finalizing overlay receives the completion event later and uses the
|
|
4350
|
+
// same claim; without it, browser recordings opened once here and then a
|
|
4351
|
+
// second time when finalization completed.
|
|
4352
|
+
await openNativeUploadUrl(id, absoluteViewUrl);
|
|
4353
|
+
const recorderStopTimedOut = await Promise.race([
|
|
4354
|
+
recorderStopped.then(() => false),
|
|
4355
|
+
wait(MEDIA_RECORDER_STOP_TIMEOUT_MS).then(() => true),
|
|
4356
|
+
]);
|
|
4357
|
+
if (recorderStopTimedOut) {
|
|
4358
|
+
stoppedAt = Date.now();
|
|
4359
|
+
failed ??= new Error(
|
|
4360
|
+
"The recorder did not finish stopping. Your local backup was kept so you can retry or download it from Clips.",
|
|
4361
|
+
);
|
|
4362
|
+
void transcriptionCapture?.cancel().catch((err) => {
|
|
4363
|
+
console.warn(
|
|
4364
|
+
"[clips-recorder] transcription cancel after stop timeout failed:",
|
|
4365
|
+
err,
|
|
4366
|
+
);
|
|
4367
|
+
});
|
|
3273
4368
|
}
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
4369
|
+
// Use the recorder's stop event as the content boundary. If WebKit never
|
|
4370
|
+
// emits it, the watchdog boundary preserves the available chunks and keeps
|
|
4371
|
+
// post-processing time out of the recovery copy's duration.
|
|
4372
|
+
|
|
4373
|
+
const videoSettings = uploadPrimaryVideo.stream
|
|
4374
|
+
.getVideoTracks()[0]
|
|
4375
|
+
?.getSettings();
|
|
4376
|
+
const displaySettings = displayStream?.getVideoTracks()[0]?.getSettings();
|
|
4377
|
+
const durationMs = Math.max(
|
|
4378
|
+
0,
|
|
4379
|
+
Math.round(stoppedAt - startedAt - accumulatedPauseMs),
|
|
3279
4380
|
);
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
4381
|
+
const width =
|
|
4382
|
+
typeof videoSettings?.width === "number"
|
|
4383
|
+
? videoSettings.width
|
|
4384
|
+
: typeof displaySettings?.width === "number"
|
|
4385
|
+
? displaySettings.width
|
|
4386
|
+
: null;
|
|
4387
|
+
const height =
|
|
4388
|
+
typeof videoSettings?.height === "number"
|
|
4389
|
+
? videoSettings.height
|
|
4390
|
+
: typeof displaySettings?.height === "number"
|
|
4391
|
+
? displaySettings.height
|
|
4392
|
+
: null;
|
|
4393
|
+
const finalMimeType = mimeType || backupMeta.mimeType || "video/webm";
|
|
4394
|
+
try {
|
|
4395
|
+
await persistBackupMeta({
|
|
4396
|
+
durationMs,
|
|
4397
|
+
width,
|
|
4398
|
+
height,
|
|
4399
|
+
bytes: backupBytes,
|
|
4400
|
+
hasAudio: uploadCombined.getAudioTracks().length > 0,
|
|
4401
|
+
hasCamera: wantsCamera,
|
|
4402
|
+
chunkCount: backupChunkCount,
|
|
4403
|
+
mimeType: finalMimeType,
|
|
4404
|
+
lastError: null,
|
|
4405
|
+
});
|
|
4406
|
+
} catch (err) {
|
|
4407
|
+
backupFailure = err instanceof Error ? err : new Error(String(err));
|
|
4408
|
+
console.warn(
|
|
4409
|
+
"[clips-recorder] local backup final metadata failed:",
|
|
4410
|
+
err,
|
|
4411
|
+
);
|
|
3283
4412
|
}
|
|
3284
|
-
};
|
|
3285
4413
|
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
4414
|
+
if (!recorderStopTimedOut) {
|
|
4415
|
+
void captureAndUploadRecordingThumbnail({
|
|
4416
|
+
serverUrl: params.serverUrl,
|
|
4417
|
+
recordingId: id,
|
|
4418
|
+
stream: primaryVideo,
|
|
4419
|
+
authToken: params.authToken,
|
|
4420
|
+
}).catch((err) => {
|
|
4421
|
+
console.warn(
|
|
4422
|
+
"[clips-recorder] thumbnail capture/upload failed:",
|
|
4423
|
+
err,
|
|
4424
|
+
);
|
|
4425
|
+
});
|
|
3291
4426
|
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
Math.round(Date.now() - startedAt - accumulatedPauseMs),
|
|
3310
|
-
);
|
|
3311
|
-
let files: LocalExportedFile[] = [];
|
|
3312
|
-
try {
|
|
3313
|
-
files = await localExport.stop(durationMs);
|
|
3314
|
-
} finally {
|
|
3315
|
-
detachCombinedStream();
|
|
3316
|
-
stopOwnedStreams();
|
|
3317
|
-
await hideChrome();
|
|
4427
|
+
const capturedTranscript = await transcriptionCapture
|
|
4428
|
+
?.stop()
|
|
4429
|
+
.catch((err) => {
|
|
4430
|
+
console.warn("[clips-recorder] transcript stop failed:", err);
|
|
4431
|
+
return null;
|
|
4432
|
+
});
|
|
4433
|
+
if (capturedTranscript?.text.trim()) {
|
|
4434
|
+
await saveRecordingTranscript(
|
|
4435
|
+
params.serverUrl,
|
|
4436
|
+
id,
|
|
4437
|
+
capturedTranscript,
|
|
4438
|
+
params.authToken,
|
|
4439
|
+
);
|
|
4440
|
+
} else if (wantsRecordedAudio) {
|
|
4441
|
+
await saveTranscriptFailure(
|
|
4442
|
+
"No speech was captured during this recording. If you spoke or played system audio, check System Audio, Microphone input, Speech Recognition permission, and the selected mic, then retry transcription.",
|
|
4443
|
+
);
|
|
3318
4444
|
}
|
|
3319
|
-
return {
|
|
3320
|
-
recordingId: id,
|
|
3321
|
-
viewUrl: "",
|
|
3322
|
-
localOnly: true,
|
|
3323
|
-
localFolder: localExport.folderPath,
|
|
3324
|
-
localFiles: files,
|
|
3325
|
-
};
|
|
3326
|
-
},
|
|
3327
|
-
|
|
3328
|
-
async cancel() {
|
|
3329
|
-
if (stopped) return;
|
|
3330
|
-
stopped = true;
|
|
3331
|
-
if (tickHandle) clearInterval(tickHandle);
|
|
3332
|
-
stateUnlistens.forEach((unlisten) => unlisten());
|
|
3333
|
-
stateUnlistens = [];
|
|
3334
|
-
await localExport.cancel();
|
|
3335
|
-
detachCombinedStream();
|
|
3336
|
-
stopOwnedStreams();
|
|
3337
|
-
await hideChrome();
|
|
3338
|
-
},
|
|
3339
|
-
};
|
|
3340
|
-
|
|
3341
|
-
return handle;
|
|
3342
|
-
}
|
|
3343
|
-
|
|
3344
|
-
const uploadPrimaryVideo = createUploadOptimizedVideoStream(primaryVideo);
|
|
3345
|
-
streamCleanups.push(uploadPrimaryVideo.cleanup);
|
|
3346
|
-
|
|
3347
|
-
const uploadCombined = new MediaStream();
|
|
3348
|
-
uploadPrimaryVideo.stream
|
|
3349
|
-
.getVideoTracks()
|
|
3350
|
-
.forEach((track) => uploadCombined.addTrack(track));
|
|
3351
|
-
recordingAudio.tracks.forEach((track) => uploadCombined.addTrack(track));
|
|
3352
|
-
|
|
3353
|
-
// MIME type is resolved up front so create-recording can initialize the
|
|
3354
|
-
// resumable session with the correct content type when the server supports
|
|
3355
|
-
// streaming uploads.
|
|
3356
|
-
const mimeCandidates = [
|
|
3357
|
-
"video/webm;codecs=vp8,opus",
|
|
3358
|
-
"video/webm;codecs=vp9,opus",
|
|
3359
|
-
"video/webm",
|
|
3360
|
-
];
|
|
3361
|
-
const mimeType =
|
|
3362
|
-
mimeCandidates.find((m) => MediaRecorder.isTypeSupported(m)) ?? "";
|
|
3363
|
-
|
|
3364
|
-
// 2+3. Countdown + create-recording happen IN PARALLEL. The countdown is
|
|
3365
|
-
// pure visual feedback — gating it on a network round-trip makes the
|
|
3366
|
-
// 3-2-1 feel laggy after the user picks a screen. Kick both off and
|
|
3367
|
-
// wait at the end before starting the MediaRecorder.
|
|
3368
|
-
console.log(
|
|
3369
|
-
"[clips-recorder] invoking show_countdown + createServerRecording",
|
|
3370
|
-
);
|
|
3371
|
-
const countdownPromise = runRecordingCountdown(wantsScreen);
|
|
3372
|
-
console.time("[clips-recorder] createServerRecording duration");
|
|
3373
|
-
const recordingPromise = createServerRecording(
|
|
3374
|
-
params.serverUrl,
|
|
3375
|
-
wantsCamera,
|
|
3376
|
-
recordingAudio.tracks.length > 0,
|
|
3377
|
-
captureTitle,
|
|
3378
|
-
{ mimeType: mimeType || "video/webm", requestStreaming: true },
|
|
3379
|
-
).finally(() => {
|
|
3380
|
-
console.timeEnd("[clips-recorder] createServerRecording duration");
|
|
3381
|
-
});
|
|
3382
|
-
console.log("[clips-recorder] awaiting countdown + createServerRecording");
|
|
3383
|
-
let createRes: Awaited<ReturnType<typeof createServerRecording>>;
|
|
3384
|
-
try {
|
|
3385
|
-
[, createRes] = await Promise.all([countdownPromise, recordingPromise]);
|
|
3386
|
-
} catch (err) {
|
|
3387
|
-
abortCreatedRecordingOnCountdownCancel(
|
|
3388
|
-
err,
|
|
3389
|
-
recordingPromise,
|
|
3390
|
-
params.serverUrl,
|
|
3391
|
-
);
|
|
3392
|
-
throw err;
|
|
3393
|
-
}
|
|
3394
|
-
const { id, uploadMode } = createRes;
|
|
3395
|
-
console.log(
|
|
3396
|
-
"[clips-recorder] countdown + createServerRecording both resolved, id=",
|
|
3397
|
-
id,
|
|
3398
|
-
);
|
|
3399
|
-
console.log("[clips-recorder] recording row created", { id, uploadMode });
|
|
3400
|
-
let nativeTranscriptFailureSaved = false;
|
|
3401
|
-
const saveTranscriptFailure = async (
|
|
3402
|
-
failureReason: string,
|
|
3403
|
-
): Promise<boolean> => {
|
|
3404
|
-
if (!wantsRecordedAudio || nativeTranscriptFailureSaved) return false;
|
|
3405
|
-
nativeTranscriptFailureSaved = true;
|
|
3406
|
-
return saveRecordingTranscriptFailure(
|
|
3407
|
-
params.serverUrl,
|
|
3408
|
-
id,
|
|
3409
|
-
failureReason,
|
|
3410
|
-
params.authToken,
|
|
3411
|
-
);
|
|
3412
|
-
};
|
|
3413
|
-
|
|
3414
|
-
// 4. Start MediaRecorder with a 2-second timeslice — each `ondataavailable`
|
|
3415
|
-
// streams a chunk to the server, so we don't hold 5-min buffers in memory.
|
|
3416
|
-
const recorder = createCloudMediaRecorder(uploadCombined, mimeType);
|
|
3417
|
-
let chunkIndex = 0;
|
|
3418
|
-
let failed: Error | null = null;
|
|
3419
|
-
let backupBytes = 0;
|
|
3420
|
-
// Backup chunks are indexed by raw MediaRecorder blob (one per
|
|
3421
|
-
// `ondataavailable`), independent of `chunkIndex` — on the streaming path
|
|
3422
|
-
// `chunkIndex` counts aligned upload slices, not raw blobs.
|
|
3423
|
-
let backupChunkCount = 0;
|
|
3424
|
-
const streamMimeType = mimeType || "video/webm";
|
|
3425
|
-
let backupMeta: BrowserRecordingBackupMeta = {
|
|
3426
|
-
recordingId: id,
|
|
3427
|
-
serverUrl: params.serverUrl.replace(/\/+$/, ""),
|
|
3428
|
-
durationMs: 0,
|
|
3429
|
-
width: null,
|
|
3430
|
-
height: null,
|
|
3431
|
-
bytes: 0,
|
|
3432
|
-
hasAudio: uploadCombined.getAudioTracks().length > 0,
|
|
3433
|
-
hasCamera: wantsCamera,
|
|
3434
|
-
savedAt: new Date().toISOString(),
|
|
3435
|
-
lastAttemptAt: null,
|
|
3436
|
-
lastError: null,
|
|
3437
|
-
retryCount: 0,
|
|
3438
|
-
chunkCount: 0,
|
|
3439
|
-
mimeType: mimeType || "video/webm",
|
|
3440
|
-
};
|
|
3441
|
-
const persistBackupMeta = async (
|
|
3442
|
-
patch: Partial<BrowserRecordingBackupMeta> = {},
|
|
3443
|
-
) => {
|
|
3444
|
-
backupMeta = { ...backupMeta, ...patch };
|
|
3445
|
-
await putBrowserRecordingBackupMeta(backupMeta);
|
|
3446
|
-
};
|
|
3447
|
-
let backupFailure: Error | null = null;
|
|
3448
|
-
const backupWrites = new Set<Promise<void>>();
|
|
3449
|
-
let initialBackupWrite: Promise<void>;
|
|
3450
|
-
initialBackupWrite = persistBackupMeta()
|
|
3451
|
-
.catch((err) => {
|
|
3452
|
-
backupFailure = err instanceof Error ? err : new Error(String(err));
|
|
3453
|
-
console.warn("[clips-recorder] local backup metadata failed:", err);
|
|
3454
|
-
})
|
|
3455
|
-
.finally(() => {
|
|
3456
|
-
backupWrites.delete(initialBackupWrite);
|
|
3457
|
-
});
|
|
3458
|
-
backupWrites.add(initialBackupWrite);
|
|
3459
|
-
|
|
3460
|
-
// Every raw MediaRecorder blob is mirrored to IndexedDB on both upload paths.
|
|
3461
|
-
// If uploads fail the recording can still be recovered locally — replayed to
|
|
3462
|
-
// the server (the retry first resets any resumable session so replay routes
|
|
3463
|
-
// through the buffered chunk path) or exported to a local file.
|
|
3464
|
-
const backupChunkLocally = (blob: Blob): Promise<void> => {
|
|
3465
|
-
const backupIdx = backupChunkCount++;
|
|
3466
|
-
backupBytes += blob.size;
|
|
3467
|
-
const chunkMimeType = blob.type || streamMimeType;
|
|
3468
|
-
let w: Promise<void>;
|
|
3469
|
-
w = (async () => {
|
|
3470
|
-
try {
|
|
3471
|
-
await putBrowserRecordingBackupChunk({
|
|
3472
|
-
recordingId: id,
|
|
3473
|
-
index: backupIdx,
|
|
3474
|
-
blob,
|
|
3475
|
-
bytes: blob.size,
|
|
3476
|
-
mimeType: chunkMimeType,
|
|
3477
|
-
createdAt: new Date().toISOString(),
|
|
3478
|
-
});
|
|
3479
|
-
await persistBackupMeta({
|
|
3480
|
-
bytes: backupBytes,
|
|
3481
|
-
chunkCount: backupChunkCount,
|
|
3482
|
-
mimeType: chunkMimeType,
|
|
3483
|
-
});
|
|
3484
|
-
} catch (err) {
|
|
3485
|
-
backupFailure = err instanceof Error ? err : new Error(String(err));
|
|
3486
|
-
console.warn("[clips-recorder] local chunk backup failed:", err);
|
|
3487
4445
|
}
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
// accumulate here until at least STREAM_CHUNK_BYTES is available, then a
|
|
3505
|
-
// 256 KiB-aligned slice is uploaded as a non-final chunk. Resumable sessions
|
|
3506
|
-
// append by byte offset server-side, so streamed chunks MUST arrive in order
|
|
3507
|
-
// — uploads are serialized through `streamQueue`. The unaligned remainder is
|
|
3508
|
-
// sent as the final chunk on stop().
|
|
3509
|
-
let pendingStreamBlobs: Blob[] = [];
|
|
3510
|
-
let pendingStreamBytes = 0;
|
|
3511
|
-
let streamQueue: Promise<void> = Promise.resolve();
|
|
3512
|
-
|
|
3513
|
-
const queueStreamChunk = (blob: Blob, idx: number) => {
|
|
3514
|
-
const url = chunkUrl(params.serverUrl, id, idx, false, {
|
|
3515
|
-
mimeType: streamMimeType,
|
|
3516
|
-
});
|
|
3517
|
-
streamQueue = streamQueue.then(async () => {
|
|
3518
|
-
if (failed) return;
|
|
4446
|
+
if (popoverOwnsCamera) {
|
|
4447
|
+
console.log("[clips-recorder] releasing popover camera");
|
|
4448
|
+
emit("clips:release-camera").catch(() => {});
|
|
4449
|
+
}
|
|
4450
|
+
|
|
4451
|
+
// Null the data handler so the final MediaRecorder teardown
|
|
4452
|
+
// doesn't keep the closure (which captures `inflight`, the URL
|
|
4453
|
+
// builder, and indirectly the MediaStream) reachable after we're
|
|
4454
|
+
// done with it. WebKit's MediaRecorder can retain a reference to
|
|
4455
|
+
// its event handler for the life of the object if you leave a
|
|
4456
|
+
// non-null ondataavailable in place — null it to break the chain.
|
|
4457
|
+
recorder.ondataavailable = null;
|
|
4458
|
+
// Clear MediaStream track lists — just removing our
|
|
4459
|
+
// references to the tracks is enough; the tracks themselves are
|
|
4460
|
+
// owned by `displayStream` / `audioStream` / `bubbleCameraStream`
|
|
4461
|
+
// and get stopped below.
|
|
3519
4462
|
try {
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
4463
|
+
uploadCombined
|
|
4464
|
+
.getTracks()
|
|
4465
|
+
.forEach((t) => uploadCombined.removeTrack(t));
|
|
4466
|
+
combined.getTracks().forEach((t) => combined.removeTrack(t));
|
|
4467
|
+
} catch {
|
|
4468
|
+
// ignore — best-effort
|
|
3523
4469
|
}
|
|
3524
|
-
});
|
|
3525
|
-
};
|
|
3526
4470
|
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
STREAM_CHUNK_BYTES,
|
|
3533
|
-
combined.size,
|
|
3534
|
-
streamMimeType,
|
|
4471
|
+
// Stop the streams WE own so OS permission indicators clear. The
|
|
4472
|
+
// camera stream is owned by the popover when reused — we leave it
|
|
4473
|
+
// alone so the bubble stays live if the popover is still open.
|
|
4474
|
+
[displayStream, audioStream].forEach((s) =>
|
|
4475
|
+
s?.getTracks().forEach((t) => t.stop()),
|
|
3535
4476
|
);
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
};
|
|
3541
|
-
|
|
3542
|
-
recorder.ondataavailable = (ev) => {
|
|
3543
|
-
if (!ev.data || ev.data.size === 0) return;
|
|
3544
|
-
|
|
3545
|
-
// Always mirror the raw blob to the local backup first (disaster recovery).
|
|
3546
|
-
void backupChunkLocally(ev.data);
|
|
3547
|
-
|
|
3548
|
-
if (uploadMode === "streaming") {
|
|
3549
|
-
// Resumable session on the server: buffer and flush 256 KiB-aligned
|
|
3550
|
-
// slices, uploaded in order. The unaligned remainder is sent as the
|
|
3551
|
-
// final chunk on stop().
|
|
3552
|
-
pendingStreamBlobs.push(ev.data);
|
|
3553
|
-
pendingStreamBytes += ev.data.size;
|
|
3554
|
-
flushAlignedStreamChunks();
|
|
3555
|
-
return;
|
|
3556
|
-
}
|
|
3557
|
-
|
|
3558
|
-
const idx = chunkIndex++;
|
|
3559
|
-
const chunkMimeType = ev.data.type || mimeType || "video/webm";
|
|
3560
|
-
const url = chunkUrl(params.serverUrl, id, idx, false, {
|
|
3561
|
-
mimeType: chunkMimeType,
|
|
3562
|
-
});
|
|
3563
|
-
// Wrap so `inflight.delete(p)` runs regardless of outcome. The closure
|
|
3564
|
-
// holds the Blob only for the duration of this fetch — once removed,
|
|
3565
|
-
// the Blob (and this promise) become GC-able. Note we assign `p` before
|
|
3566
|
-
// constructing the promise body so `inflight.delete(p)` inside the
|
|
3567
|
-
// `.finally` can reference the same handle we added.
|
|
3568
|
-
let p: Promise<void>;
|
|
3569
|
-
p = uploadChunk(url, ev.data)
|
|
3570
|
-
.catch((err) => {
|
|
3571
|
-
failed ??= err instanceof Error ? err : new Error(String(err));
|
|
3572
|
-
})
|
|
3573
|
-
.finally(() => {
|
|
3574
|
-
inflight.delete(p);
|
|
3575
|
-
});
|
|
3576
|
-
inflight.add(p);
|
|
3577
|
-
};
|
|
3578
|
-
|
|
3579
|
-
// Stamped at the real capture start below — kept 0 until then so the tick
|
|
3580
|
-
// never reports elapsed against a stale baseline (which showed the clock
|
|
3581
|
-
// counting up and then resetting to 0 when recorder.start finally fired).
|
|
3582
|
-
let startedAt = 0;
|
|
3583
|
-
let pausedAt: number | null = null;
|
|
3584
|
-
let accumulatedPauseMs = 0;
|
|
3585
|
-
let stopped = false;
|
|
3586
|
-
let stateUnlistens: UnlistenFn[] = [];
|
|
3587
|
-
let tickHandle: ReturnType<typeof setInterval> | null = null;
|
|
4477
|
+
streamCleanups.forEach((cleanup) => cleanup());
|
|
4478
|
+
if (!popoverOwnsCamera) {
|
|
4479
|
+
bubbleCameraStream?.getTracks().forEach((t) => t.stop());
|
|
4480
|
+
}
|
|
3588
4481
|
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
4482
|
+
// Hide the recording-specific overlays (countdown + toolbar). The
|
|
4483
|
+
// bubble is managed by the popover's session effect — when the
|
|
4484
|
+
// popover is hidden or the user turns camera off, that effect tears
|
|
4485
|
+
// down the bubble. Closing it here would cause a flicker on the
|
|
4486
|
+
// cancel path where the popover re-appears with camera still on.
|
|
4487
|
+
console.log("[clips-recorder] hiding recording chrome");
|
|
4488
|
+
await invoke("hide_recording_chrome").catch((err) =>
|
|
4489
|
+
console.error(`[clips-recorder] hide_recording_chrome failed:`, err),
|
|
4490
|
+
);
|
|
3598
4491
|
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
} catch {
|
|
3610
|
-
// ignore
|
|
3611
|
-
}
|
|
4492
|
+
// Wait for any in-flight chunk uploads to settle before sending the
|
|
4493
|
+
// final chunk. Otherwise the server could finalize before the last
|
|
4494
|
+
// few bytes land. On the streaming path uploads are serialized through
|
|
4495
|
+
// `streamQueue`; on the buffered path they run concurrently and each
|
|
4496
|
+
// `.finally` has already removed settled entries from `inflight`.
|
|
4497
|
+
const pending = Array.from(inflight);
|
|
4498
|
+
if (uploadMode === "streaming") {
|
|
4499
|
+
await streamQueue;
|
|
4500
|
+
} else {
|
|
4501
|
+
await Promise.allSettled(pending);
|
|
3612
4502
|
}
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
if (recorder.state === "paused") {
|
|
4503
|
+
inflight.clear();
|
|
4504
|
+
if (failed) {
|
|
3616
4505
|
try {
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
4506
|
+
// Keep the guard until the final metadata and every trailing chunk
|
|
4507
|
+
// backup are durable, even though this upload cannot be finalized.
|
|
4508
|
+
await Promise.allSettled([...backupWrites]);
|
|
4509
|
+
console.error("[clips-recorder] chunk upload failed:", failed);
|
|
4510
|
+
await markBrowserRecordingBackupError(id, failed.message).catch(
|
|
4511
|
+
() => {},
|
|
3623
4512
|
);
|
|
3624
|
-
|
|
3625
|
-
}
|
|
3626
|
-
|
|
4513
|
+
await abortRecordingUpload(params.serverUrl, id, failed.message);
|
|
4514
|
+
} finally {
|
|
4515
|
+
await clearRecordingState();
|
|
4516
|
+
await publishFinalizingResult({
|
|
4517
|
+
recordingId: id,
|
|
4518
|
+
viewUrl: absoluteViewUrl,
|
|
4519
|
+
ok: false,
|
|
4520
|
+
error: failed.message,
|
|
4521
|
+
});
|
|
3627
4522
|
}
|
|
4523
|
+
throw failed;
|
|
3628
4524
|
}
|
|
3629
|
-
}),
|
|
3630
|
-
listen("clips:recorder-stop", () => {
|
|
3631
|
-
console.log("[clips-recorder] stop event received");
|
|
3632
|
-
handle.stop().catch((err) => {
|
|
3633
|
-
console.error("[clips-recorder] handle.stop() threw:", err);
|
|
3634
|
-
});
|
|
3635
|
-
}),
|
|
3636
|
-
listen("clips:recorder-cancel", () => {
|
|
3637
|
-
console.log("[clips-recorder] cancel event received");
|
|
3638
|
-
handle.cancel().catch((err) => {
|
|
3639
|
-
console.error("[clips-recorder] handle.cancel() threw:", err);
|
|
3640
|
-
});
|
|
3641
|
-
}),
|
|
3642
|
-
]);
|
|
3643
|
-
stateUnlistens = toolbarUnlistens;
|
|
3644
|
-
|
|
3645
|
-
await showRegionGuidesForRecording(wantsScreen);
|
|
3646
|
-
await audioCue.playBeforeCapture();
|
|
3647
|
-
recorder.start(LIVE_UPLOAD_CHUNK_MS);
|
|
3648
|
-
startedAt = Date.now();
|
|
3649
|
-
tickHandle = setInterval(() => emitState(pausedAt != null), 500);
|
|
3650
|
-
// The toolbar is already open (the popover's bubble-session effect
|
|
3651
|
-
// spawns it alongside the bubble in its pre-record, disabled state).
|
|
3652
|
-
// Now that MediaRecorder is actually ticking, flip the toolbar's
|
|
3653
|
-
// Stop / Pause buttons to enabled so the user can drive the recorder.
|
|
3654
|
-
emit("clips:toolbar-enabled", true).catch(() => {});
|
|
3655
|
-
// Seed the initial recorder-state so the time / paused styling match
|
|
3656
|
-
// MediaRecorder's real state (before the first 500ms tick).
|
|
3657
|
-
emitState(false);
|
|
3658
|
-
|
|
3659
|
-
// Live transcription capture starts AFTER the recorder is live. Its mic +
|
|
3660
|
-
// ScreenCaptureKit spin-up takes ~1s; awaiting it before recorder.start was
|
|
3661
|
-
// delaying capture, so the first ~1s the user expected to record was lost
|
|
3662
|
-
// (and the recording felt cut at the end). It's a separate capture from the
|
|
3663
|
-
// recorded audio tracks, so starting it slightly late is safe.
|
|
3664
|
-
transcriptionCapture = wantsRecordedAudio
|
|
3665
|
-
? await startTranscriptionCapture(
|
|
3666
|
-
{
|
|
3667
|
-
deviceId: params.micId,
|
|
3668
|
-
label: params.micLabel,
|
|
3669
|
-
},
|
|
3670
|
-
wantsSystemAudio,
|
|
3671
|
-
// Match native path: VoiceProcessingIO AEC/ducking on a shared mic
|
|
3672
|
-
// can tank live call volume and attenuate the recorded mic leg.
|
|
3673
|
-
{ voiceProcessing: false },
|
|
3674
|
-
)
|
|
3675
|
-
: null;
|
|
3676
|
-
// Stop/Cancel can fire during the await above — at that point stop()/cancel()
|
|
3677
|
-
// ran while transcriptionCapture was still null, so it never tore this down.
|
|
3678
|
-
// Cancel the freshly-started session here so it doesn't keep running.
|
|
3679
|
-
if (stopped && transcriptionCapture) {
|
|
3680
|
-
void transcriptionCapture.cancel().catch(() => {});
|
|
3681
|
-
transcriptionCapture = null;
|
|
3682
|
-
} else if (pausedAt != null && transcriptionCapture) {
|
|
3683
|
-
// The user paused while the engine was still starting; honor it now.
|
|
3684
|
-
console.log(
|
|
3685
|
-
"[clips-recorder] recorder: paused during startup, pausing transcription",
|
|
3686
|
-
);
|
|
3687
|
-
void transcriptionCapture.pause().catch(() => {});
|
|
3688
|
-
} else if (
|
|
3689
|
-
wantsRecordedAudio &&
|
|
3690
|
-
!transcriptionCapture &&
|
|
3691
|
-
shouldSaveLocalTranscriptionStartupFailure()
|
|
3692
|
-
) {
|
|
3693
|
-
void saveTranscriptFailure(
|
|
3694
|
-
"macOS Speech recognition could not start for this recording. Check Speech Recognition, System Audio, and Microphone permissions, then retry transcription.",
|
|
3695
|
-
);
|
|
3696
|
-
}
|
|
3697
4525
|
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
// follow, which add ~seconds and would overstate the saved duration.
|
|
3711
|
-
let stoppedAt = 0;
|
|
3712
|
-
const viewUrl = `/r/${id}`;
|
|
3713
|
-
const absoluteViewUrl = `${params.serverUrl.replace(/\/+$/, "")}${viewUrl}`;
|
|
3714
|
-
console.log("[clips-recorder] stop requested");
|
|
3715
|
-
showFinalizingFeedback();
|
|
3716
|
-
if (tickHandle) clearInterval(tickHandle);
|
|
3717
|
-
stateUnlistens.forEach((u) => u());
|
|
3718
|
-
stateUnlistens = [];
|
|
3719
|
-
|
|
3720
|
-
// Flush the in-flight recorder buffer, then wait for it to fully stop
|
|
3721
|
-
// so we get the trailing dataavailable event.
|
|
3722
|
-
const recorderStopped = new Promise<void>((resolve) => {
|
|
3723
|
-
if (recorder.state === "inactive") {
|
|
3724
|
-
stoppedAt = Date.now();
|
|
3725
|
-
resolve();
|
|
3726
|
-
return;
|
|
3727
|
-
}
|
|
3728
|
-
recorder.addEventListener(
|
|
3729
|
-
"stop",
|
|
3730
|
-
() => {
|
|
3731
|
-
stoppedAt = Date.now();
|
|
3732
|
-
resolve();
|
|
3733
|
-
},
|
|
3734
|
-
{ once: true },
|
|
3735
|
-
);
|
|
3736
|
-
try {
|
|
3737
|
-
if (recorder.state === "paused") {
|
|
3738
|
-
recorder.resume();
|
|
3739
|
-
if (pausedAt) accumulatedPauseMs += Date.now() - pausedAt;
|
|
3740
|
-
pausedAt = null;
|
|
3741
|
-
}
|
|
3742
|
-
} catch {
|
|
3743
|
-
// ignore
|
|
3744
|
-
}
|
|
3745
|
-
try {
|
|
3746
|
-
recorder.requestData();
|
|
3747
|
-
} catch {
|
|
3748
|
-
// ignore
|
|
3749
|
-
}
|
|
3750
|
-
try {
|
|
3751
|
-
recorder.stop();
|
|
3752
|
-
} catch {
|
|
3753
|
-
// ignore
|
|
3754
|
-
}
|
|
3755
|
-
});
|
|
3756
|
-
// `recorder.stop()` has already been requested, so recorded duration is
|
|
3757
|
-
// fixed even if launching the browser takes a moment. Open the existing
|
|
3758
|
-
// recording row now and let its page poll while upload/finalize continues.
|
|
3759
|
-
//
|
|
3760
|
-
// This must claim the native upload-open slot before launching. The
|
|
3761
|
-
// Finalizing overlay receives the completion event later and uses the
|
|
3762
|
-
// same claim; without it, browser recordings opened once here and then a
|
|
3763
|
-
// second time when finalization completed.
|
|
3764
|
-
await openNativeUploadUrl(id, absoluteViewUrl);
|
|
3765
|
-
const recorderStopTimedOut = await Promise.race([
|
|
3766
|
-
recorderStopped.then(() => false),
|
|
3767
|
-
wait(MEDIA_RECORDER_STOP_TIMEOUT_MS).then(() => true),
|
|
3768
|
-
]);
|
|
3769
|
-
if (recorderStopTimedOut) {
|
|
3770
|
-
stoppedAt = Date.now();
|
|
3771
|
-
failed ??= new Error(
|
|
3772
|
-
"The recorder did not finish stopping. Your local backup was kept so you can retry or download it from Clips.",
|
|
3773
|
-
);
|
|
3774
|
-
void transcriptionCapture?.cancel().catch((err) => {
|
|
3775
|
-
console.warn(
|
|
3776
|
-
"[clips-recorder] transcription cancel after stop timeout failed:",
|
|
3777
|
-
err,
|
|
3778
|
-
);
|
|
3779
|
-
});
|
|
3780
|
-
}
|
|
3781
|
-
// Use the recorder's stop event as the content boundary. If WebKit never
|
|
3782
|
-
// emits it, the watchdog boundary preserves the available chunks and keeps
|
|
3783
|
-
// post-processing time out of the recovery copy's duration.
|
|
3784
|
-
|
|
3785
|
-
const videoSettings = uploadPrimaryVideo.stream
|
|
3786
|
-
.getVideoTracks()[0]
|
|
3787
|
-
?.getSettings();
|
|
3788
|
-
const displaySettings = displayStream?.getVideoTracks()[0]?.getSettings();
|
|
3789
|
-
const durationMs = Math.max(
|
|
3790
|
-
0,
|
|
3791
|
-
Math.round(stoppedAt - startedAt - accumulatedPauseMs),
|
|
3792
|
-
);
|
|
3793
|
-
const width =
|
|
3794
|
-
typeof videoSettings?.width === "number"
|
|
3795
|
-
? videoSettings.width
|
|
3796
|
-
: typeof displaySettings?.width === "number"
|
|
3797
|
-
? displaySettings.width
|
|
3798
|
-
: null;
|
|
3799
|
-
const height =
|
|
3800
|
-
typeof videoSettings?.height === "number"
|
|
3801
|
-
? videoSettings.height
|
|
3802
|
-
: typeof displaySettings?.height === "number"
|
|
3803
|
-
? displaySettings.height
|
|
3804
|
-
: null;
|
|
3805
|
-
const finalMimeType = mimeType || backupMeta.mimeType || "video/webm";
|
|
3806
|
-
try {
|
|
3807
|
-
await persistBackupMeta({
|
|
3808
|
-
durationMs,
|
|
3809
|
-
width,
|
|
3810
|
-
height,
|
|
3811
|
-
bytes: backupBytes,
|
|
3812
|
-
hasAudio: uploadCombined.getAudioTracks().length > 0,
|
|
3813
|
-
hasCamera: wantsCamera,
|
|
3814
|
-
chunkCount: backupChunkCount,
|
|
4526
|
+
// Streaming: the closing bytes are whatever remains under the 256 KiB
|
|
4527
|
+
// alignment boundary — send them as the final chunk so the resumable
|
|
4528
|
+
// session can complete. Buffered: bytes are already staged server-side,
|
|
4529
|
+
// so the final chunk is a 0-byte close sentinel.
|
|
4530
|
+
const finalBody =
|
|
4531
|
+
uploadMode === "streaming"
|
|
4532
|
+
? new Blob(pendingStreamBlobs, { type: finalMimeType })
|
|
4533
|
+
: new Blob([], { type: finalMimeType });
|
|
4534
|
+
pendingStreamBlobs = [];
|
|
4535
|
+
pendingStreamBytes = 0;
|
|
4536
|
+
|
|
4537
|
+
const finalizeUrl = chunkUrl(params.serverUrl, id, chunkIndex, true, {
|
|
3815
4538
|
mimeType: finalMimeType,
|
|
3816
|
-
|
|
4539
|
+
durationMs: String(durationMs),
|
|
4540
|
+
...(width ? { width: String(width) } : {}),
|
|
4541
|
+
...(height ? { height: String(height) } : {}),
|
|
4542
|
+
hasAudio: backupMeta.hasAudio ? "1" : "0",
|
|
4543
|
+
hasCamera: wantsCamera ? "1" : "0",
|
|
3817
4544
|
});
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
void captureAndUploadRecordingThumbnail({
|
|
3825
|
-
serverUrl: params.serverUrl,
|
|
3826
|
-
recordingId: id,
|
|
3827
|
-
stream: primaryVideo,
|
|
3828
|
-
authToken: params.authToken,
|
|
3829
|
-
}).catch((err) => {
|
|
3830
|
-
console.warn("[clips-recorder] thumbnail capture/upload failed:", err);
|
|
4545
|
+
console.log("[clips-recorder] finalize POST", finalizeUrl, {
|
|
4546
|
+
chunksSent: chunkIndex,
|
|
4547
|
+
inflightAtFinalize: pending.length,
|
|
4548
|
+
finalBodyBytes: finalBody.size,
|
|
4549
|
+
uploadMode,
|
|
4550
|
+
anyFailed: !!failed,
|
|
3831
4551
|
});
|
|
4552
|
+
try {
|
|
4553
|
+
const result = await finalizeAfterDurableBackup({
|
|
4554
|
+
// Opening the browser must not make the desktop look idle until every
|
|
4555
|
+
// trailing backup write and the final metadata are durable.
|
|
4556
|
+
ensureBackupDurable: async () => {
|
|
4557
|
+
await Promise.all([...backupWrites]);
|
|
4558
|
+
if (backupFailure) throw backupFailure;
|
|
4559
|
+
await persistBackupMeta({
|
|
4560
|
+
durationMs,
|
|
4561
|
+
width,
|
|
4562
|
+
height,
|
|
4563
|
+
bytes: backupBytes,
|
|
4564
|
+
hasAudio: uploadCombined.getAudioTracks().length > 0,
|
|
4565
|
+
hasCamera: wantsCamera,
|
|
4566
|
+
chunkCount: backupChunkCount,
|
|
4567
|
+
mimeType: finalMimeType,
|
|
4568
|
+
lastError: null,
|
|
4569
|
+
});
|
|
4570
|
+
},
|
|
4571
|
+
attemptFinalize: async () => {
|
|
4572
|
+
try {
|
|
4573
|
+
const finalRes = await fetch(finalizeUrl, {
|
|
4574
|
+
method: "POST",
|
|
4575
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
4576
|
+
credentials: "include",
|
|
4577
|
+
body: finalBody,
|
|
4578
|
+
signal: AbortSignal.timeout(FINALIZE_UPLOAD_TIMEOUT_MS),
|
|
4579
|
+
});
|
|
4580
|
+
const bodyText = await finalRes.text().catch(() => "");
|
|
4581
|
+
console.log(
|
|
4582
|
+
"[clips-recorder] finalize response:",
|
|
4583
|
+
finalRes.status,
|
|
4584
|
+
bodyText.slice(0, 500),
|
|
4585
|
+
);
|
|
4586
|
+
if (!finalRes.ok) {
|
|
4587
|
+
throw new Error(
|
|
4588
|
+
`Finalize failed (${finalRes.status}): ${bodyText.slice(0, 200)}`,
|
|
4589
|
+
);
|
|
4590
|
+
}
|
|
4591
|
+
const receipt = parseFinalizeReceipt(bodyText);
|
|
4592
|
+
const receiptStatus = verifyFinalizeReceipt(receipt, {
|
|
4593
|
+
bytes: backupBytes,
|
|
4594
|
+
durationMs,
|
|
4595
|
+
});
|
|
4596
|
+
if (receiptStatus === "processing") {
|
|
4597
|
+
scheduleBrowserBackupCleanupAfterProcessing({
|
|
4598
|
+
serverUrl: params.serverUrl,
|
|
4599
|
+
recordingId: id,
|
|
4600
|
+
authToken: params.authToken,
|
|
4601
|
+
});
|
|
4602
|
+
return { recordingId: id, viewUrl };
|
|
4603
|
+
}
|
|
4604
|
+
} catch (err) {
|
|
4605
|
+
console.error("[clips-recorder] finalize fetch failed:", err);
|
|
4606
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
4607
|
+
if (
|
|
4608
|
+
await recoverAcceptedRecordingAfterFinalizeError({
|
|
4609
|
+
serverUrl: params.serverUrl,
|
|
4610
|
+
recordingId: id,
|
|
4611
|
+
authToken: params.authToken,
|
|
4612
|
+
})
|
|
4613
|
+
) {
|
|
4614
|
+
return { recordingId: id, viewUrl };
|
|
4615
|
+
}
|
|
4616
|
+
await markBrowserRecordingBackupError(id, error.message).catch(
|
|
4617
|
+
() => {},
|
|
4618
|
+
);
|
|
4619
|
+
await abortRecordingUpload(params.serverUrl, id, error.message);
|
|
4620
|
+
throw error;
|
|
4621
|
+
}
|
|
4622
|
+
await deleteBrowserRecordingBackup(id).catch((err) => {
|
|
4623
|
+
console.warn(
|
|
4624
|
+
"[clips-recorder] local backup cleanup failed:",
|
|
4625
|
+
err,
|
|
4626
|
+
);
|
|
4627
|
+
});
|
|
3832
4628
|
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
console.warn("[clips-recorder] transcript stop failed:", err);
|
|
3837
|
-
return null;
|
|
4629
|
+
return { recordingId: id, viewUrl };
|
|
4630
|
+
},
|
|
4631
|
+
releaseGuard: clearRecordingState,
|
|
3838
4632
|
});
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
} else if (wantsRecordedAudio) {
|
|
3847
|
-
await saveTranscriptFailure(
|
|
3848
|
-
"No speech was captured during this recording. If you spoke or played system audio, check System Audio, Microphone input, Speech Recognition permission, and the selected mic, then retry transcription.",
|
|
3849
|
-
);
|
|
3850
|
-
}
|
|
3851
|
-
}
|
|
3852
|
-
if (popoverOwnsCamera) {
|
|
3853
|
-
console.log("[clips-recorder] releasing popover camera");
|
|
3854
|
-
emit("clips:release-camera").catch(() => {});
|
|
3855
|
-
}
|
|
3856
|
-
|
|
3857
|
-
// Null the data handler so the final MediaRecorder teardown
|
|
3858
|
-
// doesn't keep the closure (which captures `inflight`, the URL
|
|
3859
|
-
// builder, and indirectly the MediaStream) reachable after we're
|
|
3860
|
-
// done with it. WebKit's MediaRecorder can retain a reference to
|
|
3861
|
-
// its event handler for the life of the object if you leave a
|
|
3862
|
-
// non-null ondataavailable in place — null it to break the chain.
|
|
3863
|
-
recorder.ondataavailable = null;
|
|
3864
|
-
// Clear MediaStream track lists — just removing our
|
|
3865
|
-
// references to the tracks is enough; the tracks themselves are
|
|
3866
|
-
// owned by `displayStream` / `audioStream` / `bubbleCameraStream`
|
|
3867
|
-
// and get stopped below.
|
|
3868
|
-
try {
|
|
3869
|
-
uploadCombined.getTracks().forEach((t) => uploadCombined.removeTrack(t));
|
|
3870
|
-
combined.getTracks().forEach((t) => combined.removeTrack(t));
|
|
3871
|
-
} catch {
|
|
3872
|
-
// ignore — best-effort
|
|
3873
|
-
}
|
|
3874
|
-
|
|
3875
|
-
// Stop the streams WE own so OS permission indicators clear. The
|
|
3876
|
-
// camera stream is owned by the popover when reused — we leave it
|
|
3877
|
-
// alone so the bubble stays live if the popover is still open.
|
|
3878
|
-
[displayStream, audioStream].forEach((s) =>
|
|
3879
|
-
s?.getTracks().forEach((t) => t.stop()),
|
|
3880
|
-
);
|
|
3881
|
-
streamCleanups.forEach((cleanup) => cleanup());
|
|
3882
|
-
if (!popoverOwnsCamera) {
|
|
3883
|
-
bubbleCameraStream?.getTracks().forEach((t) => t.stop());
|
|
3884
|
-
}
|
|
3885
|
-
|
|
3886
|
-
// Hide the recording-specific overlays (countdown + toolbar). The
|
|
3887
|
-
// bubble is managed by the popover's session effect — when the
|
|
3888
|
-
// popover is hidden or the user turns camera off, that effect tears
|
|
3889
|
-
// down the bubble. Closing it here would cause a flicker on the
|
|
3890
|
-
// cancel path where the popover re-appears with camera still on.
|
|
3891
|
-
console.log("[clips-recorder] hiding recording chrome");
|
|
3892
|
-
await invoke("hide_recording_chrome").catch((err) =>
|
|
3893
|
-
console.error(`[clips-recorder] hide_recording_chrome failed:`, err),
|
|
3894
|
-
);
|
|
3895
|
-
|
|
3896
|
-
// Wait for any in-flight chunk uploads to settle before sending the
|
|
3897
|
-
// final chunk. Otherwise the server could finalize before the last
|
|
3898
|
-
// few bytes land. On the streaming path uploads are serialized through
|
|
3899
|
-
// `streamQueue`; on the buffered path they run concurrently and each
|
|
3900
|
-
// `.finally` has already removed settled entries from `inflight`.
|
|
3901
|
-
const pending = Array.from(inflight);
|
|
3902
|
-
if (uploadMode === "streaming") {
|
|
3903
|
-
await streamQueue;
|
|
3904
|
-
} else {
|
|
3905
|
-
await Promise.allSettled(pending);
|
|
3906
|
-
}
|
|
3907
|
-
inflight.clear();
|
|
3908
|
-
if (failed) {
|
|
3909
|
-
try {
|
|
3910
|
-
// Keep the guard until the final metadata and every trailing chunk
|
|
3911
|
-
// backup are durable, even though this upload cannot be finalized.
|
|
3912
|
-
await Promise.allSettled([...backupWrites]);
|
|
3913
|
-
console.error("[clips-recorder] chunk upload failed:", failed);
|
|
3914
|
-
await markBrowserRecordingBackupError(id, failed.message).catch(
|
|
3915
|
-
() => {},
|
|
3916
|
-
);
|
|
3917
|
-
await abortRecordingUpload(params.serverUrl, id, failed.message);
|
|
3918
|
-
} finally {
|
|
3919
|
-
await clearRecordingState();
|
|
4633
|
+
await publishFinalizingResult({
|
|
4634
|
+
recordingId: id,
|
|
4635
|
+
viewUrl: absoluteViewUrl,
|
|
4636
|
+
ok: true,
|
|
4637
|
+
});
|
|
4638
|
+
return result;
|
|
4639
|
+
} catch (err) {
|
|
3920
4640
|
await publishFinalizingResult({
|
|
3921
4641
|
recordingId: id,
|
|
3922
4642
|
viewUrl: absoluteViewUrl,
|
|
3923
4643
|
ok: false,
|
|
3924
|
-
error:
|
|
4644
|
+
error: err instanceof Error ? err.message : String(err),
|
|
3925
4645
|
});
|
|
4646
|
+
throw err;
|
|
3926
4647
|
}
|
|
3927
|
-
|
|
3928
|
-
}
|
|
3929
|
-
|
|
3930
|
-
// Streaming: the closing bytes are whatever remains under the 256 KiB
|
|
3931
|
-
// alignment boundary — send them as the final chunk so the resumable
|
|
3932
|
-
// session can complete. Buffered: bytes are already staged server-side,
|
|
3933
|
-
// so the final chunk is a 0-byte close sentinel.
|
|
3934
|
-
const finalBody =
|
|
3935
|
-
uploadMode === "streaming"
|
|
3936
|
-
? new Blob(pendingStreamBlobs, { type: finalMimeType })
|
|
3937
|
-
: new Blob([], { type: finalMimeType });
|
|
3938
|
-
pendingStreamBlobs = [];
|
|
3939
|
-
pendingStreamBytes = 0;
|
|
3940
|
-
|
|
3941
|
-
const finalizeUrl = chunkUrl(params.serverUrl, id, chunkIndex, true, {
|
|
3942
|
-
mimeType: finalMimeType,
|
|
3943
|
-
durationMs: String(durationMs),
|
|
3944
|
-
...(width ? { width: String(width) } : {}),
|
|
3945
|
-
...(height ? { height: String(height) } : {}),
|
|
3946
|
-
hasAudio: backupMeta.hasAudio ? "1" : "0",
|
|
3947
|
-
hasCamera: wantsCamera ? "1" : "0",
|
|
3948
|
-
});
|
|
3949
|
-
console.log("[clips-recorder] finalize POST", finalizeUrl, {
|
|
3950
|
-
chunksSent: chunkIndex,
|
|
3951
|
-
inflightAtFinalize: pending.length,
|
|
3952
|
-
finalBodyBytes: finalBody.size,
|
|
3953
|
-
uploadMode,
|
|
3954
|
-
anyFailed: !!failed,
|
|
3955
|
-
});
|
|
3956
|
-
try {
|
|
3957
|
-
const result = await finalizeAfterDurableBackup({
|
|
3958
|
-
// Opening the browser must not make the desktop look idle until every
|
|
3959
|
-
// trailing backup write and the final metadata are durable.
|
|
3960
|
-
ensureBackupDurable: async () => {
|
|
3961
|
-
await Promise.all([...backupWrites]);
|
|
3962
|
-
if (backupFailure) throw backupFailure;
|
|
3963
|
-
await persistBackupMeta({
|
|
3964
|
-
durationMs,
|
|
3965
|
-
width,
|
|
3966
|
-
height,
|
|
3967
|
-
bytes: backupBytes,
|
|
3968
|
-
hasAudio: uploadCombined.getAudioTracks().length > 0,
|
|
3969
|
-
hasCamera: wantsCamera,
|
|
3970
|
-
chunkCount: backupChunkCount,
|
|
3971
|
-
mimeType: finalMimeType,
|
|
3972
|
-
lastError: null,
|
|
3973
|
-
});
|
|
3974
|
-
},
|
|
3975
|
-
attemptFinalize: async () => {
|
|
3976
|
-
try {
|
|
3977
|
-
const finalRes = await fetch(finalizeUrl, {
|
|
3978
|
-
method: "POST",
|
|
3979
|
-
headers: { "Content-Type": "application/octet-stream" },
|
|
3980
|
-
credentials: "include",
|
|
3981
|
-
body: finalBody,
|
|
3982
|
-
signal: AbortSignal.timeout(FINALIZE_UPLOAD_TIMEOUT_MS),
|
|
3983
|
-
});
|
|
3984
|
-
const bodyText = await finalRes.text().catch(() => "");
|
|
3985
|
-
console.log(
|
|
3986
|
-
"[clips-recorder] finalize response:",
|
|
3987
|
-
finalRes.status,
|
|
3988
|
-
bodyText.slice(0, 500),
|
|
3989
|
-
);
|
|
3990
|
-
if (!finalRes.ok) {
|
|
3991
|
-
throw new Error(
|
|
3992
|
-
`Finalize failed (${finalRes.status}): ${bodyText.slice(0, 200)}`,
|
|
3993
|
-
);
|
|
3994
|
-
}
|
|
3995
|
-
const receipt = parseFinalizeReceipt(bodyText);
|
|
3996
|
-
const receiptStatus = verifyFinalizeReceipt(receipt, {
|
|
3997
|
-
bytes: backupBytes,
|
|
3998
|
-
durationMs,
|
|
3999
|
-
});
|
|
4000
|
-
if (receiptStatus === "processing") {
|
|
4001
|
-
scheduleBrowserBackupCleanupAfterProcessing({
|
|
4002
|
-
serverUrl: params.serverUrl,
|
|
4003
|
-
recordingId: id,
|
|
4004
|
-
authToken: params.authToken,
|
|
4005
|
-
});
|
|
4006
|
-
return { recordingId: id, viewUrl };
|
|
4007
|
-
}
|
|
4008
|
-
} catch (err) {
|
|
4009
|
-
console.error("[clips-recorder] finalize fetch failed:", err);
|
|
4010
|
-
const error = err instanceof Error ? err : new Error(String(err));
|
|
4011
|
-
if (
|
|
4012
|
-
await recoverAcceptedRecordingAfterFinalizeError({
|
|
4013
|
-
serverUrl: params.serverUrl,
|
|
4014
|
-
recordingId: id,
|
|
4015
|
-
authToken: params.authToken,
|
|
4016
|
-
})
|
|
4017
|
-
) {
|
|
4018
|
-
return { recordingId: id, viewUrl };
|
|
4019
|
-
}
|
|
4020
|
-
await markBrowserRecordingBackupError(id, error.message).catch(
|
|
4021
|
-
() => {},
|
|
4022
|
-
);
|
|
4023
|
-
await abortRecordingUpload(params.serverUrl, id, error.message);
|
|
4024
|
-
throw error;
|
|
4025
|
-
}
|
|
4026
|
-
await deleteBrowserRecordingBackup(id).catch((err) => {
|
|
4027
|
-
console.warn("[clips-recorder] local backup cleanup failed:", err);
|
|
4028
|
-
});
|
|
4029
|
-
|
|
4030
|
-
return { recordingId: id, viewUrl };
|
|
4031
|
-
},
|
|
4032
|
-
releaseGuard: clearRecordingState,
|
|
4033
|
-
});
|
|
4034
|
-
await publishFinalizingResult({
|
|
4035
|
-
recordingId: id,
|
|
4036
|
-
viewUrl: absoluteViewUrl,
|
|
4037
|
-
ok: true,
|
|
4038
|
-
});
|
|
4039
|
-
return result;
|
|
4040
|
-
} catch (err) {
|
|
4041
|
-
await publishFinalizingResult({
|
|
4042
|
-
recordingId: id,
|
|
4043
|
-
viewUrl: absoluteViewUrl,
|
|
4044
|
-
ok: false,
|
|
4045
|
-
error: err instanceof Error ? err.message : String(err),
|
|
4046
|
-
});
|
|
4047
|
-
throw err;
|
|
4048
|
-
}
|
|
4049
|
-
};
|
|
4648
|
+
};
|
|
4050
4649
|
|
|
4051
|
-
|
|
4052
|
-
|
|
4650
|
+
const handle: RecorderHandle = {
|
|
4651
|
+
stop: singleFlight(performStop),
|
|
4053
4652
|
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
);
|
|
4088
|
-
streamCleanups.forEach((cleanup) => cleanup());
|
|
4089
|
-
if (!popoverOwnsCamera) {
|
|
4090
|
-
bubbleCameraStream?.getTracks().forEach((t) => t.stop());
|
|
4091
|
-
}
|
|
4092
|
-
// Drop remaining in-flight chunk Blobs aggressively. Their fetches
|
|
4093
|
-
// will still settle (we don't AbortController them — dev server is
|
|
4094
|
-
// local and won't hang long) but we no longer hold references to the
|
|
4095
|
-
// Blobs via this Set. Combined with the `ondataavailable = null`
|
|
4096
|
-
// above, this guarantees no new Blobs latch on during the stop.
|
|
4097
|
-
inflight.clear();
|
|
4098
|
-
await invoke("hide_recording_chrome").catch(() => {});
|
|
4099
|
-
// Tell the server to abort the partial recording (drops chunks from
|
|
4100
|
-
// application_state, flips the recording row to 'failed'), then trash
|
|
4101
|
-
// it. This is best-effort background cleanup: redo/cancel must release
|
|
4102
|
-
// the desktop chrome immediately even if the server is slow or offline.
|
|
4103
|
-
if (id) {
|
|
4104
|
-
void cleanupCancelledRemoteRecording(params.serverUrl, id).catch(
|
|
4105
|
-
(err) => {
|
|
4106
|
-
console.warn("[clips-recorder] abort failed (non-fatal):", err);
|
|
4107
|
-
},
|
|
4653
|
+
async cancel() {
|
|
4654
|
+
if (stopped) return;
|
|
4655
|
+
stopped = true;
|
|
4656
|
+
if (tickHandle) clearInterval(tickHandle);
|
|
4657
|
+
stateUnlistens.forEach((u) => u());
|
|
4658
|
+
stateUnlistens = [];
|
|
4659
|
+
void transcriptionCapture?.cancel().catch((err) => {
|
|
4660
|
+
console.warn("[clips-recorder] transcription cancel failed:", err);
|
|
4661
|
+
});
|
|
4662
|
+
// Remove MediaRecorder's data handler so any final `ondataavailable`
|
|
4663
|
+
// from the stop() below doesn't push a new Blob into `inflight`
|
|
4664
|
+
// after we've decided to discard everything.
|
|
4665
|
+
recorder.ondataavailable = null;
|
|
4666
|
+
try {
|
|
4667
|
+
if (recorder.state !== "inactive") recorder.stop();
|
|
4668
|
+
} catch {
|
|
4669
|
+
// ignore
|
|
4670
|
+
}
|
|
4671
|
+
// Drop stream track references — same rationale as in stop(). This
|
|
4672
|
+
// detaches only from the MediaStreams; the originating streams own the
|
|
4673
|
+
// tracks and we stop them below.
|
|
4674
|
+
try {
|
|
4675
|
+
uploadCombined
|
|
4676
|
+
.getTracks()
|
|
4677
|
+
.forEach((t) => uploadCombined.removeTrack(t));
|
|
4678
|
+
combined.getTracks().forEach((t) => combined.removeTrack(t));
|
|
4679
|
+
} catch {
|
|
4680
|
+
// ignore
|
|
4681
|
+
}
|
|
4682
|
+
// Stop the streams WE own. Camera stays alive when the popover
|
|
4683
|
+
// owns it (see stop() for the same split).
|
|
4684
|
+
[displayStream, audioStream].forEach((s) =>
|
|
4685
|
+
s?.getTracks().forEach((t) => t.stop()),
|
|
4108
4686
|
);
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4687
|
+
streamCleanups.forEach((cleanup) => cleanup());
|
|
4688
|
+
if (!popoverOwnsCamera) {
|
|
4689
|
+
bubbleCameraStream?.getTracks().forEach((t) => t.stop());
|
|
4690
|
+
}
|
|
4691
|
+
// Drop remaining in-flight chunk Blobs aggressively. Their fetches
|
|
4692
|
+
// will still settle (we don't AbortController them — dev server is
|
|
4693
|
+
// local and won't hang long) but we no longer hold references to the
|
|
4694
|
+
// Blobs via this Set. Combined with the `ondataavailable = null`
|
|
4695
|
+
// above, this guarantees no new Blobs latch on during the stop.
|
|
4696
|
+
inflight.clear();
|
|
4697
|
+
await invoke("hide_recording_chrome").catch(() => {});
|
|
4698
|
+
// Tell the server to abort the partial recording (drops chunks from
|
|
4699
|
+
// application_state, flips the recording row to 'failed'), then trash
|
|
4700
|
+
// it. This is best-effort background cleanup: redo/cancel must release
|
|
4701
|
+
// the desktop chrome immediately even if the server is slow or offline.
|
|
4702
|
+
if (id) {
|
|
4703
|
+
void cleanupCancelledRemoteRecording(params.serverUrl, id).catch(
|
|
4704
|
+
(err) => {
|
|
4705
|
+
console.warn("[clips-recorder] abort failed (non-fatal):", err);
|
|
4706
|
+
},
|
|
4707
|
+
);
|
|
4708
|
+
}
|
|
4709
|
+
await deleteBrowserRecordingBackup(id).catch((err) => {
|
|
4710
|
+
console.warn("[clips-recorder] local backup cleanup failed:", err);
|
|
4711
|
+
});
|
|
4712
|
+
},
|
|
4713
|
+
};
|
|
4115
4714
|
|
|
4116
|
-
|
|
4715
|
+
return recorderWithCaptureSuspension(handle, releaseCaptureSuspension);
|
|
4716
|
+
} catch (err) {
|
|
4717
|
+
await releaseCaptureSuspension();
|
|
4718
|
+
throw err;
|
|
4719
|
+
}
|
|
4117
4720
|
}
|