@gotcos/glasses-server 6.3.1 → 6.5.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/.env.example +18 -7
- package/CHANGELOG.md +83 -0
- package/README.md +24 -8
- package/bin/cli.cjs +22 -10
- package/package.json +18 -6
- package/server/bin/cos-output-image-publisher.mjs +324 -0
- package/server/bootstrap.ts +16 -0
- package/server/index.ts +57 -15
- package/server/lib/activity-preview.ts +168 -0
- package/server/lib/archive.ts +20 -6
- package/server/lib/claude-bridge.ts +215 -60
- package/server/lib/claude-run-ledger.ts +7 -2
- package/server/lib/codex-bridge.ts +186 -71
- package/server/lib/codex-engine-sessions.ts +24 -2
- package/server/lib/codex-model-catalog.ts +450 -0
- package/server/lib/codex-run-ledger.ts +20 -4
- package/server/lib/conversation.ts +64 -2
- package/server/lib/image-safety.ts +458 -0
- package/server/lib/listener-startup.ts +29 -0
- package/server/lib/media-store.ts +833 -0
- package/server/lib/model-image-input.ts +27 -0
- package/server/lib/model-router.ts +67 -8
- package/server/lib/query-attachments.ts +132 -0
- package/server/lib/run-output-images.ts +442 -0
- package/server/lib/server-instance-lock.ts +122 -0
- package/server/routes/health.ts +17 -1
- package/server/routes/media.ts +285 -0
- package/server/routes/message-ref.ts +18 -6
- package/server/routes/openai-compat.ts +44 -11
- package/server/routes/query.ts +51 -16
- package/server/routes/sessions.ts +33 -4
- package/shared/media-attachment.ts +126 -0
- package/shared/model-preference.ts +140 -17
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
// Image ingestion safety — every byte that enters the media store passes
|
|
2
|
+
// through here. Magic-byte type detection (never trust MIME or filename),
|
|
3
|
+
// header dimension parsing (decompression-bomb defense BEFORE any decode),
|
|
4
|
+
// strict base64, and ffmpeg normalization (EXIF/metadata stripped, bounded
|
|
5
|
+
// output size, deterministic JPEG profile).
|
|
6
|
+
//
|
|
7
|
+
// ffmpeg runs via spawn with an argument array — never a shell string — with
|
|
8
|
+
// -nostdin, a hard timeout, bounded stderr, and private tmp paths. If ffmpeg
|
|
9
|
+
// is unavailable the caller gets a typed `media_processing_unavailable`
|
|
10
|
+
// failure; unnormalized originals are NEVER stored as a fallback.
|
|
11
|
+
|
|
12
|
+
import { spawn } from 'node:child_process'
|
|
13
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import { tmpdir } from 'node:os'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
import type { MediaMime } from '../../shared/media-attachment.js'
|
|
17
|
+
|
|
18
|
+
// ── Limits (Release A contract) ──────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export const MAX_IMAGE_BYTES = 2 * 1024 * 1024 // 2 MiB decoded per source image
|
|
21
|
+
// Agent artifacts are often lossless PNG/WebP/HEIC/AVIF and can be larger
|
|
22
|
+
// than the phone-upload contract before COS re-encodes them. This separate,
|
|
23
|
+
// trusted-local boundary stays bounded and still probes dimensions before a
|
|
24
|
+
// full decode; normalized bytes return to the original JPEG contract.
|
|
25
|
+
export const MAX_OUTPUT_ARTIFACT_BYTES = 16 * 1024 * 1024
|
|
26
|
+
export const MAX_BATCH_BYTES = 8 * 1024 * 1024 // 8 MiB decoded per request
|
|
27
|
+
export const MAX_MEGAPIXELS = 16_000_000 // 16 MP per source image
|
|
28
|
+
export const NORMALIZED_MAX_EDGE = 1024 // longest edge after normalization
|
|
29
|
+
export const THUMB_MAX_EDGE = 256
|
|
30
|
+
const FFMPEG_TIMEOUT_MS = 15_000
|
|
31
|
+
const FFMPEG_STDERR_CAP = 4096
|
|
32
|
+
|
|
33
|
+
// ── Typed failures ───────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export type ImageSafetyErrorCode =
|
|
36
|
+
| 'invalid_base64'
|
|
37
|
+
| 'unsupported_format'
|
|
38
|
+
| 'image_too_large'
|
|
39
|
+
| 'dimensions_too_large'
|
|
40
|
+
| 'corrupt_image'
|
|
41
|
+
| 'media_processing_unavailable'
|
|
42
|
+
| 'normalization_failed'
|
|
43
|
+
|
|
44
|
+
export class ImageSafetyError extends Error {
|
|
45
|
+
readonly code: ImageSafetyErrorCode
|
|
46
|
+
constructor(code: ImageSafetyErrorCode, message: string) {
|
|
47
|
+
super(message)
|
|
48
|
+
this.code = code
|
|
49
|
+
this.name = 'ImageSafetyError'
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Strict base64 ────────────────────────────────────────────────────────────
|
|
54
|
+
// Node's Buffer.from(b64) silently tolerates garbage; reject malformed input
|
|
55
|
+
// explicitly so a truncated/hostile payload can't half-decode into the store.
|
|
56
|
+
|
|
57
|
+
const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/
|
|
58
|
+
|
|
59
|
+
export function strictBase64Decode(b64: unknown): Buffer {
|
|
60
|
+
if (typeof b64 !== 'string' || b64.length === 0) {
|
|
61
|
+
throw new ImageSafetyError('invalid_base64', 'image data must be a non-empty base64 string')
|
|
62
|
+
}
|
|
63
|
+
const clean = b64.replace(/\s+/g, '')
|
|
64
|
+
if (clean.length % 4 !== 0 || !BASE64_RE.test(clean)) {
|
|
65
|
+
throw new ImageSafetyError('invalid_base64', 'malformed base64 image data')
|
|
66
|
+
}
|
|
67
|
+
const buf = Buffer.from(clean, 'base64')
|
|
68
|
+
// Round-trip length check catches embedded padding / truncation.
|
|
69
|
+
const expected = (clean.length / 4) * 3 - (clean.endsWith('==') ? 2 : clean.endsWith('=') ? 1 : 0)
|
|
70
|
+
if (buf.length !== expected) {
|
|
71
|
+
throw new ImageSafetyError('invalid_base64', 'base64 image data failed round-trip validation')
|
|
72
|
+
}
|
|
73
|
+
return buf
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Magic-byte type detection ────────────────────────────────────────────────
|
|
77
|
+
// JPEG: FF D8 FF. PNG: 89 50 4E 47 0D 0A 1A 0A. Everything else — SVG, GIF,
|
|
78
|
+
// PDF, HEIC, polyglots, data URIs — is rejected at this gate.
|
|
79
|
+
|
|
80
|
+
export function sniffImageType(buf: Buffer): MediaMime | null {
|
|
81
|
+
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg'
|
|
82
|
+
if (
|
|
83
|
+
buf.length >= 8 &&
|
|
84
|
+
buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47 &&
|
|
85
|
+
buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a
|
|
86
|
+
) return 'image/png'
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type OutputArtifactType = MediaMime | 'image/webp' | 'image/heic' | 'image/avif'
|
|
91
|
+
|
|
92
|
+
/** Magic-byte allowlist for already-local agent artifacts. Filenames and
|
|
93
|
+
* claimed MIME are never trusted. SVG/GIF/PDF remain rejected. */
|
|
94
|
+
export function sniffOutputArtifactType(buf: Buffer): OutputArtifactType | null {
|
|
95
|
+
const baseline = sniffImageType(buf)
|
|
96
|
+
if (baseline) return baseline
|
|
97
|
+
if (buf.length >= 12 && buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') {
|
|
98
|
+
return 'image/webp'
|
|
99
|
+
}
|
|
100
|
+
if (buf.length >= 12 && buf.toString('ascii', 4, 8) === 'ftyp') {
|
|
101
|
+
const declaredBoxSize = buf.readUInt32BE(0)
|
|
102
|
+
const boxEnd = Math.min(buf.length, declaredBoxSize >= 16 ? declaredBoxSize : 16, 256)
|
|
103
|
+
const brands: string[] = [buf.toString('ascii', 8, 12)]
|
|
104
|
+
for (let offset = 16; offset + 4 <= boxEnd; offset += 4) brands.push(buf.toString('ascii', offset, offset + 4))
|
|
105
|
+
if (brands.some((brand) => brand === 'avif' || brand === 'avis')) return 'image/avif'
|
|
106
|
+
if (brands.some((brand) => ['heic', 'heix', 'hevc', 'hevx', 'heim', 'heis', 'mif1', 'msf1'].includes(brand))) {
|
|
107
|
+
return 'image/heic'
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Header dimension parsing ─────────────────────────────────────────────────
|
|
114
|
+
// Read declared dimensions from the container header itself so the megapixel
|
|
115
|
+
// gate runs BEFORE any pixel decode (decompression-bomb defense).
|
|
116
|
+
|
|
117
|
+
export function parsePngDimensions(buf: Buffer): { width: number; height: number } | null {
|
|
118
|
+
// IHDR must be the first chunk: 8-byte signature, 4-byte len, 'IHDR', W, H.
|
|
119
|
+
if (buf.length < 24) return null
|
|
120
|
+
if (buf.toString('ascii', 12, 16) !== 'IHDR') return null
|
|
121
|
+
const width = buf.readUInt32BE(16)
|
|
122
|
+
const height = buf.readUInt32BE(20)
|
|
123
|
+
if (width === 0 || height === 0) return null
|
|
124
|
+
return { width, height }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function parseJpegDimensions(buf: Buffer): { width: number; height: number } | null {
|
|
128
|
+
// Walk JPEG markers to the first SOF0-SOF15 frame header (excluding
|
|
129
|
+
// DHT/DAC/RST which share the 0xC0 nibble but aren't frames).
|
|
130
|
+
let off = 2
|
|
131
|
+
while (off + 9 < buf.length) {
|
|
132
|
+
if (buf[off] !== 0xff) return null
|
|
133
|
+
const marker = buf[off + 1]
|
|
134
|
+
if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01) {
|
|
135
|
+
off += 2
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
const size = buf.readUInt16BE(off + 2)
|
|
139
|
+
if (size < 2) return null
|
|
140
|
+
const isSof = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc
|
|
141
|
+
if (isSof) {
|
|
142
|
+
if (off + 9 > buf.length) return null
|
|
143
|
+
const height = buf.readUInt16BE(off + 5)
|
|
144
|
+
const width = buf.readUInt16BE(off + 7)
|
|
145
|
+
if (width === 0 || height === 0) return null
|
|
146
|
+
return { width, height }
|
|
147
|
+
}
|
|
148
|
+
off += 2 + size
|
|
149
|
+
}
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function parseImageDimensions(buf: Buffer, mime: MediaMime): { width: number; height: number } | null {
|
|
154
|
+
return mime === 'image/png' ? parsePngDimensions(buf) : parseJpegDimensions(buf)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Pre-normalization validation ─────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
export interface ValidatedImage {
|
|
160
|
+
bytes: Buffer
|
|
161
|
+
mime: MediaMime
|
|
162
|
+
width: number
|
|
163
|
+
height: number
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Full ingestion gate for ONE source image: type sniff, size cap, header
|
|
167
|
+
* dimension consistency, megapixel bomb defense. Throws ImageSafetyError. */
|
|
168
|
+
export function validateSourceImage(bytes: Buffer): ValidatedImage {
|
|
169
|
+
if (bytes.length > MAX_IMAGE_BYTES) {
|
|
170
|
+
throw new ImageSafetyError('image_too_large', `image is ${bytes.length} bytes (max ${MAX_IMAGE_BYTES})`)
|
|
171
|
+
}
|
|
172
|
+
const mime = sniffImageType(bytes)
|
|
173
|
+
if (!mime) {
|
|
174
|
+
throw new ImageSafetyError('unsupported_format', 'only JPEG and PNG images are accepted')
|
|
175
|
+
}
|
|
176
|
+
const dims = parseImageDimensions(bytes, mime)
|
|
177
|
+
if (!dims) {
|
|
178
|
+
throw new ImageSafetyError('corrupt_image', 'image header has no readable dimensions')
|
|
179
|
+
}
|
|
180
|
+
if (dims.width * dims.height > MAX_MEGAPIXELS) {
|
|
181
|
+
throw new ImageSafetyError('dimensions_too_large', `${dims.width}x${dims.height} exceeds ${MAX_MEGAPIXELS / 1e6}MP limit`)
|
|
182
|
+
}
|
|
183
|
+
return { bytes, mime, width: dims.width, height: dims.height }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── ffmpeg availability ──────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
let ffmpegReady: boolean | null = null
|
|
189
|
+
|
|
190
|
+
/** Probe ffmpeg once per process. Health surfaces this as mediaProcessingReady. */
|
|
191
|
+
export async function isMediaProcessingReady(): Promise<boolean> {
|
|
192
|
+
if (ffmpegReady !== null) return ffmpegReady
|
|
193
|
+
ffmpegReady = await new Promise<boolean>((resolve) => {
|
|
194
|
+
let settled = false
|
|
195
|
+
const settle = (ok: boolean) => { if (!settled) { settled = true; resolve(ok) } }
|
|
196
|
+
try {
|
|
197
|
+
const proc = spawn('ffmpeg', ['-version'], { stdio: ['ignore', 'ignore', 'ignore'] })
|
|
198
|
+
const timer = setTimeout(() => { proc.kill('SIGKILL'); settle(false) }, 5_000)
|
|
199
|
+
proc.on('close', (code) => { clearTimeout(timer); settle(code === 0) })
|
|
200
|
+
proc.on('error', () => { clearTimeout(timer); settle(false) })
|
|
201
|
+
} catch {
|
|
202
|
+
settle(false)
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
return ffmpegReady
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Test hook — reset the cached probe result. */
|
|
209
|
+
export function _resetMediaProcessingProbe(): void {
|
|
210
|
+
ffmpegReady = null
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── ffmpeg normalization ─────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
function runFfmpeg(args: string[]): Promise<void> {
|
|
216
|
+
return new Promise((resolve, reject) => {
|
|
217
|
+
let stderr = ''
|
|
218
|
+
let settled = false
|
|
219
|
+
const settle = (err?: Error) => {
|
|
220
|
+
if (settled) return
|
|
221
|
+
settled = true
|
|
222
|
+
clearTimeout(timer)
|
|
223
|
+
if (err) reject(err)
|
|
224
|
+
else resolve()
|
|
225
|
+
}
|
|
226
|
+
const proc = spawn('ffmpeg', ['-nostdin', '-v', 'error', ...args], {
|
|
227
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
228
|
+
})
|
|
229
|
+
const timer = setTimeout(() => {
|
|
230
|
+
proc.kill('SIGKILL')
|
|
231
|
+
settle(new ImageSafetyError('normalization_failed', 'ffmpeg timed out'))
|
|
232
|
+
}, FFMPEG_TIMEOUT_MS)
|
|
233
|
+
proc.stderr.on('data', (chunk: Buffer) => {
|
|
234
|
+
if (stderr.length < FFMPEG_STDERR_CAP) stderr += chunk.toString().slice(0, FFMPEG_STDERR_CAP - stderr.length)
|
|
235
|
+
})
|
|
236
|
+
proc.on('close', (code) => {
|
|
237
|
+
if (code === 0) settle()
|
|
238
|
+
else settle(new ImageSafetyError('normalization_failed', `ffmpeg exit ${code}: ${stderr.trim().slice(0, 300)}`))
|
|
239
|
+
})
|
|
240
|
+
proc.on('error', (err) => {
|
|
241
|
+
settle(new ImageSafetyError('media_processing_unavailable', `ffmpeg unavailable: ${err.message}`))
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function probeImageDimensions(path: string): Promise<{ width: number; height: number }> {
|
|
247
|
+
return new Promise((resolveProbe, rejectProbe) => {
|
|
248
|
+
let stdout = ''
|
|
249
|
+
let stderr = ''
|
|
250
|
+
let settled = false
|
|
251
|
+
const settle = (result?: { width: number; height: number }, err?: Error) => {
|
|
252
|
+
if (settled) return
|
|
253
|
+
settled = true
|
|
254
|
+
clearTimeout(timer)
|
|
255
|
+
if (err) rejectProbe(err)
|
|
256
|
+
else resolveProbe(result!)
|
|
257
|
+
}
|
|
258
|
+
const proc = spawn('ffprobe', [
|
|
259
|
+
'-v', 'error',
|
|
260
|
+
'-select_streams', 'v:0',
|
|
261
|
+
'-show_entries', 'stream=width,height',
|
|
262
|
+
'-of', 'json',
|
|
263
|
+
path,
|
|
264
|
+
], { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
265
|
+
const timer = setTimeout(() => {
|
|
266
|
+
proc.kill('SIGKILL')
|
|
267
|
+
settle(undefined, new ImageSafetyError('normalization_failed', 'ffprobe timed out'))
|
|
268
|
+
}, 5_000)
|
|
269
|
+
proc.stdout.on('data', (chunk: Buffer) => {
|
|
270
|
+
if (stdout.length < FFMPEG_STDERR_CAP) stdout += chunk.toString().slice(0, FFMPEG_STDERR_CAP - stdout.length)
|
|
271
|
+
})
|
|
272
|
+
proc.stderr.on('data', (chunk: Buffer) => {
|
|
273
|
+
if (stderr.length < FFMPEG_STDERR_CAP) stderr += chunk.toString().slice(0, FFMPEG_STDERR_CAP - stderr.length)
|
|
274
|
+
})
|
|
275
|
+
proc.on('close', (code) => {
|
|
276
|
+
if (code !== 0) {
|
|
277
|
+
settle(undefined, new ImageSafetyError('corrupt_image', `ffprobe rejected image: ${stderr.trim().slice(0, 200)}`))
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
const parsed = JSON.parse(stdout) as { streams?: Array<{ width?: unknown; height?: unknown }> }
|
|
282
|
+
const width = parsed.streams?.[0]?.width
|
|
283
|
+
const height = parsed.streams?.[0]?.height
|
|
284
|
+
if (!Number.isInteger(width) || !Number.isInteger(height) || Number(width) <= 0 || Number(height) <= 0) {
|
|
285
|
+
throw new Error('missing dimensions')
|
|
286
|
+
}
|
|
287
|
+
settle({ width: Number(width), height: Number(height) })
|
|
288
|
+
} catch {
|
|
289
|
+
settle(undefined, new ImageSafetyError('corrupt_image', 'image probe returned no readable dimensions'))
|
|
290
|
+
}
|
|
291
|
+
})
|
|
292
|
+
proc.on('error', (err) => {
|
|
293
|
+
settle(undefined, new ImageSafetyError('media_processing_unavailable', `ffprobe unavailable: ${err.message}`))
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export interface NormalizedImage {
|
|
299
|
+
/** Normalized primary asset — metadata-stripped JPEG, longest edge <= 1024. */
|
|
300
|
+
normalized: Buffer
|
|
301
|
+
/** Thumbnail — same profile, longest edge <= 256. */
|
|
302
|
+
thumb: Buffer
|
|
303
|
+
width: number
|
|
304
|
+
height: number
|
|
305
|
+
mime: 'image/jpeg'
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── G2 lens variant (Release B) ──────────────────────────────────────────────
|
|
309
|
+
// The canary run of 2026-07-10 (G2 S211GABA296089, SDK 0.0.9) proved 288x144
|
|
310
|
+
// image containers on hardware. The lens variant is EXACTLY that size — the
|
|
311
|
+
// firmware TILES undersized data (canary injection evidence), so exact
|
|
312
|
+
// dimensions are a hard contract, guaranteed here by scale+pad.
|
|
313
|
+
export const G2_VARIANT_W = 288
|
|
314
|
+
export const G2_VARIANT_H = 144
|
|
315
|
+
|
|
316
|
+
/** Render the on-lens variant from an already-normalized asset: fit inside
|
|
317
|
+
* 288x144, pad to exactly 288x144 with black bars, grayscale, PNG (the
|
|
318
|
+
* hardware-proven payload format — the phone host converts to Gray4). */
|
|
319
|
+
export async function renderG2Variant(normalizedBytes: Buffer): Promise<Buffer> {
|
|
320
|
+
if (!(await isMediaProcessingReady())) {
|
|
321
|
+
throw new ImageSafetyError('media_processing_unavailable', 'G2 variant rendering requires ffmpeg')
|
|
322
|
+
}
|
|
323
|
+
const workDir = mkdtempSync(join(tmpdir(), 'cos-media-g2-'))
|
|
324
|
+
const inPath = join(workDir, 'in')
|
|
325
|
+
const outPath = join(workDir, 'g2.png')
|
|
326
|
+
try {
|
|
327
|
+
writeFileSync(inPath, normalizedBytes, { mode: 0o600 })
|
|
328
|
+
await runFfmpeg([
|
|
329
|
+
'-i', inPath,
|
|
330
|
+
'-frames:v', '1',
|
|
331
|
+
'-vf', [
|
|
332
|
+
`scale=${G2_VARIANT_W}:${G2_VARIANT_H}:force_original_aspect_ratio=decrease`,
|
|
333
|
+
`pad=${G2_VARIANT_W}:${G2_VARIANT_H}:(ow-iw)/2:(oh-ih)/2:black`,
|
|
334
|
+
'format=gray',
|
|
335
|
+
].join(','),
|
|
336
|
+
'-map_metadata', '-1',
|
|
337
|
+
'-c:v', 'png',
|
|
338
|
+
'-f', 'image2', '-y', outPath,
|
|
339
|
+
])
|
|
340
|
+
const out = readFileSync(outPath)
|
|
341
|
+
const dims = parsePngDimensions(out)
|
|
342
|
+
if (!dims || dims.width !== G2_VARIANT_W || dims.height !== G2_VARIANT_H) {
|
|
343
|
+
throw new ImageSafetyError('normalization_failed', `G2 variant is ${dims?.width}x${dims?.height}, expected ${G2_VARIANT_W}x${G2_VARIANT_H}`)
|
|
344
|
+
}
|
|
345
|
+
return out
|
|
346
|
+
} finally {
|
|
347
|
+
try { rmSync(workDir, { recursive: true, force: true }) } catch { /* best effort */ }
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Normalize a VALIDATED source image: strip all metadata/EXIF, clamp the
|
|
352
|
+
* longest edge, re-encode to a deterministic JPEG profile, and emit a
|
|
353
|
+
* thumbnail. Runs in a private tmp dir; one output frame per invocation. */
|
|
354
|
+
export async function normalizeImage(source: ValidatedImage): Promise<NormalizedImage> {
|
|
355
|
+
if (!(await isMediaProcessingReady())) {
|
|
356
|
+
throw new ImageSafetyError('media_processing_unavailable', 'image normalization requires ffmpeg')
|
|
357
|
+
}
|
|
358
|
+
const workDir = mkdtempSync(join(tmpdir(), 'cos-media-'))
|
|
359
|
+
const inPath = join(workDir, 'in')
|
|
360
|
+
const outPath = join(workDir, 'normalized.jpg')
|
|
361
|
+
const thumbPath = join(workDir, 'thumb.jpg')
|
|
362
|
+
try {
|
|
363
|
+
writeFileSync(inPath, source.bytes, { mode: 0o600 })
|
|
364
|
+
const scale = (edge: number) =>
|
|
365
|
+
`scale='min(${edge},iw)':'min(${edge},ih)':force_original_aspect_ratio=decrease`
|
|
366
|
+
await runFfmpeg([
|
|
367
|
+
'-i', inPath,
|
|
368
|
+
'-frames:v', '1',
|
|
369
|
+
'-vf', scale(NORMALIZED_MAX_EDGE),
|
|
370
|
+
'-map_metadata', '-1',
|
|
371
|
+
'-c:v', 'mjpeg', '-q:v', '3', '-pix_fmt', 'yuvj420p',
|
|
372
|
+
'-f', 'image2', '-y', outPath,
|
|
373
|
+
])
|
|
374
|
+
await runFfmpeg([
|
|
375
|
+
'-i', outPath,
|
|
376
|
+
'-frames:v', '1',
|
|
377
|
+
'-vf', scale(THUMB_MAX_EDGE),
|
|
378
|
+
'-map_metadata', '-1',
|
|
379
|
+
'-c:v', 'mjpeg', '-q:v', '5', '-pix_fmt', 'yuvj420p',
|
|
380
|
+
'-f', 'image2', '-y', thumbPath,
|
|
381
|
+
])
|
|
382
|
+
const normalized = readFileSync(outPath)
|
|
383
|
+
const thumb = readFileSync(thumbPath)
|
|
384
|
+
const dims = parseJpegDimensions(normalized)
|
|
385
|
+
if (!dims) {
|
|
386
|
+
throw new ImageSafetyError('normalization_failed', 'normalized output has no readable dimensions')
|
|
387
|
+
}
|
|
388
|
+
if (Math.max(dims.width, dims.height) > NORMALIZED_MAX_EDGE) {
|
|
389
|
+
throw new ImageSafetyError('normalization_failed', 'normalized output exceeded edge limit')
|
|
390
|
+
}
|
|
391
|
+
return { normalized, thumb, width: dims.width, height: dims.height, mime: 'image/jpeg' }
|
|
392
|
+
} finally {
|
|
393
|
+
try { rmSync(workDir, { recursive: true, force: true }) } catch { /* best effort */ }
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Normalize a trusted, already-local model artifact. This is deliberately
|
|
398
|
+
* separate from the public phone-upload gate: up to 16 MiB JPEG/PNG/WebP/
|
|
399
|
+
* HEIC/AVIF is accepted by magic, probed for a <=16 MP first frame, stripped,
|
|
400
|
+
* clamped, and re-encoded into the same deterministic JPEG + thumbnail form. */
|
|
401
|
+
export async function normalizeOutputArtifact(bytes: Buffer): Promise<NormalizedImage> {
|
|
402
|
+
if (bytes.length <= 0 || bytes.length > MAX_OUTPUT_ARTIFACT_BYTES) {
|
|
403
|
+
throw new ImageSafetyError('image_too_large', `output artifact is ${bytes.length} bytes (max ${MAX_OUTPUT_ARTIFACT_BYTES})`)
|
|
404
|
+
}
|
|
405
|
+
if (!sniffOutputArtifactType(bytes)) {
|
|
406
|
+
throw new ImageSafetyError('unsupported_format', 'output artifact must be JPEG, PNG, WebP, HEIC/HEIF, or AVIF')
|
|
407
|
+
}
|
|
408
|
+
if (!(await isMediaProcessingReady())) {
|
|
409
|
+
throw new ImageSafetyError('media_processing_unavailable', 'image normalization requires ffmpeg')
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const workDir = mkdtempSync(join(tmpdir(), 'cos-output-media-'))
|
|
413
|
+
const inPath = join(workDir, 'in')
|
|
414
|
+
const outPath = join(workDir, 'normalized.jpg')
|
|
415
|
+
const thumbPath = join(workDir, 'thumb.jpg')
|
|
416
|
+
try {
|
|
417
|
+
writeFileSync(inPath, bytes, { mode: 0o600 })
|
|
418
|
+
const dims = await probeImageDimensions(inPath)
|
|
419
|
+
if (dims.width * dims.height > MAX_MEGAPIXELS) {
|
|
420
|
+
throw new ImageSafetyError('dimensions_too_large', `${dims.width}x${dims.height} exceeds ${MAX_MEGAPIXELS / 1e6}MP limit`)
|
|
421
|
+
}
|
|
422
|
+
const scale = (edge: number) =>
|
|
423
|
+
`scale='min(${edge},iw)':'min(${edge},ih)':force_original_aspect_ratio=decrease`
|
|
424
|
+
await runFfmpeg([
|
|
425
|
+
'-i', inPath,
|
|
426
|
+
'-frames:v', '1',
|
|
427
|
+
'-vf', scale(NORMALIZED_MAX_EDGE),
|
|
428
|
+
'-map_metadata', '-1',
|
|
429
|
+
'-c:v', 'mjpeg', '-q:v', '3', '-pix_fmt', 'yuvj420p',
|
|
430
|
+
'-f', 'image2', '-y', outPath,
|
|
431
|
+
])
|
|
432
|
+
await runFfmpeg([
|
|
433
|
+
'-i', outPath,
|
|
434
|
+
'-frames:v', '1',
|
|
435
|
+
'-vf', scale(THUMB_MAX_EDGE),
|
|
436
|
+
'-map_metadata', '-1',
|
|
437
|
+
'-c:v', 'mjpeg', '-q:v', '5', '-pix_fmt', 'yuvj420p',
|
|
438
|
+
'-f', 'image2', '-y', thumbPath,
|
|
439
|
+
])
|
|
440
|
+
const normalized = readFileSync(outPath)
|
|
441
|
+
const thumb = readFileSync(thumbPath)
|
|
442
|
+
const normalizedDims = parseJpegDimensions(normalized)
|
|
443
|
+
if (!normalizedDims || Math.max(normalizedDims.width, normalizedDims.height) > NORMALIZED_MAX_EDGE) {
|
|
444
|
+
throw new ImageSafetyError('normalization_failed', 'normalized output dimensions are invalid')
|
|
445
|
+
}
|
|
446
|
+
// Re-enter the original byte/type/dimension contract before publication.
|
|
447
|
+
validateSourceImage(normalized)
|
|
448
|
+
return {
|
|
449
|
+
normalized,
|
|
450
|
+
thumb,
|
|
451
|
+
width: normalizedDims.width,
|
|
452
|
+
height: normalizedDims.height,
|
|
453
|
+
mime: 'image/jpeg',
|
|
454
|
+
}
|
|
455
|
+
} finally {
|
|
456
|
+
try { rmSync(workDir, { recursive: true, force: true }) } catch { /* best effort */ }
|
|
457
|
+
}
|
|
458
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Server } from 'node:net'
|
|
2
|
+
|
|
3
|
+
export interface RequiredListener { server: Server; port: number; host: string; label: string }
|
|
4
|
+
|
|
5
|
+
function listenOne({ server, port, host, label }: RequiredListener): Promise<void> {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const cleanup = () => { server.off('error', onError); server.off('listening', onListening) }
|
|
8
|
+
const onError = (error: NodeJS.ErrnoException) => { cleanup(); error.message = `${label} listener failed on ${host}:${port}: ${error.message}`; reject(error) }
|
|
9
|
+
const onListening = () => { cleanup(); resolve() }
|
|
10
|
+
server.once('error', onError)
|
|
11
|
+
server.once('listening', onListening)
|
|
12
|
+
server.listen(port, host)
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function closeOne(server: Server): Promise<void> {
|
|
17
|
+
if (!server.listening) return
|
|
18
|
+
await new Promise<void>(resolve => server.close(() => resolve()))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function listenRequiredServers(listeners: RequiredListener[]): Promise<void> {
|
|
22
|
+
const started: Server[] = []
|
|
23
|
+
try {
|
|
24
|
+
for (const listener of listeners) { await listenOne(listener); started.push(listener.server) }
|
|
25
|
+
} catch (error) {
|
|
26
|
+
await Promise.allSettled(started.map(closeOne))
|
|
27
|
+
throw error
|
|
28
|
+
}
|
|
29
|
+
}
|