@gotcos/glasses-server 6.27.2 → 6.27.4
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/.env.example +7 -0
- package/CHANGELOG.md +46 -0
- package/README.md +6 -2
- package/package.json +1 -1
- package/server/index.ts +23 -1
- package/server/lib/media-store.ts +100 -9
- package/server/lib/query-attachments.ts +1 -1
- package/server/lib/video-upload-v2.ts +726 -0
- package/server/routes/health.ts +24 -0
- package/server/routes/maintenance.ts +20 -1
- package/server/routes/media.ts +172 -0
- package/server/routes/prompt-drafts.ts +23 -0
|
@@ -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
|
+
}
|