@gotcos/glasses-server 6.26.0 → 6.27.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 +23 -0
- package/package.json +1 -1
- package/server/lib/rich-media-safety.ts +87 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
## 6.27.0
|
|
2
|
+
|
|
3
|
+
### Video review frames: 8-16 stills instead of 1-3
|
|
4
|
+
|
|
5
|
+
A video attachment is summarized from stills. The old rule was one still per 15
|
|
6
|
+
seconds, floored at 1 and capped at 8, which gave a 12 second clip **one** frame
|
|
7
|
+
and a 44 second clip **three** - not enough to tell what a video contains. Miles,
|
|
8
|
+
on a fridge sweep: "it only selects three chunks from the video."
|
|
9
|
+
|
|
10
|
+
- Frame count is now `clamp(round(seconds / 6), 8, 16)`. A 12s clip and a 44s
|
|
11
|
+
clip both get 8; a 72s clip gets 12; anything past 96s gets 16.
|
|
12
|
+
- Each frame is the **sharpest** of 5 candidates sampled around its position,
|
|
13
|
+
ranked by encoded JPEG size at fixed quality. Measured on real footage the
|
|
14
|
+
spread within one second was 1.45x, and the large frame read product label
|
|
15
|
+
text that the small one rendered as smear.
|
|
16
|
+
- Candidate count is `frames * 5`, so a 20 minute recording costs the same temp
|
|
17
|
+
I/O as a 12 second one - the sampling rate adapts, the work does not grow.
|
|
18
|
+
- Fixed an upscale: `scale=1280:-2` was enlarging a 480x360 source to 1280x960,
|
|
19
|
+
paying roughly 7x the image tokens for detail that was never captured. Now
|
|
20
|
+
`scale='min(1280,iw)':-2`, so small sources pass through at native size.
|
|
21
|
+
- `MAX_DERIVATIVE_IMAGES` (8) is untouched, so PDF page extraction is unchanged.
|
|
22
|
+
Video no longer shares that constant.
|
|
23
|
+
|
|
1
24
|
## 6.26.0
|
|
2
25
|
|
|
3
26
|
Chunked, resumable upload: video is no longer limited by what fits in one request.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.27.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": {
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
readSync,
|
|
15
15
|
readdirSync,
|
|
16
16
|
rmSync,
|
|
17
|
+
statSync,
|
|
17
18
|
writeFileSync,
|
|
18
19
|
} from 'node:fs'
|
|
19
20
|
import { tmpdir } from 'node:os'
|
|
@@ -44,6 +45,76 @@ export const MEDIA_SNIFF_BYTES = 12
|
|
|
44
45
|
export const MAX_DOCUMENT_TEXT_CHARS = 100_000
|
|
45
46
|
export const MAX_VIDEO_DURATION_MS = 20 * 60_000
|
|
46
47
|
export const MAX_DERIVATIVE_IMAGES = 8
|
|
48
|
+
|
|
49
|
+
// ── Video summary frames ────────────────────────────────────────────────────────
|
|
50
|
+
// DELIBERATELY SEPARATE from MAX_DERIVATIVE_IMAGES, which the PDF path also uses
|
|
51
|
+
// (pdftoppm -l): raising that shared constant would silently give every PDF 16 pages.
|
|
52
|
+
//
|
|
53
|
+
// What was here: min(8, max(1, ceil(durationMs / 15_000))) — one frame per 15 seconds,
|
|
54
|
+
// capped at 8. Measured against real assets that produced:
|
|
55
|
+
// 11.7s 4K clip -> 1 frame
|
|
56
|
+
// 43.6s fridge sweep -> 3 frames (Msg 796: "I only got 3 samples")
|
|
57
|
+
// 20 min (the cap) -> 8 frames, one per 2.5 MINUTES
|
|
58
|
+
// A 12-second video summarised by a single still is not a summary, and 3 frames missed
|
|
59
|
+
// the entire contents of a refrigerator the user was asking about.
|
|
60
|
+
export const VIDEO_SUMMARY_FRAMES_MIN = 8
|
|
61
|
+
export const VIDEO_SUMMARY_FRAMES_MAX = 16
|
|
62
|
+
/** One frame per ~6s of footage, between the floor and the ceiling. */
|
|
63
|
+
const VIDEO_SUMMARY_SECONDS_PER_FRAME = 6
|
|
64
|
+
/** Candidates sampled per window; the sharpest one is kept. More candidates cost
|
|
65
|
+
* only temp I/O, but the search is pointless beyond a handful per window. */
|
|
66
|
+
export const VIDEO_SUMMARY_CANDIDATES_PER_FRAME = 5
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* How many stills represent a video.
|
|
70
|
+
*
|
|
71
|
+
* Uniform intervals, not random: random sampling clusters and leaves gaps, while a
|
|
72
|
+
* uniform grid provably covers start to finish and is reproducible across runs — which
|
|
73
|
+
* matters when the same video is asked about twice.
|
|
74
|
+
*
|
|
75
|
+
* 12 frames is the target for a typical clip: roughly one per 8% of the runtime, about
|
|
76
|
+
* 15K tokens at 1280px wide, leaving room for a transcript and the question in one turn.
|
|
77
|
+
* Below the floor of 8 whole segments go unseen; above the ceiling of 16 the marginal
|
|
78
|
+
* still adds little to a SUMMARY and that budget is better spent on a targeted
|
|
79
|
+
* native-resolution pass over a named time window.
|
|
80
|
+
*/
|
|
81
|
+
export function videoSummaryFrameCount(durationMs: number): number {
|
|
82
|
+
const seconds = Number.isFinite(durationMs) ? Math.max(0, durationMs) / 1000 : 0
|
|
83
|
+
const scaled = Math.round(seconds / VIDEO_SUMMARY_SECONDS_PER_FRAME)
|
|
84
|
+
return Math.min(VIDEO_SUMMARY_FRAMES_MAX, Math.max(VIDEO_SUMMARY_FRAMES_MIN, scaled))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Pick the sharpest candidate in each window, by encoded JPEG size.
|
|
89
|
+
*
|
|
90
|
+
* At a FIXED quality setting, a sharper frame carries more high-frequency detail and
|
|
91
|
+
* therefore encodes larger; a motion-blurred one compresses down. Measured on the real
|
|
92
|
+
* 480x360 fridge sweep: within one second the spread was 1.45x (27,311 vs 18,852 bytes),
|
|
93
|
+
* and inspecting both ends confirmed it — the largest frame reads "Mootopia WHOLE / 13g /
|
|
94
|
+
* LACTOSE FREE" and the smallest is unreadable smear. Those are the exact labels that
|
|
95
|
+
* previously required a manual frame-by-frame pass to recover.
|
|
96
|
+
*
|
|
97
|
+
* This is a proxy, not a Laplacian: it needs no image library, no new dependency, and one
|
|
98
|
+
* ffmpeg pass. It can be fooled by a window where the sharp frames are also the emptiest,
|
|
99
|
+
* which costs a slightly worse still rather than a wrong answer.
|
|
100
|
+
*/
|
|
101
|
+
export function pickSharpestPerWindow(
|
|
102
|
+
candidates: readonly { name: string; bytes: number }[],
|
|
103
|
+
windows: number,
|
|
104
|
+
): string[] {
|
|
105
|
+
if (candidates.length === 0 || windows <= 0) return []
|
|
106
|
+
const ordered = [...candidates].sort((a, b) => a.name.localeCompare(b.name))
|
|
107
|
+
const perWindow = Math.max(1, Math.floor(ordered.length / windows))
|
|
108
|
+
const picked: string[] = []
|
|
109
|
+
for (let w = 0; w < windows; w++) {
|
|
110
|
+
const start = w * perWindow
|
|
111
|
+
// The final window absorbs any remainder, so no candidate is silently dropped.
|
|
112
|
+
const slice = w === windows - 1 ? ordered.slice(start) : ordered.slice(start, start + perWindow)
|
|
113
|
+
if (slice.length === 0) continue
|
|
114
|
+
picked.push(slice.reduce((best, c) => (c.bytes > best.bytes ? c : best), slice[0]).name)
|
|
115
|
+
}
|
|
116
|
+
return picked
|
|
117
|
+
}
|
|
47
118
|
const PROCESS_STDERR_MAX = 8_192
|
|
48
119
|
const PROCESS_TIMEOUT_MS = 30_000
|
|
49
120
|
|
|
@@ -364,15 +435,25 @@ async function processVideo(
|
|
|
364
435
|
if (durationMs > MAX_VIDEO_DURATION_MS) {
|
|
365
436
|
throw new RichMediaSafetyError('video_too_long', `video exceeds ${MAX_VIDEO_DURATION_MS / 60_000} minute limit`)
|
|
366
437
|
}
|
|
367
|
-
|
|
368
|
-
|
|
438
|
+
// Uniform windows across the whole runtime, then the SHARPEST candidate in each.
|
|
439
|
+
// Sampling candidates at a duration-derived rate keeps the total constant no matter
|
|
440
|
+
// how long the video is: a 20-minute recording costs the same temp I/O as a 12-second
|
|
441
|
+
// one, in ONE ffmpeg pass.
|
|
442
|
+
const frameCount = videoSummaryFrameCount(durationMs)
|
|
443
|
+
const candidateCount = frameCount * VIDEO_SUMMARY_CANDIDATES_PER_FRAME
|
|
444
|
+
const fps = Math.max(0.001, candidateCount / (durationMs / 1000))
|
|
369
445
|
await runProcess('ffmpeg', [
|
|
370
446
|
'-nostdin', '-v', 'error', '-i', input,
|
|
371
|
-
|
|
372
|
-
|
|
447
|
+
// min(1280,iw) rather than a bare 1280: the old filter UPSCALED a 480x360 source to
|
|
448
|
+
// 1280x960, paying ~7x the image tokens for detail that was never captured. It only
|
|
449
|
+
// ever downscales now.
|
|
450
|
+
'-vf', `fps=${fps.toFixed(6)},scale='min(1280,iw)':-2`,
|
|
451
|
+
'-frames:v', String(candidateCount), '-q:v', '3', join(work, 'cand-%04d.jpg'),
|
|
373
452
|
])
|
|
374
|
-
const
|
|
375
|
-
.filter(name => /^
|
|
453
|
+
const candidates = readdirSync(work)
|
|
454
|
+
.filter(name => /^cand-\d+\.jpg$/i.test(name))
|
|
455
|
+
.map(name => ({ name, bytes: statSync(join(work, name)).size }))
|
|
456
|
+
const frames = pickSharpestPerWindow(candidates, frameCount)
|
|
376
457
|
.map(name => readFileSync(join(work, name)))
|
|
377
458
|
if (frames.length === 0) throw new RichMediaSafetyError('corrupt_attachment', 'video produced no review frames')
|
|
378
459
|
const mime: PreparedVideoFile['mime'] = declaredMime === 'video/quicktime' || ext === '.mov'
|