@gotcos/glasses-server 6.24.4 → 6.25.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.
- package/CHANGELOG.md +40 -0
- package/package.json +1 -1
- package/server/index.ts +45 -3
- package/server/lib/media-store.ts +232 -12
- package/server/lib/provider-proof.ts +7 -0
- package/server/lib/rich-media-safety.ts +175 -44
- package/server/lib/video-compression.ts +376 -0
- package/server/routes/health.ts +20 -2
- package/server/routes/media.ts +195 -16
|
@@ -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
|
+
}
|
package/server/routes/health.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
},
|