@agent-native/core 0.81.2 → 0.82.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +25 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/frame.ts +55 -0
- package/corpus/core/src/file-upload/builder.ts +199 -37
- package/corpus/core/src/file-upload/index.ts +2 -0
- package/corpus/core/src/file-upload/types.ts +38 -0
- package/corpus/core/src/mcp/build-server.ts +30 -4
- package/corpus/core/src/mcp/embed-app.ts +33 -18
- package/corpus/templates/clips/actions/create-recording.ts +44 -0
- package/corpus/templates/clips/actions/finalize-recording.ts +198 -88
- package/corpus/templates/clips/actions/lib/create-recording-schema.ts +12 -0
- package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +83 -29
- package/corpus/templates/clips/app/routes/record.tsx +12 -1
- package/corpus/templates/clips/server/lib/resumable-session.ts +39 -0
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +2 -0
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +186 -29
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +4 -0
- package/corpus/templates/clips/shared/recording-core.ts +5 -0
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +63 -16
- package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +28 -33
- package/corpus/templates/content/app/components/sidebar/document-sidebar-sections.ts +47 -0
- package/corpus/templates/content/app/hooks/use-documents.ts +37 -0
- package/corpus/templates/content/changelog/2026-06-30-sidebar-favorites-now-keep-long-titles-tidy-and-stay-in-sync.md +6 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +8 -1
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +11 -2
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +89 -16
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +21 -14
- package/corpus/templates/design/app/pages/DesignEditor.tsx +557 -211
- package/corpus/templates/design/changelog/2026-06-30-design-studio-canvas-layers-can-be-copied-pasted-and-reparen.md +6 -0
- package/corpus/templates/design/e2e/global-setup.ts +3 -1
- package/corpus/templates/design/e2e/helpers.ts +4 -1
- package/corpus/templates/design/playwright.config.ts +4 -1
- package/corpus/templates/plan/actions/consume-plan-feedback.ts +1 -9
- package/corpus/templates/plan/actions/convert-visual-plan-to-prototype.ts +1 -1
- package/corpus/templates/plan/actions/create-plan-design.ts +1 -1
- package/corpus/templates/plan/actions/create-prototype-plan.ts +1 -1
- package/corpus/templates/plan/actions/create-ui-plan.ts +1 -1
- package/corpus/templates/plan/actions/create-visual-plan.ts +36 -19
- package/corpus/templates/plan/actions/create-visual-questions.ts +1 -1
- package/corpus/templates/plan/actions/create-visual-recap.ts +1 -1
- package/corpus/templates/plan/actions/delete-plan-comment.ts +1 -9
- package/corpus/templates/plan/actions/delete-visual-plan.ts +1 -9
- package/corpus/templates/plan/actions/get-plan-feedback.ts +1 -9
- package/corpus/templates/plan/actions/get-visual-plan.ts +1 -9
- package/corpus/templates/plan/actions/import-visual-plan-source.ts +1 -1
- package/corpus/templates/plan/actions/list-visual-plans.ts +1 -9
- package/corpus/templates/plan/actions/reply-to-plan-comment.ts +1 -9
- package/corpus/templates/plan/actions/resolve-plan-comment.ts +1 -9
- package/corpus/templates/plan/actions/update-visual-plan.ts +1 -1
- package/corpus/templates/plan/actions/visual-answer.ts +1 -1
- package/corpus/templates/plan/app/components/layout/Layout.tsx +15 -0
- package/corpus/templates/plan/app/entry.client.tsx +10 -0
- package/corpus/templates/plan/app/global.css +28 -0
- package/corpus/templates/plan/shared/plan-content.ts +10 -6
- package/dist/client/frame.d.ts.map +1 -1
- package/dist/client/frame.js +59 -0
- package/dist/client/frame.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/file-upload/builder.d.ts.map +1 -1
- package/dist/file-upload/builder.js +137 -25
- package/dist/file-upload/builder.js.map +1 -1
- package/dist/file-upload/index.d.ts +1 -1
- package/dist/file-upload/index.d.ts.map +1 -1
- package/dist/file-upload/index.js.map +1 -1
- package/dist/file-upload/types.d.ts +26 -0
- package/dist/file-upload/types.d.ts.map +1 -1
- package/dist/file-upload/types.js.map +1 -1
- package/dist/mcp/build-server.d.ts.map +1 -1
- package/dist/mcp/build-server.js +31 -4
- package/dist/mcp/build-server.js.map +1 -1
- package/dist/mcp/embed-app.d.ts.map +1 -1
- package/dist/mcp/embed-app.js +33 -18
- package/dist/mcp/embed-app.js.map +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +3 -3
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
|
|
12
12
|
import { defineAction } from "@agent-native/core";
|
|
13
13
|
import { writeAppState } from "@agent-native/core/application-state";
|
|
14
|
+
import { getActiveFileUploadProviderForRequest } from "@agent-native/core/file-upload";
|
|
15
|
+
import type { UploadMode } from "@shared/recording-core.js";
|
|
16
|
+
import { MAX_UPLOAD_BYTES } from "@shared/upload-limits.js";
|
|
14
17
|
|
|
15
18
|
import { getDb, schema } from "../server/db/index.js";
|
|
16
19
|
import {
|
|
@@ -19,6 +22,7 @@ import {
|
|
|
19
22
|
requireOrganizationAccess,
|
|
20
23
|
stringifySpaceIds,
|
|
21
24
|
} from "../server/lib/recordings.js";
|
|
25
|
+
import { setResumableSession } from "../server/lib/resumable-session.js";
|
|
22
26
|
import { createRecordingSchema } from "./lib/create-recording-schema.js";
|
|
23
27
|
import { DEFAULT_RECORDING_TITLE } from "./lib/title-source.js";
|
|
24
28
|
|
|
@@ -76,6 +80,45 @@ export default defineAction({
|
|
|
76
80
|
|
|
77
81
|
console.log(`Created recording "${title}" (${id})`);
|
|
78
82
|
|
|
83
|
+
// Initialize a resumable upload session so chunks are streamed to the
|
|
84
|
+
// provider during recording (no post-stop assembly). Falls back gracefully
|
|
85
|
+
// to the SQL chunk path when no provider supports resumable uploads or the
|
|
86
|
+
// init fails.
|
|
87
|
+
let uploadMode: UploadMode = "buffered";
|
|
88
|
+
const uploadProvider = await getActiveFileUploadProviderForRequest();
|
|
89
|
+
if (args.requestStreaming && uploadProvider?.resumable) {
|
|
90
|
+
try {
|
|
91
|
+
const recordingMimeType =
|
|
92
|
+
args.mimeType?.split(";")[0]?.trim() || "video/webm";
|
|
93
|
+
const ext = /mp4|quicktime/i.test(recordingMimeType) ? "mp4" : "webm";
|
|
94
|
+
const filename = `${id}.${ext}`;
|
|
95
|
+
console.log(
|
|
96
|
+
`[create-recording] starting resumable session: provider=${uploadProvider.id} mimeType=${recordingMimeType}`,
|
|
97
|
+
);
|
|
98
|
+
const session = await uploadProvider.resumable.startSession(
|
|
99
|
+
filename,
|
|
100
|
+
recordingMimeType,
|
|
101
|
+
MAX_UPLOAD_BYTES,
|
|
102
|
+
);
|
|
103
|
+
await setResumableSession(id, {
|
|
104
|
+
providerId: uploadProvider.id,
|
|
105
|
+
sessionId: session.sessionId,
|
|
106
|
+
meta: session.meta,
|
|
107
|
+
bytesUploaded: 0,
|
|
108
|
+
lastCommittedIndex: -1,
|
|
109
|
+
});
|
|
110
|
+
uploadMode = "streaming";
|
|
111
|
+
console.log(
|
|
112
|
+
`[create-recording] resumable session ready for ${id}: provider=${uploadProvider.id}`,
|
|
113
|
+
);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.warn(
|
|
116
|
+
`[create-recording] resumable session init failed, falling back to buffered:`,
|
|
117
|
+
err instanceof Error ? err.message : String(err),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
79
122
|
return {
|
|
80
123
|
id,
|
|
81
124
|
organizationId,
|
|
@@ -84,6 +127,7 @@ export default defineAction({
|
|
|
84
127
|
abortUrl: `/api/uploads/${id}/abort`,
|
|
85
128
|
// Frontend substitutes {index}/{total}/{isFinal}
|
|
86
129
|
uploadChunkUrlTemplate: `/api/uploads/${id}/chunk?index={index}&total={total}&isFinal={isFinal}`,
|
|
130
|
+
uploadMode,
|
|
87
131
|
};
|
|
88
132
|
},
|
|
89
133
|
});
|
|
@@ -15,7 +15,10 @@ import {
|
|
|
15
15
|
deleteAppState,
|
|
16
16
|
} from "@agent-native/core/application-state";
|
|
17
17
|
import { emit } from "@agent-native/core/event-bus";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
getActiveFileUploadProvider,
|
|
20
|
+
uploadFile,
|
|
21
|
+
} from "@agent-native/core/file-upload";
|
|
19
22
|
import { captureRouteError } from "@agent-native/core/server";
|
|
20
23
|
import { MAX_UPLOAD_BYTES as MAX_RECORDING_UPLOAD_BYTES } from "@shared/upload-limits.js";
|
|
21
24
|
import { and, eq } from "drizzle-orm";
|
|
@@ -32,6 +35,10 @@ import {
|
|
|
32
35
|
getCurrentOwnerEmail,
|
|
33
36
|
ownerEmailMatches,
|
|
34
37
|
} from "../server/lib/recordings.js";
|
|
38
|
+
import {
|
|
39
|
+
deleteResumableSession,
|
|
40
|
+
getResumableSession,
|
|
41
|
+
} from "../server/lib/resumable-session.js";
|
|
35
42
|
import {
|
|
36
43
|
requiresConfiguredVideoStorage,
|
|
37
44
|
STORAGE_SETUP_REQUIRED_REASON,
|
|
@@ -96,6 +103,120 @@ const cliBoolean = z.preprocess((value) => {
|
|
|
96
103
|
return value;
|
|
97
104
|
}, z.boolean());
|
|
98
105
|
|
|
106
|
+
// Flip recording to 'ready', seed transcript row, fire background transcript,
|
|
107
|
+
// emit clip.created. Used by both the resumable and buffered upload paths.
|
|
108
|
+
async function markRecordingReady(params: {
|
|
109
|
+
id: string;
|
|
110
|
+
ownerEmail: string;
|
|
111
|
+
videoUrl: string;
|
|
112
|
+
videoSizeBytes: number;
|
|
113
|
+
videoFormat: "webm" | "mp4";
|
|
114
|
+
finalDurationMs: number;
|
|
115
|
+
finalWidth: number;
|
|
116
|
+
finalHeight: number;
|
|
117
|
+
finalHasAudio: boolean;
|
|
118
|
+
finalHasCamera: boolean;
|
|
119
|
+
existingTitle: string;
|
|
120
|
+
}) {
|
|
121
|
+
const {
|
|
122
|
+
id,
|
|
123
|
+
ownerEmail,
|
|
124
|
+
videoUrl,
|
|
125
|
+
videoSizeBytes,
|
|
126
|
+
videoFormat,
|
|
127
|
+
finalDurationMs,
|
|
128
|
+
finalWidth,
|
|
129
|
+
finalHeight,
|
|
130
|
+
finalHasAudio,
|
|
131
|
+
finalHasCamera,
|
|
132
|
+
existingTitle,
|
|
133
|
+
} = params;
|
|
134
|
+
const db = getDb();
|
|
135
|
+
const now = new Date().toISOString();
|
|
136
|
+
|
|
137
|
+
await db
|
|
138
|
+
.update(schema.recordings)
|
|
139
|
+
.set({
|
|
140
|
+
status: "ready",
|
|
141
|
+
videoUrl,
|
|
142
|
+
videoFormat,
|
|
143
|
+
videoSizeBytes,
|
|
144
|
+
durationMs: finalDurationMs,
|
|
145
|
+
width: finalWidth,
|
|
146
|
+
height: finalHeight,
|
|
147
|
+
hasAudio: finalHasAudio,
|
|
148
|
+
hasCamera: finalHasCamera,
|
|
149
|
+
failureReason: null,
|
|
150
|
+
uploadProgress: 100,
|
|
151
|
+
updatedAt: now,
|
|
152
|
+
})
|
|
153
|
+
.where(
|
|
154
|
+
and(
|
|
155
|
+
eq(schema.recordings.id, id),
|
|
156
|
+
ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const [existingTranscript] = await db
|
|
161
|
+
.select({ recordingId: schema.recordingTranscripts.recordingId })
|
|
162
|
+
.from(schema.recordingTranscripts)
|
|
163
|
+
.where(eq(schema.recordingTranscripts.recordingId, id));
|
|
164
|
+
if (!existingTranscript) {
|
|
165
|
+
await db.insert(schema.recordingTranscripts).values({
|
|
166
|
+
recordingId: id,
|
|
167
|
+
ownerEmail,
|
|
168
|
+
status: "pending",
|
|
169
|
+
createdAt: now,
|
|
170
|
+
updatedAt: now,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
await writeAppState(`recording-upload-${id}`, {
|
|
175
|
+
recordingId: id,
|
|
176
|
+
status: "ready",
|
|
177
|
+
progress: 100,
|
|
178
|
+
videoUrl,
|
|
179
|
+
finishedAt: now,
|
|
180
|
+
});
|
|
181
|
+
await writeAppState("refresh-signal", { ts: Date.now() });
|
|
182
|
+
|
|
183
|
+
// Kick off transcription in the background — fire-and-forget so the chunk
|
|
184
|
+
// endpoint gets a quick response. The request context (user email via
|
|
185
|
+
// AsyncLocalStorage) carries through to async continuations.
|
|
186
|
+
void Promise.resolve(
|
|
187
|
+
requestTranscript.run({ recordingId: id, force: true }),
|
|
188
|
+
).catch((err: unknown) => {
|
|
189
|
+
console.error("[finalize] background transcript failed", {
|
|
190
|
+
id,
|
|
191
|
+
error: err instanceof Error ? err.message : String(err),
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
emit(
|
|
197
|
+
"clip.created",
|
|
198
|
+
{
|
|
199
|
+
clipId: id,
|
|
200
|
+
title: existingTitle,
|
|
201
|
+
createdBy: ownerEmail,
|
|
202
|
+
duration: finalDurationMs,
|
|
203
|
+
url: videoUrl,
|
|
204
|
+
},
|
|
205
|
+
{ owner: ownerEmail },
|
|
206
|
+
);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
console.warn("[finalize] clip.created emit failed:", err);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
id,
|
|
213
|
+
status: "ready" as const,
|
|
214
|
+
videoUrl,
|
|
215
|
+
videoSizeBytes,
|
|
216
|
+
durationMs: finalDurationMs,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
99
220
|
export default defineAction({
|
|
100
221
|
description:
|
|
101
222
|
"Assemble recorded chunks into a final video blob, upload it to the configured storage provider, update the recording row (videoUrl, durationMs, width/height/hasAudio/hasCamera), flip status to 'ready', and trigger the agent to produce a title, summary, transcript, and chapters in the background.",
|
|
@@ -154,6 +275,21 @@ export default defineAction({
|
|
|
154
275
|
throw new Error(`Recording not found: ${id}`);
|
|
155
276
|
}
|
|
156
277
|
|
|
278
|
+
// Idempotency guard: finalize can be re-invoked when a client retries the
|
|
279
|
+
// final chunk after a lost response. If already 'ready' return the existing
|
|
280
|
+
// result instead of re-running the complete/assembly path (session and
|
|
281
|
+
// chunks are gone by then).
|
|
282
|
+
if (existing.status === "ready" && existing.videoUrl) {
|
|
283
|
+
debugLog("[finalize] already finalized, returning existing", { id });
|
|
284
|
+
return {
|
|
285
|
+
id,
|
|
286
|
+
status: "ready" as const,
|
|
287
|
+
videoUrl: existing.videoUrl,
|
|
288
|
+
videoSizeBytes: existing.videoSizeBytes ?? 0,
|
|
289
|
+
durationMs: existing.durationMs ?? 0,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
157
293
|
const uploadStateRaw = await readAppState(`recording-upload-${id}`);
|
|
158
294
|
const uploadState =
|
|
159
295
|
uploadStateRaw && typeof uploadStateRaw === "object"
|
|
@@ -190,6 +326,62 @@ export default defineAction({
|
|
|
190
326
|
? args.hasCamera
|
|
191
327
|
: (stateBoolean(uploadState, "hasCamera") ?? existing.hasCamera);
|
|
192
328
|
|
|
329
|
+
const readyParams = {
|
|
330
|
+
id,
|
|
331
|
+
ownerEmail,
|
|
332
|
+
videoFormat,
|
|
333
|
+
finalDurationMs,
|
|
334
|
+
finalWidth,
|
|
335
|
+
finalHeight,
|
|
336
|
+
finalHasAudio,
|
|
337
|
+
finalHasCamera,
|
|
338
|
+
existingTitle: existing.title,
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
// Resumable path: create-recording initialized a session and chunk.post.ts
|
|
342
|
+
// forwarded all chunks to the provider. Complete the session to get the CDN URL.
|
|
343
|
+
const resumableSession = await getResumableSession(id);
|
|
344
|
+
if (resumableSession) {
|
|
345
|
+
debugLog("[finalize] resumable session found, completing upload", {
|
|
346
|
+
id,
|
|
347
|
+
providerId: resumableSession.providerId,
|
|
348
|
+
});
|
|
349
|
+
try {
|
|
350
|
+
const uploadProvider = getActiveFileUploadProvider();
|
|
351
|
+
if (!uploadProvider?.resumable) {
|
|
352
|
+
throw new Error("No resumable upload provider configured");
|
|
353
|
+
}
|
|
354
|
+
const videoUrl = await uploadProvider.resumable.completeSession(
|
|
355
|
+
{
|
|
356
|
+
sessionId: resumableSession.sessionId,
|
|
357
|
+
meta: resumableSession.meta,
|
|
358
|
+
},
|
|
359
|
+
typeof resumableSession.meta.filename === "string"
|
|
360
|
+
? resumableSession.meta.filename
|
|
361
|
+
: "",
|
|
362
|
+
);
|
|
363
|
+
debugLog("[finalize] resumable upload completed", { id, videoUrl });
|
|
364
|
+
const result = await markRecordingReady({
|
|
365
|
+
...readyParams,
|
|
366
|
+
videoUrl,
|
|
367
|
+
videoSizeBytes: resumableSession.bytesUploaded,
|
|
368
|
+
});
|
|
369
|
+
// Delete only after durable state is written — so a retry before
|
|
370
|
+
// this point can still find the session and re-enter this path.
|
|
371
|
+
deleteResumableSession(id).catch((err) =>
|
|
372
|
+
console.warn("[finalize] failed to delete resumable session:", err),
|
|
373
|
+
);
|
|
374
|
+
return result;
|
|
375
|
+
} catch (err) {
|
|
376
|
+
console.error("[finalize] resumable complete failed:", err);
|
|
377
|
+
throw new Error(
|
|
378
|
+
`Upload completion failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Buffered path — assemble chunks from application_state, then upload.
|
|
384
|
+
|
|
193
385
|
// The recorder stashes compression metadata at
|
|
194
386
|
// `recording-compression-{id}` when its browser-side ffmpeg.wasm
|
|
195
387
|
// pass ran to bring the assembled blob under Builder.io's 100 MB
|
|
@@ -514,99 +706,17 @@ export default defineAction({
|
|
|
514
706
|
}
|
|
515
707
|
throw err;
|
|
516
708
|
}
|
|
517
|
-
const videoUrl = upload.url;
|
|
518
|
-
|
|
519
|
-
// Update the recording row with final metadata and flip to 'ready'.
|
|
520
|
-
await db
|
|
521
|
-
.update(schema.recordings)
|
|
522
|
-
.set({
|
|
523
|
-
status: "ready",
|
|
524
|
-
videoUrl,
|
|
525
|
-
videoFormat,
|
|
526
|
-
videoSizeBytes: assembled.byteLength,
|
|
527
|
-
durationMs: finalDurationMs,
|
|
528
|
-
width: finalWidth,
|
|
529
|
-
height: finalHeight,
|
|
530
|
-
hasAudio: finalHasAudio,
|
|
531
|
-
hasCamera: finalHasCamera,
|
|
532
|
-
failureReason: null,
|
|
533
|
-
uploadProgress: 100,
|
|
534
|
-
updatedAt: new Date().toISOString(),
|
|
535
|
-
})
|
|
536
|
-
.where(eq(schema.recordings.id, id));
|
|
537
|
-
|
|
538
|
-
// Seed a pending transcript row so the agent background task has a place
|
|
539
|
-
// to write results.
|
|
540
|
-
const [existingTranscript] = await db
|
|
541
|
-
.select({ recordingId: schema.recordingTranscripts.recordingId })
|
|
542
|
-
.from(schema.recordingTranscripts)
|
|
543
|
-
.where(eq(schema.recordingTranscripts.recordingId, id));
|
|
544
|
-
if (!existingTranscript) {
|
|
545
|
-
await db.insert(schema.recordingTranscripts).values({
|
|
546
|
-
recordingId: id,
|
|
547
|
-
ownerEmail,
|
|
548
|
-
status: "pending",
|
|
549
|
-
createdAt: new Date().toISOString(),
|
|
550
|
-
updatedAt: new Date().toISOString(),
|
|
551
|
-
});
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
await writeAppState(`recording-upload-${id}`, {
|
|
555
|
-
recordingId: id,
|
|
556
|
-
status: "ready",
|
|
557
|
-
progress: 100,
|
|
558
|
-
videoUrl,
|
|
559
|
-
finishedAt: new Date().toISOString(),
|
|
560
|
-
});
|
|
561
|
-
|
|
562
|
-
await writeAppState("refresh-signal", { ts: Date.now() });
|
|
563
|
-
|
|
564
|
-
// Kick off transcription in the background. Native web/macOS speech rows
|
|
565
|
-
// are preserved first; cloud transcription only fills gaps/refines when
|
|
566
|
-
// needed. The chunk endpoint already awaits this finalize call, so we
|
|
567
|
-
// fire-and-forget — the request context (user email via AsyncLocalStorage)
|
|
568
|
-
// carries through to async continuations started before this run()
|
|
569
|
-
// returns. Without this the transcript row stays in `pending` forever and
|
|
570
|
-
// the UI shows an infinite "Transcribing…" spinner.
|
|
571
|
-
void Promise.resolve(
|
|
572
|
-
requestTranscript.run({ recordingId: id, force: true }),
|
|
573
|
-
).catch((err: unknown) => {
|
|
574
|
-
console.error("[finalize] background transcript failed", {
|
|
575
|
-
id,
|
|
576
|
-
error: err instanceof Error ? err.message : String(err),
|
|
577
|
-
});
|
|
578
|
-
});
|
|
579
|
-
|
|
580
|
-
// Emit clip.created event — best-effort, never block the main flow.
|
|
581
|
-
try {
|
|
582
|
-
emit(
|
|
583
|
-
"clip.created",
|
|
584
|
-
{
|
|
585
|
-
clipId: id,
|
|
586
|
-
title: existing.title,
|
|
587
|
-
createdBy: ownerEmail,
|
|
588
|
-
duration: finalDurationMs,
|
|
589
|
-
url: videoUrl,
|
|
590
|
-
},
|
|
591
|
-
{ owner: ownerEmail },
|
|
592
|
-
);
|
|
593
|
-
} catch (err) {
|
|
594
|
-
console.warn("[finalize] clip.created emit failed:", err);
|
|
595
|
-
}
|
|
596
709
|
|
|
597
710
|
debugLog("[finalize] done", {
|
|
598
711
|
id,
|
|
599
|
-
videoUrl,
|
|
712
|
+
videoUrl: upload.url,
|
|
600
713
|
bytes: assembled.byteLength,
|
|
601
714
|
});
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
status: "ready" as const,
|
|
606
|
-
videoUrl,
|
|
715
|
+
return markRecordingReady({
|
|
716
|
+
...readyParams,
|
|
717
|
+
videoUrl: upload.url,
|
|
607
718
|
videoSizeBytes: assembled.byteLength,
|
|
608
|
-
|
|
609
|
-
};
|
|
719
|
+
});
|
|
610
720
|
} finally {
|
|
611
721
|
// Unconditional chunk scratch-space cleanup. Runs on success AND on
|
|
612
722
|
// error — a throw during uploadFile / drizzle update / anything else
|
|
@@ -64,4 +64,16 @@ export const createRecordingSchema = z.object({
|
|
|
64
64
|
.enum(["private", "org", "public"])
|
|
65
65
|
.optional()
|
|
66
66
|
.describe("Initial share visibility for the recording"),
|
|
67
|
+
mimeType: z
|
|
68
|
+
.string()
|
|
69
|
+
.optional()
|
|
70
|
+
.describe(
|
|
71
|
+
"MIME type the browser will record (e.g. video/webm, video/mp4). Used to initialize the resumable session with the correct content type.",
|
|
72
|
+
),
|
|
73
|
+
requestStreaming: z
|
|
74
|
+
.boolean()
|
|
75
|
+
.optional()
|
|
76
|
+
.describe(
|
|
77
|
+
"Opt in to the resumable streaming upload path. Only send this when the caller can produce 256 KiB-aligned chunks (GCS requirement). Defaults to false — callers that omit this always get the buffered path.",
|
|
78
|
+
),
|
|
67
79
|
});
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
chunkUploadUrl,
|
|
12
12
|
pickMimeType,
|
|
13
13
|
pickMimeTypeCandidates,
|
|
14
|
+
type UploadMode,
|
|
14
15
|
} from "@shared/recording-core";
|
|
15
16
|
|
|
16
17
|
import {
|
|
@@ -32,7 +33,7 @@ import {
|
|
|
32
33
|
|
|
33
34
|
// Re-exported for existing callers; the canonical impls live in
|
|
34
35
|
// @shared/recording-core and are shared with the Chrome extension recorder.
|
|
35
|
-
export { pickMimeType, pickMimeTypeCandidates };
|
|
36
|
+
export { pickMimeType, pickMimeTypeCandidates, canUseTimeslicedRecorderChunks };
|
|
36
37
|
|
|
37
38
|
export type RecordingMode = "screen" | "camera" | "screen+camera";
|
|
38
39
|
export type DisplaySurface = "monitor" | "window" | "browser";
|
|
@@ -95,6 +96,13 @@ export interface RecorderEngineOptions {
|
|
|
95
96
|
uploadUrl?: string;
|
|
96
97
|
/** Abort URL. Default `/api/uploads/:id/abort`. */
|
|
97
98
|
abortUrl?: string;
|
|
99
|
+
/**
|
|
100
|
+
* Upload strategy returned by create-recording.
|
|
101
|
+
* `"streaming"` — server has a resumable session; engine flushes aligned
|
|
102
|
+
* chunks during recording. `"buffered"` — blob assembled after stop() and
|
|
103
|
+
* uploaded in slices via the SQL chunk path.
|
|
104
|
+
*/
|
|
105
|
+
uploadMode?: UploadMode;
|
|
98
106
|
/** Fired whenever the state machine transitions. */
|
|
99
107
|
onState?: (state: RecorderState, detail?: Record<string, unknown>) => void;
|
|
100
108
|
/** Fired on each uploaded chunk (for progress UI). */
|
|
@@ -183,6 +191,11 @@ interface CompressionUploadMeta {
|
|
|
183
191
|
}
|
|
184
192
|
|
|
185
193
|
const DEFAULT_CHUNK_MS = 1000;
|
|
194
|
+
// GCS resumable uploads require every non-final chunk to be a multiple of
|
|
195
|
+
// 256 KiB. MediaRecorder emits arbitrary blob sizes, so on the streaming path
|
|
196
|
+
// we buffer raw blobs and only PUT aligned slices. ~4 MiB per streamed chunk.
|
|
197
|
+
const GCS_CHUNK_ALIGN_BYTES = 256 * 1024;
|
|
198
|
+
const STREAM_CHUNK_BYTES = 15 * GCS_CHUNK_ALIGN_BYTES; // 3.75 MiB
|
|
186
199
|
const CHUNK_UPLOAD_MAX_ATTEMPTS = 3;
|
|
187
200
|
const RETRYABLE_CHUNK_UPLOAD_STATUSES = new Set([
|
|
188
201
|
408, 425, 429, 500, 502, 503, 504,
|
|
@@ -430,7 +443,15 @@ export class RecorderEngine {
|
|
|
430
443
|
* fetch quietly complete and the recording finalise server-side.
|
|
431
444
|
*/
|
|
432
445
|
private uploadAbort: AbortController | null = null;
|
|
433
|
-
private
|
|
446
|
+
private uploadMode: UploadMode = "buffered";
|
|
447
|
+
/**
|
|
448
|
+
* Streaming-path buffer. MediaRecorder blobs accumulate here until at least
|
|
449
|
+
* STREAM_CHUNK_BYTES is available, then a 256 KiB-aligned slice is PUT to API
|
|
450
|
+
* as a non-final chunk. The unaligned remainder is held and uploaded as the
|
|
451
|
+
* final chunk on stop().
|
|
452
|
+
*/
|
|
453
|
+
private pendingStreamBlobs: Blob[] = [];
|
|
454
|
+
private pendingStreamBytes = 0;
|
|
434
455
|
/**
|
|
435
456
|
* One-shot guards so a camera/mic disconnect warning fires at most once even
|
|
436
457
|
* when a device exposes multiple tracks that each emit `ended`.
|
|
@@ -806,10 +827,12 @@ export class RecorderEngine {
|
|
|
806
827
|
recordingId: string;
|
|
807
828
|
uploadUrl: string;
|
|
808
829
|
abortUrl: string;
|
|
830
|
+
uploadMode?: UploadMode;
|
|
809
831
|
}): void {
|
|
810
832
|
this.opts.recordingId = target.recordingId;
|
|
811
833
|
this.opts.uploadUrl = target.uploadUrl;
|
|
812
834
|
this.opts.abortUrl = target.abortUrl;
|
|
835
|
+
this.opts.uploadMode = target.uploadMode ?? "buffered";
|
|
813
836
|
}
|
|
814
837
|
|
|
815
838
|
// -------------------------------------------------------------------------
|
|
@@ -884,7 +907,9 @@ export class RecorderEngine {
|
|
|
884
907
|
this.totalRecordedBytes = 0;
|
|
885
908
|
this.lastFinalizeMeta = null;
|
|
886
909
|
this.uploadAbort = new AbortController();
|
|
887
|
-
this.
|
|
910
|
+
this.uploadMode = this.opts.uploadMode ?? "buffered";
|
|
911
|
+
this.pendingStreamBlobs = [];
|
|
912
|
+
this.pendingStreamBytes = 0;
|
|
888
913
|
this.cameraDisconnectNotified = false;
|
|
889
914
|
this.micDisconnectNotified = false;
|
|
890
915
|
const useTimeslicedLocalChunks = canUseTimeslicedRecorderChunks(
|
|
@@ -901,9 +926,10 @@ export class RecorderEngine {
|
|
|
901
926
|
// whether this recording needs compression.
|
|
902
927
|
this.localChunks.push(blob);
|
|
903
928
|
this.totalRecordedBytes += blob.size;
|
|
904
|
-
if (this.
|
|
905
|
-
|
|
906
|
-
this.
|
|
929
|
+
if (this.uploadMode === "streaming") {
|
|
930
|
+
this.pendingStreamBlobs.push(blob);
|
|
931
|
+
this.pendingStreamBytes += blob.size;
|
|
932
|
+
this.flushAlignedStreamChunks();
|
|
907
933
|
}
|
|
908
934
|
});
|
|
909
935
|
|
|
@@ -1071,12 +1097,13 @@ export class RecorderEngine {
|
|
|
1071
1097
|
try {
|
|
1072
1098
|
if (
|
|
1073
1099
|
COMPRESSION_ENABLED &&
|
|
1074
|
-
this.totalRecordedBytes > COMPRESS_THRESHOLD_BYTES
|
|
1100
|
+
this.totalRecordedBytes > COMPRESS_THRESHOLD_BYTES &&
|
|
1101
|
+
this.opts.uploadMode !== "streaming"
|
|
1075
1102
|
) {
|
|
1076
1103
|
// Compress before the first server upload so large recordings don't
|
|
1077
1104
|
// stage their uncompressed source in SQL.
|
|
1078
1105
|
result = await this.compressAndReupload(finalizeMeta);
|
|
1079
|
-
} else if (
|
|
1106
|
+
} else if (this.uploadMode !== "streaming") {
|
|
1080
1107
|
this.transition("uploading", { progress: 0 });
|
|
1081
1108
|
const assembled = new Blob(this.localChunks, { type: this.mimeType });
|
|
1082
1109
|
result = await this.uploadBlobInSlices(
|
|
@@ -1086,26 +1113,28 @@ export class RecorderEngine {
|
|
|
1086
1113
|
this.uploadAbort?.signal,
|
|
1087
1114
|
);
|
|
1088
1115
|
} else {
|
|
1089
|
-
//
|
|
1090
|
-
//
|
|
1091
|
-
//
|
|
1092
|
-
//
|
|
1116
|
+
// Streaming path: all 256 KiB-aligned chunks were queued during
|
|
1117
|
+
// recording. Whatever bytes remain become the
|
|
1118
|
+
// final chunk. If the recording happened to end exactly
|
|
1119
|
+
// on a boundary the remainder is empty, which the server treats as a
|
|
1120
|
+
// close sentinel.
|
|
1093
1121
|
this.transition("uploading", { progress: 100 });
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1122
|
+
const remainder = new Blob(this.pendingStreamBlobs, {
|
|
1123
|
+
type: this.mimeType,
|
|
1124
|
+
});
|
|
1125
|
+
this.pendingStreamBlobs = [];
|
|
1126
|
+
this.pendingStreamBytes = 0;
|
|
1127
|
+
result = await this.uploadChunk(remainder, this.chunkIndex++, {
|
|
1128
|
+
isFinal: true,
|
|
1129
|
+
total: this.chunkIndex,
|
|
1130
|
+
mimeType: this.mimeType,
|
|
1131
|
+
durationMs,
|
|
1132
|
+
width: dimensions.width,
|
|
1133
|
+
height: dimensions.height,
|
|
1134
|
+
hasAudio,
|
|
1135
|
+
hasCamera,
|
|
1136
|
+
signal: this.uploadAbort?.signal,
|
|
1137
|
+
});
|
|
1109
1138
|
}
|
|
1110
1139
|
this.transition("complete");
|
|
1111
1140
|
completed = true;
|
|
@@ -1523,6 +1552,30 @@ export class RecorderEngine {
|
|
|
1523
1552
|
}
|
|
1524
1553
|
}
|
|
1525
1554
|
|
|
1555
|
+
/**
|
|
1556
|
+
* Drain the streaming buffer in 256 KiB-aligned chunks.
|
|
1557
|
+
* GCS rejects non-final resumable chunks that are not a multiple of 256 KiB,
|
|
1558
|
+
* so we slice on STREAM_CHUNK_BYTES boundaries and carry the remainder. The
|
|
1559
|
+
* leftover is uploaded as the final chunk on stop().
|
|
1560
|
+
*/
|
|
1561
|
+
private flushAlignedStreamChunks(): void {
|
|
1562
|
+
while (this.pendingStreamBytes >= STREAM_CHUNK_BYTES) {
|
|
1563
|
+
const combined = new Blob(this.pendingStreamBlobs, {
|
|
1564
|
+
type: this.mimeType,
|
|
1565
|
+
});
|
|
1566
|
+
const head = combined.slice(0, STREAM_CHUNK_BYTES, this.mimeType);
|
|
1567
|
+
const tail = combined.slice(
|
|
1568
|
+
STREAM_CHUNK_BYTES,
|
|
1569
|
+
combined.size,
|
|
1570
|
+
this.mimeType,
|
|
1571
|
+
);
|
|
1572
|
+
this.pendingStreamBlobs = tail.size > 0 ? [tail] : [];
|
|
1573
|
+
this.pendingStreamBytes = tail.size;
|
|
1574
|
+
const index = this.chunkIndex++;
|
|
1575
|
+
this.queueChunk(head, index, /* isFinal */ false);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1526
1579
|
private queueChunk(blob: Blob, index: number, isFinal: boolean): void {
|
|
1527
1580
|
this.chunkQueue = this.chunkQueue.then(async () => {
|
|
1528
1581
|
if (this.uploadFailure) return;
|
|
@@ -1660,8 +1713,10 @@ export class RecorderEngine {
|
|
|
1660
1713
|
signal: extra.signal,
|
|
1661
1714
|
});
|
|
1662
1715
|
} catch (err) {
|
|
1716
|
+
// The final chunk is safe to retry on a network error: finalize is
|
|
1717
|
+
// idempotent server-side (a recording already 'ready' returns its
|
|
1718
|
+
// existing result), so a lost response won't double-finalize.
|
|
1663
1719
|
if (
|
|
1664
|
-
extra.isFinal ||
|
|
1665
1720
|
attempt >= CHUNK_UPLOAD_MAX_ATTEMPTS ||
|
|
1666
1721
|
(err as { name?: string } | null)?.name === "AbortError"
|
|
1667
1722
|
) {
|
|
@@ -1673,7 +1728,6 @@ export class RecorderEngine {
|
|
|
1673
1728
|
|
|
1674
1729
|
if (
|
|
1675
1730
|
!res.ok &&
|
|
1676
|
-
!extra.isFinal &&
|
|
1677
1731
|
attempt < CHUNK_UPLOAD_MAX_ATTEMPTS &&
|
|
1678
1732
|
isRetryableChunkUploadStatus(res.status)
|
|
1679
1733
|
) {
|
|
@@ -7,7 +7,11 @@ import {
|
|
|
7
7
|
} from "@agent-native/core/client";
|
|
8
8
|
import { useLiveTranscription } from "@agent-native/core/client/transcription/use-live-transcription";
|
|
9
9
|
import type { BrowserDiagnosticsData } from "@shared/browser-diagnostics";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
chunkUploadUrl,
|
|
12
|
+
pickMimeType,
|
|
13
|
+
type UploadMode,
|
|
14
|
+
} from "@shared/recording-core";
|
|
11
15
|
import {
|
|
12
16
|
IconAlertTriangle,
|
|
13
17
|
IconArrowLeft,
|
|
@@ -95,6 +99,7 @@ import { CountdownOverlay } from "@/components/recorder/countdown-overlay";
|
|
|
95
99
|
import { PreRecordPanel } from "@/components/recorder/pre-record-panel";
|
|
96
100
|
import {
|
|
97
101
|
RecorderEngine,
|
|
102
|
+
canUseTimeslicedRecorderChunks,
|
|
98
103
|
NO_MIC_DEVICE_ID,
|
|
99
104
|
type DisplaySurface,
|
|
100
105
|
type RecorderFinalizeResult,
|
|
@@ -567,6 +572,7 @@ interface PendingRecording {
|
|
|
567
572
|
id: string;
|
|
568
573
|
uploadChunkUrl: string;
|
|
569
574
|
abortUrl: string;
|
|
575
|
+
uploadMode?: UploadMode;
|
|
570
576
|
}
|
|
571
577
|
|
|
572
578
|
function PreRecordPanelSkeleton() {
|
|
@@ -1194,6 +1200,8 @@ export default function RecordRoute() {
|
|
|
1194
1200
|
visibility: reportContext ? "org" : "public",
|
|
1195
1201
|
spaceIds: spaceIdFromUrl ? [spaceIdFromUrl] : undefined,
|
|
1196
1202
|
folderId: folderIdFromUrl ?? undefined,
|
|
1203
|
+
mimeType: pickMimeType() || undefined,
|
|
1204
|
+
requestStreaming: canUseTimeslicedRecorderChunks(pickMimeType()),
|
|
1197
1205
|
}),
|
|
1198
1206
|
},
|
|
1199
1207
|
);
|
|
@@ -1213,10 +1221,12 @@ export default function RecordRoute() {
|
|
|
1213
1221
|
id: string;
|
|
1214
1222
|
uploadChunkUrl: string;
|
|
1215
1223
|
abortUrl: string;
|
|
1224
|
+
uploadMode?: UploadMode;
|
|
1216
1225
|
};
|
|
1217
1226
|
id?: string;
|
|
1218
1227
|
uploadChunkUrl?: string;
|
|
1219
1228
|
abortUrl?: string;
|
|
1229
|
+
uploadMode?: UploadMode;
|
|
1220
1230
|
};
|
|
1221
1231
|
const info = created.result ?? (created as PendingRecording);
|
|
1222
1232
|
if (!info?.id) {
|
|
@@ -1244,6 +1254,7 @@ export default function RecordRoute() {
|
|
|
1244
1254
|
recordingId: info.id,
|
|
1245
1255
|
uploadUrl: uploadChunkUrl,
|
|
1246
1256
|
abortUrl,
|
|
1257
|
+
uploadMode: info.uploadMode,
|
|
1247
1258
|
});
|
|
1248
1259
|
await saveBugReportContextRef.current(info.id);
|
|
1249
1260
|
|