@gotcos/glasses-server 6.24.5 → 6.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,376 @@
1
+ // x265 re-encode for stored video. Bitrate only, never geometry: resolution and
2
+ // frame rate are what later frame-mining depends on and upscaling cannot recover
3
+ // them, so no scale filter is ever added and an output whose geometry drifted from
4
+ // the input is rejected rather than kept.
5
+ //
6
+ // Measured on the real 3840x2160 30fps 25.5 Mbps phone upload (2026-08-10):
7
+ // libx265 -crf 30 2.7x smaller, SSIM 0.969 <- chosen
8
+ // libx265 -crf 34 5.5x smaller, SSIM 0.953
9
+ // hevc_videotoolbox at comparable size SSIM 0.858
10
+ // Hardware encoding is far worse per byte, which is why this runs SOFTWARE x265
11
+ // despite being roughly real time. Two other settings tried produced files LARGER
12
+ // than the source, which is why the size comparison below is a real guard.
13
+ //
14
+ // This module knows nothing about the media store: it takes a path, returns a path
15
+ // inside workDir, and never modifies or deletes its input. Placement is the caller's
16
+ // job through the existing rename boundary in media-store.ts.
17
+
18
+ import { spawn } from 'node:child_process'
19
+ import { randomBytes } from 'node:crypto'
20
+ import { mkdirSync, rmSync, statSync } from 'node:fs'
21
+ import { basename, extname, join } from 'node:path'
22
+ import { getRichMediaProcessingCapabilities } from './rich-media-safety.js'
23
+
24
+ export const VIDEO_COMPRESSION_CRF = 30
25
+ export const VIDEO_COMPRESSION_LABEL = 'x265-crf30'
26
+
27
+ const PROBE_TIMEOUT_MS = 10_000
28
+ const PROCESS_STDOUT_MAX = 64_000
29
+ const PROCESS_STDERR_MAX = 8_192
30
+ const REASON_MAX_CHARS = 200
31
+ // x265 -preset medium encodes at roughly real time on the measured 4K asset, so 4x
32
+ // duration is headroom for a loaded machine without letting a background encode run
33
+ // unbounded. A timeout costs only wasted CPU — the original is never touched — so the
34
+ // ceiling is allowed to be generous rather than trying to cover the 20-minute ingest
35
+ // cap, which no sane wall-clock bound could.
36
+ const ENCODE_TIMEOUT_PER_SECOND_MS = 4_000
37
+ const ENCODE_TIMEOUT_FLOOR_MS = 60_000
38
+ const ENCODE_TIMEOUT_CEILING_MS = 20 * 60_000
39
+ const UNKNOWN_DURATION_TIMEOUT_MS = 5 * 60_000
40
+ // hevc decode runs many times faster than encode; this only has to prove the file
41
+ // parses end to end.
42
+ const DECODE_TIMEOUT_FLOOR_MS = 30_000
43
+ const DECODE_TIMEOUT_CEILING_MS = 5 * 60_000
44
+ // ffprobe reports rationals, so 30/1 and 30000/1001 both parse to floats. 0.01 fps is
45
+ // looser than float noise and still tighter than every real rate change worth
46
+ // catching (29.97 vs 30 differ by 0.03).
47
+ const FPS_EQUALITY_TOLERANCE = 0.01
48
+
49
+ let timeoutOverrides: { encodeMs?: number; decodeMs?: number } | null = null
50
+
51
+ export type CompressionStatus =
52
+ | 'compressed' // outputPath is a validated, smaller file
53
+ | 'skipped_unavailable' // ffmpeg or ffprobe missing
54
+ | 'skipped_not_smaller' // encode produced >= input; keep the original
55
+ | 'skipped_not_video'
56
+ | 'failed' // encode or validation failed; keep the original
57
+
58
+ export interface CompressionResult {
59
+ status: CompressionStatus
60
+ outputPath?: string
61
+ originalBytes: number
62
+ compressedBytes?: number
63
+ width?: number
64
+ height?: number
65
+ reason?: string // bounded, safe to log, never a full path
66
+ }
67
+
68
+ type ProcessOutcome =
69
+ | { kind: 'ok'; stdout: string }
70
+ | { kind: 'rejected'; stderr: string }
71
+ | { kind: 'timeout' }
72
+ | { kind: 'unavailable' }
73
+ | { kind: 'spawn_error'; message: string }
74
+
75
+ interface ProbeStream {
76
+ codec_type?: string
77
+ width?: number
78
+ height?: number
79
+ r_frame_rate?: string
80
+ avg_frame_rate?: string
81
+ }
82
+
83
+ interface ProbePayload {
84
+ format?: { duration?: string }
85
+ streams?: ProbeStream[]
86
+ }
87
+
88
+ interface VideoGeometry {
89
+ width: number
90
+ height: number
91
+ fps: number | null
92
+ durationMs: number | null
93
+ }
94
+
95
+ type ProbeOutcome =
96
+ | { kind: 'ok'; geometry: VideoGeometry }
97
+ | { kind: 'no_video' }
98
+ | { kind: 'unavailable' }
99
+ | { kind: 'failed'; reason: string }
100
+
101
+ /**
102
+ * Reasons are logged, and the attachment contract has always refused to let paths or
103
+ * filenames reach a log line. Known paths are replaced by name first, then any
104
+ * remaining separator-bearing token is scrubbed as a backstop.
105
+ */
106
+ function boundedReason(text: string, paths: string[] = []): string {
107
+ let scrubbed = text
108
+ for (const path of paths) {
109
+ if (!path) continue
110
+ scrubbed = scrubbed.split(path).join('[path]')
111
+ // Only strip a bare basename that actually looks like a filename. A directory's
112
+ // basename is often an ordinary word ('work', 'data') and blanket substitution
113
+ // would mangle the diagnostic instead of protecting it.
114
+ const name = basename(path)
115
+ if (name.length >= 5 && name.includes('.')) scrubbed = scrubbed.split(name).join('[file]')
116
+ }
117
+ return scrubbed
118
+ .replace(/[\u0000-\u001f\u007f]+/g, ' ')
119
+ .split(' ')
120
+ .map(word => (word.includes('/') || word.includes('\\') ? '[path]' : word))
121
+ .join(' ')
122
+ .replace(/\s+/g, ' ')
123
+ .trim()
124
+ .slice(0, REASON_MAX_CHARS)
125
+ }
126
+
127
+ /** Same spawn/timeout/stderr idiom as the ffprobe call in rich-media-safety.ts. */
128
+ async function runBounded(command: string, args: string[], timeoutMs: number): Promise<ProcessOutcome> {
129
+ return new Promise<ProcessOutcome>(resolve => {
130
+ let settled = false
131
+ let stdout = ''
132
+ let stderr = ''
133
+ const finish = (outcome: ProcessOutcome) => {
134
+ if (settled) return
135
+ settled = true
136
+ clearTimeout(timer)
137
+ resolve(outcome)
138
+ }
139
+ let proc: ReturnType<typeof spawn>
140
+ try {
141
+ proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] })
142
+ } catch (error) {
143
+ return resolve({ kind: 'spawn_error', message: (error as Error).message })
144
+ }
145
+ const timer = setTimeout(() => {
146
+ proc.kill('SIGKILL')
147
+ finish({ kind: 'timeout' })
148
+ }, timeoutMs)
149
+ timer.unref?.()
150
+ proc.stdout?.on('data', (chunk: Buffer) => {
151
+ if (stdout.length < PROCESS_STDOUT_MAX) stdout += chunk.toString('utf8')
152
+ })
153
+ // Tail, not head: libx265 prints a long info banner before any real error, so the
154
+ // diagnostic line is at the end of stderr rather than the start.
155
+ proc.stderr?.on('data', (chunk: Buffer) => {
156
+ stderr = (stderr + chunk.toString('utf8')).slice(-PROCESS_STDERR_MAX)
157
+ })
158
+ proc.once('error', error => {
159
+ const enoent = (error as NodeJS.ErrnoException).code === 'ENOENT'
160
+ finish(enoent ? { kind: 'unavailable' } : { kind: 'spawn_error', message: error.message })
161
+ })
162
+ proc.once('close', code => {
163
+ finish(code === 0 ? { kind: 'ok', stdout } : { kind: 'rejected', stderr })
164
+ })
165
+ })
166
+ }
167
+
168
+ function parseRate(value: string | undefined): number | null {
169
+ if (!value) return null
170
+ const [numerator, denominator] = value.split('/')
171
+ const top = Number(numerator)
172
+ const bottom = denominator === undefined ? 1 : Number(denominator)
173
+ if (!Number.isFinite(top) || !Number.isFinite(bottom) || bottom === 0 || top <= 0) return null
174
+ return top / bottom
175
+ }
176
+
177
+ async function probeGeometry(path: string): Promise<ProbeOutcome> {
178
+ const probe = await runBounded('ffprobe', [
179
+ '-v', 'error',
180
+ '-show_entries', 'format=duration:stream=codec_type,width,height,r_frame_rate,avg_frame_rate',
181
+ '-of', 'json', path,
182
+ ], PROBE_TIMEOUT_MS)
183
+ if (probe.kind === 'unavailable') return { kind: 'unavailable' }
184
+ if (probe.kind === 'timeout') return { kind: 'failed', reason: 'ffprobe timed out' }
185
+ if (probe.kind === 'spawn_error') return { kind: 'failed', reason: boundedReason(`ffprobe failed: ${probe.message}`, [path]) }
186
+ // A non-zero probe means the bytes are not readable video at all. That is reported
187
+ // as failed rather than skipped_not_video on purpose: skipped is silent, and a
188
+ // caller handing this module a document or a truncated file should be visible.
189
+ if (probe.kind === 'rejected') return { kind: 'failed', reason: boundedReason('ffprobe rejected input', [path]) }
190
+
191
+ let payload: ProbePayload
192
+ try {
193
+ payload = JSON.parse(probe.stdout) as ProbePayload
194
+ } catch {
195
+ return { kind: 'failed', reason: 'ffprobe returned invalid metadata' }
196
+ }
197
+ const stream = payload.streams?.find(item => item.codec_type === 'video')
198
+ if (!stream) return { kind: 'no_video' }
199
+ const width = Number(stream.width)
200
+ const height = Number(stream.height)
201
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
202
+ return { kind: 'failed', reason: 'video stream has no usable dimensions' }
203
+ }
204
+ const durationSeconds = Number(payload.format?.duration)
205
+ return {
206
+ kind: 'ok',
207
+ geometry: {
208
+ width,
209
+ height,
210
+ fps: parseRate(stream.r_frame_rate) ?? parseRate(stream.avg_frame_rate),
211
+ durationMs: Number.isFinite(durationSeconds) && durationSeconds > 0
212
+ ? Math.round(durationSeconds * 1000)
213
+ : null,
214
+ },
215
+ }
216
+ }
217
+
218
+ function encodeTimeoutMs(durationMs: number | null): number {
219
+ if (timeoutOverrides?.encodeMs !== undefined) return timeoutOverrides.encodeMs
220
+ if (durationMs === null) return UNKNOWN_DURATION_TIMEOUT_MS
221
+ const scaled = Math.round((durationMs / 1000) * ENCODE_TIMEOUT_PER_SECOND_MS)
222
+ return Math.min(ENCODE_TIMEOUT_CEILING_MS, Math.max(ENCODE_TIMEOUT_FLOOR_MS, scaled))
223
+ }
224
+
225
+ function decodeTimeoutMs(durationMs: number | null): number {
226
+ if (timeoutOverrides?.decodeMs !== undefined) return timeoutOverrides.decodeMs
227
+ if (durationMs === null) return DECODE_TIMEOUT_FLOOR_MS
228
+ return Math.min(DECODE_TIMEOUT_CEILING_MS, Math.max(DECODE_TIMEOUT_FLOOR_MS, durationMs))
229
+ }
230
+
231
+ /** Only ever called on our own output inside workDir; force means a missing file is fine. */
232
+ function discardOutput(path: string): void {
233
+ try {
234
+ rmSync(path, { force: true })
235
+ } catch {
236
+ /* work-dir cleanup is best effort; the caller's retention clock sweeps the rest */
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Re-encode one video to x265 inside workDir. Never touches inputPath, never writes
242
+ * outside workDir, and every failure mode leaves the original as the only thing the
243
+ * caller needs to keep.
244
+ */
245
+ export async function compressVideoFile(
246
+ inputPath: string,
247
+ workDir: string,
248
+ ): Promise<CompressionResult> {
249
+ let originalBytes: number
250
+ try {
251
+ const stat = statSync(inputPath)
252
+ if (!stat.isFile()) return { status: 'failed', originalBytes: 0, reason: 'input is not a regular file' }
253
+ originalBytes = stat.size
254
+ } catch {
255
+ return { status: 'failed', originalBytes: 0, reason: 'input is unreadable' }
256
+ }
257
+ if (originalBytes === 0) return { status: 'failed', originalBytes: 0, reason: 'input is empty' }
258
+
259
+ // Gated on the detection health already polls, so this never becomes a second probe
260
+ // of the same two binaries that can disagree with what health advertises.
261
+ const capabilities = await getRichMediaProcessingCapabilities()
262
+ if (!capabilities.video) {
263
+ return { status: 'skipped_unavailable', originalBytes, reason: 'ffmpeg or ffprobe unavailable' }
264
+ }
265
+
266
+ const source = await probeGeometry(inputPath)
267
+ if (source.kind === 'unavailable') return { status: 'skipped_unavailable', originalBytes, reason: 'ffprobe unavailable' }
268
+ if (source.kind === 'no_video') return { status: 'skipped_not_video', originalBytes, reason: 'input has no video stream' }
269
+ if (source.kind === 'failed') return { status: 'failed', originalBytes, reason: source.reason }
270
+ const { width, height, fps, durationMs } = source.geometry
271
+
272
+ try {
273
+ mkdirSync(workDir, { recursive: true })
274
+ } catch {
275
+ return { status: 'failed', originalBytes, width, height, reason: 'work directory unavailable' }
276
+ }
277
+
278
+ // Keep the input's container: -tag:v hvc1 is valid in MP4 and QuickTime alike, while
279
+ // remuxing a .mov into .mp4 can fail outright on an audio codec MP4 has no tag for,
280
+ // wasting the whole encode. Same .mov/.mp4 choice rich-media-safety.ts makes.
281
+ const suffix = extname(inputPath).toLowerCase() === '.mov' ? '.mov' : '.mp4'
282
+ const outputPath = join(workDir, `cos-x265-${randomBytes(6).toString('hex')}${suffix}`)
283
+ const scrub = [inputPath, outputPath, workDir]
284
+
285
+ const encode = await runBounded('ffmpeg', [
286
+ '-nostdin', '-v', 'error', '-y',
287
+ '-i', inputPath,
288
+ '-c:v', 'libx265', '-crf', String(VIDEO_COMPRESSION_CRF), '-preset', 'medium', '-tag:v', 'hvc1',
289
+ '-c:a', 'copy',
290
+ outputPath,
291
+ ], encodeTimeoutMs(durationMs))
292
+ if (encode.kind !== 'ok') {
293
+ // A killed or rejected ffmpeg leaves a partial file behind; it must not survive to
294
+ // look like a finished encode to anything that scans workDir.
295
+ discardOutput(outputPath)
296
+ if (encode.kind === 'unavailable') {
297
+ return { status: 'skipped_unavailable', originalBytes, width, height, reason: 'ffmpeg unavailable' }
298
+ }
299
+ if (encode.kind === 'timeout') {
300
+ return { status: 'failed', originalBytes, width, height, reason: `encode timed out after ${encodeTimeoutMs(durationMs)}ms` }
301
+ }
302
+ const detail = encode.kind === 'rejected' ? encode.stderr : encode.message
303
+ return { status: 'failed', originalBytes, width, height, reason: boundedReason(`encode failed: ${detail}`, scrub) }
304
+ }
305
+
306
+ let compressedBytes: number
307
+ try {
308
+ compressedBytes = statSync(outputPath).size
309
+ } catch {
310
+ return { status: 'failed', originalBytes, width, height, reason: 'encode produced no output file' }
311
+ }
312
+ if (compressedBytes === 0) {
313
+ discardOutput(outputPath)
314
+ return { status: 'failed', originalBytes, compressedBytes, width, height, reason: 'encode produced an empty file' }
315
+ }
316
+ // Measured: two encoder settings inflated the real 4K asset. A larger "compressed"
317
+ // file is strictly worse than the original, so this is a skip and not a result.
318
+ if (compressedBytes >= originalBytes) {
319
+ discardOutput(outputPath)
320
+ return { status: 'skipped_not_smaller', originalBytes, compressedBytes, width, height }
321
+ }
322
+
323
+ const encoded = await probeGeometry(outputPath)
324
+ if (encoded.kind !== 'ok') {
325
+ discardOutput(outputPath)
326
+ const reason = encoded.kind === 'failed' ? encoded.reason : `output probe returned ${encoded.kind}`
327
+ return { status: 'failed', originalBytes, compressedBytes, width, height, reason: boundedReason(`output unverifiable: ${reason}`, scrub) }
328
+ }
329
+ if (encoded.geometry.width !== width || encoded.geometry.height !== height) {
330
+ discardOutput(outputPath)
331
+ return {
332
+ status: 'failed', originalBytes, compressedBytes, width, height,
333
+ reason: `output ${encoded.geometry.width}x${encoded.geometry.height} differs from input ${width}x${height}`,
334
+ }
335
+ }
336
+ // Only enforced when the input's own rate is readable — a genuinely variable-rate
337
+ // source reports 0/0 and has no rate to preserve.
338
+ if (fps !== null) {
339
+ if (encoded.geometry.fps === null) {
340
+ discardOutput(outputPath)
341
+ return { status: 'failed', originalBytes, compressedBytes, width, height, reason: 'output frame rate unreadable' }
342
+ }
343
+ if (Math.abs(encoded.geometry.fps - fps) > FPS_EQUALITY_TOLERANCE) {
344
+ discardOutput(outputPath)
345
+ return {
346
+ status: 'failed', originalBytes, compressedBytes, width, height,
347
+ reason: `output ${encoded.geometry.fps.toFixed(3)} fps differs from input ${fps.toFixed(3)} fps`,
348
+ }
349
+ }
350
+ }
351
+
352
+ // Prove it decodes end to end before anyone is told this file can replace the
353
+ // original. Exit code alone from the encode does not establish that.
354
+ const decode = await runBounded('ffmpeg', [
355
+ '-nostdin', '-v', 'error', '-i', outputPath, '-f', 'null', '-',
356
+ ], decodeTimeoutMs(durationMs))
357
+ if (decode.kind !== 'ok') {
358
+ discardOutput(outputPath)
359
+ const detail = decode.kind === 'rejected'
360
+ ? decode.stderr
361
+ : decode.kind === 'spawn_error' ? decode.message : decode.kind
362
+ return {
363
+ status: 'failed', originalBytes, compressedBytes, width, height,
364
+ reason: boundedReason(`output failed decode validation: ${detail}`, scrub),
365
+ }
366
+ }
367
+
368
+ return { status: 'compressed', outputPath, originalBytes, compressedBytes, width, height }
369
+ }
370
+
371
+ /** Timeouts are minutes wide by design, so tests override them rather than wait. */
372
+ export function _setCompressionTimeoutOverridesForTests(
373
+ overrides: { encodeMs?: number; decodeMs?: number } | null,
374
+ ): void {
375
+ timeoutOverrides = overrides
376
+ }
@@ -30,8 +30,16 @@ import {
30
30
  isCursorProviderReady,
31
31
  } from '../lib/cursor-model-catalog.js'
32
32
  import { isMediaProcessingReady } from '../lib/image-safety.js'
33
- import { getRichMediaProcessingCapabilities } from '../lib/rich-media-safety.js'
33
+ import {
34
+ MAX_OTHER_MEDIA_BYTES,
35
+ MAX_VIDEO_MEDIA_BYTES,
36
+ getMediaLimits,
37
+ getRichMediaProcessingCapabilities,
38
+ } from '../lib/rich-media-safety.js'
34
39
  import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
40
+ // The flag lives beside the endpoints it describes, so it cannot drift from
41
+ // whether they are actually registered.
42
+ import { MEDIA_CHUNKED_UPLOAD_ENABLED } from './media.js'
35
43
  import { durableQueryJobsCapability } from '../lib/query-job-feature.js'
36
44
  import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
37
45
  import { getTranscriptionPolicySnapshot } from '../lib/transcription-policy.js'
@@ -164,6 +172,11 @@ healthRouter.get('/health', async (_req, res) => {
164
172
  // capabilities.liveCues so the two surfaces can never disagree.
165
173
  const liveCues = liveCuesCapability()
166
174
  const richMedia = await getRichMediaProcessingCapabilities()
175
+ // Upload limits are published so the client never carries its own byte caps:
176
+ // cos-glasses-app and cos-glasses-server are separate repos that have already
177
+ // diverged, so a constant in both is guaranteed to drift. Absent means an
178
+ // older server, and the client falls back to single-shot.
179
+ const mediaLimits = await getMediaLimits({ chunkedUploadEnabled: MEDIA_CHUNKED_UPLOAD_ENABLED })
167
180
  const features = {
168
181
  claude: claudeAvailable,
169
182
  codex: codexAvailable,
@@ -265,6 +278,7 @@ healthRouter.get('/health', async (_req, res) => {
265
278
  server_instance_id: getServerInstanceId(),
266
279
  boot_id: serverMetrics.bootId,
267
280
  generation_id: getServerGenerationId(),
281
+ mediaLimits,
268
282
  features,
269
283
  voice,
270
284
  readiness,
@@ -305,7 +319,11 @@ healthRouter.get('/health', async (_req, res) => {
305
319
  pdf: richMedia.pdf,
306
320
  video: richMedia.video,
307
321
  maxAttachments: 5,
308
- maxBytesPerAttachment: 64 * 1024 * 1024,
322
+ // Sourced from the constants rather than restated here: video now has a
323
+ // HIGHER cap than everything else, so a single hardcoded number on this
324
+ // surface would tell a client to refuse a video the server accepts.
325
+ maxBytesPerAttachment: MAX_OTHER_MEDIA_BYTES,
326
+ maxVideoBytesPerAttachment: MAX_VIDEO_MEDIA_BYTES,
309
327
  maxVideoMinutes: 20,
310
328
  maxStillFrames: 8,
311
329
  },