@agent-native/core 0.114.6 → 0.114.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +15 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/mcp-config-writers.ts +6 -4
  5. package/corpus/core/src/cli/mcp.ts +124 -0
  6. package/corpus/core/src/cli/skills-content/help.ts +4 -3
  7. package/corpus/core/src/cli/skills-content/index.ts +1 -0
  8. package/corpus/core/src/cli/skills-content/rewind-skill.ts +52 -0
  9. package/corpus/core/src/cli/skills.ts +115 -13
  10. package/corpus/core/src/mcp/screen-memory-stdio.ts +1360 -104
  11. package/corpus/core/src/mcp-client/index.ts +9 -0
  12. package/corpus/core/src/mcp-client/screen-memory-local.ts +847 -13
  13. package/corpus/core/src/server/agent-chat-plugin.ts +16 -9
  14. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +19 -7
  15. package/corpus/templates/analytics/app/hooks/use-chart-tooltip-portal.ts +60 -0
  16. package/corpus/templates/analytics/app/routes/chart.tsx +1 -1
  17. package/corpus/templates/analytics/changelog/2026-07-20-dashboard-requests-no-longer-stall-without-progress.md +6 -0
  18. package/corpus/templates/analytics/server/plugins/agent-chat.ts +6 -0
  19. package/corpus/templates/clips/actions/apply-rewind-extension.ts +195 -0
  20. package/corpus/templates/clips/actions/delete-agent-recording-if-unpromoted.ts +64 -0
  21. package/corpus/templates/clips/actions/get-rewind-extension-request.ts +22 -0
  22. package/corpus/templates/clips/actions/get-screen-memory-status.ts +18 -2
  23. package/corpus/templates/clips/actions/list-rewind-extension-requests.ts +52 -0
  24. package/corpus/templates/clips/actions/make-recording-private-for-rewind.ts +55 -0
  25. package/corpus/templates/clips/actions/query-screen-memory-context.ts +4 -4
  26. package/corpus/templates/clips/actions/request-rewind-extension.ts +81 -0
  27. package/corpus/templates/clips/actions/update-rewind-extension-request.ts +47 -0
  28. package/corpus/templates/clips/app/components/editor/editor-layout.tsx +25 -0
  29. package/corpus/templates/clips/app/components/editor/editor-toolbar.tsx +36 -0
  30. package/corpus/templates/clips/app/components/editor/rewind-extension-dialog.tsx +338 -0
  31. package/corpus/templates/clips/app/components/editor/stitch-manager.tsx +1 -16
  32. package/corpus/templates/clips/app/components/editor/timeline.tsx +26 -0
  33. package/corpus/templates/clips/app/i18n/en-US.ts +18 -0
  34. package/corpus/templates/clips/app/lib/ffmpeg-export.ts +19 -12
  35. package/corpus/templates/clips/app/lib/rewind-visibility.ts +31 -0
  36. package/corpus/templates/clips/app/lib/timestamp-mapping.ts +7 -0
  37. package/corpus/templates/clips/app/lib/upload-file-client.ts +17 -0
  38. package/corpus/templates/clips/changelog/2026-07-14-rewind-keeps-recent-screen-context-local-and-turns-it-into-clips.md +6 -0
  39. package/corpus/templates/clips/changelog/2026-07-17-active-clips-keep-rewind-stable-and-settings-clear.md +12 -0
  40. package/corpus/templates/clips/changelog/2026-07-17-clicking-the-clips-icon-during-a-recording-now-opens-the-app.md +6 -0
  41. package/corpus/templates/clips/changelog/2026-07-17-default-mic-now-uses-the-mac-default-and-transcription-shares-one-capture.md +6 -0
  42. package/corpus/templates/clips/changelog/2026-07-17-full-screen-clips-finalize-even-when-screencapturekit-stop-hangs.md +6 -0
  43. package/corpus/templates/clips/changelog/2026-07-17-normal-full-screen-clips-no-longer-stop-immediately-after-th.md +6 -0
  44. package/corpus/templates/clips/changelog/2026-07-17-rewind-can-hand-one-bounded-private-clip-to-your-agent.md +12 -0
  45. package/corpus/templates/clips/changelog/2026-07-17-rewind-home-can-copy-a-local-agent-prompt.md +6 -0
  46. package/corpus/templates/clips/changelog/2026-07-17-rewind-now-has-a-calm-home-status-focused-first-run-consent-.md +6 -0
  47. package/corpus/templates/clips/changelog/2026-07-17-rewind-settings-are-smaller-clearer-and-agent-first.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-07-17-the-full-clips-interface-stays-available-while-recording.md +6 -0
  49. package/corpus/templates/clips/changelog/2026-07-18-rewind-organizes-recent-work-into-local-searchable-chapters.md +8 -0
  50. package/corpus/templates/clips/changelog/2026-07-18-searchable-rewind-chapters.md +6 -0
  51. package/corpus/templates/clips/changelog/2026-07-19-rewind-backed-recordings-now-start-immediately-after-the-cou.md +6 -0
  52. package/corpus/templates/clips/changelog/2026-07-20-rewind-can-set-up-your-local-agent-once-so-future-requests-w.md +6 -0
  53. package/corpus/templates/clips/desktop/package.json +1 -0
  54. package/corpus/templates/clips/desktop/scripts/sign-macos-local.ts +12 -1
  55. package/corpus/templates/clips/desktop/src/app.tsx +2498 -239
  56. package/corpus/templates/clips/desktop/src/components/ReadinessPanel.tsx +21 -1
  57. package/corpus/templates/clips/desktop/src/components/Switch.tsx +3 -0
  58. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +156 -24
  59. package/corpus/templates/clips/desktop/src/lib/native-recording-warm.ts +6 -7
  60. package/corpus/templates/clips/desktop/src/lib/recorder.ts +1763 -1160
  61. package/corpus/templates/clips/desktop/src/lib/rewind-agent-prompt.ts +9 -0
  62. package/corpus/templates/clips/desktop/src/lib/rewind-recording-start.ts +20 -0
  63. package/corpus/templates/clips/desktop/src/main.tsx +3 -0
  64. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +14 -14
  65. package/corpus/templates/clips/desktop/src/overlays/onboarding.tsx +4 -4
  66. package/corpus/templates/clips/desktop/src/overlays/preparing.tsx +13 -0
  67. package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +27 -2
  68. package/corpus/templates/clips/desktop/src/shared/config.ts +16 -0
  69. package/corpus/templates/clips/desktop/src/styles.css +956 -134
  70. package/corpus/templates/clips/desktop/src-tauri/Cargo.toml +5 -0
  71. package/corpus/templates/clips/desktop/src-tauri/build.rs +69 -1
  72. package/corpus/templates/clips/desktop/src-tauri/src/accessibility.rs +386 -12
  73. package/corpus/templates/clips/desktop/src-tauri/src/capture_audio_bus.rs +382 -0
  74. package/corpus/templates/clips/desktop/src-tauri/src/capture_graph.rs +777 -0
  75. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +249 -22
  76. package/corpus/templates/clips/desktop/src-tauri/src/config.rs +148 -1
  77. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +58 -4
  78. package/corpus/templates/clips/desktop/src-tauri/src/logfile.rs +13 -1
  79. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +1505 -51
  80. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +124 -0
  81. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +1006 -95
  82. package/corpus/templates/clips/desktop/src-tauri/src/rewind_capture_suspension.rs +165 -0
  83. package/corpus/templates/clips/desktop/src-tauri/src/rewind_chapters.rs +2434 -0
  84. package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +1650 -0
  85. package/corpus/templates/clips/desktop/src-tauri/src/rewind_egress.rs +643 -0
  86. package/corpus/templates/clips/desktop/src-tauri/src/rewind_local_ask.rs +803 -0
  87. package/corpus/templates/clips/desktop/src-tauri/src/rewind_meeting_history.rs +595 -0
  88. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +2900 -91
  89. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_ocr.rs +360 -0
  90. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_transcript.rs +408 -0
  91. package/corpus/templates/clips/desktop/src-tauri/src/shortcuts.rs +114 -33
  92. package/corpus/templates/clips/desktop/src-tauri/src/state.rs +2 -2
  93. package/corpus/templates/clips/desktop/src-tauri/src/system_audio.rs +13 -3
  94. package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +4 -1
  95. package/corpus/templates/clips/desktop/src-tauri/src/util.rs +7 -13
  96. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +198 -11
  97. package/corpus/templates/clips/desktop/src-tauri/tauri.alpha.conf.json +17 -0
  98. package/corpus/toolkit/CHANGELOG.md +6 -0
  99. package/corpus/toolkit/package.json +1 -1
  100. package/dist/cli/mcp-config-writers.d.ts +2 -2
  101. package/dist/cli/mcp-config-writers.d.ts.map +1 -1
  102. package/dist/cli/mcp-config-writers.js +6 -6
  103. package/dist/cli/mcp-config-writers.js.map +1 -1
  104. package/dist/cli/mcp.d.ts +14 -0
  105. package/dist/cli/mcp.d.ts.map +1 -1
  106. package/dist/cli/mcp.js +79 -0
  107. package/dist/cli/mcp.js.map +1 -1
  108. package/dist/cli/skills-content/help.d.ts +1 -1
  109. package/dist/cli/skills-content/help.d.ts.map +1 -1
  110. package/dist/cli/skills-content/help.js +4 -3
  111. package/dist/cli/skills-content/help.js.map +1 -1
  112. package/dist/cli/skills-content/index.d.ts +1 -0
  113. package/dist/cli/skills-content/index.d.ts.map +1 -1
  114. package/dist/cli/skills-content/index.js +1 -0
  115. package/dist/cli/skills-content/index.js.map +1 -1
  116. package/dist/cli/skills-content/rewind-skill.d.ts +2 -0
  117. package/dist/cli/skills-content/rewind-skill.d.ts.map +1 -0
  118. package/dist/cli/skills-content/rewind-skill.js +53 -0
  119. package/dist/cli/skills-content/rewind-skill.js.map +1 -0
  120. package/dist/cli/skills.d.ts +6 -0
  121. package/dist/cli/skills.d.ts.map +1 -1
  122. package/dist/cli/skills.js +90 -14
  123. package/dist/cli/skills.js.map +1 -1
  124. package/dist/collab/struct-routes.d.ts +1 -1
  125. package/dist/mcp/screen-memory-stdio.d.ts +365 -0
  126. package/dist/mcp/screen-memory-stdio.d.ts.map +1 -1
  127. package/dist/mcp/screen-memory-stdio.js +1014 -91
  128. package/dist/mcp/screen-memory-stdio.js.map +1 -1
  129. package/dist/mcp-client/index.d.ts +1 -1
  130. package/dist/mcp-client/index.d.ts.map +1 -1
  131. package/dist/mcp-client/index.js +1 -1
  132. package/dist/mcp-client/index.js.map +1 -1
  133. package/dist/mcp-client/screen-memory-local.d.ts +90 -0
  134. package/dist/mcp-client/screen-memory-local.d.ts.map +1 -1
  135. package/dist/mcp-client/screen-memory-local.js +590 -12
  136. package/dist/mcp-client/screen-memory-local.js.map +1 -1
  137. package/dist/observability/routes.d.ts +1 -1
  138. package/dist/progress/routes.d.ts +1 -1
  139. package/dist/provider-api/actions/provider-api.d.ts +4 -4
  140. package/dist/resources/handlers.d.ts +1 -1
  141. package/dist/server/agent-chat-plugin.d.ts +5 -0
  142. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  143. package/dist/server/agent-chat-plugin.js +10 -9
  144. package/dist/server/agent-chat-plugin.js.map +1 -1
  145. package/package.json +2 -2
  146. package/src/cli/mcp-config-writers.ts +6 -4
  147. package/src/cli/mcp.ts +124 -0
  148. package/src/cli/skills-content/help.ts +4 -3
  149. package/src/cli/skills-content/index.ts +1 -0
  150. package/src/cli/skills-content/rewind-skill.ts +52 -0
  151. package/src/cli/skills.ts +115 -13
  152. package/src/mcp/screen-memory-stdio.ts +1360 -104
  153. package/src/mcp-client/index.ts +9 -0
  154. package/src/mcp-client/screen-memory-local.ts +847 -13
  155. package/src/server/agent-chat-plugin.ts +16 -9
@@ -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("native_fullscreen_recording_dismiss_upload", {
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 waitForCountdownEvent(timeoutMs = 4000): Promise<void> {
1803
- return new Promise((resolve, reject) => {
1804
- let timer: ReturnType<typeof setTimeout> | null = null;
1805
- const unlistens: UnlistenFn[] = [];
1806
- // Flag so that if the timeout fires BEFORE `listen()` resolves we can
1807
- // still call the unlisten the instant it arrives — otherwise the
1808
- // event handler closure stays registered for the life of the webview
1809
- // (leaks the `resolve` / `reject` closures + anything they pin).
1810
- let done = false;
1811
-
1812
- const cleanup = () => {
1813
- if (timer) {
1814
- clearTimeout(timer);
1815
- timer = null;
1816
- }
1817
- for (const unlisten of unlistens.splice(0)) {
1818
- try {
1819
- unlisten();
1820
- } catch {
1821
- // ignore
1822
- }
1823
- }
1824
- };
1825
- const finish = (kind: "done" | "cancel") => {
1826
- if (done) return;
1827
- done = true;
1828
- cleanup();
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
- track(listen("clips:countdown-done", () => finish("done")));
1857
- track(listen("clips:countdown-cancel", () => finish("cancel")));
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
- reject(new Error("timeout waiting for clips:countdown-done"));
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 countdownEvent = waitForCountdownEvent(COUNTDOWN_EVENT_TIMEOUT_MS);
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 countdownEvent;
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
- async function startNativeFullscreenRecording(
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
- console.log("[clips-recorder] using native full-screen capture");
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 localFolderName = localOnly ? createLocalRecordingFolderName() : "";
2018
- const streamCleanups: Array<() => void> = [audioCue.cleanup];
2019
- let id = "";
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
- let localCameraExport: LocalRecordingExportHandle | null = null;
2022
- let localCameraStream: MediaStream | null = null;
2023
- let localOwnsCameraStream = false;
2024
- let bubbleCaptureExcluded = false;
2025
- let captureRegion: RegionCaptureRect | null = null;
2026
- let transcriptionCapture: TranscriptionCapture | null = null;
2027
- // Timer baseline for the toolbar/pill elapsed clock. Stamped the instant
2028
- // native capture goes live (right after the start invoke resolves), not after
2029
- // the region-guide / transcription spin-up — which would push the displayed
2030
- // clock and the toolbar-enable behind the real recording start.
2031
- let startedAt = 0;
2032
- let nativeTranscriptFailureSaved = false;
2033
- const wantsSystemAudio = params.systemAudioOn !== false;
2034
- const wantsRecordedAudio = wantsAudio || wantsSystemAudio;
2035
- let micDeviceLabel: string | null = params.micLabel || null;
2036
- const saveTranscriptFailure = async (
2037
- failureReason: string,
2038
- ): Promise<boolean> => {
2039
- if (!wantsRecordedAudio || nativeTranscriptFailureSaved || !id)
2040
- return false;
2041
- nativeTranscriptFailureSaved = true;
2042
- return saveRecordingTranscriptFailure(
2043
- params.serverUrl,
2044
- id,
2045
- failureReason,
2046
- params.authToken,
2047
- );
2048
- };
2049
- const startNativeTranscriptionBeforeRecording = async () => {
2050
- if (localOnly || !wantsRecordedAudio || transcriptionCapture) return;
2051
- transcriptionCapture = await startTranscriptionCapture(
2052
- {
2053
- deviceId: params.micId,
2054
- label: micDeviceLabel,
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
- wantsSystemAudio,
2057
- { voiceProcessing: false },
2058
- );
2059
- if (
2060
- wantsRecordedAudio &&
2061
- !transcriptionCapture &&
2062
- shouldSaveLocalTranscriptionStartupFailure()
2063
- ) {
2064
- void saveTranscriptFailure(
2065
- "macOS Speech recognition could not start for this recording. Check Speech Recognition, System Audio, and Microphone permissions, then retry transcription.",
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
- try {
2071
- await invoke("park_popover_offscreen").catch(() => {});
2072
- emit("clips:popover-visible", false).catch(() => {});
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
- if (localOnly && localRecordingMode === "separate" && wantsCamera) {
2083
- localCameraStream =
2084
- params.preAcquiredCameraStream ??
2085
- (await getCameraStreamWithFallback(params.cameraId));
2086
- localOwnsCameraStream =
2087
- localCameraStream !== params.preAcquiredCameraStream;
2088
- localCameraExport = await prepareLocalRecordingExport(
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
- transcriptionPromise,
2182
- warmPromise,
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 transcriptionPromise;
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 recoverAcceptedRecordingAfterFinalizeError({
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
- return startNativeFullscreenRecording(
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
- // Use allSettled so a single rejection (e.g. user cancels the macOS screen
3001
- // picker → `NotAllowedError`) doesn't leave the OTHER resolved streams
3002
- // orphaned with live tracks. If ANY of the three rejected, we stop every
3003
- // track that DID resolve, then re-throw the original error so the caller's
3004
- // catch still sees `NotAllowedError` / `AbortError` as before.
3005
- console.log("[clips-recorder] allSettled IN streams dispatched");
3006
- const settled = await Promise.allSettled([
3007
- displayStreamPromise,
3008
- bubbleCameraStreamPromise,
3009
- audioStreamPromise,
3010
- ]);
3011
- console.log(
3012
- "[clips-recorder] allSettled OUT settled statuses:",
3013
- settled.map((s) => s.status),
3014
- );
3015
- const firstRejectionIndex = settled.findIndex((s) => s.status === "rejected");
3016
- const firstRejection =
3017
- firstRejectionIndex >= 0
3018
- ? (settled[firstRejectionIndex] as PromiseRejectedResult)
3019
- : null;
3020
- if (firstRejection) {
3021
- const canUseSyntheticScreen =
3022
- devSyntheticCapture &&
3023
- wantsScreen &&
3024
- displayStreamPromise != null &&
3025
- firstRejectionIndex === 0;
3026
- if (!canUseSyntheticScreen) {
3027
- for (const s of settled) {
3028
- if (s.status === "fulfilled" && s.value) {
3029
- try {
3030
- s.value.getTracks().forEach((t) => t.stop());
3031
- } catch {
3032
- // ignore — best-effort cleanup
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
- // NOTE: we do NOT stop `reusedCameraStream` tracks here. The popover
3037
- // owns the camera for the entire session (see top-of-file comment +
3038
- // `preAcquiredCameraStream` doc) — it keeps the stream alive so the
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
- console.warn(
3050
- "[clips-recorder] continuing with opt-in dev synthetic capture after stream acquisition failed:",
3051
- firstRejection.reason,
3052
- );
3053
- }
3054
- let displayStream =
3055
- settled[0].status === "fulfilled"
3056
- ? (settled[0].value as MediaStream | null)
3057
- : null;
3058
- let freshlyAcquiredCameraStream =
3059
- settled[1].status === "fulfilled"
3060
- ? (settled[1].value as MediaStream | null)
3061
- : null;
3062
- let audioStream =
3063
- settled[2].status === "fulfilled"
3064
- ? (settled[2].value as MediaStream | null)
3065
- : null;
3066
- if (
3067
- firstRejection &&
3068
- firstRejectionIndex === 0 &&
3069
- devSyntheticCapture &&
3070
- wantsScreen &&
3071
- !displayStream
3072
- ) {
3073
- [displayStream, freshlyAcquiredCameraStream, audioStream].forEach((s) =>
3074
- s?.getTracks().forEach((track) => track.stop()),
3075
- );
3076
- const syntheticDisplay = createSyntheticScreenStream();
3077
- displayStream = syntheticDisplay.stream;
3078
- streamCleanups.push(syntheticDisplay.cleanup);
3079
- if (wantsAudio && !audioStream) {
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
- freshlyAcquiredCameraStream = null;
3087
- }
3088
- // Reused (from preview) XOR freshly acquired — `bubbleCameraStreamPromise`
3089
- // was null when we reused, so only one of the two can be non-null.
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
- if (displayStream) {
3094
- console.log(
3095
- "[clips-recorder] display media acquired",
3096
- displayStream.getTracks().map((t) => t.kind),
3097
- );
3098
- }
3099
- if (bubbleCameraStream) {
3100
- const vtrack = bubbleCameraStream.getVideoTracks()[0];
3101
- console.log("[clips-recorder] camera acquired", {
3102
- label: vtrack?.label,
3103
- readyState: vtrack?.readyState,
3104
- muted: vtrack?.muted,
3105
- });
3106
- }
3107
- if (audioStream) {
3108
- console.log(
3109
- "[clips-recorder] audioStream acquired",
3110
- audioStream.getAudioTracks().map((t) => ({
3111
- label: t.label,
3112
- readyState: t.readyState,
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
- await invoke("park_popover_offscreen").catch(() => {});
3118
- emit("clips:popover-visible", false).catch(() => {});
3119
- const captureTitle = await captureTitleForRecording({
3120
- mode: params.mode,
3121
- source: captureSource,
3122
- });
3123
- let transcriptionCapture: TranscriptionCapture | null = null;
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
- const recordedScreenCameraStream =
3126
- localRecordingMode !== "separate" &&
3127
- params.mode === "screen-camera" &&
3128
- displayStream &&
3129
- bubbleCameraStream
3130
- ? createCameraCompositeStream({
3131
- displayStream,
3132
- cameraStream: bubbleCameraStream,
3133
- bubbleSizeRatio: bubbleSizeRatioForName(
3134
- await invoke<string>("load_bubble_size").catch(() => "small"),
3135
- ),
3136
- })
3137
- : null;
3138
- if (recordedScreenCameraStream) {
3139
- streamCleanups.push(recordedScreenCameraStream.cleanup);
3140
- console.log("[clips-recorder] compositing camera into recorded video");
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
- // Choose the primary video track for MediaRecorder:
3144
- // - screen mode → display
3145
- // - screen-camera mode → composited display + camera
3146
- // - camera mode → camera
3147
- const primaryVideo =
3148
- recordedScreenCameraStream?.stream ??
3149
- displayStream ??
3150
- (params.mode === "camera" ? bubbleCameraStream : null);
3151
- if (!primaryVideo) throw new Error("No video stream available");
3152
-
3153
- const combined = new MediaStream();
3154
- primaryVideo.getVideoTracks().forEach((t) => combined.addTrack(t));
3155
- // Mic + system audio (mixed when both present); see buildRecordingAudio.
3156
- const recordingAudio = buildRecordingAudio(
3157
- audioStream?.getAudioTracks() ?? [],
3158
- displayStream?.getAudioTracks() ?? [],
3159
- );
3160
- streamCleanups.push(recordingAudio.cleanup);
3161
- recordingAudio.tracks.forEach((t) => combined.addTrack(t));
3162
-
3163
- // The popover owns the camera stream whenever we reused its pre-acquired
3164
- // preview stream its session effect decides when to close the stream +
3165
- // hide the bubble + stop the pump, so the recorder must NOT stop those
3166
- // tracks on stop/cancel. The rare exception is the fresh-acquire fallback
3167
- // (preview stream wasn't ready at record start, so we opened the camera
3168
- // ourselves above) — there we own the tracks and must stop them, or the
3169
- // camera + macOS recording indicator leak after the recording ends.
3170
- const popoverOwnsCamera = bubbleCameraStream === reusedCameraStream;
3171
-
3172
- if (localRecordingMode !== "off") {
3173
- console.log("[clips-recorder] starting local-only recording", {
3174
- localRecordingMode,
3175
- });
3176
- const targets = localRecordingTargetsForMode({
3177
- localRecordingMode,
3178
- displayStream,
3179
- bubbleCameraStream,
3180
- recordingAudio,
3181
- combined,
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
- const localExportPromise = prepareLocalRecordingExport(targets);
3186
- let localExport: Awaited<ReturnType<typeof prepareLocalRecordingExport>>;
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
- [, localExport] = await Promise.all([
3189
- countdownPromise,
3190
- localExportPromise,
3191
- ]);
3971
+ [, createRes] = await Promise.all([countdownPromise, recordingPromise]);
3192
3972
  } catch (err) {
3193
- [displayStream, audioStream].forEach((stream) =>
3194
- stream?.getTracks().forEach((track) => track.stop()),
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 an elapsed time against a stale baseline (which showed the
3206
- // clock counting up and then resetting to 0 when start finally fired).
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
- localExport.pause();
3227
- pausedAt = Date.now();
3228
- emitState(true);
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
- localExport.resume();
3232
- if (pausedAt) accumulatedPauseMs += Date.now() - pausedAt;
3233
- pausedAt = null;
3234
- emitState(false);
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] local stop event received");
4219
+ console.log("[clips-recorder] stop event received");
3238
4220
  handle.stop().catch((err) => {
3239
- console.error("[clips-recorder] local handle.stop() threw:", err);
4221
+ console.error("[clips-recorder] handle.stop() threw:", err);
3240
4222
  });
3241
4223
  }),
3242
4224
  listen("clips:recorder-cancel", () => {
3243
- console.log("[clips-recorder] local cancel event received");
4225
+ console.log("[clips-recorder] cancel event received");
3244
4226
  handle.cancel().catch((err) => {
3245
- console.error("[clips-recorder] local handle.cancel() threw:", err);
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
- localExport.start(2_000);
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
- const detachCombinedStream = () => {
3260
- try {
3261
- combined.getTracks().forEach((track) => combined.removeTrack(track));
3262
- } catch {
3263
- // ignore best-effort
3264
- }
3265
- for (const target of targets) {
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
- target.stream
3268
- .getTracks()
3269
- .forEach((track) => target.stream.removeTrack(track));
4325
+ if (recorder.state === "paused") {
4326
+ recorder.resume();
4327
+ if (pausedAt) accumulatedPauseMs += Date.now() - pausedAt;
4328
+ pausedAt = null;
4329
+ }
3270
4330
  } catch {
3271
- // ignore — best-effort
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
- const stopOwnedStreams = () => {
3277
- [displayStream, audioStream].forEach((stream) =>
3278
- stream?.getTracks().forEach((track) => track.stop()),
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
- streamCleanups.forEach((cleanup) => cleanup());
3281
- if (!popoverOwnsCamera) {
3282
- bubbleCameraStream?.getTracks().forEach((track) => track.stop());
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
- const hideChrome = async () => {
3287
- await invoke("hide_recording_chrome").catch((err) =>
3288
- console.error(`[clips-recorder] hide_recording_chrome failed:`, err),
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
- const handle: RecorderHandle = {
3293
- async stop() {
3294
- if (stopped) {
3295
- return {
3296
- recordingId: id,
3297
- viewUrl: "",
3298
- localOnly: true,
3299
- localFolder: localExport.folderPath,
3300
- localFiles: [],
3301
- };
3302
- }
3303
- stopped = true;
3304
- if (tickHandle) clearInterval(tickHandle);
3305
- stateUnlistens.forEach((unlisten) => unlisten());
3306
- stateUnlistens = [];
3307
- const durationMs = Math.max(
3308
- 0,
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
- })().finally(() => {
3489
- backupWrites.delete(w);
3490
- });
3491
- backupWrites.add(w);
3492
- return w;
3493
- };
3494
- // In-flight chunk uploads. We use a Set (not an array) so entries can be
3495
- // removed as soon as each fetch settles — otherwise, for a 30-minute
3496
- // recording the array grows to 900 Promises, and EACH promise closes over
3497
- // the Blob it's uploading. MediaRecorder Blobs are the raw encoded video
3498
- // chunk ~500KB to ~1MB each. Holding 900 of them is a ~700MB leak per
3499
- // recording, and cumulative across recordings in a long-lived process.
3500
- // See `uploadChunk()` it removes its own entry in `.finally()`.
3501
- const inflight = new Set<Promise<void>>();
3502
-
3503
- // Streaming-path state. When the server opened a resumable session, blobs
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
- await uploadChunk(url, blob);
3521
- } catch (err) {
3522
- failed ??= err instanceof Error ? err : new Error(String(err));
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
- const flushAlignedStreamChunks = () => {
3528
- while (pendingStreamBytes >= STREAM_CHUNK_BYTES) {
3529
- const combined = new Blob(pendingStreamBlobs, { type: streamMimeType });
3530
- const head = combined.slice(0, STREAM_CHUNK_BYTES, streamMimeType);
3531
- const tail = combined.slice(
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
- pendingStreamBlobs = tail.size > 0 ? [tail] : [];
3537
- pendingStreamBytes = tail.size;
3538
- queueStreamChunk(head, chunkIndex++);
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
- function emitState(paused: boolean) {
3590
- const now = Date.now();
3591
- const pausedNowMs = paused && pausedAt ? now - pausedAt : 0;
3592
- const elapsedMs = now - startedAt - accumulatedPauseMs - pausedNowMs;
3593
- emit("clips:recorder-state", {
3594
- paused,
3595
- elapsedMs,
3596
- }).catch(() => {});
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
- // 5. Wire toolbar events.
3600
- const toolbarUnlistens = await Promise.all([
3601
- listen("clips:recorder-pause", () => {
3602
- if (recorder.state === "recording") {
3603
- try {
3604
- recorder.pause();
3605
- pausedAt = Date.now();
3606
- emitState(true);
3607
- console.log("[clips-recorder] recorder pause: pausing transcription");
3608
- void transcriptionCapture?.pause().catch(() => {});
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
- listen("clips:recorder-resume", () => {
3615
- if (recorder.state === "paused") {
4503
+ inflight.clear();
4504
+ if (failed) {
3616
4505
  try {
3617
- recorder.resume();
3618
- if (pausedAt) accumulatedPauseMs += Date.now() - pausedAt;
3619
- pausedAt = null;
3620
- emitState(false);
3621
- console.log(
3622
- "[clips-recorder] recorder resume: resuming transcription",
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
- void transcriptionCapture?.resume().catch(() => {});
3625
- } catch {
3626
- // ignore
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
- // 6. Bubble + toolbar visibility are owned by the popover's session
3699
- // effect (see app.tsx + bubble-pump.ts) not the recorder. Both open
3700
- // as soon as the user opens the popover in screen-camera / camera mode
3701
- // with cameraOn. The recorder reuses that camera stream for the saved
3702
- // video composite and flips the toolbar from disabled → enabled above.
3703
-
3704
- const performStop = async (): Promise<RecorderStopResult> => {
3705
- if (stopped) return { recordingId: id, viewUrl: `/r/${id}` };
3706
- stopped = true;
3707
- // Stamped right after the recorder fully stops below (see stoppedAt).
3708
- // Duration must measure recorded content — through the final flushed
3709
- // chunk but NOT the transcript-finalize + thumbnail + upload awaits that
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
- lastError: null,
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
- } catch (err) {
3819
- backupFailure = err instanceof Error ? err : new Error(String(err));
3820
- console.warn("[clips-recorder] local backup final metadata failed:", err);
3821
- }
3822
-
3823
- if (!recorderStopTimedOut) {
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
- const capturedTranscript = await transcriptionCapture
3834
- ?.stop()
3835
- .catch((err) => {
3836
- console.warn("[clips-recorder] transcript stop failed:", err);
3837
- return null;
4629
+ return { recordingId: id, viewUrl };
4630
+ },
4631
+ releaseGuard: clearRecordingState,
3838
4632
  });
3839
- if (capturedTranscript?.text.trim()) {
3840
- await saveRecordingTranscript(
3841
- params.serverUrl,
3842
- id,
3843
- capturedTranscript,
3844
- params.authToken,
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: failed.message,
4644
+ error: err instanceof Error ? err.message : String(err),
3925
4645
  });
4646
+ throw err;
3926
4647
  }
3927
- throw failed;
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
- const handle: RecorderHandle = {
4052
- stop: singleFlight(performStop),
4650
+ const handle: RecorderHandle = {
4651
+ stop: singleFlight(performStop),
4053
4652
 
4054
- async cancel() {
4055
- if (stopped) return;
4056
- stopped = true;
4057
- if (tickHandle) clearInterval(tickHandle);
4058
- stateUnlistens.forEach((u) => u());
4059
- stateUnlistens = [];
4060
- void transcriptionCapture?.cancel().catch((err) => {
4061
- console.warn("[clips-recorder] transcription cancel failed:", err);
4062
- });
4063
- // Remove MediaRecorder's data handler so any final `ondataavailable`
4064
- // from the stop() below doesn't push a new Blob into `inflight`
4065
- // after we've decided to discard everything.
4066
- recorder.ondataavailable = null;
4067
- try {
4068
- if (recorder.state !== "inactive") recorder.stop();
4069
- } catch {
4070
- // ignore
4071
- }
4072
- // Drop stream track references — same rationale as in stop(). This
4073
- // detaches only from the MediaStreams; the originating streams own the
4074
- // tracks and we stop them below.
4075
- try {
4076
- uploadCombined
4077
- .getTracks()
4078
- .forEach((t) => uploadCombined.removeTrack(t));
4079
- combined.getTracks().forEach((t) => combined.removeTrack(t));
4080
- } catch {
4081
- // ignore
4082
- }
4083
- // Stop the streams WE own. Camera stays alive when the popover
4084
- // owns it (see stop() for the same split).
4085
- [displayStream, audioStream].forEach((s) =>
4086
- s?.getTracks().forEach((t) => t.stop()),
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
- await deleteBrowserRecordingBackup(id).catch((err) => {
4111
- console.warn("[clips-recorder] local backup cleanup failed:", err);
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
- return handle;
4715
+ return recorderWithCaptureSuspension(handle, releaseCaptureSuspension);
4716
+ } catch (err) {
4717
+ await releaseCaptureSuspension();
4718
+ throw err;
4719
+ }
4117
4720
  }