@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.
- package/.env.example +7 -0
- package/CHANGELOG.md +32 -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 +28 -0
package/server/routes/health.ts
CHANGED
|
@@ -59,6 +59,8 @@ import { getEarlyMeetingSyncSnapshot } from '../lib/g2-ops-handoff.js'
|
|
|
59
59
|
import { getProgressiveHqSnapshot } from '../lib/meeting-batch-transcribe.js'
|
|
60
60
|
import { getMeetingFinalizationSnapshot } from '../lib/meeting-finalization-jobs.js'
|
|
61
61
|
import { resolveMeetingLibrary } from '../lib/cos-operations-meetings.js'
|
|
62
|
+
import { videoUploadV2Capability } from '../lib/video-upload-v2.js'
|
|
63
|
+
import { MAX_MODEL_IMAGE_INPUTS } from '../lib/query-attachments.js'
|
|
62
64
|
|
|
63
65
|
export const healthRouter = Router()
|
|
64
66
|
|
|
@@ -172,6 +174,7 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
172
174
|
// capabilities.liveCues so the two surfaces can never disagree.
|
|
173
175
|
const liveCues = liveCuesCapability()
|
|
174
176
|
const richMedia = await getRichMediaProcessingCapabilities()
|
|
177
|
+
const videoUploadV2 = videoUploadV2Capability(richMedia.video)
|
|
175
178
|
// Upload limits are published so the client never carries its own byte caps:
|
|
176
179
|
// cos-glasses-app and cos-glasses-server are separate repos that have already
|
|
177
180
|
// diverged, so a constant in both is guaranteed to drift. Absent means an
|
|
@@ -326,6 +329,11 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
326
329
|
maxVideoBytesPerAttachment: MAX_VIDEO_MEDIA_BYTES,
|
|
327
330
|
maxVideoMinutes: 20,
|
|
328
331
|
maxStillFrames: 8,
|
|
332
|
+
videoUploadV2: {
|
|
333
|
+
available: videoUploadV2.available,
|
|
334
|
+
protocol: videoUploadV2.protocol,
|
|
335
|
+
reason: videoUploadV2.reason,
|
|
336
|
+
},
|
|
329
337
|
},
|
|
330
338
|
meetingLifecycle: {
|
|
331
339
|
earlySyncClaim: getEarlyMeetingSyncSnapshot(),
|
|
@@ -359,6 +367,8 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
359
367
|
const transcriptionLive = getWhisperPreviewCapability()
|
|
360
368
|
const transcriptionProfile = getTranscriptionProfileStatus()
|
|
361
369
|
const progressiveHq = getProgressiveHqSnapshot()
|
|
370
|
+
const richMedia = await getRichMediaProcessingCapabilities()
|
|
371
|
+
const videoUploadV2 = videoUploadV2Capability(richMedia.video)
|
|
362
372
|
const cursorOptions = cursorCatalog.options.filter(option => !!option.id)
|
|
363
373
|
res.json({
|
|
364
374
|
...catalog,
|
|
@@ -391,6 +401,20 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
391
401
|
// THIS surface, so a value present only on /api/health leaves the
|
|
392
402
|
// live-cues indicator blind.
|
|
393
403
|
liveCues: liveCuesCapability(),
|
|
404
|
+
richMedia: {
|
|
405
|
+
video: richMedia.video,
|
|
406
|
+
maxPromptVisuals: MAX_MODEL_IMAGE_INPUTS,
|
|
407
|
+
videoUploadV2: {
|
|
408
|
+
...videoUploadV2,
|
|
409
|
+
// The first private train deliberately admits only one video and no
|
|
410
|
+
// other visual attachment. This keeps a server fallback at its
|
|
411
|
+
// proven 16-frame policy inside the provider-wide 16-image ceiling.
|
|
412
|
+
promptAdmission: {
|
|
413
|
+
maxVideos: 1,
|
|
414
|
+
maxOtherVisualsWithVideo: 0,
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
},
|
|
394
418
|
...(localFirstMeetings ? { localFirstMeetings } : {}),
|
|
395
419
|
},
|
|
396
420
|
})
|
|
@@ -16,6 +16,7 @@ import { serverMetrics } from '../lib/server-metrics.js'
|
|
|
16
16
|
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
17
17
|
import { getWhisperHealth } from '../lib/whisper-local.js'
|
|
18
18
|
import { getActiveTranscriptionSessionCount, getTranscriptionSessionLiveness } from './transcribe-stream.js'
|
|
19
|
+
import { getVideoUploadRegistry } from '../lib/video-upload-v2.js'
|
|
19
20
|
|
|
20
21
|
export const maintenanceRouter = Router()
|
|
21
22
|
|
|
@@ -78,6 +79,21 @@ function statusSnapshot(credentials: MaintenanceOperationCredentials = {}) {
|
|
|
78
79
|
// rather than counted, and are never deleted here.
|
|
79
80
|
const sessionLiveness = getTranscriptionSessionLiveness()
|
|
80
81
|
const managed = managedRuntimeCapability()
|
|
82
|
+
let videoUploads
|
|
83
|
+
try {
|
|
84
|
+
videoUploads = getVideoUploadRegistry().status()
|
|
85
|
+
} catch {
|
|
86
|
+
videoUploads = {
|
|
87
|
+
protocol: 1 as const,
|
|
88
|
+
enabled: false,
|
|
89
|
+
receiving: 0,
|
|
90
|
+
finalizing: 0,
|
|
91
|
+
unacknowledgedPublished: 0,
|
|
92
|
+
failed: 0,
|
|
93
|
+
blocksRestart: false,
|
|
94
|
+
blocksRollback: false,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
81
97
|
const tracked = maintenanceLifecycle.snapshot(credentials, {
|
|
82
98
|
recording_session: sessionLiveness.live,
|
|
83
99
|
})
|
|
@@ -107,7 +123,10 @@ function statusSnapshot(credentials: MaintenanceOperationCredentials = {}) {
|
|
|
107
123
|
shuttingDown: jobs.shuttingDown,
|
|
108
124
|
durableStoreState: jobs.store.state,
|
|
109
125
|
lifecycle,
|
|
110
|
-
|
|
126
|
+
videoUploads,
|
|
127
|
+
// Published receipts intentionally survive an ordinary restart. They only
|
|
128
|
+
// block a binary downgrade, where an old server could erase V2 evidence.
|
|
129
|
+
safeToRestart: lifecycle.safeToRestart && !jobs.shuttingDown && !videoUploads.blocksRestart,
|
|
111
130
|
whisper: getWhisperHealth(),
|
|
112
131
|
}
|
|
113
132
|
}
|
package/server/routes/media.ts
CHANGED
|
@@ -57,6 +57,16 @@ import {
|
|
|
57
57
|
isValidUploadId,
|
|
58
58
|
type UploadSessionErrorCode,
|
|
59
59
|
} from '../lib/upload-session.js'
|
|
60
|
+
import {
|
|
61
|
+
VIDEO_UPLOAD_V2_CHUNK_BYTES,
|
|
62
|
+
VIDEO_UPLOAD_V2_MAX_FRAME_BYTES,
|
|
63
|
+
VideoUploadError,
|
|
64
|
+
getVideoUploadRegistry,
|
|
65
|
+
isValidVideoUploadId,
|
|
66
|
+
type VideoUploadErrorCode,
|
|
67
|
+
} from '../lib/video-upload-v2.js'
|
|
68
|
+
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
69
|
+
import { maintenanceAdmissionsOpen } from '../lib/maintenance-lifecycle.js'
|
|
60
70
|
|
|
61
71
|
// Route-scoped parser: 16 MB covers the max valid batch after base64 + JSON
|
|
62
72
|
// overhead. Mounted only for /api/media in server/index.ts — the global
|
|
@@ -301,6 +311,9 @@ export function createMediaChunkBodyParser(options: MediaChunkParserOptions = {}
|
|
|
301
311
|
|
|
302
312
|
/** Chunk parser at the advertised chunk size. */
|
|
303
313
|
export const mediaChunkBodyParser = createMediaChunkBodyParser()
|
|
314
|
+
export const videoUploadChunkBodyParser = createMediaChunkBodyParser({
|
|
315
|
+
maxChunkBytes: Math.max(VIDEO_UPLOAD_V2_CHUNK_BYTES, VIDEO_UPLOAD_V2_MAX_FRAME_BYTES),
|
|
316
|
+
})
|
|
304
317
|
|
|
305
318
|
function takeChunkBody(req: Request): Buffer | undefined {
|
|
306
319
|
const bytes = chunkBodies.get(req)
|
|
@@ -347,6 +360,19 @@ const UPLOAD_ERROR_STATUS: Record<UploadSessionErrorCode, number> = {
|
|
|
347
360
|
upload_staging_failed: 500,
|
|
348
361
|
}
|
|
349
362
|
|
|
363
|
+
const VIDEO_UPLOAD_ERROR_STATUS: Record<VideoUploadErrorCode, number> = {
|
|
364
|
+
video_upload_disabled: 503,
|
|
365
|
+
video_upload_not_found: 404,
|
|
366
|
+
video_upload_conflict: 409,
|
|
367
|
+
video_upload_busy: 409,
|
|
368
|
+
video_upload_incomplete: 409,
|
|
369
|
+
video_upload_invalid: 400,
|
|
370
|
+
video_upload_quota: 507,
|
|
371
|
+
video_upload_cancelled: 410,
|
|
372
|
+
video_upload_failed: 500,
|
|
373
|
+
server_identity_mismatch: 409,
|
|
374
|
+
}
|
|
375
|
+
|
|
350
376
|
function sendMediaError(res: Response, err: unknown): void {
|
|
351
377
|
// Chunked-upload failures go through the SAME funnel as every other media
|
|
352
378
|
// error, so there is one place that decides the error body's shape.
|
|
@@ -354,6 +380,14 @@ function sendMediaError(res: Response, err: unknown): void {
|
|
|
354
380
|
res.status(UPLOAD_ERROR_STATUS[err.code] ?? 500).json({ error: err.code, ...err.detail })
|
|
355
381
|
return
|
|
356
382
|
}
|
|
383
|
+
if (err instanceof VideoUploadError) {
|
|
384
|
+
res.status(VIDEO_UPLOAD_ERROR_STATUS[err.code] ?? 500).json({
|
|
385
|
+
error: err.code,
|
|
386
|
+
detail: err.message,
|
|
387
|
+
...err.detail,
|
|
388
|
+
})
|
|
389
|
+
return
|
|
390
|
+
}
|
|
357
391
|
if (err instanceof MediaStoreError) {
|
|
358
392
|
res.status(MEDIA_ERROR_STATUS[err.code] ?? 500).json({ error: err.code })
|
|
359
393
|
return
|
|
@@ -374,6 +408,26 @@ function sendMediaError(res: Response, err: unknown): void {
|
|
|
374
408
|
res.status(500).json({ error: 'media_internal_error' })
|
|
375
409
|
}
|
|
376
410
|
|
|
411
|
+
function videoUploadServerIdentity(req: Request): string | undefined {
|
|
412
|
+
return safeString(req.header('x-cos-server-instance'), 160)
|
|
413
|
+
?? safeString(req.header('x-cos-server-instance-id'), 160)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function requireVideoUploadAdmission(res: Response): boolean {
|
|
417
|
+
if (maintenanceAdmissionsOpen()) return true
|
|
418
|
+
res.status(503).json({ error: 'server_maintenance', retryable: true })
|
|
419
|
+
return false
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function requireCurrentServerIdentity(req: Request): string {
|
|
423
|
+
const presented = videoUploadServerIdentity(req)
|
|
424
|
+
const current = getServerInstanceId()
|
|
425
|
+
if (!presented || presented !== current) {
|
|
426
|
+
throw new VideoUploadError('server_identity_mismatch', 'request targets a different COS server')
|
|
427
|
+
}
|
|
428
|
+
return presented
|
|
429
|
+
}
|
|
430
|
+
|
|
377
431
|
function safeString(v: unknown, max: number): string | undefined {
|
|
378
432
|
return typeof v === 'string' && v.trim().length > 0 ? v.trim().slice(0, max) : undefined
|
|
379
433
|
}
|
|
@@ -489,6 +543,124 @@ function uploadLabelFrom(req: Request): string | undefined {
|
|
|
489
543
|
try { return decodeURIComponent(rawLabel).slice(0, 120) } catch { return rawLabel.slice(0, 120) }
|
|
490
544
|
}
|
|
491
545
|
|
|
546
|
+
// ── Resumable video upload V2 (private canary) ───────────────────────────────
|
|
547
|
+
|
|
548
|
+
mediaRouter.post('/media/video-upload/init', (req: Request, res: Response) => {
|
|
549
|
+
try {
|
|
550
|
+
const serverInstanceId = requireCurrentServerIdentity(req)
|
|
551
|
+
const body = req.body ?? {}
|
|
552
|
+
const progress = getVideoUploadRegistry().init({
|
|
553
|
+
clientRequestId: body.clientRequestId,
|
|
554
|
+
serverInstanceId,
|
|
555
|
+
totalBytes: body.totalBytes,
|
|
556
|
+
mime: body.mime,
|
|
557
|
+
label: uploadLabelFrom(req),
|
|
558
|
+
capturedAt: safeString(req.header('x-cos-captured-at'), 40),
|
|
559
|
+
sessionId: safeString(req.header('x-cos-session-id'), 64),
|
|
560
|
+
})
|
|
561
|
+
res.json(progress)
|
|
562
|
+
} catch (err) {
|
|
563
|
+
sendMediaError(res, err)
|
|
564
|
+
}
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
mediaRouter.get('/media/video-upload/:uploadId', (req: Request, res: Response) => {
|
|
568
|
+
try {
|
|
569
|
+
const serverInstanceId = requireCurrentServerIdentity(req)
|
|
570
|
+
if (!isValidVideoUploadId(req.params.uploadId)) {
|
|
571
|
+
throw new VideoUploadError('video_upload_not_found', 'unknown or expired video upload')
|
|
572
|
+
}
|
|
573
|
+
res.json(getVideoUploadRegistry().get(req.params.uploadId, serverInstanceId))
|
|
574
|
+
} catch (err) {
|
|
575
|
+
sendMediaError(res, err)
|
|
576
|
+
}
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
mediaRouter.put(
|
|
580
|
+
'/media/video-upload/:uploadId/original/:index',
|
|
581
|
+
videoUploadChunkBodyParser,
|
|
582
|
+
async (req: Request, res: Response) => {
|
|
583
|
+
const bytes = takeChunkBody(req)
|
|
584
|
+
try {
|
|
585
|
+
if (!requireVideoUploadAdmission(res)) return
|
|
586
|
+
const serverInstanceId = requireCurrentServerIdentity(req)
|
|
587
|
+
if (!isValidVideoUploadId(req.params.uploadId) || bytes === undefined) {
|
|
588
|
+
throw new VideoUploadError('video_upload_invalid', 'valid upload id and raw chunk bytes are required')
|
|
589
|
+
}
|
|
590
|
+
res.json(await getVideoUploadRegistry().putOriginal(
|
|
591
|
+
req.params.uploadId,
|
|
592
|
+
req.params.index,
|
|
593
|
+
bytes,
|
|
594
|
+
serverInstanceId,
|
|
595
|
+
))
|
|
596
|
+
} catch (err) {
|
|
597
|
+
sendMediaError(res, err)
|
|
598
|
+
}
|
|
599
|
+
},
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
mediaRouter.put(
|
|
603
|
+
'/media/video-upload/:uploadId/frame/:index',
|
|
604
|
+
videoUploadChunkBodyParser,
|
|
605
|
+
async (req: Request, res: Response) => {
|
|
606
|
+
const bytes = takeChunkBody(req)
|
|
607
|
+
try {
|
|
608
|
+
if (!requireVideoUploadAdmission(res)) return
|
|
609
|
+
const serverInstanceId = requireCurrentServerIdentity(req)
|
|
610
|
+
if (!isValidVideoUploadId(req.params.uploadId) || bytes === undefined) {
|
|
611
|
+
throw new VideoUploadError('video_upload_invalid', 'valid upload id and raw frame bytes are required')
|
|
612
|
+
}
|
|
613
|
+
res.json(await getVideoUploadRegistry().putFrame(
|
|
614
|
+
req.params.uploadId,
|
|
615
|
+
req.params.index,
|
|
616
|
+
bytes,
|
|
617
|
+
serverInstanceId,
|
|
618
|
+
))
|
|
619
|
+
} catch (err) {
|
|
620
|
+
sendMediaError(res, err)
|
|
621
|
+
}
|
|
622
|
+
},
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
mediaRouter.post('/media/video-upload/:uploadId/finalize', async (req: Request, res: Response) => {
|
|
626
|
+
try {
|
|
627
|
+
if (!requireVideoUploadAdmission(res)) return
|
|
628
|
+
const serverInstanceId = requireCurrentServerIdentity(req)
|
|
629
|
+
if (!isValidVideoUploadId(req.params.uploadId)) {
|
|
630
|
+
throw new VideoUploadError('video_upload_not_found', 'unknown or expired video upload')
|
|
631
|
+
}
|
|
632
|
+
res.json(await getVideoUploadRegistry().finalize(req.params.uploadId, serverInstanceId))
|
|
633
|
+
} catch (err) {
|
|
634
|
+
sendMediaError(res, err)
|
|
635
|
+
}
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
mediaRouter.post('/media/video-upload/:uploadId/ack', async (req: Request, res: Response) => {
|
|
639
|
+
try {
|
|
640
|
+
const serverInstanceId = requireCurrentServerIdentity(req)
|
|
641
|
+
if (!isValidVideoUploadId(req.params.uploadId)) {
|
|
642
|
+
throw new VideoUploadError('video_upload_not_found', 'unknown or expired video upload')
|
|
643
|
+
}
|
|
644
|
+
res.json(await getVideoUploadRegistry().acknowledge(req.params.uploadId, serverInstanceId))
|
|
645
|
+
} catch (err) {
|
|
646
|
+
sendMediaError(res, err)
|
|
647
|
+
}
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
mediaRouter.delete('/media/video-upload/:uploadId', async (req: Request, res: Response) => {
|
|
651
|
+
try {
|
|
652
|
+
const serverInstanceId = requireCurrentServerIdentity(req)
|
|
653
|
+
if (!isValidVideoUploadId(req.params.uploadId)) {
|
|
654
|
+
res.json({ ok: true, dropped: false })
|
|
655
|
+
return
|
|
656
|
+
}
|
|
657
|
+
const progress = await getVideoUploadRegistry().cancel(req.params.uploadId, serverInstanceId)
|
|
658
|
+
res.json({ ok: true, dropped: progress !== null, ...(progress ? { upload: progress } : {}) })
|
|
659
|
+
} catch (err) {
|
|
660
|
+
sendMediaError(res, err)
|
|
661
|
+
}
|
|
662
|
+
})
|
|
663
|
+
|
|
492
664
|
mediaRouter.post('/media/upload/init', (req: Request, res: Response) => {
|
|
493
665
|
try {
|
|
494
666
|
const body = req.body ?? {}
|
|
@@ -124,6 +124,34 @@ async function cleanOutboundDictation(text: string, opts: AutoCleanRequest & { s
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/** Finalize already-transcribed phone text through the same glossary + optional
|
|
128
|
+
* AI cleanup used by server-owned prompt drafts. This route accepts text only:
|
|
129
|
+
* Moonshine audio and rolling preview audio never leave the phone. */
|
|
130
|
+
promptDraftsRouter.post('/dictation/finalize', async (req, res) => {
|
|
131
|
+
const text = typeof req.body?.text === 'string' ? req.body.text.trim() : ''
|
|
132
|
+
const surface = req.body?.surface
|
|
133
|
+
const model = req.body?.autocleanModel
|
|
134
|
+
if (!text) return res.status(400).json({ error: 'text_required' })
|
|
135
|
+
if (text.length > AUTOCLEAN_MAX_CHARS) {
|
|
136
|
+
return res.status(413).json({ error: 'text_too_large', maxChars: AUTOCLEAN_MAX_CHARS })
|
|
137
|
+
}
|
|
138
|
+
if (surface !== 'message' && surface !== 'meeting') {
|
|
139
|
+
return res.status(400).json({ error: 'invalid_surface' })
|
|
140
|
+
}
|
|
141
|
+
if (model !== undefined && model !== 'haiku' && model !== 'sonnet') {
|
|
142
|
+
return res.status(400).json({ error: 'invalid_autoclean_model' })
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const abort = new AbortController()
|
|
146
|
+
res.on('close', () => { if (!res.writableEnded) abort.abort() })
|
|
147
|
+
const finalText = await cleanOutboundDictation(text, {
|
|
148
|
+
enabled: true,
|
|
149
|
+
model,
|
|
150
|
+
signal: abort.signal,
|
|
151
|
+
})
|
|
152
|
+
res.json({ text: finalText, surface, polished: finalText !== text })
|
|
153
|
+
})
|
|
154
|
+
|
|
127
155
|
async function readRawBody(req: AsyncIterable<Buffer | Uint8Array | string>): Promise<Buffer> {
|
|
128
156
|
const chunks: Buffer[] = []
|
|
129
157
|
let total = 0
|