@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
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,43 @@
|
|
|
1
|
+
## 6.25.0
|
|
2
|
+
|
|
3
|
+
Large video uploads: a 100 MiB cap, streamed to disk, compressed in the background.
|
|
4
|
+
|
|
5
|
+
- Video attachments may now be up to 100 MiB. Images and documents stay at 64 MiB, and
|
|
6
|
+
the kind is decided from the file's magic bytes rather than its declared Content-Type,
|
|
7
|
+
so a declared video type cannot buy the larger ceiling.
|
|
8
|
+
- Uploads no longer buffer in memory. The body streams into the existing staging
|
|
9
|
+
directory and moves into place through the hardened atomic rename, with the byte
|
|
10
|
+
ceiling enforced during the stream so an oversized body is refused about one chunk
|
|
11
|
+
past the limit instead of after landing in full. `GET /api/media/:id/content` is
|
|
12
|
+
streamed for the same reason — raising the cap had otherwise taken that route's peak
|
|
13
|
+
allocation from 64 MiB to 100 MiB per concurrent download.
|
|
14
|
+
- `requestTimeout` is now explicit at 900s on both listeners. Node's 300s default was
|
|
15
|
+
invisible at 64 MiB but would have destroyed a 100 MiB upload's socket roughly 140
|
|
16
|
+
seconds before the client's own deadline, breaking exactly the size band this enables.
|
|
17
|
+
900s is the client's own ceiling, so the client always gives up first and can report a
|
|
18
|
+
real diagnostic instead of an opaque network error.
|
|
19
|
+
- Stored videos are compressed in the background with `libx265 -crf 30`, measured at
|
|
20
|
+
2.7x smaller and SSIM 0.969 on a real 4K 30fps upload. Resolution and frame rate are
|
|
21
|
+
never reduced, because that is what later frame-by-frame review depends on and
|
|
22
|
+
upscaling cannot recover it. An encode that is not smaller, or that fails, or that
|
|
23
|
+
changes the geometry, leaves the original in place — there is no path where the only
|
|
24
|
+
copy is lost. Requires ffmpeg and ffprobe; without them the original is simply kept.
|
|
25
|
+
- `GET /api/health` publishes `mediaLimits`, so the phone no longer hardcodes a byte cap
|
|
26
|
+
that can drift from what this server will actually accept. Chunked upload is
|
|
27
|
+
advertised as unavailable because its endpoints are not mounted yet.
|
|
28
|
+
|
|
29
|
+
## 6.24.5
|
|
30
|
+
|
|
31
|
+
COS Control provider proofs now isolate themselves from project customizations.
|
|
32
|
+
|
|
33
|
+
- Claude Code 2.1.227 began rejecting the automated readiness check in large COS
|
|
34
|
+
workspaces because it loaded project instructions, skills, plugins, and attachment
|
|
35
|
+
context before evaluating the tiny proof prompt. The request exceeded 200k tokens
|
|
36
|
+
and exited 1 even though Claude authentication and normal commands were healthy.
|
|
37
|
+
- The no-tool readiness subprocess now uses Claude Safe Mode. It still proves the
|
|
38
|
+
installed CLI, authentication, model access, process lifecycle, and exact response,
|
|
39
|
+
while avoiding unrelated workspace context. Normal glasses queries are unchanged.
|
|
40
|
+
|
|
1
41
|
## 6.24.4
|
|
2
42
|
|
|
3
43
|
Rich-media attachments extend the established authenticated photo pipeline without
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.25.0",
|
|
4
4
|
"description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/server/index.ts
CHANGED
|
@@ -192,6 +192,20 @@ app.use('/api', (req, res, next) => {
|
|
|
192
192
|
})
|
|
193
193
|
|
|
194
194
|
try {
|
|
195
|
+
// KNOWN HAZARD, deliberately not fixed here (2026-08-11). This lease is held for
|
|
196
|
+
// the WHOLE request, including the body transfer. Every other mutation is
|
|
197
|
+
// sub-second, but POST /api/media/file now accepts up to 100 MiB, which the
|
|
198
|
+
// client itself budgets ~7.3 minutes for — far past COS Control's 90s drain
|
|
199
|
+
// timeout (main.swift waitForRestartProof). A drain that catches a large upload
|
|
200
|
+
// in flight will therefore hard-fail to Repair.
|
|
201
|
+
//
|
|
202
|
+
// Why no fix in this change: there is no per-kind budget map to declare a longer
|
|
203
|
+
// allowance against, and blocksRestart belongs to the meeting-sync surface rather
|
|
204
|
+
// than a generic active-work registry, so a new 'media_upload' kind would be a
|
|
205
|
+
// label with no behaviour — a false signal that the case is handled. The correct
|
|
206
|
+
// fix is to scope this lease to the INGEST (fast: validate + index write) instead
|
|
207
|
+
// of the network transfer, which means changing a fail-closed middleware that
|
|
208
|
+
// guards every mutation. That needs its own pass and its own tests.
|
|
195
209
|
const lease = acquireMaintenanceWork('api_mutation', {
|
|
196
210
|
allowDuringDrain: controllerProof,
|
|
197
211
|
})
|
|
@@ -333,20 +347,48 @@ process.on('unhandledRejection', (reason: any) => {
|
|
|
333
347
|
// Start HTTPS alongside HTTP — Even Hub WebView prefers HTTPS (iOS ATS).
|
|
334
348
|
// Optional: drop cert.pem + key.pem in server/certs/ (e.g. via mkcert) to enable.
|
|
335
349
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
350
|
+
/**
|
|
351
|
+
* Let a slow large upload finish instead of killing its socket mid-body.
|
|
352
|
+
*
|
|
353
|
+
* Node's default `requestTimeout` is 300s. That was invisible while the cap was
|
|
354
|
+
* 64 MiB, which needs ~262s at the throughput the CLIENT itself budgets for
|
|
355
|
+
* (UPLOAD_FLOOR_BYTES_PER_SEC = 250 KiB/s, cos-glasses-app shared/media-attachment.ts).
|
|
356
|
+
* Raising the video cap to 100 MiB pushes the worst case to ~7.3 minutes, so the default
|
|
357
|
+
* would have destroyed the socket roughly 140 seconds BEFORE the client's own deadline
|
|
358
|
+
* expired — breaking exactly the size band the raise exists to enable, and surfacing as
|
|
359
|
+
* an opaque network error instead of a timeout that states its budget.
|
|
360
|
+
*
|
|
361
|
+
* 900_000 is deliberately the client's UPLOAD_TIMEOUT_CEILING_MS, so the two repos agree
|
|
362
|
+
* by construction: the client always gives up first and gets to report the diagnostic.
|
|
363
|
+
*
|
|
364
|
+
* Wrapped at each createServer call rather than looped over `listeners`, because
|
|
365
|
+
* RequiredListener types `server` as the base net.Server, which has no requestTimeout.
|
|
366
|
+
* The generic constraint makes a server that cannot carry the timeout a compile error
|
|
367
|
+
* instead of a silent no-op or a cast that hides the HTTP-specific dependency.
|
|
368
|
+
*
|
|
369
|
+
* headersTimeout stays at its default — headers arrive immediately even on a slow body,
|
|
370
|
+
* so shortening their window is the wrong lever.
|
|
371
|
+
*/
|
|
372
|
+
const MAX_REQUEST_MS = 900_000
|
|
373
|
+
function withRequestTimeout<T extends { requestTimeout: number }>(server: T): T {
|
|
374
|
+
server.requestTimeout = MAX_REQUEST_MS
|
|
375
|
+
return server
|
|
376
|
+
}
|
|
377
|
+
|
|
336
378
|
const HTTPS_PORT = parseInt(process.env.HTTPS_PORT ?? '3143', 10)
|
|
337
379
|
const certDir = path.join(__dirname, 'certs')
|
|
338
380
|
const listeners: RequiredListener[] = []
|
|
339
381
|
if (existsSync(path.join(certDir, 'cert.pem'))) {
|
|
340
|
-
const httpsServer = createHttpsServer({
|
|
382
|
+
const httpsServer = withRequestTimeout(createHttpsServer({
|
|
341
383
|
cert: readFileSync(path.join(certDir, 'cert.pem')),
|
|
342
384
|
key: readFileSync(path.join(certDir, 'key.pem')),
|
|
343
|
-
}, app)
|
|
385
|
+
}, app))
|
|
344
386
|
listeners.push({ server: httpsServer, port: HTTPS_PORT, host: BIND_HOST, label: 'HTTPS' })
|
|
345
387
|
} else {
|
|
346
388
|
console.log('[COS API] No certs found — HTTPS disabled (drop cert.pem/key.pem in server/certs to enable)')
|
|
347
389
|
}
|
|
348
390
|
|
|
349
|
-
const httpServer = createHttpServer(app)
|
|
391
|
+
const httpServer = withRequestTimeout(createHttpServer(app))
|
|
350
392
|
listeners.push({ server: httpServer, port: PORT, host: BIND_HOST, label: 'HTTP' })
|
|
351
393
|
|
|
352
394
|
listenRequiredServers(listeners).then(() => {
|
|
@@ -20,12 +20,14 @@
|
|
|
20
20
|
|
|
21
21
|
import { createHash, randomBytes } from 'node:crypto'
|
|
22
22
|
import {
|
|
23
|
+
createReadStream,
|
|
23
24
|
existsSync,
|
|
24
25
|
mkdirSync,
|
|
25
26
|
readdirSync,
|
|
26
27
|
readFileSync,
|
|
27
28
|
renameSync,
|
|
28
29
|
rmSync,
|
|
30
|
+
statSync,
|
|
29
31
|
writeFileSync,
|
|
30
32
|
} from 'node:fs'
|
|
31
33
|
import { join, resolve, sep } from 'node:path'
|
|
@@ -51,7 +53,11 @@ import {
|
|
|
51
53
|
sniffImageType,
|
|
52
54
|
validateSourceImage,
|
|
53
55
|
} from './image-safety.js'
|
|
54
|
-
import {
|
|
56
|
+
import {
|
|
57
|
+
prepareRichMediaFromFile,
|
|
58
|
+
type PreparedRichMediaFile,
|
|
59
|
+
} from './rich-media-safety.js'
|
|
60
|
+
import { VIDEO_COMPRESSION_LABEL, compressVideoFile } from './video-compression.js'
|
|
55
61
|
|
|
56
62
|
// Standalone state belongs under the same durable data root as conversations,
|
|
57
63
|
// archives, and run ledgers. COS_MEDIA_ROOT remains an explicit escape hatch
|
|
@@ -108,6 +114,24 @@ async function renameWithTransientRetry(
|
|
|
108
114
|
}
|
|
109
115
|
}
|
|
110
116
|
|
|
117
|
+
/** The compressor's contract is enforced by the compiler, not mirrored here:
|
|
118
|
+
* this alias is what the constructor's test seam has to satisfy. */
|
|
119
|
+
export type CompressVideoFile = typeof compressVideoFile
|
|
120
|
+
export type { CompressionResult, CompressionStatus } from './video-compression.js'
|
|
121
|
+
|
|
122
|
+
/** Hash without holding the file in memory — a 100 MB video would otherwise
|
|
123
|
+
* reintroduce exactly the allocation the streaming upload path removed. */
|
|
124
|
+
async function sha256OfFile(path: string): Promise<string> {
|
|
125
|
+
const hash = createHash('sha256')
|
|
126
|
+
await new Promise<void>((resolveHash, rejectHash) => {
|
|
127
|
+
const stream = createReadStream(path)
|
|
128
|
+
stream.on('data', chunk => hash.update(chunk))
|
|
129
|
+
stream.once('error', rejectHash)
|
|
130
|
+
stream.once('end', () => resolveHash())
|
|
131
|
+
})
|
|
132
|
+
return hash.digest('hex')
|
|
133
|
+
}
|
|
134
|
+
|
|
111
135
|
/** Test seam for the File Provider rename recovery contract. */
|
|
112
136
|
export async function _renameWithTransientRetryForTests(
|
|
113
137
|
source: string,
|
|
@@ -120,6 +144,17 @@ export async function _renameWithTransientRetryForTests(
|
|
|
120
144
|
|
|
121
145
|
export type MediaLifecycle = 'staged' | 'reserved' | 'associated' | 'expired' | 'deleted'
|
|
122
146
|
|
|
147
|
+
export interface VideoCompressionRecord {
|
|
148
|
+
label: string
|
|
149
|
+
/** Byte count as uploaded, before the encode. */
|
|
150
|
+
originalBytes: number
|
|
151
|
+
/** Byte count now stored. Always smaller — the module returns
|
|
152
|
+
* `skipped_not_smaller` otherwise, and two measured settings really did
|
|
153
|
+
* produce files LARGER than the source. */
|
|
154
|
+
bytes: number
|
|
155
|
+
atMs: number
|
|
156
|
+
}
|
|
157
|
+
|
|
123
158
|
export interface MediaRecord {
|
|
124
159
|
ref: MediaAttachmentRef
|
|
125
160
|
/** Relative to the media root. Never exposed through the API. */
|
|
@@ -131,6 +166,9 @@ export interface MediaRecord {
|
|
|
131
166
|
bytes: number
|
|
132
167
|
sha256: string
|
|
133
168
|
lifecycle: MediaLifecycle
|
|
169
|
+
/** Set once the background x265 pass replaced the stored original with a
|
|
170
|
+
* smaller file. Kept so the size drop is explainable and never retried. */
|
|
171
|
+
videoCompression?: VideoCompressionRecord
|
|
134
172
|
/** True once asset bytes were removed (content TTL or GC) while the
|
|
135
173
|
* metadata record remains (e.g. expired traffic frames). */
|
|
136
174
|
contentRemoved?: boolean
|
|
@@ -183,12 +221,42 @@ export interface IngestRichMediaInput {
|
|
|
183
221
|
sessionId?: string
|
|
184
222
|
}
|
|
185
223
|
|
|
224
|
+
/** A streamed upload that already landed in tmp/. Ingest MOVES this file into
|
|
225
|
+
* the asset directory, so the bytes are written exactly once. */
|
|
226
|
+
export interface IngestRichMediaFileInput {
|
|
227
|
+
sourcePath: string
|
|
228
|
+
byteLength: number
|
|
229
|
+
label?: string
|
|
230
|
+
declaredMime?: string
|
|
231
|
+
capturedAt?: string
|
|
232
|
+
sessionId?: string
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Handle for a streaming upload's staging file. `dispose()` is idempotent and
|
|
236
|
+
* becomes a no-op once ingest has moved the file out. */
|
|
237
|
+
export interface MediaStagingFile {
|
|
238
|
+
path: string
|
|
239
|
+
dispose: () => void
|
|
240
|
+
}
|
|
241
|
+
|
|
186
242
|
export type MediaContentResult =
|
|
187
243
|
| { status: 'ok'; path: string; mime: MediaMime; bytes: number }
|
|
188
244
|
| { status: 'not_found' }
|
|
189
245
|
| { status: 'expired' }
|
|
190
246
|
| { status: 'unavailable' }
|
|
191
247
|
|
|
248
|
+
/** Provenance only. A malformed value is dropped rather than invalidating the
|
|
249
|
+
* whole record — losing the note is survivable, losing the asset is not. */
|
|
250
|
+
function sanitizeVideoCompression(raw: unknown): VideoCompressionRecord | null {
|
|
251
|
+
if (!raw || typeof raw !== 'object') return null
|
|
252
|
+
const r = raw as Record<string, unknown>
|
|
253
|
+
const positiveInt = (value: unknown): value is number =>
|
|
254
|
+
typeof value === 'number' && Number.isSafeInteger(value) && value > 0
|
|
255
|
+
if (typeof r.label !== 'string' || r.label.length === 0 || r.label.length > 64) return null
|
|
256
|
+
if (!positiveInt(r.originalBytes) || !positiveInt(r.bytes) || !positiveInt(r.atMs)) return null
|
|
257
|
+
return { label: r.label.slice(0, 64), originalBytes: r.originalBytes, bytes: r.bytes, atMs: r.atMs }
|
|
258
|
+
}
|
|
259
|
+
|
|
192
260
|
function sanitizeRecord(raw: unknown): MediaRecord | null {
|
|
193
261
|
if (!raw || typeof raw !== 'object') return null
|
|
194
262
|
const r = raw as Record<string, unknown>
|
|
@@ -208,6 +276,7 @@ function sanitizeRecord(raw: unknown): MediaRecord | null {
|
|
|
208
276
|
const derivativePaths = Array.isArray(r.derivativePaths)
|
|
209
277
|
? r.derivativePaths.filter(isOwnedPath).slice(0, 8)
|
|
210
278
|
: undefined
|
|
279
|
+
const videoCompression = sanitizeVideoCompression(r.videoCompression)
|
|
211
280
|
return {
|
|
212
281
|
ref,
|
|
213
282
|
storagePath: r.storagePath,
|
|
@@ -217,6 +286,7 @@ function sanitizeRecord(raw: unknown): MediaRecord | null {
|
|
|
217
286
|
bytes: typeof r.bytes === 'number' && r.bytes >= 0 ? r.bytes : 0,
|
|
218
287
|
sha256: typeof r.sha256 === 'string' ? r.sha256 : '',
|
|
219
288
|
lifecycle,
|
|
289
|
+
...(videoCompression ? { videoCompression } : {}),
|
|
220
290
|
contentRemoved: r.contentRemoved === true,
|
|
221
291
|
...(typeof r.sessionId === 'string' ? { sessionId: r.sessionId } : {}),
|
|
222
292
|
...(typeof r.clientQueueItemId === 'string' ? { clientQueueItemId: r.clientQueueItemId } : {}),
|
|
@@ -236,8 +306,11 @@ export class MediaStore {
|
|
|
236
306
|
private readonly root: string
|
|
237
307
|
private readonly records = new Map<string, MediaRecord>()
|
|
238
308
|
private readonly renderLensVariant: typeof renderG2Variant
|
|
309
|
+
private readonly compressVideo: CompressVideoFile
|
|
239
310
|
/** One cold-cache render per media id. Callers share the same promise. */
|
|
240
311
|
private readonly g2InFlight = new Map<string, Promise<MediaContentResult>>()
|
|
312
|
+
/** Background x265 passes, keyed by media id. Never awaited by a request. */
|
|
313
|
+
private readonly compressionJobs = new Map<string, Promise<void>>()
|
|
241
314
|
/** A corrupt/unreadable index makes the asset directory authoritative only
|
|
242
315
|
* for recovery. Never classify its entries as disposable orphans that boot. */
|
|
243
316
|
private allowOrphanCleanup = true
|
|
@@ -247,10 +320,16 @@ export class MediaStore {
|
|
|
247
320
|
|
|
248
321
|
constructor(
|
|
249
322
|
root: string = DEFAULT_MEDIA_ROOT,
|
|
250
|
-
dependencies: {
|
|
323
|
+
dependencies: {
|
|
324
|
+
renderG2Variant?: typeof renderG2Variant
|
|
325
|
+
/** Injected in tests so each compression outcome can be driven without
|
|
326
|
+
* running a real x265 encode. */
|
|
327
|
+
compressVideoFile?: CompressVideoFile
|
|
328
|
+
} = {},
|
|
251
329
|
) {
|
|
252
330
|
this.root = root
|
|
253
331
|
this.renderLensVariant = dependencies.renderG2Variant ?? renderG2Variant
|
|
332
|
+
this.compressVideo = dependencies.compressVideoFile ?? compressVideoFile
|
|
254
333
|
this.ensureDirs()
|
|
255
334
|
this.loadIndex()
|
|
256
335
|
this.reconcile()
|
|
@@ -417,19 +496,57 @@ export class MediaStore {
|
|
|
417
496
|
return this.publishNormalizedImage(input, normalized)
|
|
418
497
|
}
|
|
419
498
|
|
|
420
|
-
/**
|
|
421
|
-
*
|
|
422
|
-
|
|
423
|
-
|
|
499
|
+
/** Reserve a private staging file for a streaming upload. Uploads have always
|
|
500
|
+
* staged in tmp/ before the atomic move into assets/; a streamed body uses
|
|
501
|
+
* the same convention rather than inventing a second one. */
|
|
502
|
+
createStagingFile(): MediaStagingFile {
|
|
503
|
+
// tmp/ is emptied by boot reconcile, so recreate defensively rather than
|
|
504
|
+
// trusting a directory that existed at construction time.
|
|
505
|
+
this.ensureDirs()
|
|
506
|
+
const path = join(this.root, 'tmp', `upload-${randomBytes(12).toString('hex')}.bin`)
|
|
507
|
+
return {
|
|
508
|
+
path,
|
|
509
|
+
dispose: () => {
|
|
510
|
+
try { rmSync(path, { force: true }) } catch { /* already moved or gone */ }
|
|
511
|
+
},
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Authenticated user document/video ingress from a streamed staging file.
|
|
516
|
+
* Validation and derivative generation happen before the serialized index
|
|
517
|
+
* publication; the staged file is MOVED into the asset dir, never copied. */
|
|
518
|
+
async ingestRichMediaFromFile(input: IngestRichMediaFileInput): Promise<MediaAttachmentRef> {
|
|
519
|
+
const prepared = await prepareRichMediaFromFile(input.sourcePath, {
|
|
424
520
|
label: input.label,
|
|
425
521
|
declaredMime: input.declaredMime,
|
|
522
|
+
byteLength: input.byteLength,
|
|
426
523
|
})
|
|
427
524
|
return this.publishPreparedRichMedia(input, prepared)
|
|
428
525
|
}
|
|
429
526
|
|
|
527
|
+
/** In-memory ingress for callers that already hold the bytes. Stages them in
|
|
528
|
+
* tmp/ and joins the single file-based path above — a second publish path
|
|
529
|
+
* would be a second place for the invariants to rot. */
|
|
530
|
+
async ingestRichMedia(input: IngestRichMediaInput): Promise<MediaAttachmentRef> {
|
|
531
|
+
const staged = this.createStagingFile()
|
|
532
|
+
try {
|
|
533
|
+
writeFileSync(staged.path, input.bytes, { mode: 0o600 })
|
|
534
|
+
return await this.ingestRichMediaFromFile({
|
|
535
|
+
sourcePath: staged.path,
|
|
536
|
+
byteLength: input.bytes.length,
|
|
537
|
+
label: input.label,
|
|
538
|
+
declaredMime: input.declaredMime,
|
|
539
|
+
capturedAt: input.capturedAt,
|
|
540
|
+
sessionId: input.sessionId,
|
|
541
|
+
})
|
|
542
|
+
} finally {
|
|
543
|
+
staged.dispose()
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
430
547
|
private async publishPreparedRichMedia(
|
|
431
|
-
input:
|
|
432
|
-
prepared:
|
|
548
|
+
input: { label?: string; capturedAt?: string; sessionId?: string },
|
|
549
|
+
prepared: PreparedRichMediaFile,
|
|
433
550
|
): Promise<MediaAttachmentRef> {
|
|
434
551
|
const id = `m_${randomBytes(12).toString('hex')}`
|
|
435
552
|
const now = Date.now()
|
|
@@ -449,7 +566,7 @@ export class MediaStore {
|
|
|
449
566
|
width: prepared.category === 'video' ? prepared.width : 1,
|
|
450
567
|
height: prepared.category === 'video' ? prepared.height : 1,
|
|
451
568
|
createdAt: nowIso,
|
|
452
|
-
bytes: prepared.
|
|
569
|
+
bytes: prepared.originalBytes,
|
|
453
570
|
...(prepared.category === 'video'
|
|
454
571
|
? { durationMs: prepared.durationMs, frameCount: prepared.frames.length }
|
|
455
572
|
: {
|
|
@@ -464,8 +581,13 @@ export class MediaStore {
|
|
|
464
581
|
mkdirSync(stageDir, { recursive: true, mode: 0o700 })
|
|
465
582
|
const originalName = `original.${extension}`
|
|
466
583
|
const derivativeNames: string[] = []
|
|
584
|
+
let sha256: string
|
|
467
585
|
try {
|
|
468
|
-
|
|
586
|
+
// Hash by streaming, then MOVE the staged upload in. Both live under
|
|
587
|
+
// tmp/, so this is a same-volume rename — the alternative is a second
|
|
588
|
+
// full write of a body that can be 100 MB.
|
|
589
|
+
sha256 = await sha256OfFile(prepared.originalPath)
|
|
590
|
+
await renameWithTransientRetry(prepared.originalPath, join(stageDir, originalName))
|
|
469
591
|
if (prepared.category === 'document') {
|
|
470
592
|
writeFileSync(join(stageDir, 'content.txt'), prepared.extractedText, { mode: 0o600 })
|
|
471
593
|
}
|
|
@@ -487,8 +609,8 @@ export class MediaStore {
|
|
|
487
609
|
thumbPath: derivativePaths[0] ?? storagePath,
|
|
488
610
|
...(prepared.category === 'document' ? { textPath: join('assets', id, 'content.txt') } : {}),
|
|
489
611
|
...(derivativePaths.length ? { derivativePaths } : {}),
|
|
490
|
-
bytes: prepared.
|
|
491
|
-
sha256
|
|
612
|
+
bytes: prepared.originalBytes,
|
|
613
|
+
sha256,
|
|
492
614
|
lifecycle: 'staged',
|
|
493
615
|
...(input.sessionId ? { sessionId: input.sessionId } : {}),
|
|
494
616
|
createdAtMs: now,
|
|
@@ -498,9 +620,107 @@ export class MediaStore {
|
|
|
498
620
|
this.records.set(id, record)
|
|
499
621
|
this.saveIndex()
|
|
500
622
|
})
|
|
623
|
+
// AFTER publication and outside the lock: x265 encodes at roughly real
|
|
624
|
+
// time, so awaiting it here would hold the upload response open for the
|
|
625
|
+
// length of the video.
|
|
626
|
+
if (prepared.category === 'video') this.scheduleVideoCompression(id)
|
|
501
627
|
return ref
|
|
502
628
|
}
|
|
503
629
|
|
|
630
|
+
// ── Background video compression ───────────────────────────────────────────
|
|
631
|
+
|
|
632
|
+
private scheduleVideoCompression(id: string): void {
|
|
633
|
+
const job = (async () => {
|
|
634
|
+
try {
|
|
635
|
+
await this.compressVideoAsset(id)
|
|
636
|
+
} catch (err) {
|
|
637
|
+
// A failed encode is a non-event: the original is still the asset.
|
|
638
|
+
console.error(`[media-store] video compression failed for ${id}:`, err)
|
|
639
|
+
}
|
|
640
|
+
})()
|
|
641
|
+
this.compressionJobs.set(id, job)
|
|
642
|
+
void job.then(() => {
|
|
643
|
+
if (this.compressionJobs.get(id) === job) this.compressionJobs.delete(id)
|
|
644
|
+
})
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** Encode a published video smaller in place. Every exit other than a
|
|
648
|
+
* validated smaller output leaves the original untouched, and the swap is a
|
|
649
|
+
* rename, so there is no window in which the only copy is missing. */
|
|
650
|
+
private async compressVideoAsset(id: string): Promise<void> {
|
|
651
|
+
const before = this.getRecord(id)
|
|
652
|
+
if (!before || before.videoCompression) return
|
|
653
|
+
if (mediaCategoryOf(before.ref) !== 'video') return
|
|
654
|
+
if (before.lifecycle === 'deleted' || before.lifecycle === 'expired' || before.contentRemoved) return
|
|
655
|
+
const compress = this.compressVideo
|
|
656
|
+
const inputPath = this.absPath(before.storagePath)
|
|
657
|
+
if (!existsSync(inputPath)) return
|
|
658
|
+
|
|
659
|
+
// workDir sits under the media root so the output rename into assets/ is a
|
|
660
|
+
// same-volume atomic replace; a cross-device rename fails EXDEV.
|
|
661
|
+
const workDir = join(this.root, 'tmp', `compress-${id}-${randomBytes(6).toString('hex')}`)
|
|
662
|
+
mkdirSync(workDir, { recursive: true, mode: 0o700 })
|
|
663
|
+
try {
|
|
664
|
+
const result = await compress(inputPath, workDir)
|
|
665
|
+
if (result.status !== 'compressed' || !result.outputPath) {
|
|
666
|
+
console.log(
|
|
667
|
+
`[media-store] video compression ${result.status} for ${id}` +
|
|
668
|
+
`${result.reason ? ` (${result.reason})` : ''}`,
|
|
669
|
+
)
|
|
670
|
+
return
|
|
671
|
+
}
|
|
672
|
+
if (!existsSync(result.outputPath)) {
|
|
673
|
+
console.error(`[media-store] video compression reported 'compressed' for ${id} with no output file`)
|
|
674
|
+
return
|
|
675
|
+
}
|
|
676
|
+
const compressedBytes = statSync(result.outputPath).size
|
|
677
|
+
// Trust-but-verify the module's own contract. An encode that is not
|
|
678
|
+
// smaller is a failure, not a result — two measured settings inflated
|
|
679
|
+
// the file — and swapping one in would cost storage for nothing.
|
|
680
|
+
if (compressedBytes <= 0 || compressedBytes >= before.bytes) {
|
|
681
|
+
console.warn(
|
|
682
|
+
`[media-store] discarding compression output for ${id}: ` +
|
|
683
|
+
`${compressedBytes} bytes vs original ${before.bytes}`,
|
|
684
|
+
)
|
|
685
|
+
return
|
|
686
|
+
}
|
|
687
|
+
const sha256 = await sha256OfFile(result.outputPath)
|
|
688
|
+
const outputPath = result.outputPath
|
|
689
|
+
await this.withLock(async () => {
|
|
690
|
+
// Compression is asynchronous, so a delete/release/GC may have landed
|
|
691
|
+
// while it ran. Re-check before publishing so removed bytes are never
|
|
692
|
+
// resurrected.
|
|
693
|
+
const rec = this.records.get(id)
|
|
694
|
+
if (!rec || rec.lifecycle === 'deleted' || rec.lifecycle === 'expired' || rec.contentRemoved) return
|
|
695
|
+
if (rec.storagePath !== before.storagePath || !existsSync(inputPath)) return
|
|
696
|
+
await renameWithTransientRetry(outputPath, inputPath)
|
|
697
|
+
rec.videoCompression = {
|
|
698
|
+
label: VIDEO_COMPRESSION_LABEL,
|
|
699
|
+
originalBytes: rec.bytes,
|
|
700
|
+
bytes: compressedBytes,
|
|
701
|
+
atMs: Date.now(),
|
|
702
|
+
}
|
|
703
|
+
rec.bytes = compressedBytes
|
|
704
|
+
rec.sha256 = sha256
|
|
705
|
+
// Keep the public size honest about what is actually stored.
|
|
706
|
+
rec.ref = { ...rec.ref, bytes: compressedBytes }
|
|
707
|
+
rec.updatedAtMs = Date.now()
|
|
708
|
+
this.saveIndex()
|
|
709
|
+
})
|
|
710
|
+
} finally {
|
|
711
|
+
try { rmSync(workDir, { recursive: true, force: true }) } catch { /* best effort */ }
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** Test seam: background compression is deliberately not awaited by ingest,
|
|
716
|
+
* so tests need a handle to settle it. */
|
|
717
|
+
async _awaitVideoCompressionForTests(id?: string): Promise<void> {
|
|
718
|
+
const jobs = id
|
|
719
|
+
? [this.compressionJobs.get(id)].filter((job): job is Promise<void> => job != null)
|
|
720
|
+
: [...this.compressionJobs.values()]
|
|
721
|
+
await Promise.all(jobs)
|
|
722
|
+
}
|
|
723
|
+
|
|
504
724
|
/** Trusted-local agent artifact ingress. Unlike the public upload path,
|
|
505
725
|
* this accepts a bounded larger JPEG/PNG/WebP/HEIC/AVIF and immediately
|
|
506
726
|
* converts it into the same normalized JPEG contract before publication. */
|
|
@@ -25,6 +25,12 @@ export interface ProcessResult {
|
|
|
25
25
|
aborted: boolean
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// A readiness proof must validate the installed/authenticated provider, not load
|
|
29
|
+
// the selected workspace's CLAUDE.md, skills, plugins, MCP servers, and other
|
|
30
|
+
// project customizations. Large COS workspaces can otherwise overflow Claude's
|
|
31
|
+
// context window before the tiny proof prompt is evaluated.
|
|
32
|
+
export const CLAUDE_SAFE_MODE_ENV = 'CLAUDE_CODE_SAFE_MODE'
|
|
33
|
+
|
|
28
34
|
type TerminationReason = 'timeout' | 'abort' | null
|
|
29
35
|
|
|
30
36
|
export function runBounded(
|
|
@@ -41,6 +47,7 @@ export function runBounded(
|
|
|
41
47
|
}
|
|
42
48
|
const env = { ...process.env }
|
|
43
49
|
delete env.CLAUDECODE
|
|
50
|
+
env[CLAUDE_SAFE_MODE_ENV] = '1'
|
|
44
51
|
const child = spawn(command, args, {
|
|
45
52
|
cwd: cosBrainDir() ?? process.cwd(),
|
|
46
53
|
env,
|