@agent-native/core 0.85.6 → 0.85.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +6 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/use-pinch-zoom.ts +76 -11
  5. package/corpus/core/src/collab/presence.ts +63 -3
  6. package/corpus/templates/clips/desktop/src/lib/recorder.ts +245 -77
  7. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +190 -13
  8. package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +16 -4
  9. package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +46 -3
  10. package/corpus/templates/design/AGENTS.md +33 -1
  11. package/corpus/templates/design/actions/apply-component-prop-edit.ts +20 -73
  12. package/corpus/templates/design/actions/apply-motion-edit.ts +29 -2
  13. package/corpus/templates/design/actions/apply-visual-edit.ts +4 -2
  14. package/corpus/templates/design/actions/connect-localhost.ts +25 -18
  15. package/corpus/templates/design/actions/export-pdf.ts +8 -1
  16. package/corpus/templates/design/actions/export-zip.ts +10 -1
  17. package/corpus/templates/design/actions/get-motion-timeline.ts +26 -20
  18. package/corpus/templates/design/actions/grant-localhost-write-consent.ts +10 -0
  19. package/corpus/templates/design/actions/insert-asset.ts +190 -7
  20. package/corpus/templates/design/actions/open-visual-edit.ts +27 -5
  21. package/corpus/templates/design/actions/present-design-variants.ts +2 -2
  22. package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +18 -14
  23. package/corpus/templates/design/actions/run-design-audit.ts +7 -4
  24. package/corpus/templates/design/actions/write-local-file.ts +47 -22
  25. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +221 -66
  26. package/corpus/templates/design/app/components/design/EditPanel.tsx +650 -237
  27. package/corpus/templates/design/app/components/design/LayersPanel.tsx +526 -127
  28. package/corpus/templates/design/app/components/design/MotionDock.tsx +234 -46
  29. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1497 -408
  30. package/corpus/templates/design/app/components/design/StatesPanel.tsx +25 -2
  31. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +640 -72
  32. package/corpus/templates/design/app/components/design/bridge/motion-preview.bridge.ts +605 -0
  33. package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +50 -26
  34. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +16 -4
  35. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +90 -103
  36. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +111 -5
  37. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +13 -2
  38. package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +42 -2
  39. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +11 -2
  40. package/corpus/templates/design/app/components/design/types.ts +16 -0
  41. package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +24 -6
  42. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +15 -1
  43. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +77 -0
  44. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  45. package/corpus/templates/design/app/i18n-data.ts +18 -0
  46. package/corpus/templates/design/app/lib/design-import.ts +95 -0
  47. package/corpus/templates/design/app/pages/DesignEditor.tsx +4013 -838
  48. package/corpus/templates/design/changelog/2026-07-03-canvas-interactions-now-match-figma-rotation-aware-resizing-.md +6 -0
  49. package/corpus/templates/design/changelog/2026-07-03-inspector-fixes-mixed-selections-show-mixed-instead-of-wrong.md +6 -0
  50. package/corpus/templates/design/changelog/2026-07-03-keyframe-timeline-works-reliably-drag-keyframes-smoothly-pic.md +6 -0
  51. package/corpus/templates/design/changelog/2026-07-03-layers-panel-matches-figma-top-layer-on-top-drag-with-auto-s.md +6 -0
  52. package/corpus/templates/design/changelog/2026-07-03-much-faster-editor-smooth-dragging-zooming-and-panel-updates.md +6 -0
  53. package/corpus/templates/design/changelog/2026-07-03-pen-tool-refinements-click-the-first-point-to-close-with-a-d.md +6 -0
  54. package/corpus/templates/design/changelog/2026-07-03-text-editing-enter-adds-a-line-break-international-ime-typin.md +6 -0
  55. package/corpus/templates/design/changelog/2026-07-03-undo-and-redo-are-dependable-across-agent-edits-screen-switc.md +6 -0
  56. package/corpus/templates/design/server/lib/design-export.ts +43 -1
  57. package/corpus/templates/design/shared/board-file.ts +25 -5
  58. package/corpus/templates/design/shared/canvas-math.ts +754 -9
  59. package/corpus/templates/design/shared/code-layer.ts +304 -26
  60. package/corpus/templates/design/shared/color-utils.ts +84 -0
  61. package/corpus/templates/design/shared/design-source-capabilities.ts +16 -7
  62. package/corpus/templates/design/shared/motion-compiler.ts +75 -31
  63. package/corpus/templates/design/shared/motion-timeline.ts +335 -0
  64. package/corpus/templates/design/shared/pen-path.ts +200 -23
  65. package/dist/client/use-pinch-zoom.d.ts.map +1 -1
  66. package/dist/client/use-pinch-zoom.js +76 -11
  67. package/dist/client/use-pinch-zoom.js.map +1 -1
  68. package/dist/collab/awareness.d.ts +2 -2
  69. package/dist/collab/awareness.d.ts.map +1 -1
  70. package/dist/collab/presence.d.ts.map +1 -1
  71. package/dist/collab/presence.js +54 -3
  72. package/dist/collab/presence.js.map +1 -1
  73. package/dist/collab/routes.d.ts +2 -2
  74. package/dist/collab/struct-routes.d.ts +1 -1
  75. package/dist/observability/routes.d.ts +3 -3
  76. package/dist/progress/routes.d.ts +1 -1
  77. package/dist/resources/handlers.d.ts +2 -2
  78. package/dist/secrets/routes.d.ts +3 -3
  79. package/dist/server/transcribe-voice.d.ts +1 -1
  80. package/package.json +1 -1
@@ -88,6 +88,17 @@ const DEV_SYNTHETIC_CAPTURE_FLAG = "clips:dev-synthetic-capture";
88
88
  const LEGACY_DEV_REAL_CAPTURE_FLAG = "clips:dev-real-capture";
89
89
  const LIVE_UPLOAD_CHUNK_MS = 1_000;
90
90
  const NATIVE_FULLSCREEN_SEGMENT_MS = 5 * 60_000;
91
+ // GCS resumable uploads require every non-final chunk to be a multiple of
92
+ // 256 KiB. MediaRecorder emits arbitrary blob sizes, so on the streaming path
93
+ // we buffer raw blobs and only PUT aligned slices; the unaligned remainder is
94
+ // held and sent as the final chunk on stop.
95
+ const GCS_CHUNK_ALIGN_BYTES = 256 * 1024;
96
+ const STREAM_CHUNK_BYTES = 15 * GCS_CHUNK_ALIGN_BYTES; // 3.75 MiB
97
+
98
+ // How the client delivers recorded data to the server.
99
+ // - "streaming" — server has a resumable session; flush aligned chunks live.
100
+ // - "buffered" — per-blob chunks staged server-side, assembled on finalize.
101
+ type UploadMode = "streaming" | "buffered";
91
102
  const CLOUD_CAPTURE_FRAME_RATE = 24;
92
103
  const CLOUD_CAPTURE_MAX_WIDTH = 1920;
93
104
  const CLOUD_CAPTURE_MAX_HEIGHT = 1080;
@@ -903,12 +914,14 @@ async function createServerRecording(
903
914
  hasCamera: boolean,
904
915
  hasAudio: boolean,
905
916
  titleContext?: CaptureTitleResult,
917
+ options?: { mimeType?: string; requestStreaming?: boolean },
906
918
  ) {
907
919
  const url = `${serverUrl.replace(/\/+$/, "")}/_agent-native/actions/create-recording`;
908
920
  console.log("[clips-recorder] POST", url, {
909
921
  hasCamera,
910
922
  hasAudio,
911
923
  title: titleContext?.title,
924
+ requestStreaming: options?.requestStreaming ?? false,
912
925
  });
913
926
  let res: Response;
914
927
  try {
@@ -925,6 +938,9 @@ async function createServerRecording(
925
938
  hasAudio,
926
939
  spaceIds: [],
927
940
  visibility: "public",
941
+ ...(options?.requestStreaming
942
+ ? { requestStreaming: true, mimeType: options.mimeType }
943
+ : {}),
928
944
  ...(titleContext
929
945
  ? {
930
946
  title: titleContext.title,
@@ -947,12 +963,18 @@ async function createServerRecording(
947
963
  console.error("[clips-recorder] bad response:", url, res.status, body);
948
964
  throw new Error(`create-recording ${res.status}: ${body.slice(0, 200)}`);
949
965
  }
950
- const data = (await res.json()) as { result?: { id: string }; id?: string };
966
+ const data = (await res.json()) as {
967
+ result?: { id: string; uploadMode?: string };
968
+ id?: string;
969
+ uploadMode?: string;
970
+ };
951
971
  const result = data.result ?? data;
952
972
  if (!result.id) {
953
973
  throw new Error("create-recording did not return an id");
954
974
  }
955
- return { id: result.id };
975
+ const uploadMode: UploadMode =
976
+ result.uploadMode === "streaming" ? "streaming" : "buffered";
977
+ return { id: result.id, uploadMode };
956
978
  }
957
979
 
958
980
  interface ActiveWindowContext {
@@ -1234,48 +1256,95 @@ function decChunkBusy(): void {
1234
1256
  }
1235
1257
  }
1236
1258
 
1259
+ // Bounded retry for live chunk uploads. A brief network blip (Wi-Fi roam, DNS
1260
+ // hiccup, a single 5xx) should not fail the whole recording into the manual
1261
+ // backup-replay path when the very next attempt would land
1262
+ const CHUNK_UPLOAD_MAX_ATTEMPTS = 3;
1263
+ const CHUNK_UPLOAD_RETRY_BASE_MS = 250;
1264
+
1265
+ // Only transient server responses are worth retrying inline; a 4xx (bad
1266
+ // request, auth, not found) won't fix itself on the next attempt.
1267
+ function isRetriableChunkStatus(status: number): boolean {
1268
+ return status === 429 || status >= 500;
1269
+ }
1270
+
1237
1271
  async function uploadChunk(url: string, blob: Blob): Promise<void> {
1238
- // Signal to the bubble frame pump that a chunk is being uploaded.
1239
- // The pump's tick loop checks this flag and yields its slot to the
1240
- // fetch for the ~150-300ms the POST takes to serialize and land.
1241
- // Cleared in `finally` below so a throw still releases the pump.
1242
- incChunkBusy();
1243
- let res: Response;
1244
- try {
1245
- res = await fetch(url, {
1246
- method: "POST",
1247
- headers: { "Content-Type": blob.type || "application/octet-stream" },
1248
- // Tauri webview runs on localhost:1420 (dev) or tauri://localhost (prod);
1249
- // the clips server is a different origin. The framework's dev CORS is
1250
- // permissive for "*" but won't accept credentialed requests without
1251
- // Allow-Credentials and in dev auth is bypassed anyway, so we don't
1252
- // need cookies.
1253
- credentials: "include",
1254
- body: blob,
1255
- });
1256
- } finally {
1257
- decChunkBusy();
1258
- }
1259
- if (!res.ok) {
1260
- const body = await res.text().catch(() => "");
1261
- console.error(
1262
- "[clips-recorder] chunk failed:",
1263
- res.status,
1264
- body.slice(0, 200),
1265
- );
1266
- throw new Error(`chunk ${res.status}: ${body.slice(0, 200)}`);
1267
- }
1268
- // Drain the response body even on success. If we don't consume the
1269
- // body, WebKit can keep the network buffer resident until GC — that's
1270
- // extra retention on top of the ~1MB Blob we just uploaded. Reading
1271
- // and discarding is cheap (the body is usually tiny for a chunk ack)
1272
- // and makes the memory footprint predictable.
1273
- try {
1274
- await res.text();
1275
- } catch {
1276
- // ignore — body drain is best-effort
1272
+ let lastError: Error | null = null;
1273
+ for (let attempt = 1; attempt <= CHUNK_UPLOAD_MAX_ATTEMPTS; attempt++) {
1274
+ // Signal to the bubble frame pump that a chunk is being uploaded, but only
1275
+ // around the actual network call. The pump's tick loop checks this flag and
1276
+ // yields its slot to the fetch for the ~150-300ms the POST takes to
1277
+ // serialize and land. Released before any backoff wait so the pump can
1278
+ // encode frames while we sit idle between attempts.
1279
+ incChunkBusy();
1280
+ let res: Response | null = null;
1281
+ try {
1282
+ res = await fetch(url, {
1283
+ method: "POST",
1284
+ headers: { "Content-Type": blob.type || "application/octet-stream" },
1285
+ // Tauri webview runs on localhost:1420 (dev) or tauri://localhost (prod);
1286
+ // the clips server is a different origin. The framework's dev CORS is
1287
+ // permissive for "*" but won't accept credentialed requests without
1288
+ // Allow-Credentials — and in dev auth is bypassed anyway, so we don't
1289
+ // need cookies.
1290
+ credentials: "include",
1291
+ body: blob,
1292
+ });
1293
+ } catch (err) {
1294
+ // Network-level failure (offline, connection reset, DNS) transient.
1295
+ lastError = err instanceof Error ? err : new Error(String(err));
1296
+ } finally {
1297
+ decChunkBusy();
1298
+ }
1299
+
1300
+ if (res) {
1301
+ if (res.ok) {
1302
+ // Drain the response body even on success. If we don't consume the
1303
+ // body, WebKit can keep the network buffer resident until GC — that's
1304
+ // extra retention on top of the ~1MB Blob we just uploaded. Reading
1305
+ // and discarding is cheap (the body is usually tiny for a chunk ack)
1306
+ // and makes the memory footprint predictable.
1307
+ try {
1308
+ await res.text();
1309
+ } catch {
1310
+ // ignore — body drain is best-effort
1311
+ }
1312
+ console.log(
1313
+ "[clips-recorder] chunk ok:",
1314
+ res.status,
1315
+ blob.size,
1316
+ "bytes",
1317
+ );
1318
+ return;
1319
+ }
1320
+ const body = await res.text().catch(() => "");
1321
+ lastError = new Error(`chunk ${res.status}: ${body.slice(0, 200)}`);
1322
+ if (!isRetriableChunkStatus(res.status)) {
1323
+ console.error(
1324
+ "[clips-recorder] chunk failed:",
1325
+ res.status,
1326
+ body.slice(0, 200),
1327
+ );
1328
+ throw lastError;
1329
+ }
1330
+ console.warn(
1331
+ "[clips-recorder] chunk retriable failure:",
1332
+ res.status,
1333
+ `attempt ${attempt}/${CHUNK_UPLOAD_MAX_ATTEMPTS}`,
1334
+ );
1335
+ } else {
1336
+ console.warn(
1337
+ "[clips-recorder] chunk network error:",
1338
+ lastError?.message,
1339
+ `attempt ${attempt}/${CHUNK_UPLOAD_MAX_ATTEMPTS}`,
1340
+ );
1341
+ }
1342
+
1343
+ if (attempt < CHUNK_UPLOAD_MAX_ATTEMPTS) {
1344
+ await wait(CHUNK_UPLOAD_RETRY_BASE_MS * 2 ** (attempt - 1));
1345
+ }
1277
1346
  }
1278
- console.log("[clips-recorder] chunk ok:", res.status, blob.size, "bytes");
1347
+ throw lastError ?? new Error("chunk upload failed");
1279
1348
  }
1280
1349
 
1281
1350
  async function abortRecordingUpload(
@@ -2804,6 +2873,17 @@ async function startRecordingInner(
2804
2873
  .forEach((track) => uploadCombined.addTrack(track));
2805
2874
  recordingAudio.tracks.forEach((track) => uploadCombined.addTrack(track));
2806
2875
 
2876
+ // MIME type is resolved up front so create-recording can initialize the
2877
+ // resumable session with the correct content type when the server supports
2878
+ // streaming uploads.
2879
+ const mimeCandidates = [
2880
+ "video/webm;codecs=vp9,opus",
2881
+ "video/webm;codecs=vp8,opus",
2882
+ "video/webm",
2883
+ ];
2884
+ const mimeType =
2885
+ mimeCandidates.find((m) => MediaRecorder.isTypeSupported(m)) ?? "";
2886
+
2807
2887
  // 2+3. Countdown + create-recording happen IN PARALLEL. The countdown is
2808
2888
  // pure visual feedback — gating it on a network round-trip makes the
2809
2889
  // 3-2-1 feel laggy after the user picks a screen. Kick both off and
@@ -2818,6 +2898,7 @@ async function startRecordingInner(
2818
2898
  wantsCamera,
2819
2899
  wantsAudio,
2820
2900
  captureTitle,
2901
+ { mimeType: mimeType || "video/webm", requestStreaming: true },
2821
2902
  ).finally(() => {
2822
2903
  console.timeEnd("[clips-recorder] createServerRecording duration");
2823
2904
  });
@@ -2833,12 +2914,12 @@ async function startRecordingInner(
2833
2914
  );
2834
2915
  throw err;
2835
2916
  }
2836
- const { id } = createRes;
2917
+ const { id, uploadMode } = createRes;
2837
2918
  console.log(
2838
2919
  "[clips-recorder] countdown + createServerRecording both resolved, id=",
2839
2920
  id,
2840
2921
  );
2841
- console.log("[clips-recorder] recording row created", { id });
2922
+ console.log("[clips-recorder] recording row created", { id, uploadMode });
2842
2923
  let nativeTranscriptFailureSaved = false;
2843
2924
  const saveTranscriptFailure = async (failureReason: string) => {
2844
2925
  if (!wantsAudio || nativeTranscriptFailureSaved) return;
@@ -2853,17 +2934,15 @@ async function startRecordingInner(
2853
2934
 
2854
2935
  // 4. Start MediaRecorder with a 2-second timeslice — each `ondataavailable`
2855
2936
  // streams a chunk to the server, so we don't hold 5-min buffers in memory.
2856
- const mimeCandidates = [
2857
- "video/webm;codecs=vp9,opus",
2858
- "video/webm;codecs=vp8,opus",
2859
- "video/webm",
2860
- ];
2861
- const mimeType =
2862
- mimeCandidates.find((m) => MediaRecorder.isTypeSupported(m)) ?? "";
2863
2937
  const recorder = createCloudMediaRecorder(uploadCombined, mimeType);
2864
2938
  let chunkIndex = 0;
2865
2939
  let failed: Error | null = null;
2866
2940
  let backupBytes = 0;
2941
+ // Backup chunks are indexed by raw MediaRecorder blob (one per
2942
+ // `ondataavailable`), independent of `chunkIndex` — on the streaming path
2943
+ // `chunkIndex` counts aligned upload slices, not raw blobs.
2944
+ let backupChunkCount = 0;
2945
+ const streamMimeType = mimeType || "video/webm";
2867
2946
  let backupMeta: BrowserRecordingBackupMeta = {
2868
2947
  recordingId: id,
2869
2948
  serverUrl: params.serverUrl.replace(/\/+$/, ""),
@@ -2889,6 +2968,41 @@ async function startRecordingInner(
2889
2968
  persistBackupMeta().catch((err) => {
2890
2969
  console.warn("[clips-recorder] local backup metadata failed:", err);
2891
2970
  });
2971
+
2972
+ // Every raw MediaRecorder blob is mirrored to IndexedDB on both upload paths.
2973
+ // If uploads fail the recording can still be recovered locally — replayed to
2974
+ // the server (the retry first resets any resumable session so replay routes
2975
+ // through the buffered chunk path) or exported to a local file.
2976
+ const backupWrites = new Set<Promise<void>>();
2977
+ const backupChunkLocally = (blob: Blob): Promise<void> => {
2978
+ const backupIdx = backupChunkCount++;
2979
+ backupBytes += blob.size;
2980
+ const chunkMimeType = blob.type || streamMimeType;
2981
+ let w: Promise<void>;
2982
+ w = (async () => {
2983
+ try {
2984
+ await putBrowserRecordingBackupChunk({
2985
+ recordingId: id,
2986
+ index: backupIdx,
2987
+ blob,
2988
+ bytes: blob.size,
2989
+ mimeType: chunkMimeType,
2990
+ createdAt: new Date().toISOString(),
2991
+ });
2992
+ await persistBackupMeta({
2993
+ bytes: backupBytes,
2994
+ chunkCount: backupChunkCount,
2995
+ mimeType: chunkMimeType,
2996
+ });
2997
+ } catch (err) {
2998
+ console.warn("[clips-recorder] local chunk backup failed:", err);
2999
+ }
3000
+ })().finally(() => {
3001
+ backupWrites.delete(w);
3002
+ });
3003
+ backupWrites.add(w);
3004
+ return w;
3005
+ };
2892
3006
  // In-flight chunk uploads. We use a Set (not an array) so entries can be
2893
3007
  // removed as soon as each fetch settles — otherwise, for a 30-minute
2894
3008
  // recording the array grows to 900 Promises, and EACH promise closes over
@@ -2898,10 +3012,62 @@ async function startRecordingInner(
2898
3012
  // See `uploadChunk()` — it removes its own entry in `.finally()`.
2899
3013
  const inflight = new Set<Promise<void>>();
2900
3014
 
3015
+ // Streaming-path state. When the server opened a resumable session, blobs
3016
+ // accumulate here until at least STREAM_CHUNK_BYTES is available, then a
3017
+ // 256 KiB-aligned slice is uploaded as a non-final chunk. Resumable sessions
3018
+ // append by byte offset server-side, so streamed chunks MUST arrive in order
3019
+ // — uploads are serialized through `streamQueue`. The unaligned remainder is
3020
+ // sent as the final chunk on stop().
3021
+ let pendingStreamBlobs: Blob[] = [];
3022
+ let pendingStreamBytes = 0;
3023
+ let streamQueue: Promise<void> = Promise.resolve();
3024
+
3025
+ const queueStreamChunk = (blob: Blob, idx: number) => {
3026
+ const url = chunkUrl(params.serverUrl, id, idx, false, {
3027
+ mimeType: streamMimeType,
3028
+ });
3029
+ streamQueue = streamQueue.then(async () => {
3030
+ if (failed) return;
3031
+ try {
3032
+ await uploadChunk(url, blob);
3033
+ } catch (err) {
3034
+ failed ??= err instanceof Error ? err : new Error(String(err));
3035
+ }
3036
+ });
3037
+ };
3038
+
3039
+ const flushAlignedStreamChunks = () => {
3040
+ while (pendingStreamBytes >= STREAM_CHUNK_BYTES) {
3041
+ const combined = new Blob(pendingStreamBlobs, { type: streamMimeType });
3042
+ const head = combined.slice(0, STREAM_CHUNK_BYTES, streamMimeType);
3043
+ const tail = combined.slice(
3044
+ STREAM_CHUNK_BYTES,
3045
+ combined.size,
3046
+ streamMimeType,
3047
+ );
3048
+ pendingStreamBlobs = tail.size > 0 ? [tail] : [];
3049
+ pendingStreamBytes = tail.size;
3050
+ queueStreamChunk(head, chunkIndex++);
3051
+ }
3052
+ };
3053
+
2901
3054
  recorder.ondataavailable = (ev) => {
2902
3055
  if (!ev.data || ev.data.size === 0) return;
3056
+
3057
+ // Always mirror the raw blob to the local backup first (disaster recovery).
3058
+ void backupChunkLocally(ev.data);
3059
+
3060
+ if (uploadMode === "streaming") {
3061
+ // Resumable session on the server: buffer and flush 256 KiB-aligned
3062
+ // slices, uploaded in order. The unaligned remainder is sent as the
3063
+ // final chunk on stop().
3064
+ pendingStreamBlobs.push(ev.data);
3065
+ pendingStreamBytes += ev.data.size;
3066
+ flushAlignedStreamChunks();
3067
+ return;
3068
+ }
3069
+
2903
3070
  const idx = chunkIndex++;
2904
- backupBytes += ev.data.size;
2905
3071
  const chunkMimeType = ev.data.type || mimeType || "video/webm";
2906
3072
  const url = chunkUrl(params.serverUrl, id, idx, false, {
2907
3073
  mimeType: chunkMimeType,
@@ -2912,26 +3078,7 @@ async function startRecordingInner(
2912
3078
  // constructing the promise body so `inflight.delete(p)` inside the
2913
3079
  // `.finally` can reference the same handle we added.
2914
3080
  let p: Promise<void>;
2915
- p = (async () => {
2916
- try {
2917
- await putBrowserRecordingBackupChunk({
2918
- recordingId: id,
2919
- index: idx,
2920
- blob: ev.data,
2921
- bytes: ev.data.size,
2922
- mimeType: chunkMimeType,
2923
- createdAt: new Date().toISOString(),
2924
- });
2925
- await persistBackupMeta({
2926
- bytes: backupBytes,
2927
- chunkCount: Math.max(backupMeta.chunkCount, idx + 1),
2928
- mimeType: chunkMimeType,
2929
- });
2930
- } catch (err) {
2931
- console.warn("[clips-recorder] local chunk backup failed:", err);
2932
- }
2933
- await uploadChunk(url, ev.data);
2934
- })()
3081
+ p = uploadChunk(url, ev.data)
2935
3082
  .catch((err) => {
2936
3083
  failed ??= err instanceof Error ? err : new Error(String(err));
2937
3084
  })
@@ -3182,7 +3329,7 @@ async function startRecordingInner(
3182
3329
  bytes: backupBytes,
3183
3330
  hasAudio: uploadCombined.getAudioTracks().length > 0,
3184
3331
  hasCamera: wantsCamera,
3185
- chunkCount: chunkIndex,
3332
+ chunkCount: backupChunkCount,
3186
3333
  mimeType: finalMimeType,
3187
3334
  lastError: null,
3188
3335
  }).catch((err) => {
@@ -3235,11 +3382,19 @@ async function startRecordingInner(
3235
3382
 
3236
3383
  // Wait for any in-flight chunk uploads to settle before sending the
3237
3384
  // final chunk. Otherwise the server could finalize before the last
3238
- // few bytes land. Snapshot the current set the `.finally` in each
3239
- // upload will have already removed settled entries from `inflight`.
3385
+ // few bytes land. On the streaming path uploads are serialized through
3386
+ // `streamQueue`; on the buffered path they run concurrently and each
3387
+ // `.finally` has already removed settled entries from `inflight`.
3240
3388
  const pending = Array.from(inflight);
3241
- await Promise.allSettled(pending);
3389
+ if (uploadMode === "streaming") {
3390
+ await streamQueue;
3391
+ } else {
3392
+ await Promise.allSettled(pending);
3393
+ }
3242
3394
  inflight.clear();
3395
+ // Ensure the local backup is fully written before we either delete it
3396
+ // (on success) or leave it in place for recovery (on failure).
3397
+ await Promise.allSettled([...backupWrites]);
3243
3398
  if (failed) {
3244
3399
  console.error("[clips-recorder] chunk upload failed:", failed);
3245
3400
  await markBrowserRecordingBackupError(id, failed.message).catch(
@@ -3253,6 +3408,17 @@ async function startRecordingInner(
3253
3408
  throw failed;
3254
3409
  }
3255
3410
 
3411
+ // Streaming: the closing bytes are whatever remains under the 256 KiB
3412
+ // alignment boundary — send them as the final chunk so the resumable
3413
+ // session can complete. Buffered: bytes are already staged server-side,
3414
+ // so the final chunk is a 0-byte close sentinel.
3415
+ const finalBody =
3416
+ uploadMode === "streaming"
3417
+ ? new Blob(pendingStreamBlobs, { type: finalMimeType })
3418
+ : new Blob([], { type: finalMimeType });
3419
+ pendingStreamBlobs = [];
3420
+ pendingStreamBytes = 0;
3421
+
3256
3422
  const finalizeUrl = chunkUrl(params.serverUrl, id, chunkIndex, true, {
3257
3423
  mimeType: finalMimeType,
3258
3424
  durationMs: String(durationMs),
@@ -3264,6 +3430,8 @@ async function startRecordingInner(
3264
3430
  console.log("[clips-recorder] finalize POST", finalizeUrl, {
3265
3431
  chunksSent: chunkIndex,
3266
3432
  inflightAtFinalize: pending.length,
3433
+ finalBodyBytes: finalBody.size,
3434
+ uploadMode,
3267
3435
  anyFailed: !!failed,
3268
3436
  });
3269
3437
  try {
@@ -3271,7 +3439,7 @@ async function startRecordingInner(
3271
3439
  method: "POST",
3272
3440
  headers: { "Content-Type": "application/octet-stream" },
3273
3441
  credentials: "include",
3274
- body: new Blob([], { type: finalMimeType }),
3442
+ body: finalBody,
3275
3443
  });
3276
3444
  const bodyText = await finalRes.text().catch(() => "");
3277
3445
  console.log(