@agent-native/core 0.85.5 → 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.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +12 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/AssistantChat.tsx +1 -1
- package/corpus/core/src/client/use-pinch-zoom.ts +76 -11
- package/corpus/core/src/collab/presence.ts +63 -3
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +245 -77
- package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +190 -13
- package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +16 -4
- package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +46 -3
- package/corpus/templates/design/AGENTS.md +40 -4
- package/corpus/templates/design/actions/apply-component-prop-edit.ts +20 -73
- package/corpus/templates/design/actions/apply-motion-edit.ts +29 -2
- package/corpus/templates/design/actions/apply-visual-edit.ts +4 -2
- package/corpus/templates/design/actions/connect-localhost.ts +25 -18
- package/corpus/templates/design/actions/edit-design.ts +5 -3
- package/corpus/templates/design/actions/export-pdf.ts +8 -1
- package/corpus/templates/design/actions/export-zip.ts +10 -1
- package/corpus/templates/design/actions/get-motion-timeline.ts +26 -20
- package/corpus/templates/design/actions/grant-localhost-write-consent.ts +10 -0
- package/corpus/templates/design/actions/insert-asset.ts +190 -7
- package/corpus/templates/design/actions/open-visual-edit.ts +27 -5
- package/corpus/templates/design/actions/present-design-variants.ts +12 -8
- package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +18 -14
- package/corpus/templates/design/actions/run-design-audit.ts +7 -4
- package/corpus/templates/design/actions/write-local-file.ts +47 -22
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +221 -66
- package/corpus/templates/design/app/components/design/EditPanel.tsx +650 -237
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +526 -127
- package/corpus/templates/design/app/components/design/MotionDock.tsx +234 -46
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1520 -410
- package/corpus/templates/design/app/components/design/StatesPanel.tsx +25 -2
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +640 -72
- package/corpus/templates/design/app/components/design/bridge/motion-preview.bridge.ts +605 -0
- package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +50 -26
- package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +16 -4
- package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +90 -103
- package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +111 -5
- package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +13 -2
- package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +42 -2
- package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +11 -2
- package/corpus/templates/design/app/components/design/types.ts +16 -0
- package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +24 -6
- package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +15 -1
- package/corpus/templates/design/app/hooks/use-question-flow.ts +2 -2
- package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +77 -0
- package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
- package/corpus/templates/design/app/i18n-data.ts +18 -0
- package/corpus/templates/design/app/lib/design-import.ts +95 -0
- package/corpus/templates/design/app/pages/DesignEditor.tsx +4015 -840
- package/corpus/templates/design/changelog/2026-07-02-creating-two-frames-in-a-row-no-longer-freezes-and-reloads-t.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-canvas-interactions-now-match-figma-rotation-aware-resizing-.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-design-retries-stalled-screen-edits-with-a-smaller-safer-fol.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-inspector-fixes-mixed-selections-show-mixed-instead-of-wrong.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-keyframe-timeline-works-reliably-drag-keyframes-smoothly-pic.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-layers-panel-matches-figma-top-layer-on-top-drag-with-auto-s.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-much-faster-editor-smooth-dragging-zooming-and-panel-updates.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-pen-tool-refinements-click-the-first-point-to-close-with-a-d.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-text-editing-enter-adds-a-line-break-international-ime-typin.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-undo-and-redo-are-dependable-across-agent-edits-screen-switc.md +6 -0
- package/corpus/templates/design/server/lib/design-export.ts +43 -1
- package/corpus/templates/design/server/plugins/agent-chat.ts +1 -1
- package/corpus/templates/design/shared/board-file.ts +25 -5
- package/corpus/templates/design/shared/canvas-math.ts +754 -9
- package/corpus/templates/design/shared/code-layer.ts +304 -26
- package/corpus/templates/design/shared/color-utils.ts +84 -0
- package/corpus/templates/design/shared/design-source-capabilities.ts +16 -7
- package/corpus/templates/design/shared/motion-compiler.ts +75 -31
- package/corpus/templates/design/shared/motion-timeline.ts +335 -0
- package/corpus/templates/design/shared/pen-path.ts +200 -23
- package/dist/client/AssistantChat.js +1 -1
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/use-pinch-zoom.d.ts.map +1 -1
- package/dist/client/use-pinch-zoom.js +76 -11
- package/dist/client/use-pinch-zoom.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/presence.d.ts.map +1 -1
- package/dist/collab/presence.js +54 -3
- package/dist/collab/presence.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +2 -2
- package/dist/observability/routes.d.ts +2 -2
- package/dist/secrets/routes.d.ts +3 -3
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
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
|
-
|
|
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 = (
|
|
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:
|
|
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.
|
|
3239
|
-
//
|
|
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
|
-
|
|
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:
|
|
3442
|
+
body: finalBody,
|
|
3275
3443
|
});
|
|
3276
3444
|
const bodyText = await finalRes.text().catch(() => "");
|
|
3277
3445
|
console.log(
|