@gotcos/glasses-server 6.27.1 → 6.27.3

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.
@@ -0,0 +1,726 @@
1
+ // Durable resumable video upload protocol.
2
+ //
3
+ // Unlike the legacy generic upload registry, this state lives outside media/tmp,
4
+ // survives a server restart, binds init to an idempotency key + server identity,
5
+ // and retains a terminal publication receipt until the phone acknowledges it.
6
+
7
+ import { createHash, randomBytes } from 'node:crypto'
8
+ import {
9
+ closeSync,
10
+ constants,
11
+ existsSync,
12
+ fsyncSync,
13
+ mkdirSync,
14
+ openSync,
15
+ readFileSync,
16
+ readdirSync,
17
+ rmSync,
18
+ statSync,
19
+ statfsSync,
20
+ writeFileSync,
21
+ writeSync,
22
+ } from 'node:fs'
23
+ import { join } from 'node:path'
24
+ import type { MediaAttachmentRef } from '../../shared/media-attachment.js'
25
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
26
+ import { getMediaStore } from './media-store.js'
27
+ import { MAX_CHUNKED_MEDIA_BYTES, MAX_VIDEO_DURATION_MS } from './rich-media-safety.js'
28
+
29
+ export const VIDEO_UPLOAD_V2_PROTOCOL = 1
30
+ export const VIDEO_UPLOAD_V2_CHUNK_BYTES = 256 * 1024
31
+ export const VIDEO_UPLOAD_V2_MAX_FRAME_BYTES = 256 * 1024
32
+ export const VIDEO_UPLOAD_V2_MAX_FRAME_PACK_BYTES = 2 * 1024 * 1024
33
+ export const VIDEO_UPLOAD_PHONE_FRAMES_MIN = 8
34
+ export const VIDEO_UPLOAD_PHONE_FRAMES_MAX = 12
35
+ export const VIDEO_UPLOAD_SERVER_FRAMES_MIN = 8
36
+ export const VIDEO_UPLOAD_SERVER_FRAMES_MAX = 16
37
+ export const VIDEO_UPLOAD_V2_TTL_MS = 4 * 60 * 60_000
38
+ export const VIDEO_UPLOAD_V2_RECEIPT_TTL_MS = 24 * 60 * 60_000
39
+ export const VIDEO_UPLOAD_V2_MAX_CONCURRENT = 8
40
+ export const VIDEO_UPLOAD_V2_TOTAL_RESERVED_BYTES = 4 * 1024 * 1024 * 1024
41
+ export const VIDEO_UPLOAD_V2_FREE_DISK_RESERVE_BYTES = 512 * 1024 * 1024
42
+ export const VIDEO_UPLOAD_V2_ACCEPTED_MIMES = ['video/mp4', 'video/quicktime'] as const
43
+
44
+ const UPLOAD_ID_RE = /^vu_[0-9a-f]{24}$/
45
+ const CLIENT_REQUEST_RE = /^[A-Za-z0-9._:-]{16,160}$/
46
+ const MEDIA_ID_RE = /^m_[0-9a-f]{24}$/
47
+
48
+ export function videoUploadV2Enabled(): boolean {
49
+ return process.env.COS_VIDEO_UPLOAD_V2 === '1'
50
+ }
51
+
52
+ export function phoneVideoFramesEnabled(): boolean {
53
+ return videoUploadV2Enabled() && process.env.COS_VIDEO_PHONE_FRAMES === '1'
54
+ }
55
+
56
+ export function isValidVideoUploadId(value: unknown): value is string {
57
+ return typeof value === 'string' && UPLOAD_ID_RE.test(value)
58
+ }
59
+
60
+ export type VideoUploadState = 'receiving' | 'finalizing' | 'published' | 'cancelled' | 'failed'
61
+
62
+ interface AcceptedPart {
63
+ bytes: number
64
+ sha256: string
65
+ }
66
+
67
+ export interface VideoUploadManifest {
68
+ v: 1
69
+ uploadId: string
70
+ clientRequestId: string
71
+ serverInstanceId: string
72
+ state: VideoUploadState
73
+ generation: number
74
+ totalBytes: number
75
+ chunkBytes: number
76
+ chunkCount: number
77
+ mime: typeof VIDEO_UPLOAD_V2_ACCEPTED_MIMES[number]
78
+ label?: string
79
+ capturedAt?: string
80
+ sessionId?: string
81
+ original: Record<string, AcceptedPart>
82
+ frames: Record<string, AcceptedPart>
83
+ mediaId?: string
84
+ receipt?: MediaAttachmentRef
85
+ acknowledged?: boolean
86
+ failure?: string
87
+ createdAtMs: number
88
+ updatedAtMs: number
89
+ expiresAtMs: number
90
+ }
91
+
92
+ export interface VideoUploadInitInput {
93
+ clientRequestId: unknown
94
+ serverInstanceId: unknown
95
+ totalBytes: unknown
96
+ mime: unknown
97
+ label?: unknown
98
+ capturedAt?: unknown
99
+ sessionId?: unknown
100
+ }
101
+
102
+ export type VideoUploadErrorCode =
103
+ | 'video_upload_disabled'
104
+ | 'video_upload_not_found'
105
+ | 'video_upload_conflict'
106
+ | 'video_upload_busy'
107
+ | 'video_upload_incomplete'
108
+ | 'video_upload_invalid'
109
+ | 'video_upload_quota'
110
+ | 'video_upload_cancelled'
111
+ | 'video_upload_failed'
112
+ | 'server_identity_mismatch'
113
+
114
+ export class VideoUploadError extends Error {
115
+ constructor(
116
+ readonly code: VideoUploadErrorCode,
117
+ message: string,
118
+ readonly detail: Readonly<Record<string, unknown>> = {},
119
+ ) {
120
+ super(message)
121
+ this.name = 'VideoUploadError'
122
+ }
123
+ }
124
+
125
+ export interface VideoUploadProgress {
126
+ protocol: 1
127
+ uploadId: string
128
+ serverInstanceId: string
129
+ state: VideoUploadState
130
+ totalBytes: number
131
+ chunkBytes: number
132
+ chunkCount: number
133
+ receivedOriginalChunks: number[]
134
+ missingOriginalChunks: number[]
135
+ receivedFrames: number[]
136
+ expiresAt: string
137
+ acknowledged: boolean
138
+ attachment?: MediaAttachmentRef
139
+ failure?: string
140
+ }
141
+
142
+ export interface VideoUploadStatus {
143
+ protocol: 1
144
+ enabled: boolean
145
+ receiving: number
146
+ finalizing: number
147
+ unacknowledgedPublished: number
148
+ failed: number
149
+ blocksRestart: boolean
150
+ blocksRollback: boolean
151
+ }
152
+
153
+ export interface VideoUploadRegistryOptions {
154
+ root?: string
155
+ now?: () => number
156
+ maxConcurrent?: number
157
+ maxReservedBytes?: number
158
+ freeDiskReserveBytes?: number
159
+ }
160
+
161
+ function boundedString(value: unknown, max: number): string | undefined {
162
+ return typeof value === 'string' && value.trim()
163
+ ? value.trim().replace(/[\u0000-\u001f\u007f]/g, '').slice(0, max)
164
+ : undefined
165
+ }
166
+
167
+ function sha256(bytes: Buffer): string {
168
+ return createHash('sha256').update(bytes).digest('hex')
169
+ }
170
+
171
+ function positiveSafeInteger(value: unknown): value is number {
172
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0
173
+ }
174
+
175
+ function parseIndex(value: unknown): number | null {
176
+ const raw = typeof value === 'string' && /^\d{1,9}$/.test(value) ? Number(value) : value
177
+ return typeof raw === 'number' && Number.isSafeInteger(raw) && raw >= 0 ? raw : null
178
+ }
179
+
180
+ function sameInit(manifest: VideoUploadManifest, input: Required<Pick<VideoUploadManifest,
181
+ 'serverInstanceId' | 'totalBytes' | 'mime'>> & Pick<VideoUploadManifest, 'label' | 'capturedAt' | 'sessionId'>): boolean {
182
+ return manifest.serverInstanceId === input.serverInstanceId
183
+ && manifest.totalBytes === input.totalBytes
184
+ && manifest.mime === input.mime
185
+ && (manifest.label ?? '') === (input.label ?? '')
186
+ && (manifest.capturedAt ?? '') === (input.capturedAt ?? '')
187
+ && (manifest.sessionId ?? '') === (input.sessionId ?? '')
188
+ }
189
+
190
+ function parseManifest(raw: unknown): VideoUploadManifest | null {
191
+ if (!raw || typeof raw !== 'object') return null
192
+ const r = raw as Record<string, unknown>
193
+ if (r.v !== 1 || !isValidVideoUploadId(r.uploadId)
194
+ || typeof r.clientRequestId !== 'string' || !CLIENT_REQUEST_RE.test(r.clientRequestId)
195
+ || typeof r.serverInstanceId !== 'string' || r.serverInstanceId.length < 8
196
+ || !positiveSafeInteger(r.totalBytes) || r.totalBytes > MAX_CHUNKED_MEDIA_BYTES
197
+ || !positiveSafeInteger(r.chunkBytes) || !positiveSafeInteger(r.chunkCount)
198
+ || !VIDEO_UPLOAD_V2_ACCEPTED_MIMES.includes(r.mime as typeof VIDEO_UPLOAD_V2_ACCEPTED_MIMES[number])) return null
199
+ const state = r.state
200
+ if (state !== 'receiving' && state !== 'finalizing' && state !== 'published'
201
+ && state !== 'cancelled' && state !== 'failed') return null
202
+ const parts = (value: unknown): Record<string, AcceptedPart> => {
203
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
204
+ return Object.fromEntries(Object.entries(value as Record<string, unknown>).flatMap(([key, item]) => {
205
+ if (!/^\d{1,9}$/.test(key) || !item || typeof item !== 'object') return []
206
+ const p = item as Record<string, unknown>
207
+ if (!positiveSafeInteger(p.bytes) || typeof p.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(p.sha256)) return []
208
+ return [[key, { bytes: p.bytes, sha256: p.sha256 }]]
209
+ }))
210
+ }
211
+ return {
212
+ v: 1,
213
+ uploadId: r.uploadId,
214
+ clientRequestId: r.clientRequestId,
215
+ serverInstanceId: r.serverInstanceId,
216
+ state,
217
+ generation: typeof r.generation === 'number' && Number.isSafeInteger(r.generation) ? r.generation : 0,
218
+ totalBytes: r.totalBytes,
219
+ chunkBytes: r.chunkBytes,
220
+ chunkCount: r.chunkCount,
221
+ mime: r.mime as VideoUploadManifest['mime'],
222
+ ...(boundedString(r.label, 120) ? { label: boundedString(r.label, 120) } : {}),
223
+ ...(boundedString(r.capturedAt, 40) ? { capturedAt: boundedString(r.capturedAt, 40) } : {}),
224
+ ...(boundedString(r.sessionId, 64) ? { sessionId: boundedString(r.sessionId, 64) } : {}),
225
+ original: parts(r.original),
226
+ frames: parts(r.frames),
227
+ ...(typeof r.mediaId === 'string' && MEDIA_ID_RE.test(r.mediaId) ? { mediaId: r.mediaId } : {}),
228
+ ...(r.receipt && typeof r.receipt === 'object' ? { receipt: r.receipt as MediaAttachmentRef } : {}),
229
+ acknowledged: r.acknowledged === true,
230
+ ...(boundedString(r.failure, 160) ? { failure: boundedString(r.failure, 160) } : {}),
231
+ createdAtMs: typeof r.createdAtMs === 'number' ? r.createdAtMs : Date.now(),
232
+ updatedAtMs: typeof r.updatedAtMs === 'number' ? r.updatedAtMs : Date.now(),
233
+ expiresAtMs: typeof r.expiresAtMs === 'number' ? r.expiresAtMs : Date.now(),
234
+ }
235
+ }
236
+
237
+ export class VideoUploadRegistry {
238
+ private readonly root: string
239
+ private readonly now: () => number
240
+ private readonly maxConcurrent: number
241
+ private readonly maxReservedBytes: number
242
+ private readonly freeDiskReserveBytes: number
243
+ private readonly manifests = new Map<string, VideoUploadManifest>()
244
+ private readonly byClientRequest = new Map<string, string>()
245
+ private readonly locks = new Map<string, Promise<unknown>>()
246
+ private readonly finalizers = new Map<string, Promise<VideoUploadProgress>>()
247
+ private readonly activeWriters = new Map<string, number>()
248
+
249
+ constructor(options: VideoUploadRegistryOptions = {}) {
250
+ this.root = options.root ?? join(getMediaStore().rootDirectory(), 'video-upload-v1')
251
+ this.now = options.now ?? Date.now
252
+ this.maxConcurrent = options.maxConcurrent ?? VIDEO_UPLOAD_V2_MAX_CONCURRENT
253
+ this.maxReservedBytes = options.maxReservedBytes ?? VIDEO_UPLOAD_V2_TOTAL_RESERVED_BYTES
254
+ this.freeDiskReserveBytes = options.freeDiskReserveBytes ?? VIDEO_UPLOAD_V2_FREE_DISK_RESERVE_BYTES
255
+ mkdirSync(this.root, { recursive: true, mode: 0o700 })
256
+ this.load()
257
+ this.reconcilePublished()
258
+ this.sweepExpired()
259
+ }
260
+
261
+ init(input: VideoUploadInitInput): VideoUploadProgress {
262
+ if (!videoUploadV2Enabled()) throw new VideoUploadError('video_upload_disabled', 'resumable video upload is disabled')
263
+ const clientRequestId = boundedString(input.clientRequestId, 160)
264
+ const serverInstanceId = boundedString(input.serverInstanceId, 160)
265
+ if (!clientRequestId || !CLIENT_REQUEST_RE.test(clientRequestId) || !serverInstanceId) {
266
+ throw new VideoUploadError('video_upload_invalid', 'valid clientRequestId and serverInstanceId are required')
267
+ }
268
+ if (!positiveSafeInteger(input.totalBytes) || input.totalBytes > MAX_CHUNKED_MEDIA_BYTES) {
269
+ throw new VideoUploadError('video_upload_invalid', 'video size is invalid', { maxBytes: MAX_CHUNKED_MEDIA_BYTES })
270
+ }
271
+ if (!VIDEO_UPLOAD_V2_ACCEPTED_MIMES.includes(input.mime as VideoUploadManifest['mime'])) {
272
+ throw new VideoUploadError('video_upload_invalid', 'only MP4 and MOV videos are accepted')
273
+ }
274
+ const normalized = {
275
+ serverInstanceId,
276
+ totalBytes: input.totalBytes,
277
+ mime: input.mime as VideoUploadManifest['mime'],
278
+ label: boundedString(input.label, 120),
279
+ capturedAt: boundedString(input.capturedAt, 40),
280
+ sessionId: boundedString(input.sessionId, 64),
281
+ }
282
+ const existingId = this.byClientRequest.get(clientRequestId)
283
+ if (existingId) {
284
+ const existing = this.manifests.get(existingId)
285
+ if (existing && sameInit(existing, normalized)) return this.progress(existing)
286
+ throw new VideoUploadError('video_upload_conflict', 'clientRequestId was already used for different video metadata')
287
+ }
288
+ this.sweepExpired()
289
+ const active = [...this.manifests.values()].filter(item => item.state === 'receiving' || item.state === 'finalizing')
290
+ if (active.length >= this.maxConcurrent) throw new VideoUploadError('video_upload_quota', 'too many video uploads are active')
291
+ const reserved = active.reduce((sum, item) => sum + item.totalBytes, 0)
292
+ if (reserved + normalized.totalBytes > this.maxReservedBytes) {
293
+ throw new VideoUploadError('video_upload_quota', 'video upload disk quota is full')
294
+ }
295
+ try {
296
+ const fs = statfsSync(this.root)
297
+ const free = Number(fs.bavail) * Number(fs.bsize)
298
+ if (free - normalized.totalBytes < this.freeDiskReserveBytes) {
299
+ throw new VideoUploadError('video_upload_quota', 'not enough free disk for video upload')
300
+ }
301
+ } catch (error) {
302
+ if (error instanceof VideoUploadError) throw error
303
+ throw new VideoUploadError('video_upload_quota', 'free disk could not be verified')
304
+ }
305
+ const now = this.now()
306
+ const uploadId = `vu_${randomBytes(12).toString('hex')}`
307
+ const chunkCount = Math.ceil(normalized.totalBytes / VIDEO_UPLOAD_V2_CHUNK_BYTES)
308
+ const manifest: VideoUploadManifest = {
309
+ v: 1,
310
+ uploadId,
311
+ clientRequestId,
312
+ serverInstanceId,
313
+ state: 'receiving',
314
+ generation: 1,
315
+ totalBytes: normalized.totalBytes,
316
+ chunkBytes: VIDEO_UPLOAD_V2_CHUNK_BYTES,
317
+ chunkCount,
318
+ mime: normalized.mime,
319
+ ...(normalized.label ? { label: normalized.label } : {}),
320
+ ...(normalized.capturedAt ? { capturedAt: normalized.capturedAt } : {}),
321
+ ...(normalized.sessionId ? { sessionId: normalized.sessionId } : {}),
322
+ original: {},
323
+ frames: {},
324
+ createdAtMs: now,
325
+ updatedAtMs: now,
326
+ expiresAtMs: now + VIDEO_UPLOAD_V2_TTL_MS,
327
+ }
328
+ mkdirSync(this.dir(uploadId), { recursive: false, mode: 0o700 })
329
+ mkdirSync(this.originalDir(uploadId), { mode: 0o700 })
330
+ mkdirSync(this.frameDir(uploadId), { mode: 0o700 })
331
+ this.save(manifest)
332
+ this.manifests.set(uploadId, manifest)
333
+ this.byClientRequest.set(clientRequestId, uploadId)
334
+ return this.progress(manifest)
335
+ }
336
+
337
+ get(uploadId: string, serverInstanceId?: string): VideoUploadProgress {
338
+ const manifest = this.require(uploadId, serverInstanceId)
339
+ return this.progress(manifest)
340
+ }
341
+
342
+ async putOriginal(uploadId: string, indexValue: unknown, bytes: Buffer, serverInstanceId?: string): Promise<VideoUploadProgress> {
343
+ return this.putPart(uploadId, 'original', indexValue, bytes, serverInstanceId)
344
+ }
345
+
346
+ async putFrame(uploadId: string, indexValue: unknown, bytes: Buffer, serverInstanceId?: string): Promise<VideoUploadProgress> {
347
+ if (!phoneVideoFramesEnabled()) throw new VideoUploadError('video_upload_disabled', 'phone frame acceleration is disabled')
348
+ return this.putPart(uploadId, 'frames', indexValue, bytes, serverInstanceId)
349
+ }
350
+
351
+ async finalize(uploadId: string, serverInstanceId?: string): Promise<VideoUploadProgress> {
352
+ const existing = this.finalizers.get(uploadId)
353
+ if (existing) return existing
354
+ const task = this.finalizeOnce(uploadId, serverInstanceId)
355
+ this.finalizers.set(uploadId, task)
356
+ try { return await task } finally {
357
+ if (this.finalizers.get(uploadId) === task) this.finalizers.delete(uploadId)
358
+ }
359
+ }
360
+
361
+ async acknowledge(uploadId: string, serverInstanceId?: string): Promise<VideoUploadProgress> {
362
+ return this.withLock(uploadId, async () => {
363
+ const manifest = this.require(uploadId, serverInstanceId)
364
+ if (manifest.state !== 'published' || !manifest.receipt) {
365
+ throw new VideoUploadError('video_upload_incomplete', 'upload has no terminal receipt')
366
+ }
367
+ manifest.acknowledged = true
368
+ manifest.updatedAtMs = this.now()
369
+ manifest.expiresAtMs = manifest.updatedAtMs + VIDEO_UPLOAD_V2_RECEIPT_TTL_MS
370
+ this.save(manifest)
371
+ this.removeBodies(manifest)
372
+ return this.progress(manifest)
373
+ })
374
+ }
375
+
376
+ async cancel(uploadId: string, serverInstanceId?: string): Promise<VideoUploadProgress | null> {
377
+ return this.withLock(uploadId, async () => {
378
+ const manifest = this.manifests.get(uploadId)
379
+ if (!manifest) return null
380
+ this.assertIdentity(manifest, serverInstanceId)
381
+ if (manifest.state === 'published' && manifest.receipt) {
382
+ await getMediaStore().deleteExactlyStaged(manifest.receipt.id)
383
+ }
384
+ manifest.state = 'cancelled'
385
+ manifest.generation++
386
+ manifest.updatedAtMs = this.now()
387
+ manifest.expiresAtMs = manifest.updatedAtMs + VIDEO_UPLOAD_V2_RECEIPT_TTL_MS
388
+ this.save(manifest)
389
+ this.removeBodies(manifest)
390
+ return this.progress(manifest)
391
+ })
392
+ }
393
+
394
+ status(): VideoUploadStatus {
395
+ this.sweepExpired()
396
+ let receiving = 0; let finalizing = 0; let unacknowledgedPublished = 0; let failed = 0
397
+ for (const item of this.manifests.values()) {
398
+ if (item.state === 'receiving') receiving++
399
+ else if (item.state === 'finalizing') finalizing++
400
+ else if (item.state === 'published' && !item.acknowledged) unacknowledgedPublished++
401
+ else if (item.state === 'failed') failed++
402
+ }
403
+ return {
404
+ protocol: 1,
405
+ enabled: videoUploadV2Enabled(),
406
+ receiving,
407
+ finalizing,
408
+ unacknowledgedPublished,
409
+ failed,
410
+ blocksRestart: receiving + finalizing > 0,
411
+ blocksRollback: receiving + finalizing + unacknowledgedPublished > 0,
412
+ }
413
+ }
414
+
415
+ sweepExpired(at = this.now()): number {
416
+ let swept = 0
417
+ for (const manifest of [...this.manifests.values()]) {
418
+ if (manifest.expiresAtMs > at || this.activeWriters.get(manifest.uploadId)) continue
419
+ // A published asset belongs to MediaStore, not the upload registry. The
420
+ // receipt is retained for 24 hours so a phone that lost the finalize
421
+ // response can recover it, but a phone/WebView that never sends ACK must
422
+ // not block server rollback forever. Expiring this manifest deliberately
423
+ // leaves the staged/reserved/associated media record untouched; MediaStore
424
+ // owns its normal lifecycle and GC from this point forward.
425
+ if (manifest.state === 'receiving' || manifest.state === 'failed' || manifest.state === 'cancelled'
426
+ || manifest.state === 'published') {
427
+ this.manifests.delete(manifest.uploadId)
428
+ this.byClientRequest.delete(manifest.clientRequestId)
429
+ rmSync(this.dir(manifest.uploadId), { recursive: true, force: true })
430
+ swept++
431
+ }
432
+ }
433
+ return swept
434
+ }
435
+
436
+ private async putPart(
437
+ uploadId: string,
438
+ kind: 'original' | 'frames',
439
+ indexValue: unknown,
440
+ bytes: Buffer,
441
+ serverInstanceId?: string,
442
+ ): Promise<VideoUploadProgress> {
443
+ const index = parseIndex(indexValue)
444
+ if (index === null || bytes.length === 0) throw new VideoUploadError('video_upload_invalid', 'valid non-empty part required')
445
+ const max = kind === 'original' ? VIDEO_UPLOAD_V2_CHUNK_BYTES : VIDEO_UPLOAD_V2_MAX_FRAME_BYTES
446
+ if (bytes.length > max) throw new VideoUploadError('video_upload_invalid', 'part exceeds its byte ceiling', { maxBytes: max })
447
+ this.activeWriters.set(uploadId, (this.activeWriters.get(uploadId) ?? 0) + 1)
448
+ try {
449
+ return await this.withLock(uploadId, () => {
450
+ const manifest = this.require(uploadId, serverInstanceId)
451
+ if (manifest.state !== 'receiving') throw new VideoUploadError('video_upload_busy', `upload is ${manifest.state}`)
452
+ if (kind === 'original' && index >= manifest.chunkCount) throw new VideoUploadError('video_upload_invalid', 'chunk index exceeds declared upload')
453
+ if (kind === 'frames' && index >= VIDEO_UPLOAD_PHONE_FRAMES_MAX) throw new VideoUploadError('video_upload_invalid', 'frame index exceeds pack limit')
454
+ const collection = manifest[kind]
455
+ const key = String(index)
456
+ const digest = sha256(bytes)
457
+ const accepted = collection[key]
458
+ if (accepted) {
459
+ if (accepted.bytes === bytes.length && accepted.sha256 === digest) return this.progress(manifest)
460
+ throw new VideoUploadError('video_upload_conflict', 'part index already contains different bytes')
461
+ }
462
+ if (kind === 'frames') {
463
+ const packBytes = Object.values(manifest.frames).reduce((sum, part) => sum + part.bytes, 0)
464
+ if (packBytes + bytes.length > VIDEO_UPLOAD_V2_MAX_FRAME_PACK_BYTES) {
465
+ throw new VideoUploadError('video_upload_invalid', 'frame pack exceeds byte ceiling')
466
+ }
467
+ }
468
+ const path = this.partPath(manifest, kind, index)
469
+ const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600)
470
+ try {
471
+ const written = writeSync(fd, bytes)
472
+ if (written !== bytes.length) throw new Error(`short write: ${written}/${bytes.length}`)
473
+ fsyncSync(fd)
474
+ } finally { closeSync(fd) }
475
+ collection[key] = { bytes: bytes.length, sha256: digest }
476
+ manifest.updatedAtMs = this.now()
477
+ this.save(manifest)
478
+ return this.progress(manifest)
479
+ })
480
+ } finally {
481
+ const count = (this.activeWriters.get(uploadId) ?? 1) - 1
482
+ if (count <= 0) this.activeWriters.delete(uploadId)
483
+ else this.activeWriters.set(uploadId, count)
484
+ }
485
+ }
486
+
487
+ private async finalizeOnce(uploadId: string, serverInstanceId?: string): Promise<VideoUploadProgress> {
488
+ const manifest = await this.withLock(uploadId, () => {
489
+ const current = this.require(uploadId, serverInstanceId)
490
+ if (current.state === 'published' && current.receipt) return current
491
+ if (current.state === 'cancelled') throw new VideoUploadError('video_upload_cancelled', 'upload was cancelled')
492
+ if (this.activeWriters.get(uploadId)) throw new VideoUploadError('video_upload_busy', 'upload still has an active writer')
493
+ const missing = this.missingChunks(current)
494
+ if (missing.length > 0) throw new VideoUploadError('video_upload_incomplete', 'video upload is incomplete', { missingOriginalChunks: missing })
495
+ if (!current.mediaId) current.mediaId = `m_${randomBytes(12).toString('hex')}`
496
+ current.state = 'finalizing'
497
+ current.generation++
498
+ current.updatedAtMs = this.now()
499
+ this.save(current)
500
+ return current
501
+ })
502
+ if (manifest.state === 'published' && manifest.receipt) return this.progress(manifest)
503
+
504
+ const recovered = getMediaStore().findByVideoUploadId(uploadId)
505
+ if (recovered) return this.commitReceipt(uploadId, recovered.ref)
506
+
507
+ const assembledPath = join(this.dir(uploadId), 'original-assembled.bin')
508
+ try {
509
+ this.assembleOriginal(manifest, assembledPath)
510
+ const ref = await getMediaStore().ingestRichMediaFromFile({
511
+ sourcePath: assembledPath,
512
+ byteLength: manifest.totalBytes,
513
+ label: manifest.label,
514
+ declaredMime: manifest.mime,
515
+ capturedAt: manifest.capturedAt,
516
+ sessionId: manifest.sessionId,
517
+ transfer: 'chunked',
518
+ mediaId: manifest.mediaId,
519
+ videoUploadId: manifest.uploadId,
520
+ })
521
+ const current = this.manifests.get(uploadId)
522
+ if (current?.state === 'cancelled') {
523
+ await getMediaStore().deleteExactlyStaged(ref.id)
524
+ throw new VideoUploadError('video_upload_cancelled', 'upload was cancelled during finalization')
525
+ }
526
+ return this.commitReceipt(uploadId, ref)
527
+ } catch (error) {
528
+ if (error instanceof VideoUploadError && error.code === 'video_upload_cancelled') throw error
529
+ await this.withLock(uploadId, () => {
530
+ const current = this.manifests.get(uploadId)
531
+ if (current && current.state !== 'published' && current.state !== 'cancelled') {
532
+ current.state = 'failed'
533
+ current.failure = error instanceof Error ? error.message.slice(0, 160) : 'video finalization failed'
534
+ current.updatedAtMs = this.now()
535
+ this.save(current)
536
+ }
537
+ })
538
+ throw error
539
+ } finally {
540
+ try { rmSync(assembledPath, { force: true }) } catch { /* ingest may have moved it */ }
541
+ }
542
+ }
543
+
544
+ private async commitReceipt(uploadId: string, ref: MediaAttachmentRef): Promise<VideoUploadProgress> {
545
+ return this.withLock(uploadId, () => {
546
+ const current = this.require(uploadId)
547
+ if (current.state === 'cancelled') throw new VideoUploadError('video_upload_cancelled', 'upload was cancelled')
548
+ current.state = 'published'
549
+ current.receipt = ref
550
+ current.acknowledged = false
551
+ current.updatedAtMs = this.now()
552
+ current.expiresAtMs = current.updatedAtMs + VIDEO_UPLOAD_V2_RECEIPT_TTL_MS
553
+ this.save(current)
554
+ return this.progress(current)
555
+ })
556
+ }
557
+
558
+ private assembleOriginal(manifest: VideoUploadManifest, target: string): void {
559
+ rmSync(target, { force: true })
560
+ const fd = openSync(target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600)
561
+ let total = 0
562
+ try {
563
+ for (let index = 0; index < manifest.chunkCount; index++) {
564
+ const bytes = readFileSync(this.partPath(manifest, 'original', index))
565
+ const part = manifest.original[String(index)]
566
+ if (!part || bytes.length !== part.bytes || sha256(bytes) !== part.sha256) {
567
+ throw new VideoUploadError('video_upload_failed', `chunk ${index} failed integrity verification`)
568
+ }
569
+ const written = writeSync(fd, bytes)
570
+ if (written !== bytes.length) throw new Error(`short assembly write: ${written}/${bytes.length}`)
571
+ total += written
572
+ }
573
+ if (total !== manifest.totalBytes) throw new VideoUploadError('video_upload_failed', 'assembled video size mismatch')
574
+ fsyncSync(fd)
575
+ } finally { closeSync(fd) }
576
+ }
577
+
578
+ private load(): void {
579
+ for (const entry of readdirSync(this.root, { withFileTypes: true })) {
580
+ if (!entry.isDirectory() || !isValidVideoUploadId(entry.name)) continue
581
+ try {
582
+ const manifest = parseManifest(JSON.parse(readFileSync(join(this.root, entry.name, 'manifest.json'), 'utf8')))
583
+ if (!manifest) continue
584
+ this.manifests.set(manifest.uploadId, manifest)
585
+ this.byClientRequest.set(manifest.clientRequestId, manifest.uploadId)
586
+ } catch { /* preserve unreadable draft on disk; do not invent state */ }
587
+ }
588
+ }
589
+
590
+ private reconcilePublished(): void {
591
+ for (const manifest of this.manifests.values()) {
592
+ if (manifest.state !== 'finalizing' && manifest.state !== 'failed'
593
+ && !(manifest.state === 'published' && !manifest.receipt)) continue
594
+ const record = getMediaStore().findByVideoUploadId(manifest.uploadId)
595
+ if (record) {
596
+ manifest.state = 'published'
597
+ manifest.receipt = record.ref
598
+ manifest.acknowledged = false
599
+ manifest.updatedAtMs = this.now()
600
+ manifest.expiresAtMs = manifest.updatedAtMs + VIDEO_UPLOAD_V2_RECEIPT_TTL_MS
601
+ this.save(manifest)
602
+ } else if (manifest.state === 'finalizing') {
603
+ // Finalize was claimed but media publication never completed. The
604
+ // complete draft is durable, so reopen it for an idempotent retry.
605
+ manifest.state = 'receiving'
606
+ manifest.failure = undefined
607
+ manifest.generation++
608
+ manifest.updatedAtMs = this.now()
609
+ this.save(manifest)
610
+ }
611
+ }
612
+ }
613
+
614
+ private require(uploadId: string, serverInstanceId?: string): VideoUploadManifest {
615
+ if (!isValidVideoUploadId(uploadId)) throw new VideoUploadError('video_upload_not_found', 'unknown video upload')
616
+ const manifest = this.manifests.get(uploadId)
617
+ if (!manifest) throw new VideoUploadError('video_upload_not_found', 'unknown or expired video upload')
618
+ this.assertIdentity(manifest, serverInstanceId)
619
+ return manifest
620
+ }
621
+
622
+ private assertIdentity(manifest: VideoUploadManifest, serverInstanceId?: string): void {
623
+ if (serverInstanceId && serverInstanceId !== manifest.serverInstanceId) {
624
+ throw new VideoUploadError('server_identity_mismatch', 'video upload belongs to a different COS server')
625
+ }
626
+ }
627
+
628
+ private progress(manifest: VideoUploadManifest): VideoUploadProgress {
629
+ const receivedOriginalChunks = Object.keys(manifest.original).map(Number).sort((a, b) => a - b)
630
+ const receivedFrames = Object.keys(manifest.frames).map(Number).sort((a, b) => a - b)
631
+ return {
632
+ protocol: 1,
633
+ uploadId: manifest.uploadId,
634
+ serverInstanceId: manifest.serverInstanceId,
635
+ state: manifest.state,
636
+ totalBytes: manifest.totalBytes,
637
+ chunkBytes: manifest.chunkBytes,
638
+ chunkCount: manifest.chunkCount,
639
+ receivedOriginalChunks,
640
+ missingOriginalChunks: this.missingChunks(manifest),
641
+ receivedFrames,
642
+ expiresAt: new Date(manifest.expiresAtMs).toISOString(),
643
+ acknowledged: manifest.acknowledged === true,
644
+ ...(manifest.receipt ? { attachment: manifest.receipt } : {}),
645
+ ...(manifest.failure ? { failure: manifest.failure } : {}),
646
+ }
647
+ }
648
+
649
+ private missingChunks(manifest: VideoUploadManifest): number[] {
650
+ const missing: number[] = []
651
+ for (let index = 0; index < manifest.chunkCount; index++) {
652
+ if (!manifest.original[String(index)]) missing.push(index)
653
+ }
654
+ return missing
655
+ }
656
+
657
+ private save(manifest: VideoUploadManifest): void {
658
+ durableAtomicWriteFileSync(join(this.dir(manifest.uploadId), 'manifest.json'), JSON.stringify(manifest), { mode: 0o600 })
659
+ }
660
+
661
+ private removeBodies(manifest: VideoUploadManifest): void {
662
+ rmSync(this.originalDir(manifest.uploadId), { recursive: true, force: true })
663
+ rmSync(this.frameDir(manifest.uploadId), { recursive: true, force: true })
664
+ }
665
+
666
+ private dir(uploadId: string): string { return join(this.root, uploadId) }
667
+ private originalDir(uploadId: string): string { return join(this.dir(uploadId), 'original') }
668
+ private frameDir(uploadId: string): string { return join(this.dir(uploadId), 'frames') }
669
+ private partPath(manifest: VideoUploadManifest, kind: 'original' | 'frames', index: number): string {
670
+ return join(kind === 'original' ? this.originalDir(manifest.uploadId) : this.frameDir(manifest.uploadId), `${index}.bin`)
671
+ }
672
+
673
+ private withLock<T>(uploadId: string, work: () => T | Promise<T>): Promise<T> {
674
+ const prior = this.locks.get(uploadId) ?? Promise.resolve()
675
+ const run = prior.then(work, work)
676
+ this.locks.set(uploadId, run.then(() => undefined, () => undefined))
677
+ return run
678
+ }
679
+ }
680
+
681
+ let defaultRegistry: VideoUploadRegistry | null = null
682
+
683
+ export function getVideoUploadRegistry(): VideoUploadRegistry {
684
+ if (!defaultRegistry) defaultRegistry = new VideoUploadRegistry()
685
+ return defaultRegistry
686
+ }
687
+
688
+ export function _setVideoUploadRegistryForTests(registry: VideoUploadRegistry | null): VideoUploadRegistry | null {
689
+ const previous = defaultRegistry
690
+ defaultRegistry = registry
691
+ return previous
692
+ }
693
+
694
+ export function videoUploadV2Capability(videoProcessingReady: boolean) {
695
+ let registryReady = false
696
+ let reason = 'disabled'
697
+ if (videoUploadV2Enabled()) {
698
+ try {
699
+ getVideoUploadRegistry()
700
+ registryReady = true
701
+ reason = videoProcessingReady ? 'ready' : 'video_processing_unavailable'
702
+ } catch {
703
+ reason = 'storage_unavailable'
704
+ }
705
+ }
706
+ return {
707
+ available: registryReady && videoProcessingReady,
708
+ protocol: VIDEO_UPLOAD_V2_PROTOCOL,
709
+ chunkBytes: VIDEO_UPLOAD_V2_CHUNK_BYTES,
710
+ maxOriginalBytes: MAX_CHUNKED_MEDIA_BYTES,
711
+ maxDurationMs: MAX_VIDEO_DURATION_MS,
712
+ acceptedMimes: [...VIDEO_UPLOAD_V2_ACCEPTED_MIMES],
713
+ // The route and manifest format are present so the phone implementation can
714
+ // be canaried without another wire change. Do not advertise frame packs as
715
+ // usable until the server consumes them and physical WKWebView proof passes.
716
+ phoneFramesAvailable: false,
717
+ phoneFramesMin: VIDEO_UPLOAD_PHONE_FRAMES_MIN,
718
+ phoneFramesMax: VIDEO_UPLOAD_PHONE_FRAMES_MAX,
719
+ serverFramesMin: VIDEO_UPLOAD_SERVER_FRAMES_MIN,
720
+ serverFramesMax: VIDEO_UPLOAD_SERVER_FRAMES_MAX,
721
+ maxFrameBytes: VIDEO_UPLOAD_V2_MAX_FRAME_BYTES,
722
+ maxPackBytes: VIDEO_UPLOAD_V2_MAX_FRAME_PACK_BYTES,
723
+ maxSourcePixels: 33_177_600,
724
+ reason,
725
+ }
726
+ }