@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 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.26.0",
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
- const frameCount = Math.min(MAX_DERIVATIVE_IMAGES, Math.max(1, Math.ceil(durationMs / 15_000)))
368
- const fps = Math.max(0.001, frameCount / (durationMs / 1000))
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
- '-vf', `fps=${fps.toFixed(6)},scale=1280:-2:force_original_aspect_ratio=decrease`,
372
- '-frames:v', String(frameCount), '-q:v', '3', join(work, 'frame-%02d.jpg'),
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 frames = readdirSync(work)
375
- .filter(name => /^frame-\d+\.jpg$/i.test(name)).sort().slice(0, MAX_DERIVATIVE_IMAGES)
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'