@agent-native/core 0.109.0 → 0.109.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +14 -0
- package/corpus/core/docs/content/integrations.mdx +87 -253
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/a2a/client.ts +24 -2
- package/corpus/core/src/a2a/correlation.ts +50 -0
- package/corpus/core/src/a2a/handlers.ts +140 -14
- package/corpus/core/src/a2a/index.ts +1 -0
- package/corpus/core/src/a2a/invoke.ts +10 -1
- package/corpus/core/src/a2a/task-store.ts +146 -6
- package/corpus/core/src/a2a/types.ts +16 -0
- package/corpus/core/src/action.ts +2 -0
- package/corpus/core/src/agent/production-agent.ts +23 -0
- package/corpus/core/src/client/blocks/library/diagram.config.ts +10 -1
- package/corpus/core/src/client/blocks/library/diagram.tsx +9 -4
- package/corpus/core/src/client/use-run-stuck-detection.ts +27 -1
- package/corpus/core/src/db/client.ts +55 -0
- package/corpus/core/src/deploy/build.ts +63 -3
- package/corpus/core/src/observability/traces.ts +172 -19
- package/corpus/core/src/scripts/call-agent.ts +53 -2
- package/corpus/core/src/server/agent-chat/action-filters-a2a.ts +72 -25
- package/corpus/core/src/server/agent-chat-plugin.ts +107 -6
- package/corpus/core/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +21 -0
- package/corpus/core/src/templates/workspace-core/.agents/skills/observability/SKILL.md +7 -1
- package/corpus/core/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -1
- package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +1 -1
- package/corpus/templates/analytics/changelog/2026-07-17-analytics-now-includes-a-default-alert-for-spikes-in-stuck-agent-chats.md +6 -0
- package/corpus/templates/analytics/docs/schemas/first-party-analytics.md +23 -14
- package/corpus/templates/analytics/docs/schemas/tracked-events.md +5 -1
- package/corpus/templates/analytics/server/jobs/analytics-alerts.ts +3 -3
- package/corpus/templates/analytics/server/lib/analytics-alerts.ts +209 -87
- package/corpus/templates/clips/.agents/skills/dictate/SKILL.md +28 -5
- package/corpus/templates/clips/.agents/skills/meetings/SKILL.md +7 -0
- package/corpus/templates/clips/.agents/skills/recording/SKILL.md +23 -0
- package/corpus/templates/clips/AGENTS.md +10 -0
- package/corpus/templates/clips/actions/create-dictation.ts +29 -4
- package/corpus/templates/clips/actions/finalize-recording.ts +542 -65
- package/corpus/templates/clips/actions/get-recording-player-data.ts +16 -6
- package/corpus/templates/clips/actions/set-mobile-capture-state.ts +37 -0
- package/corpus/templates/clips/actions/update-dictation.ts +9 -1
- package/corpus/templates/clips/app/components/player/playback-comment-overlay.tsx +17 -2
- package/corpus/templates/clips/app/components/player/video-player.tsx +14 -2
- package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +2 -2
- package/corpus/templates/clips/app/routes/r.$recordingId.tsx +14 -3
- package/corpus/templates/clips/app/routes/record.tsx +3 -3
- package/corpus/templates/clips/app/routes/share.$shareId.tsx +19 -5
- package/corpus/templates/clips/changelog/2026-07-17-capture-dictation-background-meeting-audio-and-camera-videos.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-clips-now-retries-storage-verification-in-the-background-ins.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-delayed-upload-verification-keeps-local-backups-retryable.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-fixed-broken-thumbnail-icons-appearing-over-playable-clip-em.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-playback-comments-stay-readable-at-faster-playback-speeds.md +6 -0
- package/corpus/templates/clips/chrome-extension/src/finalize-recovery.ts +3 -0
- package/corpus/templates/clips/chrome-extension/src/offscreen.ts +3 -3
- package/corpus/templates/clips/desktop/src/app.tsx +35 -6
- package/corpus/templates/clips/desktop/src/lib/processing-backup-recovery.ts +19 -0
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +141 -18
- package/corpus/templates/clips/desktop/src/lib/upload-verification.ts +28 -7
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -4
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +32 -15
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +59 -18
- package/corpus/templates/clips/server/db/schema.ts +9 -1
- package/corpus/templates/clips/server/jobs/media-verification.ts +130 -0
- package/corpus/templates/clips/server/lib/builder-media-compression.ts +74 -7
- package/corpus/templates/clips/server/lib/media-verification-state.ts +55 -0
- package/corpus/templates/clips/server/lib/player-thumbnail-url.ts +31 -0
- package/corpus/templates/clips/server/lib/post-finalize-dispatch.ts +10 -3
- package/corpus/templates/clips/server/plugins/auth.ts +5 -5
- package/corpus/templates/clips/server/plugins/calendar-jobs.ts +2 -0
- package/corpus/templates/clips/server/routes/api/_agent-native-background/post-finalize-worker.post.ts +12 -2
- package/corpus/templates/clips/server/routes/api/public-recording.get.ts +13 -1
- package/corpus/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts +340 -0
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +24 -7
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +91 -7
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +22 -1
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/status.get.ts +14 -0
- package/corpus/templates/clips/shared/finalize-recovery.ts +61 -2
- package/corpus/templates/clips/shared/share-meta.ts +1 -1
- package/corpus/templates/plan/changelog/2026-07-17-diagrams-can-opt-into-a-polished-clean-rendering-mode-withou.md +6 -0
- package/corpus/templates/plan/shared/plan-content.ts +3 -0
- package/dist/a2a/client.d.ts +11 -2
- package/dist/a2a/client.d.ts.map +1 -1
- package/dist/a2a/client.js +16 -3
- package/dist/a2a/client.js.map +1 -1
- package/dist/a2a/correlation.d.ts +10 -0
- package/dist/a2a/correlation.d.ts.map +1 -0
- package/dist/a2a/correlation.js +40 -0
- package/dist/a2a/correlation.js.map +1 -0
- package/dist/a2a/handlers.d.ts.map +1 -1
- package/dist/a2a/handlers.js +100 -15
- package/dist/a2a/handlers.js.map +1 -1
- package/dist/a2a/index.d.ts +1 -1
- package/dist/a2a/index.d.ts.map +1 -1
- package/dist/a2a/index.js.map +1 -1
- package/dist/a2a/invoke.d.ts +4 -1
- package/dist/a2a/invoke.d.ts.map +1 -1
- package/dist/a2a/invoke.js +3 -0
- package/dist/a2a/invoke.js.map +1 -1
- package/dist/a2a/task-store.d.ts +12 -1
- package/dist/a2a/task-store.d.ts.map +1 -1
- package/dist/a2a/task-store.js +96 -6
- package/dist/a2a/task-store.js.map +1 -1
- package/dist/a2a/types.d.ts +15 -0
- package/dist/a2a/types.d.ts.map +1 -1
- package/dist/a2a/types.js.map +1 -1
- package/dist/action.d.ts +2 -0
- package/dist/action.d.ts.map +1 -1
- package/dist/action.js.map +1 -1
- package/dist/agent/production-agent.d.ts +9 -0
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +14 -0
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/client/blocks/library/diagram.config.d.ts +2 -0
- package/dist/client/blocks/library/diagram.config.d.ts.map +1 -1
- package/dist/client/blocks/library/diagram.config.js +8 -1
- package/dist/client/blocks/library/diagram.config.js.map +1 -1
- package/dist/client/blocks/library/diagram.d.ts.map +1 -1
- package/dist/client/blocks/library/diagram.js +9 -4
- package/dist/client/blocks/library/diagram.js.map +1 -1
- package/dist/client/use-run-stuck-detection.d.ts.map +1 -1
- package/dist/client/use-run-stuck-detection.js +25 -1
- package/dist/client/use-run-stuck-detection.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/db/client.d.ts +7 -0
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +33 -0
- package/dist/db/client.js.map +1 -1
- package/dist/deploy/build.d.ts +3 -0
- package/dist/deploy/build.d.ts.map +1 -1
- package/dist/deploy/build.js +45 -1
- package/dist/deploy/build.js.map +1 -1
- package/dist/observability/routes.d.ts +1 -1
- package/dist/observability/traces.d.ts +9 -0
- package/dist/observability/traces.d.ts.map +1 -1
- package/dist/observability/traces.js +137 -19
- package/dist/observability/traces.js.map +1 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/scripts/call-agent.d.ts.map +1 -1
- package/dist/scripts/call-agent.js +35 -3
- package/dist/scripts/call-agent.js.map +1 -1
- package/dist/server/agent-chat/action-filters-a2a.d.ts +18 -2
- package/dist/server/agent-chat/action-filters-a2a.d.ts.map +1 -1
- package/dist/server/agent-chat/action-filters-a2a.js +35 -7
- package/dist/server/agent-chat/action-filters-a2a.js.map +1 -1
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +93 -6
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/dist/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +21 -0
- package/dist/templates/workspace-core/.agents/skills/observability/SKILL.md +7 -1
- package/dist/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -1
- package/docs/content/integrations.mdx +87 -253
- package/package.json +1 -1
- package/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +21 -0
- package/src/templates/workspace-core/.agents/skills/observability/SKILL.md +7 -1
- package/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -1
|
@@ -10,16 +10,17 @@
|
|
|
10
10
|
|
|
11
11
|
import { defineAction } from "@agent-native/core";
|
|
12
12
|
import {
|
|
13
|
+
compareAndSetAppState,
|
|
14
|
+
deleteAppState,
|
|
13
15
|
readAppState,
|
|
14
16
|
writeAppState,
|
|
15
|
-
deleteAppState,
|
|
16
17
|
} from "@agent-native/core/application-state";
|
|
17
18
|
import { emit } from "@agent-native/core/event-bus";
|
|
18
19
|
import { uploadFile } from "@agent-native/core/file-upload";
|
|
19
20
|
import { captureRouteError } from "@agent-native/core/server";
|
|
20
21
|
import { isStoredButUnservableFinalizeError } from "@shared/finalize-recovery.js";
|
|
21
22
|
import { MAX_UPLOAD_BYTES as MAX_RECORDING_UPLOAD_BYTES } from "@shared/upload-limits.js";
|
|
22
|
-
import { and, eq, isNull
|
|
23
|
+
import { and, eq, isNull } from "drizzle-orm";
|
|
23
24
|
import { z } from "zod";
|
|
24
25
|
|
|
25
26
|
import { getDb, schema } from "../server/db/index.js";
|
|
@@ -29,6 +30,10 @@ import {
|
|
|
29
30
|
applyFaststart,
|
|
30
31
|
hasPlayableMp4Metadata,
|
|
31
32
|
} from "../server/lib/faststart.js";
|
|
33
|
+
import {
|
|
34
|
+
mediaVerificationStateKey,
|
|
35
|
+
parseMediaVerificationMarker,
|
|
36
|
+
} from "../server/lib/media-verification-state.js";
|
|
32
37
|
import { dispatchPostFinalizeJob } from "../server/lib/post-finalize-dispatch.js";
|
|
33
38
|
import {
|
|
34
39
|
listRecordingChunkKeys,
|
|
@@ -97,6 +102,8 @@ const RECORDING_TOO_LARGE_REASON =
|
|
|
97
102
|
const MEDIA_SERVE_VERIFICATION_TIMEOUT_MS = 8_000;
|
|
98
103
|
const MEDIA_SERVE_VERIFICATION_ATTEMPTS = 3;
|
|
99
104
|
const MEDIA_SERVE_VERIFICATION_BACKOFF_MS = 350;
|
|
105
|
+
const MEDIA_VERIFICATION_MAX_DURABLE_ATTEMPTS = 10;
|
|
106
|
+
const MEDIA_VERIFICATION_INITIAL_RETRY_DELAY_MS = 5_000;
|
|
100
107
|
|
|
101
108
|
function stateNumber(
|
|
102
109
|
value: Record<string, unknown> | null | undefined,
|
|
@@ -115,6 +122,14 @@ function stateBoolean(
|
|
|
115
122
|
return typeof raw === "boolean" ? raw : undefined;
|
|
116
123
|
}
|
|
117
124
|
|
|
125
|
+
function stateString(
|
|
126
|
+
value: Record<string, unknown> | null | undefined,
|
|
127
|
+
key: string,
|
|
128
|
+
): string | undefined {
|
|
129
|
+
const raw = value?.[key];
|
|
130
|
+
return typeof raw === "string" && raw.trim() ? raw : undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
118
133
|
const cliBoolean = z.preprocess((value) => {
|
|
119
134
|
if (value === "true") return true;
|
|
120
135
|
if (value === "false") return false;
|
|
@@ -166,11 +181,8 @@ function servedMediaSizeBytes(response: Response): number | null {
|
|
|
166
181
|
return null;
|
|
167
182
|
}
|
|
168
183
|
|
|
169
|
-
async function verifyServedMediaUrl(
|
|
170
|
-
videoUrl
|
|
171
|
-
expectedBytes: number,
|
|
172
|
-
): Promise<void> {
|
|
173
|
-
if (!shouldVerifyServedMediaUrl(videoUrl)) return;
|
|
184
|
+
async function verifyServedMediaUrl(videoUrl: string): Promise<number | null> {
|
|
185
|
+
if (!shouldVerifyServedMediaUrl(videoUrl)) return null;
|
|
174
186
|
|
|
175
187
|
let lastFailure = "media URL did not serve readable bytes";
|
|
176
188
|
for (
|
|
@@ -192,12 +204,12 @@ async function verifyServedMediaUrl(
|
|
|
192
204
|
const statusOk = response.status === 200 || response.status === 206;
|
|
193
205
|
if (statusOk) {
|
|
194
206
|
const servedBytes = servedMediaSizeBytes(response);
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
return;
|
|
207
|
+
if (await responseHasReadableMediaBytes(response)) {
|
|
208
|
+
// Builder stable video URLs intentionally replace the source object
|
|
209
|
+
// with a smaller compressed generation at the same URL. A positive,
|
|
210
|
+
// readable object is the invariant here; source-byte equality is
|
|
211
|
+
// verified separately by the upload receipt/local backup contract.
|
|
212
|
+
return servedBytes;
|
|
201
213
|
} else {
|
|
202
214
|
lastFailure = "media URL did not serve readable bytes";
|
|
203
215
|
}
|
|
@@ -219,6 +231,13 @@ async function verifyServedMediaUrl(
|
|
|
219
231
|
throw new Error(`Upload was stored-but-unservable: ${lastFailure}`);
|
|
220
232
|
}
|
|
221
233
|
|
|
234
|
+
function mediaVerificationRetryDelayMs(attempt: number): number {
|
|
235
|
+
return Math.min(
|
|
236
|
+
30_000,
|
|
237
|
+
MEDIA_VERIFICATION_INITIAL_RETRY_DELAY_MS * 2 ** Math.max(0, attempt - 1),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
222
241
|
function queueBackgroundBuilderCompression(args: {
|
|
223
242
|
recordingId: string;
|
|
224
243
|
ownerEmail: string;
|
|
@@ -241,11 +260,11 @@ async function failStoredButUnservableRecording(params: {
|
|
|
241
260
|
id: string;
|
|
242
261
|
ownerEmail: string;
|
|
243
262
|
failureReason: string;
|
|
244
|
-
}): Promise<
|
|
263
|
+
}): Promise<boolean> {
|
|
245
264
|
const { id, ownerEmail, failureReason } = params;
|
|
246
265
|
const now = new Date().toISOString();
|
|
247
266
|
const db = getDb();
|
|
248
|
-
await db
|
|
267
|
+
const failed = await db
|
|
249
268
|
.update(schema.recordings)
|
|
250
269
|
.set({
|
|
251
270
|
status: "failed",
|
|
@@ -256,8 +275,11 @@ async function failStoredButUnservableRecording(params: {
|
|
|
256
275
|
and(
|
|
257
276
|
eq(schema.recordings.id, id),
|
|
258
277
|
ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
|
|
278
|
+
eq(schema.recordings.status, "processing"),
|
|
259
279
|
),
|
|
260
|
-
)
|
|
280
|
+
)
|
|
281
|
+
.returning({ id: schema.recordings.id });
|
|
282
|
+
if (failed.length !== 1) return false;
|
|
261
283
|
const uploadStateRaw = await readAppState(`recording-upload-${id}`).catch(
|
|
262
284
|
() => null,
|
|
263
285
|
);
|
|
@@ -272,7 +294,223 @@ async function failStoredButUnservableRecording(params: {
|
|
|
272
294
|
failureReason,
|
|
273
295
|
updatedAt: now,
|
|
274
296
|
});
|
|
297
|
+
await deleteAppState(mediaVerificationStateKey(id)).catch((err) => {
|
|
298
|
+
console.warn("[finalize] failed to clear media verification marker", {
|
|
299
|
+
id,
|
|
300
|
+
error: err instanceof Error ? err.message : String(err),
|
|
301
|
+
});
|
|
302
|
+
});
|
|
275
303
|
await writeAppState("refresh-signal", { ts: Date.now() });
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
type PendingMediaVerification = {
|
|
308
|
+
videoUrl: string;
|
|
309
|
+
videoSizeBytes: number;
|
|
310
|
+
sourceSizeBytes: number;
|
|
311
|
+
videoFormat: "webm" | "mp4";
|
|
312
|
+
finalDurationMs: number;
|
|
313
|
+
finalWidth: number;
|
|
314
|
+
finalHeight: number;
|
|
315
|
+
finalHasAudio: boolean;
|
|
316
|
+
finalHasCamera: boolean;
|
|
317
|
+
seekableApplied: boolean;
|
|
318
|
+
mimeType: string;
|
|
319
|
+
providerId?: string;
|
|
320
|
+
assetDbId?: string;
|
|
321
|
+
locallyTranscoded: boolean;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
function pendingMediaVerificationFromState(
|
|
325
|
+
state: Record<string, unknown> | null,
|
|
326
|
+
): PendingMediaVerification | null {
|
|
327
|
+
if (state?.pendingMediaVerification !== true) return null;
|
|
328
|
+
const videoUrl = stateString(state, "videoUrl");
|
|
329
|
+
const videoSizeBytes = stateNumber(state, "videoSizeBytes");
|
|
330
|
+
const sourceSizeBytes = stateNumber(state, "sourceSizeBytes");
|
|
331
|
+
const videoFormat = stateString(state, "videoFormat");
|
|
332
|
+
if (
|
|
333
|
+
!videoUrl ||
|
|
334
|
+
!videoSizeBytes ||
|
|
335
|
+
!sourceSizeBytes ||
|
|
336
|
+
(videoFormat !== "webm" && videoFormat !== "mp4")
|
|
337
|
+
) {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
videoUrl,
|
|
342
|
+
videoSizeBytes,
|
|
343
|
+
sourceSizeBytes,
|
|
344
|
+
videoFormat,
|
|
345
|
+
finalDurationMs: stateNumber(state, "durationMs") ?? 0,
|
|
346
|
+
finalWidth: stateNumber(state, "width") ?? 0,
|
|
347
|
+
finalHeight: stateNumber(state, "height") ?? 0,
|
|
348
|
+
finalHasAudio: stateBoolean(state, "hasAudio") ?? true,
|
|
349
|
+
finalHasCamera: stateBoolean(state, "hasCamera") ?? false,
|
|
350
|
+
seekableApplied: stateBoolean(state, "seekableApplied") ?? false,
|
|
351
|
+
mimeType: stateString(state, "mimeType") ?? `video/${videoFormat}`,
|
|
352
|
+
providerId: stateString(state, "providerId"),
|
|
353
|
+
assetDbId: stateString(state, "assetDbId"),
|
|
354
|
+
locallyTranscoded: stateBoolean(state, "locallyTranscoded") ?? false,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function persistPendingMediaVerification(params: {
|
|
359
|
+
id: string;
|
|
360
|
+
ownerEmail: string;
|
|
361
|
+
media: PendingMediaVerification;
|
|
362
|
+
failureReason: string;
|
|
363
|
+
retryAttempt?: number;
|
|
364
|
+
}): Promise<boolean> {
|
|
365
|
+
const { id, ownerEmail, media, failureReason } = params;
|
|
366
|
+
const now = new Date().toISOString();
|
|
367
|
+
const retryAttempt = Math.max(0, params.retryAttempt ?? 0);
|
|
368
|
+
const nextRetryAttempt = Math.min(
|
|
369
|
+
MEDIA_VERIFICATION_MAX_DURABLE_ATTEMPTS,
|
|
370
|
+
retryAttempt + 1,
|
|
371
|
+
);
|
|
372
|
+
const nextAttemptAt = new Date(
|
|
373
|
+
Date.now() + mediaVerificationRetryDelayMs(nextRetryAttempt),
|
|
374
|
+
).toISOString();
|
|
375
|
+
const db = getDb();
|
|
376
|
+
const persisted = await db
|
|
377
|
+
.update(schema.recordings)
|
|
378
|
+
.set({
|
|
379
|
+
status: "processing",
|
|
380
|
+
videoUrl: media.videoUrl,
|
|
381
|
+
videoFormat: media.videoFormat,
|
|
382
|
+
videoSizeBytes: media.videoSizeBytes,
|
|
383
|
+
durationMs: media.finalDurationMs,
|
|
384
|
+
width: media.finalWidth,
|
|
385
|
+
height: media.finalHeight,
|
|
386
|
+
hasAudio: media.finalHasAudio,
|
|
387
|
+
hasCamera: media.finalHasCamera,
|
|
388
|
+
failureReason: null,
|
|
389
|
+
uploadProgress: 100,
|
|
390
|
+
updatedAt: now,
|
|
391
|
+
})
|
|
392
|
+
.where(
|
|
393
|
+
and(
|
|
394
|
+
eq(schema.recordings.id, id),
|
|
395
|
+
ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
|
|
396
|
+
eq(schema.recordings.status, "processing"),
|
|
397
|
+
isNull(schema.recordings.trashedAt),
|
|
398
|
+
),
|
|
399
|
+
)
|
|
400
|
+
.returning({ id: schema.recordings.id });
|
|
401
|
+
if (persisted.length !== 1) return false;
|
|
402
|
+
|
|
403
|
+
await writeAppState(`recording-upload-${id}`, {
|
|
404
|
+
recordingId: id,
|
|
405
|
+
status: "processing",
|
|
406
|
+
progress: 100,
|
|
407
|
+
pendingMediaVerification: true,
|
|
408
|
+
mediaVerificationAttempt: retryAttempt,
|
|
409
|
+
mediaVerificationNextAttemptAt: nextAttemptAt,
|
|
410
|
+
mediaVerificationLastError: failureReason,
|
|
411
|
+
videoUrl: media.videoUrl,
|
|
412
|
+
videoSizeBytes: media.videoSizeBytes,
|
|
413
|
+
sourceSizeBytes: media.sourceSizeBytes,
|
|
414
|
+
videoFormat: media.videoFormat,
|
|
415
|
+
durationMs: media.finalDurationMs,
|
|
416
|
+
width: media.finalWidth,
|
|
417
|
+
height: media.finalHeight,
|
|
418
|
+
hasAudio: media.finalHasAudio,
|
|
419
|
+
hasCamera: media.finalHasCamera,
|
|
420
|
+
seekableApplied: media.seekableApplied,
|
|
421
|
+
mimeType: media.mimeType,
|
|
422
|
+
providerId: media.providerId,
|
|
423
|
+
assetDbId: media.assetDbId,
|
|
424
|
+
locallyTranscoded: media.locallyTranscoded,
|
|
425
|
+
updatedAt: now,
|
|
426
|
+
});
|
|
427
|
+
await writeAppState(mediaVerificationStateKey(id), {
|
|
428
|
+
recordingId: id,
|
|
429
|
+
status: "pending",
|
|
430
|
+
completedAttempts: retryAttempt,
|
|
431
|
+
nextAttemptAt,
|
|
432
|
+
leaseUntil: null,
|
|
433
|
+
updatedAt: now,
|
|
434
|
+
});
|
|
435
|
+
await writeAppState("refresh-signal", { ts: Date.now() });
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function claimPendingMediaVerification(
|
|
440
|
+
id: string,
|
|
441
|
+
retryAttempt: number,
|
|
442
|
+
): Promise<boolean> {
|
|
443
|
+
const key = mediaVerificationStateKey(id);
|
|
444
|
+
const raw = await readAppState(key).catch(() => null);
|
|
445
|
+
const marker = parseMediaVerificationMarker(raw);
|
|
446
|
+
const now = Date.now();
|
|
447
|
+
if (
|
|
448
|
+
!marker ||
|
|
449
|
+
marker.recordingId !== id ||
|
|
450
|
+
retryAttempt !== marker.completedAttempts + 1 ||
|
|
451
|
+
now < Date.parse(marker.nextAttemptAt) ||
|
|
452
|
+
(marker.status === "leased" &&
|
|
453
|
+
marker.leaseUntil !== null &&
|
|
454
|
+
now < Date.parse(marker.leaseUntil))
|
|
455
|
+
) {
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const updatedAt = new Date(now).toISOString();
|
|
460
|
+
return compareAndSetAppState(key, raw as Record<string, unknown>, {
|
|
461
|
+
...marker,
|
|
462
|
+
status: "leased",
|
|
463
|
+
leaseUntil: new Date(now + 60_000).toISOString(),
|
|
464
|
+
updatedAt,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function dispatchMediaVerificationRetry(
|
|
469
|
+
id: string,
|
|
470
|
+
retryAttempt: number,
|
|
471
|
+
): Promise<void> {
|
|
472
|
+
await dispatchPostFinalizeJob({
|
|
473
|
+
recordingId: id,
|
|
474
|
+
kind: "media-ready",
|
|
475
|
+
delayMs: mediaVerificationRetryDelayMs(retryAttempt),
|
|
476
|
+
retryAttempt,
|
|
477
|
+
requireAccepted: true,
|
|
478
|
+
}).catch((err: unknown) => {
|
|
479
|
+
console.error("[finalize] media verification dispatch failed", {
|
|
480
|
+
id,
|
|
481
|
+
retryAttempt,
|
|
482
|
+
error: err instanceof Error ? err.message : String(err),
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function leaveRecordingProcessingForMediaVerification(params: {
|
|
488
|
+
id: string;
|
|
489
|
+
ownerEmail: string;
|
|
490
|
+
media: PendingMediaVerification;
|
|
491
|
+
failureReason: string;
|
|
492
|
+
}) {
|
|
493
|
+
const persisted = await persistPendingMediaVerification(params);
|
|
494
|
+
if (!persisted) {
|
|
495
|
+
return {
|
|
496
|
+
id: params.id,
|
|
497
|
+
status: "failed" as const,
|
|
498
|
+
videoUrl: params.media.videoUrl,
|
|
499
|
+
videoSizeBytes: params.media.videoSizeBytes,
|
|
500
|
+
sourceSizeBytes: params.media.sourceSizeBytes,
|
|
501
|
+
durationMs: params.media.finalDurationMs,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
await dispatchMediaVerificationRetry(params.id, 1);
|
|
505
|
+
return {
|
|
506
|
+
id: params.id,
|
|
507
|
+
status: "processing" as const,
|
|
508
|
+
verificationPending: true,
|
|
509
|
+
videoUrl: params.media.videoUrl,
|
|
510
|
+
videoSizeBytes: params.media.videoSizeBytes,
|
|
511
|
+
sourceSizeBytes: params.media.sourceSizeBytes,
|
|
512
|
+
durationMs: params.media.finalDurationMs,
|
|
513
|
+
};
|
|
276
514
|
}
|
|
277
515
|
|
|
278
516
|
// Flip recording to 'ready', seed transcript row, fire background transcript,
|
|
@@ -313,7 +551,7 @@ async function markRecordingReady(params: {
|
|
|
313
551
|
const db = getDb();
|
|
314
552
|
const now = new Date().toISOString();
|
|
315
553
|
|
|
316
|
-
await db
|
|
554
|
+
const promoted = await db
|
|
317
555
|
.update(schema.recordings)
|
|
318
556
|
.set({
|
|
319
557
|
status: "ready",
|
|
@@ -333,12 +571,10 @@ async function markRecordingReady(params: {
|
|
|
333
571
|
and(
|
|
334
572
|
eq(schema.recordings.id, id),
|
|
335
573
|
ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
// the
|
|
339
|
-
|
|
340
|
-
// flipping 'failed' back to 'ready'.
|
|
341
|
-
ne(schema.recordings.status, "failed"),
|
|
574
|
+
// The processing -> ready transition is the idempotency fence for
|
|
575
|
+
// duplicate finalize requests and durable verification workers. Only
|
|
576
|
+
// the winner performs transcript/event/post-finalize side effects.
|
|
577
|
+
eq(schema.recordings.status, "processing"),
|
|
342
578
|
// Guard against the other direction of the cancel/finalize race:
|
|
343
579
|
// trash-recording's skipIfReady only blocks trashing a row that is
|
|
344
580
|
// ALREADY 'ready'. If cancel lands while this finalize is still
|
|
@@ -347,32 +583,30 @@ async function markRecordingReady(params: {
|
|
|
347
583
|
// 'ready' underneath a recording the user just trashed.
|
|
348
584
|
isNull(schema.recordings.trashedAt),
|
|
349
585
|
),
|
|
350
|
-
)
|
|
586
|
+
)
|
|
587
|
+
.returning({ id: schema.recordings.id });
|
|
351
588
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
),
|
|
367
|
-
);
|
|
368
|
-
if (!postUpdate || postUpdate.status !== "ready") {
|
|
369
|
-
debugLog(
|
|
370
|
-
"[finalize] markRecordingReady blocked — recording was aborted concurrently",
|
|
371
|
-
{ id, status: postUpdate?.status },
|
|
372
|
-
);
|
|
589
|
+
if (promoted.length !== 1) {
|
|
590
|
+
const [postUpdate] = await db
|
|
591
|
+
.select({ status: schema.recordings.status })
|
|
592
|
+
.from(schema.recordings)
|
|
593
|
+
.where(
|
|
594
|
+
and(
|
|
595
|
+
eq(schema.recordings.id, id),
|
|
596
|
+
ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
|
|
597
|
+
),
|
|
598
|
+
);
|
|
599
|
+
debugLog("[finalize] markRecordingReady transition already resolved", {
|
|
600
|
+
id,
|
|
601
|
+
status: postUpdate?.status,
|
|
602
|
+
});
|
|
373
603
|
return {
|
|
374
604
|
id,
|
|
375
|
-
status:
|
|
605
|
+
status:
|
|
606
|
+
postUpdate?.status === "ready"
|
|
607
|
+
? ("ready" as const)
|
|
608
|
+
: ("failed" as const),
|
|
609
|
+
transitionedToReady: false,
|
|
376
610
|
videoUrl,
|
|
377
611
|
videoSizeBytes,
|
|
378
612
|
sourceSizeBytes,
|
|
@@ -404,6 +638,12 @@ async function markRecordingReady(params: {
|
|
|
404
638
|
durationMs: finalDurationMs,
|
|
405
639
|
finishedAt: now,
|
|
406
640
|
});
|
|
641
|
+
await deleteAppState(mediaVerificationStateKey(id)).catch((err) => {
|
|
642
|
+
console.warn("[finalize] failed to clear media verification marker", {
|
|
643
|
+
id,
|
|
644
|
+
error: err instanceof Error ? err.message : String(err),
|
|
645
|
+
});
|
|
646
|
+
});
|
|
407
647
|
await writeAppState("refresh-signal", { ts: Date.now() });
|
|
408
648
|
|
|
409
649
|
if (seekableApplied) {
|
|
@@ -464,6 +704,7 @@ async function markRecordingReady(params: {
|
|
|
464
704
|
return {
|
|
465
705
|
id,
|
|
466
706
|
status: "ready" as const,
|
|
707
|
+
transitionedToReady: true,
|
|
467
708
|
videoUrl,
|
|
468
709
|
videoSizeBytes,
|
|
469
710
|
sourceSizeBytes,
|
|
@@ -471,6 +712,165 @@ async function markRecordingReady(params: {
|
|
|
471
712
|
};
|
|
472
713
|
}
|
|
473
714
|
|
|
715
|
+
async function retryPendingMediaVerification(params: {
|
|
716
|
+
id: string;
|
|
717
|
+
ownerEmail: string;
|
|
718
|
+
existingTitle: string;
|
|
719
|
+
media: PendingMediaVerification;
|
|
720
|
+
retryAttempt: number;
|
|
721
|
+
}) {
|
|
722
|
+
const { id, ownerEmail, existingTitle, media, retryAttempt } = params;
|
|
723
|
+
const db = getDb();
|
|
724
|
+
const [recording] = await db
|
|
725
|
+
.select({
|
|
726
|
+
status: schema.recordings.status,
|
|
727
|
+
videoUrl: schema.recordings.videoUrl,
|
|
728
|
+
})
|
|
729
|
+
.from(schema.recordings)
|
|
730
|
+
.where(
|
|
731
|
+
and(
|
|
732
|
+
eq(schema.recordings.id, id),
|
|
733
|
+
ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
|
|
734
|
+
),
|
|
735
|
+
);
|
|
736
|
+
if (!recording || recording.status !== "processing") {
|
|
737
|
+
return {
|
|
738
|
+
id,
|
|
739
|
+
status:
|
|
740
|
+
recording?.status === "ready"
|
|
741
|
+
? ("ready" as const)
|
|
742
|
+
: ("failed" as const),
|
|
743
|
+
videoUrl: recording?.videoUrl ?? media.videoUrl,
|
|
744
|
+
videoSizeBytes: media.videoSizeBytes,
|
|
745
|
+
sourceSizeBytes: media.sourceSizeBytes,
|
|
746
|
+
durationMs: media.finalDurationMs,
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const candidate: PendingMediaVerification = {
|
|
751
|
+
...media,
|
|
752
|
+
videoUrl: recording.videoUrl || media.videoUrl,
|
|
753
|
+
};
|
|
754
|
+
try {
|
|
755
|
+
const servedBytes = await verifyServedMediaUrl(candidate.videoUrl);
|
|
756
|
+
const result = await markRecordingReady({
|
|
757
|
+
id,
|
|
758
|
+
ownerEmail,
|
|
759
|
+
existingTitle,
|
|
760
|
+
videoUrl: candidate.videoUrl,
|
|
761
|
+
videoSizeBytes: servedBytes ?? candidate.videoSizeBytes,
|
|
762
|
+
sourceSizeBytes: candidate.sourceSizeBytes,
|
|
763
|
+
videoFormat: candidate.videoFormat,
|
|
764
|
+
finalDurationMs: candidate.finalDurationMs,
|
|
765
|
+
finalWidth: candidate.finalWidth,
|
|
766
|
+
finalHeight: candidate.finalHeight,
|
|
767
|
+
finalHasAudio: candidate.finalHasAudio,
|
|
768
|
+
finalHasCamera: candidate.finalHasCamera,
|
|
769
|
+
seekableApplied: candidate.seekableApplied,
|
|
770
|
+
});
|
|
771
|
+
if (result.status === "ready" && result.transitionedToReady) {
|
|
772
|
+
queueBackgroundBuilderCompression({
|
|
773
|
+
recordingId: id,
|
|
774
|
+
ownerEmail,
|
|
775
|
+
videoUrl: candidate.videoUrl,
|
|
776
|
+
mimeType: candidate.mimeType,
|
|
777
|
+
providerId: candidate.providerId,
|
|
778
|
+
assetDbId: candidate.assetDbId,
|
|
779
|
+
sourceSizeBytes: candidate.videoSizeBytes,
|
|
780
|
+
locallyTranscoded: candidate.locallyTranscoded,
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
return result;
|
|
784
|
+
} catch (err) {
|
|
785
|
+
const failureReason = err instanceof Error ? err.message : String(err);
|
|
786
|
+
if (retryAttempt >= MEDIA_VERIFICATION_MAX_DURABLE_ATTEMPTS) {
|
|
787
|
+
const terminalReason = `${failureReason} after ${retryAttempt} durable verification attempts`;
|
|
788
|
+
const failed = await failStoredButUnservableRecording({
|
|
789
|
+
id,
|
|
790
|
+
ownerEmail,
|
|
791
|
+
failureReason: terminalReason,
|
|
792
|
+
});
|
|
793
|
+
if (!failed) {
|
|
794
|
+
const [resolved] = await db
|
|
795
|
+
.select({
|
|
796
|
+
status: schema.recordings.status,
|
|
797
|
+
videoUrl: schema.recordings.videoUrl,
|
|
798
|
+
})
|
|
799
|
+
.from(schema.recordings)
|
|
800
|
+
.where(
|
|
801
|
+
and(
|
|
802
|
+
eq(schema.recordings.id, id),
|
|
803
|
+
ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
|
|
804
|
+
),
|
|
805
|
+
);
|
|
806
|
+
return {
|
|
807
|
+
id,
|
|
808
|
+
status:
|
|
809
|
+
resolved?.status === "ready"
|
|
810
|
+
? ("ready" as const)
|
|
811
|
+
: ("failed" as const),
|
|
812
|
+
videoUrl: resolved?.videoUrl ?? candidate.videoUrl,
|
|
813
|
+
videoSizeBytes: candidate.videoSizeBytes,
|
|
814
|
+
sourceSizeBytes: candidate.sourceSizeBytes,
|
|
815
|
+
durationMs: candidate.finalDurationMs,
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
return {
|
|
819
|
+
id,
|
|
820
|
+
status: "failed" as const,
|
|
821
|
+
videoUrl: candidate.videoUrl,
|
|
822
|
+
videoSizeBytes: candidate.videoSizeBytes,
|
|
823
|
+
sourceSizeBytes: candidate.sourceSizeBytes,
|
|
824
|
+
durationMs: candidate.finalDurationMs,
|
|
825
|
+
failureReason: terminalReason,
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const persisted = await persistPendingMediaVerification({
|
|
830
|
+
id,
|
|
831
|
+
ownerEmail,
|
|
832
|
+
media: candidate,
|
|
833
|
+
failureReason,
|
|
834
|
+
retryAttempt,
|
|
835
|
+
});
|
|
836
|
+
if (!persisted) {
|
|
837
|
+
const [resolved] = await db
|
|
838
|
+
.select({
|
|
839
|
+
status: schema.recordings.status,
|
|
840
|
+
videoUrl: schema.recordings.videoUrl,
|
|
841
|
+
})
|
|
842
|
+
.from(schema.recordings)
|
|
843
|
+
.where(
|
|
844
|
+
and(
|
|
845
|
+
eq(schema.recordings.id, id),
|
|
846
|
+
ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
|
|
847
|
+
),
|
|
848
|
+
);
|
|
849
|
+
return {
|
|
850
|
+
id,
|
|
851
|
+
status:
|
|
852
|
+
resolved?.status === "ready"
|
|
853
|
+
? ("ready" as const)
|
|
854
|
+
: ("failed" as const),
|
|
855
|
+
videoUrl: resolved?.videoUrl ?? candidate.videoUrl,
|
|
856
|
+
videoSizeBytes: candidate.videoSizeBytes,
|
|
857
|
+
sourceSizeBytes: candidate.sourceSizeBytes,
|
|
858
|
+
durationMs: candidate.finalDurationMs,
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
await dispatchMediaVerificationRetry(id, retryAttempt + 1);
|
|
862
|
+
return {
|
|
863
|
+
id,
|
|
864
|
+
status: "processing" as const,
|
|
865
|
+
verificationPending: true,
|
|
866
|
+
videoUrl: candidate.videoUrl,
|
|
867
|
+
videoSizeBytes: candidate.videoSizeBytes,
|
|
868
|
+
sourceSizeBytes: candidate.sourceSizeBytes,
|
|
869
|
+
durationMs: candidate.finalDurationMs,
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
474
874
|
export default defineAction({
|
|
475
875
|
description:
|
|
476
876
|
"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.",
|
|
@@ -499,6 +899,7 @@ export default defineAction({
|
|
|
499
899
|
.describe(
|
|
500
900
|
"Whether the uploaded video bytes were already locally transcoded/compressed before upload",
|
|
501
901
|
),
|
|
902
|
+
mediaVerificationRetryAttempt: z.number().int().min(1).max(10).optional(),
|
|
502
903
|
}),
|
|
503
904
|
run: async (args) => {
|
|
504
905
|
const db = getDb();
|
|
@@ -547,6 +948,7 @@ export default defineAction({
|
|
|
547
948
|
await deleteResumableSession(id).catch((err) =>
|
|
548
949
|
console.warn("[finalize] failed to delete resumable session:", err),
|
|
549
950
|
);
|
|
951
|
+
await deleteAppState(mediaVerificationStateKey(id)).catch(() => {});
|
|
550
952
|
return {
|
|
551
953
|
id,
|
|
552
954
|
status: "ready" as const,
|
|
@@ -605,6 +1007,33 @@ export default defineAction({
|
|
|
605
1007
|
existingTitle: existing.title,
|
|
606
1008
|
};
|
|
607
1009
|
|
|
1010
|
+
const pendingMedia = pendingMediaVerificationFromState(uploadState);
|
|
1011
|
+
if (existing.status === "processing" && pendingMedia) {
|
|
1012
|
+
const retryAttempt =
|
|
1013
|
+
args.mediaVerificationRetryAttempt ??
|
|
1014
|
+
stateNumber(uploadState, "mediaVerificationAttempt") ??
|
|
1015
|
+
1;
|
|
1016
|
+
const claimed = await claimPendingMediaVerification(id, retryAttempt);
|
|
1017
|
+
if (!claimed) {
|
|
1018
|
+
return {
|
|
1019
|
+
id,
|
|
1020
|
+
status: "processing" as const,
|
|
1021
|
+
verificationPending: true,
|
|
1022
|
+
videoUrl: pendingMedia.videoUrl,
|
|
1023
|
+
videoSizeBytes: pendingMedia.videoSizeBytes,
|
|
1024
|
+
sourceSizeBytes: pendingMedia.sourceSizeBytes,
|
|
1025
|
+
durationMs: pendingMedia.finalDurationMs,
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
return retryPendingMediaVerification({
|
|
1029
|
+
id,
|
|
1030
|
+
ownerEmail,
|
|
1031
|
+
existingTitle: existing.title,
|
|
1032
|
+
media: pendingMedia,
|
|
1033
|
+
retryAttempt,
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
|
|
608
1037
|
// Resumable path: create-recording initialized a session and chunk.post.ts
|
|
609
1038
|
// forwarded all chunks to the provider. Complete the session to get the CDN URL.
|
|
610
1039
|
const resumableSession = await getResumableSession(id);
|
|
@@ -651,6 +1080,24 @@ export default defineAction({
|
|
|
651
1080
|
updatedAt: recoveryStartedAt,
|
|
652
1081
|
});
|
|
653
1082
|
}
|
|
1083
|
+
if (existing.status !== "processing" && existing.status !== "failed") {
|
|
1084
|
+
const processingStartedAt = new Date().toISOString();
|
|
1085
|
+
await db
|
|
1086
|
+
.update(schema.recordings)
|
|
1087
|
+
.set({
|
|
1088
|
+
status: "processing",
|
|
1089
|
+
failureReason: null,
|
|
1090
|
+
uploadProgress: 100,
|
|
1091
|
+
updatedAt: processingStartedAt,
|
|
1092
|
+
})
|
|
1093
|
+
.where(
|
|
1094
|
+
and(
|
|
1095
|
+
eq(schema.recordings.id, id),
|
|
1096
|
+
ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
|
|
1097
|
+
eq(schema.recordings.status, existing.status),
|
|
1098
|
+
),
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
654
1101
|
try {
|
|
655
1102
|
const uploadProvider = await resolveResumableUploadProvider(
|
|
656
1103
|
resumableSession.providerId,
|
|
@@ -672,32 +1119,51 @@ export default defineAction({
|
|
|
672
1119
|
{ stableUrl: true, recordAsset: false },
|
|
673
1120
|
);
|
|
674
1121
|
debugLog("[finalize] resumable upload completed", { id, videoUrl });
|
|
1122
|
+
let servedBytes: number | null;
|
|
675
1123
|
try {
|
|
676
|
-
await verifyServedMediaUrl(
|
|
677
|
-
videoUrl,
|
|
678
|
-
resumableSession.bytesUploaded,
|
|
679
|
-
);
|
|
1124
|
+
servedBytes = await verifyServedMediaUrl(videoUrl);
|
|
680
1125
|
} catch (err) {
|
|
681
1126
|
const failureReason =
|
|
682
1127
|
err instanceof Error ? err.message : String(err);
|
|
683
|
-
await
|
|
1128
|
+
const pending = await leaveRecordingProcessingForMediaVerification({
|
|
684
1129
|
id,
|
|
685
1130
|
ownerEmail,
|
|
686
1131
|
failureReason,
|
|
1132
|
+
media: {
|
|
1133
|
+
videoUrl,
|
|
1134
|
+
videoSizeBytes: resumableSession.bytesUploaded,
|
|
1135
|
+
sourceSizeBytes: resumableSession.bytesUploaded,
|
|
1136
|
+
videoFormat,
|
|
1137
|
+
finalDurationMs,
|
|
1138
|
+
finalWidth,
|
|
1139
|
+
finalHeight,
|
|
1140
|
+
finalHasAudio,
|
|
1141
|
+
finalHasCamera,
|
|
1142
|
+
seekableApplied: false,
|
|
1143
|
+
mimeType,
|
|
1144
|
+
providerId: resumableSession.providerId,
|
|
1145
|
+
locallyTranscoded: args.locallyTranscoded === true,
|
|
1146
|
+
},
|
|
687
1147
|
});
|
|
688
|
-
|
|
1148
|
+
await deleteResumableSession(id).catch((deleteErr) =>
|
|
1149
|
+
console.warn(
|
|
1150
|
+
"[finalize] failed to retire pending resumable session:",
|
|
1151
|
+
deleteErr,
|
|
1152
|
+
),
|
|
1153
|
+
);
|
|
1154
|
+
return pending;
|
|
689
1155
|
}
|
|
690
1156
|
const result = await markRecordingReady({
|
|
691
1157
|
...readyParams,
|
|
692
1158
|
videoUrl,
|
|
693
|
-
videoSizeBytes: resumableSession.bytesUploaded,
|
|
1159
|
+
videoSizeBytes: servedBytes ?? resumableSession.bytesUploaded,
|
|
694
1160
|
sourceSizeBytes: resumableSession.bytesUploaded,
|
|
695
1161
|
// Streaming path forwards raw MediaRecorder bytes straight to the
|
|
696
1162
|
// provider — no faststart/Cues rewrite happened. Repair in the
|
|
697
1163
|
// background.
|
|
698
1164
|
seekableApplied: false,
|
|
699
1165
|
});
|
|
700
|
-
if (result.status === "ready") {
|
|
1166
|
+
if (result.status === "ready" && result.transitionedToReady) {
|
|
701
1167
|
queueBackgroundBuilderCompression({
|
|
702
1168
|
recordingId: id,
|
|
703
1169
|
ownerEmail,
|
|
@@ -1197,22 +1663,33 @@ export default defineAction({
|
|
|
1197
1663
|
videoUrl: upload.url,
|
|
1198
1664
|
bytes: uploadData.byteLength,
|
|
1199
1665
|
});
|
|
1666
|
+
let servedBytes: number | null;
|
|
1200
1667
|
try {
|
|
1201
|
-
await verifyServedMediaUrl(upload.url
|
|
1668
|
+
servedBytes = await verifyServedMediaUrl(upload.url);
|
|
1202
1669
|
} catch (err) {
|
|
1203
|
-
if (!requiresConfiguredVideoStorage()) {
|
|
1204
|
-
// Local dev can keep scratch chunks so the user can retry after fixing
|
|
1205
|
-
// a local storage/server issue. Hosted SQL must not keep video blobs in
|
|
1206
|
-
// application_state after a provider returned an unservable URL.
|
|
1207
|
-
chunkKeysToPurge = [];
|
|
1208
|
-
}
|
|
1209
1670
|
const failureReason = err instanceof Error ? err.message : String(err);
|
|
1210
|
-
await
|
|
1671
|
+
return await leaveRecordingProcessingForMediaVerification({
|
|
1211
1672
|
id,
|
|
1212
1673
|
ownerEmail,
|
|
1213
1674
|
failureReason,
|
|
1675
|
+
media: {
|
|
1676
|
+
videoUrl: upload.url,
|
|
1677
|
+
videoSizeBytes: uploadData.byteLength,
|
|
1678
|
+
sourceSizeBytes: assembled.byteLength,
|
|
1679
|
+
videoFormat,
|
|
1680
|
+
finalDurationMs,
|
|
1681
|
+
finalWidth,
|
|
1682
|
+
finalHeight,
|
|
1683
|
+
finalHasAudio: correctedHasAudio,
|
|
1684
|
+
finalHasCamera,
|
|
1685
|
+
seekableApplied,
|
|
1686
|
+
mimeType,
|
|
1687
|
+
providerId: upload.provider,
|
|
1688
|
+
assetDbId: upload.id,
|
|
1689
|
+
locallyTranscoded:
|
|
1690
|
+
args.locallyTranscoded === true || Boolean(compressionMeta),
|
|
1691
|
+
},
|
|
1214
1692
|
});
|
|
1215
|
-
throw err;
|
|
1216
1693
|
}
|
|
1217
1694
|
const result = await markRecordingReady({
|
|
1218
1695
|
...readyParams,
|
|
@@ -1220,11 +1697,11 @@ export default defineAction({
|
|
|
1220
1697
|
// `readyParams` — see the audio sanity check above.
|
|
1221
1698
|
finalHasAudio: correctedHasAudio,
|
|
1222
1699
|
videoUrl: upload.url,
|
|
1223
|
-
videoSizeBytes: uploadData.byteLength,
|
|
1700
|
+
videoSizeBytes: servedBytes ?? uploadData.byteLength,
|
|
1224
1701
|
sourceSizeBytes: assembled.byteLength,
|
|
1225
1702
|
seekableApplied,
|
|
1226
1703
|
});
|
|
1227
|
-
if (result.status === "ready") {
|
|
1704
|
+
if (result.status === "ready" && result.transitionedToReady) {
|
|
1228
1705
|
queueBackgroundBuilderCompression({
|
|
1229
1706
|
recordingId: id,
|
|
1230
1707
|
ownerEmail,
|