@larkup/tool-video-intelligence 0.2.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.
Files changed (54) hide show
  1. package/.env.example +150 -0
  2. package/LICENSE +176 -0
  3. package/README.md +281 -0
  4. package/compose.gpu.yaml +14 -0
  5. package/compose.yaml +71 -0
  6. package/dist/agent.d.ts +131 -0
  7. package/dist/agent.js +2087 -0
  8. package/dist/brief.d.ts +2 -0
  9. package/dist/brief.js +37 -0
  10. package/dist/client.d.ts +46 -0
  11. package/dist/client.js +139 -0
  12. package/dist/contracts.d.ts +331 -0
  13. package/dist/contracts.js +1 -0
  14. package/dist/index.d.ts +87 -0
  15. package/dist/index.js +391 -0
  16. package/dist/runtime.d.ts +96 -0
  17. package/dist/runtime.js +592 -0
  18. package/dist/ui.d.ts +82 -0
  19. package/dist/ui.js +87 -0
  20. package/package.json +84 -0
  21. package/runtime/Dockerfile +119 -0
  22. package/runtime/app/__init__.py +3 -0
  23. package/runtime/app/__main__.py +19 -0
  24. package/runtime/app/api/__init__.py +0 -0
  25. package/runtime/app/api/deps.py +69 -0
  26. package/runtime/app/api/v1.py +166 -0
  27. package/runtime/app/config.py +78 -0
  28. package/runtime/app/db/__init__.py +0 -0
  29. package/runtime/app/db/schemas.py +162 -0
  30. package/runtime/app/db/store.py +466 -0
  31. package/runtime/app/main.py +27 -0
  32. package/runtime/app/model_configuration.py +157 -0
  33. package/runtime/app/services/__init__.py +0 -0
  34. package/runtime/app/services/brain.py +2221 -0
  35. package/runtime/app/services/embedding.py +473 -0
  36. package/runtime/app/services/jobs.py +237 -0
  37. package/runtime/app/services/motion.py +66 -0
  38. package/runtime/app/services/pipeline.py +1911 -0
  39. package/runtime/app/services/scene.py +161 -0
  40. package/runtime/app/services/storage.py +44 -0
  41. package/runtime/app/services/transcription.py +667 -0
  42. package/runtime/app/services/vision.py +1441 -0
  43. package/runtime/app/utils/__init__.py +0 -0
  44. package/runtime/app/utils/timing.py +99 -0
  45. package/runtime/app/worker.py +20 -0
  46. package/runtime/pyproject.toml +56 -0
  47. package/runtime/requirements-cpu.txt +15 -0
  48. package/runtime/requirements-smoke.txt +7 -0
  49. package/runtime/requirements.txt +14 -0
  50. package/runtime/uv.lock +3637 -0
  51. package/scripts/grant-cloud-credits.sh +43 -0
  52. package/scripts/runtime.mjs +156 -0
  53. package/scripts/validate-indexing.mjs +168 -0
  54. package/tool.manifest.json +617 -0
@@ -0,0 +1,1911 @@
1
+ """Orchestrates one video-indexing job: probe -> transcribe -> decode/detect/OCR
2
+ -> per-clip semantic captioning -> optional video embeddings -> the evidence
3
+ bundle returned to the caller. Everything else in this package (transcription,
4
+ vision, embedding, scene, motion) is a service this file calls in sequence;
5
+ this is the one place that ties them together.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import bisect
11
+ import base64
12
+ from concurrent.futures import ThreadPoolExecutor
13
+ import json
14
+ import math
15
+ import os
16
+ import shutil
17
+ import subprocess
18
+ import threading
19
+ import time
20
+ from collections import Counter, defaultdict
21
+ from dataclasses import dataclass, replace
22
+ from pathlib import Path
23
+ from typing import Any, Callable, Iterator
24
+
25
+ import cv2
26
+ import numpy as np
27
+
28
+ from app.utils.timing import normalized_important_ranges, rebase_result_timestamps
29
+ from app.services.brain import (
30
+ AgentPlanner,
31
+ ExtractionPlan,
32
+ PriorityRange,
33
+ estimate_plan_runtime,
34
+ fallback_plan,
35
+ )
36
+ from app.services.embedding import VideoClipInput, get_video_embedding_provider
37
+ from app.services.motion import MotionSampler
38
+ from app.services.scene import ClipBounds, SceneDetector
39
+ from app.services.transcription import TranscriptionService
40
+ from app.services.vision import SemanticVisionService
41
+
42
+ # (stage, overall percent, message, percent within this stage). A host that
43
+ # renders one bar per step needs the stage-relative figure; deriving it from
44
+ # the overall percent would mean keeping a second copy of PHASES in sync,
45
+ # so the pipeline reports both and the host never has to guess.
46
+ ProgressDetails = dict[str, int | float | str]
47
+ ProgressCallback = Callable[[str, int, str, int, ProgressDetails], None]
48
+
49
+ COCO_LABELS = (
50
+ "person bicycle car motorcycle airplane bus train truck boat traffic-light fire-hydrant "
51
+ "stop-sign parking-meter bench bird cat dog horse sheep cow elephant bear zebra giraffe "
52
+ "backpack umbrella handbag tie suitcase frisbee skis snowboard sports-ball kite baseball-bat "
53
+ "baseball-glove skateboard surfboard tennis-racket bottle wine-glass cup fork knife spoon bowl "
54
+ "banana apple sandwich orange broccoli carrot hot-dog pizza donut cake chair couch potted-plant "
55
+ "bed dining-table toilet tv laptop mouse remote keyboard cell-phone microwave oven toaster sink "
56
+ "refrigerator book clock vase scissors teddy-bear hair-drier toothbrush"
57
+ ).split()
58
+ # An overlay caption, title, or readout is short. Longer OCR lines are body
59
+ # text and stay in the ordinary visible-text index instead.
60
+ MAX_OVERLAY_TEXT_LENGTH = 64
61
+
62
+ # Each phase owns a slice of the bar. The slices are sized by how long the
63
+ # phase actually takes, not by how many steps it has, so the bar advances at
64
+ # roughly a constant rate: reading frames emits hundreds of milestones but is
65
+ # cheap, while captioning clips emits a handful and dominates the wall clock.
66
+ PHASES = {
67
+ "plan": (1, 8),
68
+ "scout": (8, 16),
69
+ "transcribe": (16, 24),
70
+ "replan": (24, 30),
71
+ "segment": (30, 34),
72
+ "frames": (34, 58),
73
+ "describe": (58, 94),
74
+ "synthesize": (94, 99),
75
+ }
76
+
77
+ # The phases each reported stage covers, in the order they run. `probe` owns
78
+ # three because planning resumes after transcription.
79
+ STAGE_PHASES = {
80
+ "probe": ("plan", "scout", "replan"),
81
+ "transcribe": ("transcribe",),
82
+ "decode": ("segment",),
83
+ "detect": ("frames",),
84
+ "ocr": ("frames",),
85
+ "synthesize": ("describe", "synthesize"),
86
+ }
87
+
88
+
89
+ def _stage_percent(stage: str, overall_percent: float) -> int:
90
+ """How far through its own stage the job is, for a per-step host bar."""
91
+ phases = STAGE_PHASES.get(stage)
92
+ if not phases:
93
+ return max(0, min(99, round(overall_percent)))
94
+ start = min(PHASES[phase][0] for phase in phases)
95
+ end = max(PHASES[phase][1] for phase in phases)
96
+ return max(0, min(99, round((overall_percent - start) / (end - start) * 100)))
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class Probe:
101
+ duration_seconds: float
102
+ width: int
103
+ height: int
104
+ fps: float
105
+ has_audio: bool
106
+
107
+
108
+ def _run(command: list[str]) -> str:
109
+ completed = subprocess.run(command, check=True, capture_output=True, text=True)
110
+ return completed.stdout
111
+
112
+
113
+ def transcription_hints(brief: dict[str, Any]) -> list[str]:
114
+ """Return bounded explicit entity hints for speech recognition."""
115
+ hints: list[str] = []
116
+ for entity in brief.get("knownEntities") or []:
117
+ if isinstance(entity, str) and entity.strip():
118
+ hints.append(entity.strip())
119
+ return list(dict.fromkeys(hints))[:100]
120
+
121
+
122
+ def probe_video(path: Path) -> Probe:
123
+ if shutil.which("ffprobe") is None:
124
+ return _probe_video_with_av(path)
125
+ raw = _run(
126
+ [
127
+ "ffprobe",
128
+ "-v",
129
+ "error",
130
+ "-show_streams",
131
+ "-show_format",
132
+ "-of",
133
+ "json",
134
+ str(path),
135
+ ]
136
+ )
137
+ data = json.loads(raw)
138
+ video = next(
139
+ (
140
+ stream
141
+ for stream in data.get("streams", [])
142
+ if stream.get("codec_type") == "video"
143
+ ),
144
+ None,
145
+ )
146
+ if video is None:
147
+ raise ValueError("the uploaded file does not contain a video stream")
148
+ duration = float(
149
+ video.get("duration") or data.get("format", {}).get("duration") or 0
150
+ )
151
+ rate = str(video.get("avg_frame_rate") or "0/1").split("/")
152
+ fps = float(rate[0]) / max(float(rate[1]), 1) if len(rate) == 2 else 0
153
+ return Probe(
154
+ duration_seconds=max(duration, 0.001),
155
+ width=int(video.get("width") or 0),
156
+ height=int(video.get("height") or 0),
157
+ fps=max(fps, 0.001),
158
+ has_audio=any(
159
+ stream.get("codec_type") == "audio" for stream in data.get("streams", [])
160
+ ),
161
+ )
162
+
163
+
164
+ def _probe_video_with_av(path: Path) -> Probe:
165
+ import av
166
+
167
+ with av.open(str(path)) as container:
168
+ video = next(
169
+ (stream for stream in container.streams if stream.type == "video"), None
170
+ )
171
+ if video is None:
172
+ raise ValueError("the uploaded file does not contain a video stream")
173
+ stream_duration = (
174
+ float(video.duration * video.time_base)
175
+ if video.duration is not None and video.time_base is not None
176
+ else 0
177
+ )
178
+ container_duration = (
179
+ float(container.duration / av.time_base)
180
+ if container.duration is not None
181
+ else 0
182
+ )
183
+ return Probe(
184
+ duration_seconds=max(stream_duration or container_duration, 0.001),
185
+ width=int(video.width or 0),
186
+ height=int(video.height or 0),
187
+ fps=max(float(video.average_rate or 0), 0.001),
188
+ has_audio=any(stream.type == "audio" for stream in container.streams),
189
+ )
190
+
191
+
192
+ class VisualOperators:
193
+ """Per-frame object detection (YOLOX/ONNX) and OCR (PaddleOCR/RapidOCR).
194
+
195
+ Both load their model lazily on first use and stay resident for the rest
196
+ of the job; `disabled` short-circuits both to empty results for a fast
197
+ smoke run with no model weights available.
198
+ """
199
+
200
+ def __init__(self, model_dir: Path, device: str, disabled: bool = False) -> None:
201
+ import threading
202
+
203
+ self.model_dir = model_dir
204
+ self.device = _resolve_device(device)
205
+ self.disabled = disabled
206
+ self._lock = threading.Lock()
207
+ self._ocr: Any = None
208
+ self._detector: Any = None
209
+
210
+ def read_text(self, frame: np.ndarray) -> list[dict[str, Any]]:
211
+ if self.disabled:
212
+ return []
213
+ with self._lock:
214
+ if self._ocr is None:
215
+ try:
216
+ from paddleocr import PaddleOCR
217
+
218
+ self._ocr = (
219
+ "paddle",
220
+ PaddleOCR(
221
+ use_doc_orientation_classify=False,
222
+ use_doc_unwarping=False,
223
+ use_textline_orientation=True,
224
+ ),
225
+ )
226
+ except Exception:
227
+ from rapidocr_onnxruntime import RapidOCR
228
+
229
+ self._ocr = ("rapid", RapidOCR())
230
+ engine_name, engine = self._ocr
231
+ if engine_name == "paddle":
232
+ return _normalize_ocr(engine.predict(frame))
233
+ predictions, _ = engine(frame)
234
+ return _normalize_rapid_ocr(predictions)
235
+
236
+ def detect(self, frame: np.ndarray) -> list[dict[str, Any]]:
237
+ if self.disabled:
238
+ return []
239
+ with self._lock:
240
+ if self._detector is None:
241
+ model = self.model_dir / "yolox_s.onnx"
242
+ if not model.exists():
243
+ # OCR and semantic vision are independently useful. A
244
+ # local lightweight install may deliberately omit the
245
+ # detector weights, so retain the rest of the pipeline
246
+ # instead of failing an unrelated verification job.
247
+ self._detector = False
248
+ return []
249
+ import onnxruntime as ort
250
+
251
+ self._detector = ort.InferenceSession(
252
+ str(model),
253
+ providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
254
+ )
255
+ session = self._detector
256
+ if session is False:
257
+ return []
258
+ return _detect_yolox(session, frame)
259
+
260
+
261
+ def _resolve_device(requested: str) -> str:
262
+ if requested != "auto":
263
+ return requested
264
+ try:
265
+ import onnxruntime as ort
266
+
267
+ return (
268
+ "cuda"
269
+ if "CUDAExecutionProvider" in ort.get_available_providers()
270
+ else "cpu"
271
+ )
272
+ except ImportError:
273
+ return "cpu"
274
+
275
+
276
+ def _normalize_ocr(predictions: Any) -> list[dict[str, Any]]:
277
+ lines: list[dict[str, Any]] = []
278
+ for prediction in predictions or []:
279
+ payload = getattr(prediction, "json", prediction)
280
+ if callable(payload):
281
+ payload = payload()
282
+ if isinstance(payload, str):
283
+ payload = json.loads(payload)
284
+ if isinstance(payload, dict) and "res" in payload:
285
+ payload = payload["res"]
286
+ if not isinstance(payload, dict):
287
+ continue
288
+ texts = payload.get("rec_texts") or []
289
+ scores = payload.get("rec_scores") or []
290
+ boxes = payload.get("rec_boxes") or payload.get("dt_polys") or []
291
+ for index, text in enumerate(texts):
292
+ clean = str(text).strip()
293
+ if not clean:
294
+ continue
295
+ box = (
296
+ np.asarray(boxes[index]).reshape(-1, 2)
297
+ if index < len(boxes)
298
+ else np.zeros((0, 2))
299
+ )
300
+ lines.append(
301
+ {
302
+ "text": clean,
303
+ "confidence": round(
304
+ float(scores[index]) if index < len(scores) else 0, 4
305
+ ),
306
+ "box": box.astype(float).round(1).tolist(),
307
+ }
308
+ )
309
+ return lines
310
+
311
+
312
+ def _normalize_rapid_ocr(predictions: Any) -> list[dict[str, Any]]:
313
+ lines: list[dict[str, Any]] = []
314
+ for prediction in predictions or []:
315
+ if not isinstance(prediction, (list, tuple)) or len(prediction) < 3:
316
+ continue
317
+ points, text, confidence = prediction[:3]
318
+ if not text:
319
+ continue
320
+ lines.append(
321
+ {
322
+ "text": str(text).strip(),
323
+ "confidence": round(float(confidence), 4),
324
+ "box": [[round(float(x), 2), round(float(y), 2)] for x, y in points],
325
+ }
326
+ )
327
+ return lines
328
+
329
+
330
+ def _detect_yolox(
331
+ session: Any, frame: np.ndarray, confidence: float = 0.3
332
+ ) -> list[dict[str, Any]]:
333
+ input_size = 640
334
+ height, width = frame.shape[:2]
335
+ ratio = min(input_size / height, input_size / width)
336
+ resized = cv2.resize(frame, (round(width * ratio), round(height * ratio)))
337
+ padded = np.full((input_size, input_size, 3), 114, dtype=np.uint8)
338
+ padded[: resized.shape[0], : resized.shape[1]] = resized
339
+ tensor = padded.transpose(2, 0, 1).astype(np.float32)[None, ...]
340
+ output = np.asarray(
341
+ session.run(None, {session.get_inputs()[0].name: tensor})[0]
342
+ ).squeeze(0)
343
+
344
+ grids: list[np.ndarray] = []
345
+ strides: list[np.ndarray] = []
346
+ for stride in (8, 16, 32):
347
+ size = input_size // stride
348
+ grid_x, grid_y = np.meshgrid(np.arange(size), np.arange(size))
349
+ grids.append(np.stack((grid_x, grid_y), axis=2).reshape(-1, 2))
350
+ strides.append(np.full((size * size, 1), stride))
351
+ grid = np.concatenate(grids)
352
+ expanded_stride = np.concatenate(strides)
353
+ output[:, :2] = (output[:, :2] + grid) * expanded_stride
354
+ output[:, 2:4] = np.exp(output[:, 2:4]) * expanded_stride
355
+ class_ids = np.argmax(output[:, 5:], axis=1)
356
+ scores = output[:, 4] * output[np.arange(len(output)), class_ids + 5]
357
+ keep = scores >= confidence
358
+ boxes = output[keep, :4]
359
+ scores = scores[keep]
360
+ class_ids = class_ids[keep]
361
+ if not len(boxes):
362
+ return []
363
+ xywh = (
364
+ np.column_stack(
365
+ (
366
+ boxes[:, 0] - boxes[:, 2] / 2,
367
+ boxes[:, 1] - boxes[:, 3] / 2,
368
+ boxes[:, 2],
369
+ boxes[:, 3],
370
+ )
371
+ )
372
+ / ratio
373
+ )
374
+ indices = cv2.dnn.NMSBoxes(xywh.tolist(), scores.tolist(), confidence, 0.45)
375
+ detections: list[dict[str, Any]] = []
376
+ for index in np.asarray(indices).reshape(-1):
377
+ x, y, w, h = xywh[index]
378
+ class_id = int(class_ids[index])
379
+ detections.append(
380
+ {
381
+ "label": (
382
+ COCO_LABELS[class_id]
383
+ if class_id < len(COCO_LABELS)
384
+ else str(class_id)
385
+ ),
386
+ "classId": class_id,
387
+ "confidence": round(float(scores[index]), 4),
388
+ "box": [
389
+ round(float(x), 1),
390
+ round(float(y), 1),
391
+ round(float(x + w), 1),
392
+ round(float(y + h), 1),
393
+ ],
394
+ }
395
+ )
396
+ return detections
397
+
398
+
399
+ def _recurring_overlay_text(
400
+ occurrences: dict[str, list[int]],
401
+ confidence_totals: dict[str, float],
402
+ limit: int = 120,
403
+ ) -> list[dict[str, Any]]:
404
+ """Summarise short on-screen text that persists or recurs over time.
405
+
406
+ A title card, slide heading, lower-third name, dashboard readout, caption,
407
+ timer, or any other overlay shares one property regardless of what the
408
+ video is about: the same short string is legible in several frames. Those
409
+ strings are strong navigation anchors -- they say where a display was
410
+ present and when it changed -- so they are recorded with their full
411
+ timestamp trail. They remain observations of *text*, never a claim about
412
+ what the text means; interpreting one is the vision reader's job.
413
+ """
414
+ summaries: list[dict[str, Any]] = []
415
+ for text, times in occurrences.items():
416
+ if len(times) < 2 or len(text) > MAX_OVERLAY_TEXT_LENGTH:
417
+ continue
418
+ ordered = sorted(times)
419
+ summaries.append(
420
+ {
421
+ "text": text,
422
+ "firstSeenMs": ordered[0],
423
+ "lastSeenMs": ordered[-1],
424
+ "observations": len(ordered),
425
+ "timestampsMs": ordered[:60],
426
+ "confidence": round(confidence_totals.get(text, 0.0) / len(ordered), 4),
427
+ }
428
+ )
429
+ # Longer-lived overlays anchor more of the source, so they are the ones
430
+ # worth keeping when the cap is reached.
431
+ summaries.sort(
432
+ key=lambda item: (-int(item["observations"]), int(item["firstSeenMs"]))
433
+ )
434
+ return sorted(summaries[:limit], key=lambda item: int(item["firstSeenMs"]))
435
+
436
+
437
+ def _iter_frames(
438
+ path: Path,
439
+ probe: Probe,
440
+ brief: dict[str, Any],
441
+ plan: ExtractionPlan,
442
+ ) -> Iterator[tuple[int, np.ndarray, int, int]]:
443
+ capture = cv2.VideoCapture(str(path))
444
+ if not capture.isOpened():
445
+ raise ValueError("OpenCV could not decode the uploaded video")
446
+ important_ranges = normalized_important_ranges(brief, probe.duration_seconds)
447
+ ranges = important_ranges or [(0.0, probe.duration_seconds)]
448
+ priority_ranges = [
449
+ (item.start_secs, item.end_secs) for item in plan.priority_ranges
450
+ ]
451
+ timestamps: set[float] = set()
452
+ for start, end in ranges:
453
+ cursor = start
454
+ while cursor <= end + 0.001:
455
+ timestamps.add(round(cursor, 3))
456
+ cursor += plan.sample_interval_secs
457
+ timestamps.add(round(end, 3))
458
+ for priority_start, priority_end in priority_ranges:
459
+ for range_start, range_end in ranges:
460
+ start, end = max(priority_start, range_start), min(priority_end, range_end)
461
+ cursor = start
462
+ while cursor <= end + 0.001:
463
+ timestamps.add(round(cursor, 3))
464
+ cursor += plan.priority_sample_interval_secs
465
+ requested_timestamps = sorted(timestamps)
466
+ requested_samples = max(1, len(requested_timestamps))
467
+ try:
468
+ for sample_index, timestamp_secs in enumerate(requested_timestamps, start=1):
469
+ capture.set(cv2.CAP_PROP_POS_MSEC, timestamp_secs * 1_000)
470
+ ok, frame = capture.read()
471
+ if not ok:
472
+ continue
473
+ actual_timestamp_secs = float(capture.get(cv2.CAP_PROP_POS_MSEC)) / 1_000
474
+ yield (
475
+ round(actual_timestamp_secs * 1_000),
476
+ frame,
477
+ sample_index,
478
+ requested_samples,
479
+ )
480
+ finally:
481
+ capture.release()
482
+
483
+
484
+ def _intersection_over_union(left: list[float], right: list[float]) -> float:
485
+ x1, y1 = max(left[0], right[0]), max(left[1], right[1])
486
+ x2, y2 = min(left[2], right[2]), min(left[3], right[3])
487
+ intersection = max(0.0, x2 - x1) * max(0.0, y2 - y1)
488
+ left_area = max(0.0, left[2] - left[0]) * max(0.0, left[3] - left[1])
489
+ right_area = max(0.0, right[2] - right[0]) * max(0.0, right[3] - right[1])
490
+ union = left_area + right_area - intersection
491
+ return intersection / union if union else 0.0
492
+
493
+
494
+ class AnonymousTracker:
495
+ def __init__(self) -> None:
496
+ self.next_id = 1
497
+ self.tracks: dict[int, dict[str, Any]] = {}
498
+
499
+ def update(self, detections: list[dict[str, Any]], time_ms: int) -> None:
500
+ claimed: set[int] = set()
501
+ for detection in sorted(detections, key=lambda item: -item["confidence"]):
502
+ candidates = [
503
+ (track_id, _intersection_over_union(track["lastBox"], detection["box"]))
504
+ for track_id, track in self.tracks.items()
505
+ if track_id not in claimed
506
+ and track["label"] == detection["label"]
507
+ and time_ms - track["endMs"] <= 6_000
508
+ ]
509
+ track_id, overlap = max(
510
+ candidates, key=lambda item: item[1], default=(0, 0.0)
511
+ )
512
+ if overlap < 0.2:
513
+ track_id = self.next_id
514
+ self.next_id += 1
515
+ self.tracks[track_id] = {
516
+ "trackId": track_id,
517
+ "classId": detection["classId"],
518
+ "label": detection["label"],
519
+ "startMs": time_ms,
520
+ "endMs": time_ms,
521
+ "observations": 0,
522
+ "confidenceTotal": 0.0,
523
+ "lastBox": detection["box"],
524
+ }
525
+ track = self.tracks[track_id]
526
+ track["endMs"] = time_ms
527
+ track["observations"] += 1
528
+ track["confidenceTotal"] += detection["confidence"]
529
+ track["lastBox"] = detection["box"]
530
+ detection["trackId"] = track_id
531
+ claimed.add(track_id)
532
+
533
+ def summaries(self) -> list[dict[str, Any]]:
534
+ return [
535
+ {
536
+ "trackId": track["trackId"],
537
+ "classId": track["classId"],
538
+ "label": track["label"],
539
+ "startMs": track["startMs"],
540
+ "endMs": track["endMs"],
541
+ "observations": track["observations"],
542
+ "confidence": round(
543
+ track["confidenceTotal"] / max(track["observations"], 1), 4
544
+ ),
545
+ }
546
+ for track in self.tracks.values()
547
+ ]
548
+
549
+
550
+ def _retain_clip_frame(
551
+ sampler: MotionSampler,
552
+ frames: list[tuple[int, np.ndarray]],
553
+ scores: list[float],
554
+ time_ms: int,
555
+ frame: np.ndarray,
556
+ limit: int,
557
+ previous_gray: np.ndarray | None,
558
+ ) -> np.ndarray:
559
+ """Keeps a motion-biased, temporally-spread sample within one clip's frame bucket.
560
+
561
+ `scores` runs parallel to `frames`, holding each retained frame's motion
562
+ score at the moment it was captured. Eviction protects high-motion
563
+ frames -- a likely action, reveal, or state change -- over pure temporal
564
+ spacing alone, while an interior low-motion frame remains the first to
565
+ go, so coverage still spans the whole clip. Returns this frame's
566
+ grayscale, to thread into the next call's `previous_gray`.
567
+ """
568
+ motion_score, gray = sampler.score_frame(frame, previous_gray)
569
+ if len(frames) < limit:
570
+ frames.append((time_ms, frame.copy()))
571
+ scores.append(motion_score)
572
+ return gray
573
+ times = [item[0] for item in frames] + [time_ms]
574
+ images = [item[1] for item in frames] + [frame.copy()]
575
+ all_scores = scores + [motion_score]
576
+ order = sorted(range(len(times)), key=lambda index: times[index])
577
+ times = [times[index] for index in order]
578
+ images = [images[index] for index in order]
579
+ all_scores = [all_scores[index] for index in order]
580
+ gaps = [times[index + 1] - times[index] for index in range(len(times) - 1)]
581
+ if not gaps:
582
+ return gray
583
+
584
+ def removability(index: int) -> float:
585
+ # min() below evicts the smallest score, so a larger motion weight
586
+ # here must make a frame LESS likely to be picked -- i.e. protected.
587
+ gap_score = gaps[index - 1] + gaps[index]
588
+ motion_weight = 1.0 + all_scores[index]
589
+ return gap_score * motion_weight
590
+
591
+ remove_index = min(range(1, len(times) - 1), key=removability)
592
+ del times[remove_index]
593
+ del images[remove_index]
594
+ del all_scores[remove_index]
595
+ frames[:] = list(zip(times, images))
596
+ scores[:] = all_scores
597
+ return gray
598
+
599
+
600
+ class SmoothProgress:
601
+ """Reports measured milestones and separate liveness heartbeats.
602
+
603
+ Percent only changes when work completes. While a provider request is in
604
+ flight, a heartbeat refreshes the same measured state so the host can
605
+ distinguish a healthy long request from a dead worker without inventing
606
+ progress that later parks at a phase ceiling.
607
+ """
608
+
609
+ def __init__(self, emit: ProgressCallback, tick_seconds: float = 1.0) -> None:
610
+ self._emit = emit
611
+ self._tick_seconds = tick_seconds
612
+ self._lock = threading.Lock()
613
+ self._stop = threading.Event()
614
+ self._thread: threading.Thread | None = None
615
+ self._stage = "probe"
616
+ self._message = "Preparing"
617
+ self._percent = 0.0
618
+ self._reported: tuple[int, int] | None = None
619
+ self._anchor_percent = 0.0
620
+ self._anchor_at = time.monotonic()
621
+ self._ceiling = 99.0
622
+ # How long a phase is expected to take. The curve is scaled to it so a
623
+ # slow phase drifts slowly and a quick one settles near its ceiling.
624
+ self._span_seconds = 60.0
625
+ self._started_at = time.monotonic()
626
+ self._estimated_total_seconds: float | None = None
627
+ self._eta_override_seconds: float | None = None
628
+ self._eta_override_at = self._started_at
629
+ self._current: int | None = None
630
+ self._total: int | None = None
631
+ self._unit: str | None = None
632
+ self._sequence = 0
633
+
634
+ def configure_eta(self, estimated_total_seconds: float) -> None:
635
+ """Set the current whole-pipeline estimate without resetting elapsed time."""
636
+ with self._lock:
637
+ self._estimated_total_seconds = max(1.0, estimated_total_seconds)
638
+ self._eta_override_seconds = None
639
+ self._reported = None
640
+ self._flush()
641
+
642
+ def milestone(
643
+ self,
644
+ stage: str,
645
+ percent: float,
646
+ message: str,
647
+ ceiling: float | None = None,
648
+ estimated_remaining_seconds: float | None = None,
649
+ ) -> None:
650
+ """Record real progress: a step that actually completed."""
651
+ with self._lock:
652
+ self._stage = stage
653
+ self._message = message
654
+ self._percent = max(self._percent, min(99.0, percent))
655
+ self._anchor_percent = self._percent
656
+ self._anchor_at = time.monotonic()
657
+ self._ceiling = max(
658
+ self._percent, min(99.0, ceiling if ceiling is not None else 99.0)
659
+ )
660
+ self._current = None
661
+ self._total = None
662
+ self._unit = None
663
+ self._eta_override_seconds = (
664
+ max(0.0, estimated_remaining_seconds)
665
+ if estimated_remaining_seconds is not None
666
+ else None
667
+ )
668
+ self._eta_override_at = self._anchor_at
669
+ self._flush()
670
+
671
+ def phase(
672
+ self,
673
+ stage: str,
674
+ percent: float,
675
+ message: str,
676
+ ceiling: float,
677
+ span_seconds: float,
678
+ estimated_remaining_seconds: float | None = None,
679
+ ) -> None:
680
+ """Enter a phase that will drift from `percent` toward `ceiling`."""
681
+ with self._lock:
682
+ self._span_seconds = max(5.0, span_seconds)
683
+ self.milestone(
684
+ stage,
685
+ percent,
686
+ message,
687
+ ceiling,
688
+ estimated_remaining_seconds,
689
+ )
690
+
691
+ def step(
692
+ self,
693
+ stage: str,
694
+ completed: int,
695
+ total: int,
696
+ message: str,
697
+ band: tuple[int, int],
698
+ span_seconds: float | None = None,
699
+ estimated_remaining_seconds: float | None = None,
700
+ unit: str = "units",
701
+ ) -> None:
702
+ """Report progress through a phase of `total` countable units.
703
+
704
+ The bar sits where the finished units put it, and may drift only as
705
+ far as the *next* unit would reach. So a phase whose units run slower
706
+ than predicted creeps and waits, rather than sailing up to the end of
707
+ its band and then sitting there while most of the work is still
708
+ outstanding.
709
+ """
710
+ start, end = band
711
+ total = max(1, total)
712
+ completed = max(0, min(total, completed))
713
+ width = end - start
714
+ if span_seconds is not None:
715
+ with self._lock:
716
+ self._span_seconds = max(5.0, span_seconds)
717
+ with self._lock:
718
+ self._stage = stage
719
+ self._message = message
720
+ self._percent = max(
721
+ self._percent, min(99.0, start + width * completed / total)
722
+ )
723
+ self._anchor_percent = self._percent
724
+ self._anchor_at = time.monotonic()
725
+ self._ceiling = max(
726
+ self._percent,
727
+ min(99.0, start + width * min(total, completed + 1) / total),
728
+ )
729
+ self._current = completed
730
+ self._total = total
731
+ self._unit = unit
732
+ if estimated_remaining_seconds is not None:
733
+ self._eta_override_seconds = max(0.0, estimated_remaining_seconds)
734
+ self._eta_override_at = self._anchor_at
735
+ self._flush()
736
+
737
+ def message(self, message: str) -> None:
738
+ """Change the status text without claiming any additional progress."""
739
+ with self._lock:
740
+ self._message = message
741
+ self._reported = None
742
+ self._flush()
743
+
744
+ def _flush(self, force: bool = False) -> None:
745
+ percent = round(self._percent)
746
+ stage_percent = _stage_percent(self._stage, self._percent)
747
+ report_key = (percent, stage_percent)
748
+ if report_key == self._reported and not force:
749
+ return
750
+ self._reported = report_key
751
+ self._sequence += 1
752
+ elapsed_seconds = max(0.0, time.monotonic() - self._started_at)
753
+ remaining_candidates: list[float] = []
754
+ if self._estimated_total_seconds is not None:
755
+ remaining_candidates.append(
756
+ max(0.0, self._estimated_total_seconds - elapsed_seconds)
757
+ )
758
+ # Do not infer wall time from overall percent velocity: phase
759
+ # bands express workflow weight, not equal seconds. That shortcut
760
+ # turned a nearly-finished short clip into a three-minute ETA.
761
+ # Countable phases install a measured override below.
762
+ if self._eta_override_seconds is not None:
763
+ # A countable phase has measured throughput. Once that exists it
764
+ # is more trustworthy than the conservative whole-job budget;
765
+ # keeping the older estimate in a max() made the UI say ten
766
+ # minutes while the clip counter correctly said under a minute.
767
+ remaining_candidates = [
768
+ max(
769
+ 0.0,
770
+ self._eta_override_seconds
771
+ - (time.monotonic() - self._eta_override_at),
772
+ )
773
+ ]
774
+ details: ProgressDetails = {
775
+ "sequence": self._sequence,
776
+ "elapsedSeconds": round(elapsed_seconds),
777
+ }
778
+ if remaining_candidates:
779
+ # Zero explicitly clears an expired estimate in hosts that merge
780
+ # progress patches. Keeping a one-second floor made the UI promise
781
+ # "1 second left" indefinitely during an over-budget model call.
782
+ details["estimatedRemainingSeconds"] = max(
783
+ 0, round(max(remaining_candidates))
784
+ )
785
+ if self._current is not None and self._total is not None:
786
+ details.update(
787
+ {
788
+ "current": self._current,
789
+ "total": self._total,
790
+ "unit": self._unit or "units",
791
+ }
792
+ )
793
+ self._emit(self._stage, percent, self._message, stage_percent, details)
794
+
795
+ def _heartbeat(self) -> None:
796
+ while not self._stop.wait(self._tick_seconds):
797
+ with self._lock:
798
+ self._flush(force=True)
799
+
800
+ def __enter__(self) -> SmoothProgress:
801
+ self._thread = threading.Thread(target=self._heartbeat, daemon=True)
802
+ self._thread.start()
803
+ return self
804
+
805
+ def __exit__(self, *_exception: object) -> None:
806
+ self._stop.set()
807
+ if self._thread:
808
+ self._thread.join(timeout=self._tick_seconds * 2)
809
+
810
+
811
+ def semantic_frames_per_clip(
812
+ indexing_mode: str, targeted_verification: bool, dense_content: bool
813
+ ) -> int:
814
+ """Choose a bounded visual sample budget for one semantic clip."""
815
+ if indexing_mode == "thorough" and targeted_verification:
816
+ return 16
817
+ if targeted_verification:
818
+ return 10
819
+ return 8 if dense_content else 4
820
+
821
+
822
+ def semantic_frame_budget(brief: dict[str, Any], planned_frames: int) -> int:
823
+ """Honor an agent's bounded close-read budget without changing full indexing."""
824
+ if not brief.get("continuousSequence"):
825
+ return planned_frames
826
+ try:
827
+ requested = int(brief.get("maxFrames") or planned_frames)
828
+ except (TypeError, ValueError):
829
+ requested = planned_frames
830
+ return max(planned_frames, min(24, max(1, requested)))
831
+
832
+
833
+ def _semantic_clip_budget(mode: str, duration_seconds: float, available: int) -> int:
834
+ """Bound model-read clips while scaling predictably with source length.
835
+
836
+ A full index is a navigation skim, not the final close-read. The later chat
837
+ phase can re-open a bounded source range when a question needs more detail.
838
+ These rates keep chronological coverage while preventing a long source from
839
+ turning into hundreds of nearly-identical model calls.
840
+ """
841
+ clips_per_hour = {"fast": 30, "balanced": 60, "thorough": 180}.get(mode, 60)
842
+ minimum = {"fast": 12, "balanced": 24, "thorough": 48}.get(mode, 24)
843
+ return min(
844
+ available, max(minimum, math.ceil(duration_seconds / 3_600 * clips_per_hour))
845
+ )
846
+
847
+
848
+ def _select_semantic_clip_ids(
849
+ clip_plan: list[ClipBounds],
850
+ clip_frame_scores: dict[str, list[float]],
851
+ observations: list[dict[str, Any]],
852
+ transcript: list[dict[str, Any]],
853
+ priority_ranges: list[PriorityRange],
854
+ mode: str,
855
+ duration_seconds: float,
856
+ ) -> list[str]:
857
+ """Choose a generic coverage-and-salience skim without content-type rules."""
858
+ if not clip_plan:
859
+ return []
860
+ budget = _semantic_clip_budget(mode, duration_seconds, len(clip_plan))
861
+ if budget >= len(clip_plan):
862
+ return [clip.clip_id for clip in clip_plan]
863
+
864
+ evidence_times = [
865
+ float(item.get("timeMs") or 0) / 1_000
866
+ for item in observations
867
+ if (item.get("objects") or item.get("ocr"))
868
+ ]
869
+ scored: list[tuple[float, int, ClipBounds]] = []
870
+ priority_ids: set[str] = set()
871
+ for index, clip in enumerate(clip_plan):
872
+ motion = clip_frame_scores.get(clip.clip_id) or []
873
+ motion_score = (max(motion, default=0.0) * 1.5) + (
874
+ sum(motion) / max(1, len(motion))
875
+ )
876
+ local_evidence = sum(
877
+ clip.start_secs <= time <= clip.end_secs for time in evidence_times
878
+ )
879
+ speech_chars = sum(
880
+ len(str(segment.get("text") or ""))
881
+ for segment in transcript
882
+ if float(segment.get("endMs") or 0) / 1_000 >= clip.start_secs
883
+ and float(segment.get("startMs") or 0) / 1_000 <= clip.end_secs
884
+ )
885
+ prioritized = any(
886
+ clip.start_secs < item.end_secs and clip.end_secs > item.start_secs
887
+ for item in priority_ranges
888
+ )
889
+ if prioritized:
890
+ priority_ids.add(clip.clip_id)
891
+ # Motion, visible evidence, and local speech density are all generic
892
+ # signals. A tiny chronological tie-break keeps selection deterministic.
893
+ score = (
894
+ motion_score
895
+ + local_evidence * 8
896
+ + min(12.0, speech_chars / 80)
897
+ + (100 if prioritized else 0)
898
+ )
899
+ scored.append((score, index, clip))
900
+
901
+ selected: set[str] = set(priority_ids)
902
+ # Divide the source into equal chronological buckets and keep the most
903
+ # informative clip in each. This guarantees whole-video coverage while
904
+ # behaving like a person who skims and pauses on change-rich moments.
905
+ for bucket in range(budget):
906
+ start = math.floor(bucket * len(scored) / budget)
907
+ end = max(start + 1, math.floor((bucket + 1) * len(scored) / budget))
908
+ candidates = scored[start:end]
909
+ if candidates:
910
+ selected.add(
911
+ max(candidates, key=lambda item: (item[0], -item[1]))[2].clip_id
912
+ )
913
+ return [clip.clip_id for clip in clip_plan if clip.clip_id in selected]
914
+
915
+
916
+ def _evenly_spaced_frames(
917
+ frames: list[tuple[int, np.ndarray]],
918
+ limit: int,
919
+ ) -> list[tuple[int, np.ndarray]]:
920
+ """Select a deterministic bounded chronology while preserving both ends."""
921
+ ordered = sorted({time_ms: frame for time_ms, frame in frames}.items())
922
+ if len(ordered) <= limit:
923
+ return ordered
924
+ return [
925
+ ordered[round(index * (len(ordered) - 1) / max(1, limit - 1))]
926
+ for index in range(limit)
927
+ ]
928
+
929
+
930
+ def _scout_video(
931
+ path: Path,
932
+ probe: Probe,
933
+ operators: VisualOperators,
934
+ plan: ExtractionPlan,
935
+ smooth: SmoothProgress,
936
+ ) -> tuple[dict[str, Any], list[dict[str, Any]], list[dict[str, Any]]]:
937
+ """Collects a bounded, reusable source reconnaissance for plan refinement.
938
+
939
+ This is intentionally local and model-free. The first agent decision says
940
+ whether OCR is worth loading; the scout then returns chronological visual
941
+ change and optional text signals to the refinement call. Frames inspected
942
+ here become ordinary timestamped observations, avoiding discarded work.
943
+ """
944
+ scout_start, scout_end = PHASES["scout"]
945
+ scout_middle = scout_start + (scout_end - scout_start) / 2
946
+ budget = {"fast": 8, "balanced": 14, "thorough": 24}[plan.mode]
947
+ budget = min(budget, max(2, round(probe.duration_seconds) + 1))
948
+ timestamps = (
949
+ [0.0]
950
+ if budget <= 1
951
+ else [probe.duration_seconds * index / (budget - 1) for index in range(budget)]
952
+ )
953
+ capture = cv2.VideoCapture(str(path))
954
+ if not capture.isOpened():
955
+ return {"frames": [], "visualChangePeaks": [], "ocr": []}, [], []
956
+ frames: list[tuple[int, np.ndarray, float, float]] = []
957
+ previous_gray: np.ndarray | None = None
958
+ try:
959
+ for index, timestamp_secs in enumerate(timestamps):
960
+ capture.set(cv2.CAP_PROP_POS_MSEC, timestamp_secs * 1_000)
961
+ ok, frame = capture.read()
962
+ if not ok:
963
+ continue
964
+ actual_ms = round(float(capture.get(cv2.CAP_PROP_POS_MSEC)))
965
+ thumbnail = cv2.resize(frame, (96, 54), interpolation=cv2.INTER_AREA)
966
+ gray = cv2.cvtColor(thumbnail, cv2.COLOR_BGR2GRAY)
967
+ change = (
968
+ 0.0
969
+ if previous_gray is None
970
+ else float(np.mean(cv2.absdiff(gray, previous_gray)))
971
+ )
972
+ brightness = float(np.mean(gray))
973
+ frames.append((actual_ms, frame, change, brightness))
974
+ previous_gray = gray
975
+ # The scout owns the first half of its slice; reading text owns
976
+ # the second, so both parts of the phase visibly advance.
977
+ scanned = (index + 1) / max(len(timestamps), 1)
978
+ smooth.milestone(
979
+ "probe",
980
+ scout_start + scanned * (scout_middle - scout_start),
981
+ f"Reading video signals ({index + 1}/{len(timestamps)})",
982
+ )
983
+ finally:
984
+ capture.release()
985
+
986
+ ocr_budget = (
987
+ {"fast": 3, "balanced": 6, "thorough": 10}[plan.mode] if plan.use_ocr else 0
988
+ )
989
+ selected_for_ocr = sorted(
990
+ range(len(frames)), key=lambda index: frames[index][2], reverse=True
991
+ )[:ocr_budget]
992
+ if frames and ocr_budget:
993
+ selected_for_ocr = list(dict.fromkeys([0, len(frames) - 1, *selected_for_ocr]))[
994
+ :ocr_budget
995
+ ]
996
+ observations: list[dict[str, Any]] = []
997
+ ocr_signals: list[dict[str, Any]] = []
998
+ for ocr_index, frame_index in enumerate(selected_for_ocr):
999
+ time_ms, frame, _, _ = frames[frame_index]
1000
+ # Loading the text reader takes a while on its first frame, so this
1001
+ # part of the phase gets its own drift rather than sitting still.
1002
+ read = (ocr_index + 1) / max(len(selected_for_ocr), 1)
1003
+ smooth.phase(
1004
+ "probe",
1005
+ scout_middle
1006
+ + (read - 1 / max(len(selected_for_ocr), 1)) * (scout_end - scout_middle),
1007
+ "Reading visible text signals",
1008
+ scout_middle + read * (scout_end - scout_middle),
1009
+ 30,
1010
+ )
1011
+ lines = operators.read_text(frame)
1012
+ if lines:
1013
+ observations.append({"timeMs": time_ms, "objects": [], "ocr": lines})
1014
+ ocr_signals.extend(
1015
+ {
1016
+ "timeMs": time_ms,
1017
+ "text": str(line.get("text") or "")[:160],
1018
+ "confidence": round(float(line.get("confidence") or 0), 3),
1019
+ }
1020
+ for line in lines[:20]
1021
+ )
1022
+ ranked_changes = sorted(frames, key=lambda item: item[2], reverse=True)[:12]
1023
+ visual_indices = sorted(
1024
+ set(
1025
+ [0, max(0, len(frames) - 1)]
1026
+ + sorted(
1027
+ range(len(frames)), key=lambda index: frames[index][2], reverse=True
1028
+ )[:2]
1029
+ )
1030
+ )[:4]
1031
+ visual_samples: list[dict[str, Any]] = []
1032
+ for index in visual_indices:
1033
+ time_ms, frame, _, _ = frames[index]
1034
+ height, width = frame.shape[:2]
1035
+ if width > 480:
1036
+ frame = cv2.resize(
1037
+ frame,
1038
+ (480, max(1, round(height * 480 / width))),
1039
+ interpolation=cv2.INTER_AREA,
1040
+ )
1041
+ ok, encoded = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 65])
1042
+ if ok:
1043
+ visual_samples.append(
1044
+ {
1045
+ "timeMs": time_ms,
1046
+ "dataUrl": "data:image/jpeg;base64,"
1047
+ + base64.b64encode(encoded.tobytes()).decode("ascii"),
1048
+ }
1049
+ )
1050
+ signals = {
1051
+ "frames": [
1052
+ {
1053
+ "timeMs": item[0],
1054
+ "change": round(item[2], 2),
1055
+ "brightness": round(item[3], 2),
1056
+ }
1057
+ for item in frames
1058
+ ],
1059
+ "visualChangePeaks": [
1060
+ {"timeMs": item[0], "change": round(item[2], 2)}
1061
+ for item in ranked_changes
1062
+ if item[2] > 0
1063
+ ],
1064
+ "ocr": ocr_signals[:80],
1065
+ "visualSamples": [{"timeMs": item["timeMs"]} for item in visual_samples],
1066
+ }
1067
+ return signals, observations, visual_samples
1068
+
1069
+
1070
+ def _planner_transcript_signal(
1071
+ transcript: list[dict[str, Any]],
1072
+ ) -> list[dict[str, Any]]:
1073
+ """Bound long transcripts while preserving chronological coverage."""
1074
+ if not transcript:
1075
+ return []
1076
+ step = max(1, math.ceil(len(transcript) / 120))
1077
+ return [
1078
+ {
1079
+ "startMs": round(float(segment.get("startMs") or 0)),
1080
+ "endMs": round(float(segment.get("endMs") or 0)),
1081
+ "text": str(segment.get("text") or "").strip()[:240],
1082
+ }
1083
+ for segment in transcript[::step]
1084
+ if str(segment.get("text") or "").strip()
1085
+ ][:120]
1086
+
1087
+
1088
+ def _compute_video_embeddings(
1089
+ clip_plan: list[ClipBounds],
1090
+ clip_frames: dict[str, list[tuple[int, np.ndarray]]],
1091
+ progress: Callable[[str, int, str], None],
1092
+ on_clip_progress: Callable[[int, int], None] | None = None,
1093
+ ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
1094
+ """Runs the configured video-embedding provider over the same per-clip frame
1095
+ buckets semantic vision already collected, so there is no extra decode
1096
+ pass. Disabled by default; a provider failure degrades to
1097
+ caption/OCR/transcript-only retrieval rather than failing the job.
1098
+ """
1099
+ provider_name = (
1100
+ os.getenv("LARKUP_VIDEO_EMBEDDING_PROVIDER", "disabled").strip().lower()
1101
+ )
1102
+ fallback_provider_name = (
1103
+ os.getenv(
1104
+ "LARKUP_VIDEO_EMBEDDING_FALLBACK_PROVIDER",
1105
+ "gateway-gemini-embedding-2",
1106
+ )
1107
+ .strip()
1108
+ .lower()
1109
+ )
1110
+ diagnostics: dict[str, Any] = {
1111
+ "attempted": False,
1112
+ "requestedProvider": provider_name,
1113
+ "provider": provider_name,
1114
+ "fallbackProvider": None,
1115
+ "fallbackUsed": False,
1116
+ "primaryError": None,
1117
+ "error": None,
1118
+ }
1119
+ if provider_name == "disabled" or not any(clip_frames.values()):
1120
+ return [], diagnostics
1121
+ diagnostics["attempted"] = True
1122
+ clip_inputs = [
1123
+ VideoClipInput(
1124
+ clip_id=clip.clip_id,
1125
+ start_ms=round(clip.start_secs * 1_000),
1126
+ end_ms=round(clip.end_secs * 1_000),
1127
+ frames=clip_frames[clip.clip_id],
1128
+ )
1129
+ for clip in clip_plan
1130
+ if clip_frames[clip.clip_id]
1131
+ ]
1132
+ total_clips = len(clip_inputs)
1133
+ describe_start, describe_end = PHASES["describe"]
1134
+ progress(
1135
+ "synthesize",
1136
+ describe_start,
1137
+ f"Creating the visual search index (0/{total_clips} clips)",
1138
+ )
1139
+
1140
+ def report_embedding_progress(completed: int, total: int) -> None:
1141
+ if total <= 0:
1142
+ return
1143
+ if on_clip_progress:
1144
+ on_clip_progress(completed, total)
1145
+ return
1146
+ percent = describe_start + (completed / total) * (describe_end - describe_start)
1147
+ progress(
1148
+ "synthesize",
1149
+ round(percent),
1150
+ f"Creating the visual search index ({completed}/{total} clips)",
1151
+ )
1152
+
1153
+ provider_attempts = [provider_name]
1154
+ if fallback_provider_name not in {"", "disabled", provider_name}:
1155
+ provider_attempts.append(fallback_provider_name)
1156
+
1157
+ errors: list[str] = []
1158
+ for attempt, candidate_name in enumerate(provider_attempts):
1159
+ try:
1160
+ if attempt:
1161
+ progress(
1162
+ "synthesize",
1163
+ describe_start,
1164
+ "Primary visual search index unavailable; switching provider",
1165
+ )
1166
+ provider = get_video_embedding_provider(candidate_name)
1167
+ embeddings = provider.embed_clips(clip_inputs, report_embedding_progress)
1168
+ diagnostics["provider"] = provider.name
1169
+ if attempt:
1170
+ diagnostics["fallbackProvider"] = provider.name
1171
+ diagnostics["fallbackUsed"] = True
1172
+ diagnostics["primaryError"] = errors[0]
1173
+ return (
1174
+ [
1175
+ {
1176
+ "clipId": embedding.clip_id,
1177
+ "startMs": embedding.start_ms,
1178
+ "endMs": embedding.end_ms,
1179
+ "vector": embedding.vector,
1180
+ "dimensions": len(embedding.vector),
1181
+ "provider": provider.name,
1182
+ }
1183
+ for embedding in embeddings
1184
+ ],
1185
+ diagnostics,
1186
+ )
1187
+ except Exception as error:
1188
+ errors.append(f"{type(error).__name__}: {error}"[:500])
1189
+
1190
+ diagnostics["primaryError"] = errors[0] if errors else None
1191
+ diagnostics["error"] = "; fallback: ".join(errors)[:500] if errors else None
1192
+ return [], diagnostics
1193
+
1194
+
1195
+ def _require_semantic_coverage(
1196
+ *,
1197
+ expected: int,
1198
+ actual: int,
1199
+ provider_error: str | None,
1200
+ minimum_ratio: float = 0.8,
1201
+ ) -> None:
1202
+ """Reject a misleading success when most planned visual evidence is absent."""
1203
+ if expected <= 0:
1204
+ return
1205
+ required = max(1, math.ceil(expected * minimum_ratio))
1206
+ if actual >= required:
1207
+ return
1208
+ detail = (
1209
+ provider_error or "the configured vision provider returned no usable evidence"
1210
+ )
1211
+ raise RuntimeError(
1212
+ f"semantic vision coverage {actual}/{expected} is below the required {required}/{expected}: "
1213
+ f"{detail[:500]}"
1214
+ )
1215
+
1216
+
1217
+ def _link_chronological_notes(
1218
+ observations: list[dict[str, Any]],
1219
+ ) -> list[dict[str, Any]]:
1220
+ """Order clip evidence before synthesis builds genuine cross-scene continuity.
1221
+
1222
+ Repeating the previous independently generated caption only looked linked,
1223
+ often duplicated mistakes, and could inject English into another language.
1224
+ The synthesis sees the whole ordered source and resolves the actual story.
1225
+ """
1226
+ return sorted(
1227
+ observations,
1228
+ key=lambda item: (float(item.get("startMs") or 0), float(item.get("endMs") or 0)),
1229
+ )
1230
+
1231
+
1232
+ def _apply_video_embedding_policy(
1233
+ plan: ExtractionPlan,
1234
+ brief: dict[str, Any],
1235
+ provider_name: str | None = None,
1236
+ ) -> ExtractionPlan:
1237
+ """Build cross-modal retrieval vectors for every offline index.
1238
+
1239
+ The planner decides how to read a source, but it must not accidentally
1240
+ omit the retrieval index the answering agent depends on later. Bounded
1241
+ live inspections opt out explicitly because their evidence is returned
1242
+ directly and cannot benefit from vectors created in the same request.
1243
+ """
1244
+ selected_provider = (
1245
+ (provider_name or os.getenv("LARKUP_VIDEO_EMBEDDING_PROVIDER", "disabled"))
1246
+ .strip()
1247
+ .lower()
1248
+ )
1249
+ enabled = not brief.get("skipVideoEmbeddings") and selected_provider != "disabled"
1250
+ return replace(plan, use_video_embeddings=enabled)
1251
+
1252
+
1253
+ def run_pipeline(
1254
+ path: Path,
1255
+ brief: dict[str, Any],
1256
+ model_dir: Path,
1257
+ device: str,
1258
+ progress: ProgressCallback,
1259
+ disable_heavy_operators: bool = False,
1260
+ semantic_vision_enabled: bool = True,
1261
+ semantic_vision_model: str = "",
1262
+ timestamp_offset_secs: float = 0.0,
1263
+ source_duration_secs: float | None = None,
1264
+ ) -> tuple[dict[str, Any], float]:
1265
+ pipeline_started = time.monotonic()
1266
+ with SmoothProgress(progress) as smooth:
1267
+ return _run_pipeline(
1268
+ path,
1269
+ brief,
1270
+ model_dir,
1271
+ device,
1272
+ smooth,
1273
+ pipeline_started,
1274
+ disable_heavy_operators,
1275
+ semantic_vision_enabled,
1276
+ timestamp_offset_secs,
1277
+ source_duration_secs,
1278
+ )
1279
+
1280
+
1281
+ def _run_pipeline(
1282
+ path: Path,
1283
+ brief: dict[str, Any],
1284
+ model_dir: Path,
1285
+ device: str,
1286
+ smooth: SmoothProgress,
1287
+ pipeline_started: float,
1288
+ disable_heavy_operators: bool,
1289
+ semantic_vision_enabled: bool,
1290
+ timestamp_offset_secs: float,
1291
+ source_duration_secs: float | None,
1292
+ ) -> tuple[dict[str, Any], float]:
1293
+ smooth.phase(
1294
+ "probe", PHASES["plan"][0], "Reading video metadata", PHASES["plan"][1], 20
1295
+ )
1296
+ probe = probe_video(path)
1297
+ important_ranges = normalized_important_ranges(brief, probe.duration_seconds)
1298
+ interactive = brief.get("interactive") is True
1299
+ skip_heavy_operators = disable_heavy_operators or bool(
1300
+ brief.get("skipHeavyOperators")
1301
+ )
1302
+ operators = VisualOperators(model_dir, device, skip_heavy_operators)
1303
+ transcription = TranscriptionService(operators.device)
1304
+ planner = AgentPlanner()
1305
+ smooth.message(
1306
+ "Preparing the bounded visual read"
1307
+ if interactive
1308
+ else "Choosing the first-pass evidence tools"
1309
+ )
1310
+ # A chat refinement has already bounded the source and supplied a focused
1311
+ # question. General planning plus final timeline synthesis add two remote
1312
+ # model turns without changing the one visual answer it needs, so its fast
1313
+ # lane uses the deterministic, validated fast policy instead.
1314
+ plan = (
1315
+ fallback_plan(str(brief.get("indexingMode")), probe.duration_seconds, probe.has_audio)
1316
+ if interactive
1317
+ else planner.plan(
1318
+ brief=brief,
1319
+ duration_secs=probe.duration_seconds,
1320
+ width=probe.width,
1321
+ height=probe.height,
1322
+ fps=probe.fps,
1323
+ has_audio=probe.has_audio,
1324
+ )
1325
+ )
1326
+
1327
+ transcript: list[dict[str, Any]] = [
1328
+ dict(segment)
1329
+ for segment in (brief.get("transcriptContext") or [])
1330
+ if isinstance(segment, dict)
1331
+ ]
1332
+ detected_language: str | None = None
1333
+ transcription_error: BaseException | None = None
1334
+ transcription_units = {"completed": 0, "total": 1}
1335
+ transcription_units_lock = threading.Lock()
1336
+
1337
+ def _transcription_progress(completed: int, total: int) -> None:
1338
+ with transcription_units_lock:
1339
+ transcription_units["completed"] = max(0, min(total, completed))
1340
+ transcription_units["total"] = max(1, total)
1341
+
1342
+ def _run_transcription() -> None:
1343
+ nonlocal transcript, detected_language, transcription_error
1344
+ try:
1345
+ raw_transcript, language = transcription.transcribe(
1346
+ path,
1347
+ None if brief.get("language") == "auto" else brief.get("language"),
1348
+ transcription_hints(brief),
1349
+ probe.duration_seconds,
1350
+ _transcription_progress,
1351
+ )
1352
+ if important_ranges:
1353
+ raw_transcript = [
1354
+ segment
1355
+ for segment in raw_transcript
1356
+ if segment["endMs"] / 1_000 >= important_ranges[0][0]
1357
+ and segment["startMs"] / 1_000 <= important_ranges[-1][1]
1358
+ ]
1359
+ transcript, detected_language = raw_transcript, language
1360
+ except (
1361
+ BaseException
1362
+ ) as error: # re-raised on the main thread after the frame loop
1363
+ transcription_error = error
1364
+
1365
+ transcription_thread: threading.Thread | None = None
1366
+ if probe.has_audio and plan.use_transcript and not brief.get("skipTranscription"):
1367
+ transcription_thread = threading.Thread(target=_run_transcription, daemon=True)
1368
+ transcription_thread.start()
1369
+
1370
+ smooth.phase(
1371
+ "probe", PHASES["scout"][0], "Reading video signals", PHASES["scout"][1], 25
1372
+ )
1373
+ scout_signals, scout_observations, visual_samples = _scout_video(
1374
+ path, probe, operators, plan, smooth
1375
+ )
1376
+ if transcription_thread is not None:
1377
+ while transcription_thread.is_alive():
1378
+ with transcription_units_lock:
1379
+ completed = transcription_units["completed"]
1380
+ total = transcription_units["total"]
1381
+ smooth.step(
1382
+ "transcribe",
1383
+ completed,
1384
+ total,
1385
+ f"Reading timestamped speech ({completed}/{total} audio chunks)",
1386
+ PHASES["transcribe"],
1387
+ unit="audio chunks",
1388
+ )
1389
+ transcription_thread.join(timeout=1)
1390
+ with transcription_units_lock:
1391
+ completed = transcription_units["completed"]
1392
+ total = transcription_units["total"]
1393
+ smooth.step(
1394
+ "transcribe",
1395
+ completed,
1396
+ total,
1397
+ f"Timestamped speech ready ({completed}/{total} audio chunks)",
1398
+ PHASES["transcribe"],
1399
+ unit="audio chunks",
1400
+ )
1401
+ # Transcription is one evidence source, not a single point of failure for
1402
+ # a video index. Preserve the diagnostic and continue with visual evidence.
1403
+ if detected_language:
1404
+ # Downstream visual notes and the final synthesis can now use the
1405
+ # language actually heard in the source instead of guessing from its
1406
+ # title. Keep the configured language too for audit/debugging.
1407
+ brief = {**brief, "detectedLanguage": detected_language}
1408
+ signals = {
1409
+ **scout_signals,
1410
+ "transcript": _planner_transcript_signal(transcript),
1411
+ **(
1412
+ {
1413
+ "transcriptionError": f"{type(transcription_error).__name__}: {transcription_error}"[
1414
+ :300
1415
+ ]
1416
+ }
1417
+ if transcription_error
1418
+ else {}
1419
+ ),
1420
+ }
1421
+ smooth.phase(
1422
+ "probe",
1423
+ PHASES["replan"][0],
1424
+ "Refining the extraction plan from source evidence",
1425
+ PHASES["replan"][1],
1426
+ 20,
1427
+ )
1428
+ if not interactive:
1429
+ plan = planner.plan(
1430
+ brief=brief,
1431
+ duration_secs=probe.duration_seconds,
1432
+ width=probe.width,
1433
+ height=probe.height,
1434
+ fps=probe.fps,
1435
+ has_audio=probe.has_audio,
1436
+ signals=signals,
1437
+ previous=plan,
1438
+ visual_samples=visual_samples,
1439
+ )
1440
+ # Explicit host overrides remain authoritative even when a model suggests
1441
+ # an unavailable or intentionally disabled service.
1442
+ if brief.get("skipTranscription") and not transcript:
1443
+ plan = replace(plan, use_transcript=False)
1444
+ plan = _apply_video_embedding_policy(plan, brief)
1445
+ if skip_heavy_operators:
1446
+ plan = replace(plan, use_ocr=False, use_object_detection=False)
1447
+ vision_key = os.getenv("LARKUP_VIDEO_VISION_API_KEY") or os.getenv(
1448
+ "AI_GATEWAY_API_KEY"
1449
+ )
1450
+ if not semantic_vision_enabled or not vision_key:
1451
+ plan = replace(plan, use_semantic_vision=False)
1452
+ elif brief.get("requireSemanticVision"):
1453
+ plan = replace(plan, use_semantic_vision=True)
1454
+ plan = replace(
1455
+ plan,
1456
+ estimated_seconds=estimate_plan_runtime(plan, probe.duration_seconds),
1457
+ )
1458
+ smooth.configure_eta(plan.estimated_seconds)
1459
+
1460
+ def with_eta(message: str) -> str:
1461
+ if " left" in message:
1462
+ return message
1463
+ remaining = max(
1464
+ 0, round(plan.estimated_seconds - (time.monotonic() - pipeline_started))
1465
+ )
1466
+ eta = (
1467
+ f"~{max(1, round(remaining / 60))} min left"
1468
+ if remaining >= 60
1469
+ else f"~{remaining} sec left"
1470
+ )
1471
+ return f"{message} · {eta}"
1472
+
1473
+ def progress(stage: str, percent: float, message: str) -> None:
1474
+ smooth.milestone(stage, percent, with_eta(message))
1475
+
1476
+ smooth.milestone("probe", PHASES["replan"][1], with_eta("Agent plan ready"))
1477
+
1478
+ # Cloud indexing can skip local OCR/detection while retaining semantic
1479
+ # source reading. The planner may also disable semantic calls for a truly
1480
+ # audio-only goal, avoiding empty or unnecessary model requests.
1481
+ semantic_vision = SemanticVisionService(
1482
+ semantic_vision_enabled and plan.use_semantic_vision,
1483
+ False,
1484
+ )
1485
+ motion_sampler = MotionSampler()
1486
+ scene_detector = SceneDetector(
1487
+ max_clip_secs=plan.clip_window_secs,
1488
+ detect_scene_cuts=plan.use_scene_cuts,
1489
+ )
1490
+ smooth.phase(
1491
+ "decode",
1492
+ PHASES["segment"][0],
1493
+ with_eta("Planning content-aware video segments"),
1494
+ PHASES["segment"][1],
1495
+ 30,
1496
+ )
1497
+
1498
+ observations: list[dict[str, Any]] = list(scout_observations)
1499
+ label_counts: Counter[str] = Counter()
1500
+ text_occurrences: defaultdict[str, list[int]] = defaultdict(list)
1501
+ text_confidence_totals: Counter[str] = Counter()
1502
+ for observation in scout_observations:
1503
+ for line in observation.get("ocr") or []:
1504
+ text = str(line.get("text") or "").strip()
1505
+ confidence = float(line.get("confidence") or 0)
1506
+ if len(text) >= 2 and confidence >= 0.5:
1507
+ text_occurrences[text].append(int(observation["timeMs"]))
1508
+ text_confidence_totals[text] += confidence
1509
+ tracker = AnonymousTracker()
1510
+ clip_plan = scene_detector.plan_clips(
1511
+ path,
1512
+ important_ranges or [(0.0, probe.duration_seconds)],
1513
+ [(item.start_secs, item.end_secs) for item in plan.priority_ranges],
1514
+ )
1515
+ plan = replace(
1516
+ plan,
1517
+ estimated_seconds=estimate_plan_runtime(
1518
+ plan,
1519
+ probe.duration_seconds,
1520
+ actual_clip_count=len(clip_plan),
1521
+ ),
1522
+ )
1523
+ smooth.configure_eta(plan.estimated_seconds)
1524
+ clip_starts_ms = [round(clip.start_secs * 1_000) for clip in clip_plan]
1525
+ clip_frames: dict[str, list[tuple[int, np.ndarray]]] = {
1526
+ clip.clip_id: [] for clip in clip_plan
1527
+ }
1528
+ clip_frame_scores: dict[str, list[float]] = {clip.clip_id: [] for clip in clip_plan}
1529
+ clip_previous_gray: dict[str, np.ndarray] = {}
1530
+ # The mode and bounded question decide the remote-frame budget. Motion
1531
+ # retention below then makes that budget content-aware without assuming a
1532
+ # genre from user-supplied descriptive metadata.
1533
+ frames_per_clip = semantic_frame_budget(brief, plan.frames_per_clip)
1534
+ analyzed_frames = len(scout_signals.get("frames") or [])
1535
+ source_frames = max(1, round(probe.duration_seconds * probe.fps))
1536
+ decoded_frames = analyzed_frames
1537
+ frames_start, frames_end = PHASES["frames"]
1538
+ smooth.phase(
1539
+ "detect", frames_start, with_eta("Reading video frames"), frames_end, 30
1540
+ )
1541
+ for time_ms, frame, sample_index, sample_total in _iter_frames(
1542
+ path, probe, brief, plan
1543
+ ):
1544
+ analyzed_frames += 1
1545
+ decoded_frames += 1
1546
+ smooth.step(
1547
+ "detect",
1548
+ sample_index,
1549
+ sample_total,
1550
+ with_eta(f"Reading video frames ({sample_index:,}/{sample_total:,})"),
1551
+ (frames_start, frames_end),
1552
+ unit="frames",
1553
+ )
1554
+ detections = operators.detect(frame) if plan.use_object_detection else []
1555
+ tracker.update(detections, time_ms)
1556
+ ocr_lines = operators.read_text(frame) if plan.use_ocr else []
1557
+ if plan.use_semantic_vision and clip_starts_ms:
1558
+ clip_index = min(
1559
+ max(bisect.bisect_right(clip_starts_ms, time_ms) - 1, 0),
1560
+ len(clip_plan) - 1,
1561
+ )
1562
+ clip_id = clip_plan[clip_index].clip_id
1563
+ clip_previous_gray[clip_id] = _retain_clip_frame(
1564
+ motion_sampler,
1565
+ clip_frames[clip_id],
1566
+ clip_frame_scores[clip_id],
1567
+ time_ms,
1568
+ frame,
1569
+ frames_per_clip,
1570
+ clip_previous_gray.get(clip_id),
1571
+ )
1572
+ for detection in detections:
1573
+ label_counts[detection["label"]] += 1
1574
+ for line in ocr_lines:
1575
+ text = str(line.get("text") or "").strip()
1576
+ confidence = float(line.get("confidence") or 0)
1577
+ if len(text) >= 2 and confidence >= 0.5:
1578
+ text_occurrences[text].append(time_ms)
1579
+ text_confidence_totals[text] += confidence
1580
+ if detections or ocr_lines:
1581
+ observations.append(
1582
+ {"timeMs": time_ms, "objects": detections, "ocr": ocr_lines}
1583
+ )
1584
+
1585
+ clips_for_description = {
1586
+ clip.clip_id: (
1587
+ round(clip.start_secs * 1_000),
1588
+ round(clip.end_secs * 1_000),
1589
+ clip_frames[clip.clip_id],
1590
+ )
1591
+ for clip in clip_plan
1592
+ }
1593
+ selected_clip_ids = set(clips_for_description)
1594
+ if not brief.get("continuousSequence") and plan.use_semantic_vision:
1595
+ selected_clip_ids = set(
1596
+ _select_semantic_clip_ids(
1597
+ clip_plan,
1598
+ clip_frame_scores,
1599
+ observations,
1600
+ transcript,
1601
+ plan.priority_ranges,
1602
+ plan.mode,
1603
+ probe.duration_seconds,
1604
+ )
1605
+ )
1606
+ clips_for_description = {
1607
+ clip_id: value
1608
+ for clip_id, value in clips_for_description.items()
1609
+ if clip_id in selected_clip_ids
1610
+ }
1611
+ if brief.get("continuousSequence") and clip_plan:
1612
+ sequence_frame_budget = semantic_frame_budget(brief, frames_per_clip)
1613
+ chronology_frames = _evenly_spaced_frames(
1614
+ [frame for clip in clip_plan for frame in clip_frames[clip.clip_id]],
1615
+ sequence_frame_budget,
1616
+ )
1617
+ if chronology_frames:
1618
+ # The caller asked for this range to be read as one continuous
1619
+ # sequence rather than as independent clips. Split clips would let
1620
+ # the reader interpret each fragment on its own, losing the
1621
+ # before/after relationship the caller needs, and would spend
1622
+ # several provider requests on the same short span.
1623
+ clips_for_description = {
1624
+ "clip_continuous_sequence": (
1625
+ (
1626
+ round(min(start for start, _ in important_ranges) * 1_000)
1627
+ if important_ranges
1628
+ else chronology_frames[0][0]
1629
+ ),
1630
+ (
1631
+ round(max(end for _, end in important_ranges) * 1_000)
1632
+ if important_ranges
1633
+ else chronology_frames[-1][0]
1634
+ ),
1635
+ chronology_frames,
1636
+ )
1637
+ }
1638
+ # Both branches use the already-selected frames and make independent
1639
+ # remote calls. Start them together for offline indexing; a live bounded
1640
+ # inspection explicitly skips embeddings because it cannot use vectors
1641
+ # before returning its answer evidence.
1642
+ caption_total = (
1643
+ sum(1 for _, _, frames in clips_for_description.values() if frames)
1644
+ if plan.use_semantic_vision
1645
+ else 0
1646
+ )
1647
+ embedding_clip_plan = [
1648
+ clip for clip in clip_plan if clip.clip_id in selected_clip_ids
1649
+ ]
1650
+ embedding_total = len(embedding_clip_plan) if plan.use_video_embeddings else 0
1651
+ remote_total = max(1, caption_total + embedding_total)
1652
+ remote_progress = {"captions": 0, "embeddings": 0}
1653
+ remote_progress_lock = threading.Lock()
1654
+ remote_budget_seconds = max(
1655
+ 15.0,
1656
+ plan.estimated_seconds - (time.monotonic() - pipeline_started),
1657
+ )
1658
+
1659
+ describe_start, describe_end = PHASES["describe"]
1660
+ describe_started = time.monotonic()
1661
+
1662
+ def report_remote_progress(kind: str, completed: int, total: int) -> None:
1663
+ if total <= 0:
1664
+ return
1665
+ with remote_progress_lock:
1666
+ remote_progress[kind] = min(total, completed)
1667
+ completed_work = remote_progress["captions"] + remote_progress["embeddings"]
1668
+ elapsed = time.monotonic() - describe_started
1669
+ # Once a clip or two has landed, their real pace is a better
1670
+ # forecast for the rest than any up-front estimate.
1671
+ remaining_seconds = (
1672
+ round(elapsed * (remote_total - completed_work) / completed_work)
1673
+ if completed_work > 0
1674
+ else round(remote_budget_seconds)
1675
+ )
1676
+ eta = (
1677
+ f"~{max(1, round(remaining_seconds / 60))} min left"
1678
+ if remaining_seconds >= 60
1679
+ else f"~{remaining_seconds} sec left"
1680
+ )
1681
+ smooth.step(
1682
+ "synthesize",
1683
+ completed_work,
1684
+ remote_total,
1685
+ "Watching video segments "
1686
+ f"({remote_progress['captions']}/{caption_total} described · "
1687
+ f"{remote_progress['embeddings']}/{embedding_total} indexed) · {eta}",
1688
+ (describe_start, describe_end),
1689
+ span_seconds=max(15.0, elapsed / max(1, completed_work)),
1690
+ estimated_remaining_seconds=remaining_seconds + 30,
1691
+ unit="clips",
1692
+ )
1693
+
1694
+ # This phase makes the remote calls, so it usually dominates the wall
1695
+ # clock and owns the widest slice of the bar. Each finished clip is a real
1696
+ # milestone; between them the drift keeps the bar alive without ever
1697
+ # running past what the next clip would be worth.
1698
+ smooth.step(
1699
+ "synthesize",
1700
+ 0,
1701
+ remote_total,
1702
+ f"Watching video segments (0/{caption_total} described)",
1703
+ (describe_start, describe_end),
1704
+ span_seconds=remote_budget_seconds / max(1, remote_total),
1705
+ estimated_remaining_seconds=remote_budget_seconds + 30,
1706
+ unit="clips",
1707
+ )
1708
+ vision_brief = {
1709
+ **brief,
1710
+ "indexingMode": plan.mode,
1711
+ "agentExtractionFocus": plan.extraction_focus,
1712
+ }
1713
+ with ThreadPoolExecutor(max_workers=2) as pool:
1714
+ semantic_future = pool.submit(
1715
+ semantic_vision.describe_clips,
1716
+ clips_for_description,
1717
+ vision_brief,
1718
+ transcript or list(brief.get("transcriptContext") or []),
1719
+ lambda completed, total: report_remote_progress(
1720
+ "captions", completed, total
1721
+ ),
1722
+ observations,
1723
+ )
1724
+ embedding_future = (
1725
+ None
1726
+ if not plan.use_video_embeddings
1727
+ else pool.submit(
1728
+ _compute_video_embeddings,
1729
+ embedding_clip_plan,
1730
+ clip_frames,
1731
+ lambda *_progress: None,
1732
+ lambda completed, total: report_remote_progress(
1733
+ "embeddings", completed, total
1734
+ ),
1735
+ )
1736
+ )
1737
+ semantic_observations = semantic_future.result()
1738
+ if embedding_future is None:
1739
+ video_embeddings = []
1740
+ video_embedding_diagnostics = {
1741
+ "attempted": False,
1742
+ "provider": os.getenv("LARKUP_VIDEO_EMBEDDING_PROVIDER", "disabled"),
1743
+ "error": None,
1744
+ "skipped": (
1745
+ "interactive-inspection"
1746
+ if brief.get("skipVideoEmbeddings")
1747
+ else "provider-disabled"
1748
+ ),
1749
+ }
1750
+ else:
1751
+ video_embeddings, video_embedding_diagnostics = embedding_future.result()
1752
+ _require_semantic_coverage(
1753
+ expected=caption_total if plan.use_semantic_vision else 0,
1754
+ actual=len(semantic_observations),
1755
+ provider_error=semantic_vision.last_error,
1756
+ )
1757
+ semantic_evidence = _link_chronological_notes(
1758
+ [
1759
+ {
1760
+ "startMs": observation.start_ms,
1761
+ "endMs": observation.end_ms,
1762
+ "text": observation.text,
1763
+ "confidence": observation.confidence,
1764
+ }
1765
+ for observation in semantic_observations
1766
+ ]
1767
+ )
1768
+ recurring_overlay_text = _recurring_overlay_text(
1769
+ text_occurrences, text_confidence_totals
1770
+ )
1771
+ smooth.phase(
1772
+ "synthesize",
1773
+ PHASES["synthesize"][0],
1774
+ "Putting the timeline together",
1775
+ PHASES["synthesize"][1],
1776
+ 30,
1777
+ estimated_remaining_seconds=30,
1778
+ )
1779
+ if interactive:
1780
+ knowledge_summary = {
1781
+ "overview": "Bounded interactive inspection completed from timestamped visual observations.",
1782
+ "participants": [],
1783
+ "stateHistory": [],
1784
+ "keyEvents": [],
1785
+ "narrative": [],
1786
+ "context": [],
1787
+ "sourceItems": [],
1788
+ "uncertainties": [],
1789
+ }
1790
+ else:
1791
+ knowledge_summary = planner.synthesize_knowledge(
1792
+ brief=brief,
1793
+ duration_secs=probe.duration_seconds,
1794
+ plan=plan,
1795
+ semantic_observations=semantic_evidence,
1796
+ transcript=transcript,
1797
+ overlay_text=recurring_overlay_text,
1798
+ )
1799
+ smooth.phase(
1800
+ "synthesize",
1801
+ PHASES["synthesize"][0],
1802
+ "Cataloging questions and written items",
1803
+ PHASES["synthesize"][1],
1804
+ 12,
1805
+ estimated_remaining_seconds=12,
1806
+ )
1807
+ knowledge_summary["sourceItems"] = planner.extract_source_inventory(
1808
+ duration_secs=probe.duration_seconds,
1809
+ transcript=transcript,
1810
+ semantic_observations=semantic_evidence,
1811
+ overlay_text=recurring_overlay_text,
1812
+ )
1813
+ elapsed_seconds = round(time.monotonic() - pipeline_started, 3)
1814
+ result = {
1815
+ "schemaVersion": 1,
1816
+ "durationMs": round((source_duration_secs or probe.duration_seconds) * 1_000),
1817
+ "video": {
1818
+ "width": probe.width,
1819
+ "height": probe.height,
1820
+ "fps": round(probe.fps, 3),
1821
+ },
1822
+ "brief": brief,
1823
+ "transcript": transcript,
1824
+ "detectedLanguage": detected_language,
1825
+ "visualObservations": observations,
1826
+ "tracks": [
1827
+ track
1828
+ for track in tracker.summaries()
1829
+ if int(track.get("observations") or 0) >= 2
1830
+ ],
1831
+ "recurringOverlayText": recurring_overlay_text,
1832
+ # Gateway batches complete out of order. Persisting arrival order
1833
+ # makes chronological retrieval and timeline answers unnecessarily
1834
+ # brittle even though every observation already has precise bounds.
1835
+ "semanticObservations": semantic_evidence,
1836
+ "semanticDiagnostics": {
1837
+ "attempted": bool(plan.use_semantic_vision and any(clip_frames.values())),
1838
+ "error": semantic_vision.last_error,
1839
+ },
1840
+ "agentPlan": plan.to_dict(),
1841
+ "agentDiagnostics": planner.diagnostics().to_dict(),
1842
+ "knowledgeSummary": knowledge_summary,
1843
+ "processingDiagnostics": {
1844
+ "estimatedTotalSeconds": plan.estimated_seconds,
1845
+ "elapsedSeconds": elapsed_seconds,
1846
+ "estimateErrorSeconds": round(elapsed_seconds - plan.estimated_seconds, 3),
1847
+ },
1848
+ "transcriptionDiagnostics": {
1849
+ "requested": plan.use_transcript,
1850
+ **transcription.last_diagnostics,
1851
+ "error": (
1852
+ f"{type(transcription_error).__name__}: {transcription_error}"[:500]
1853
+ if transcription_error
1854
+ else None
1855
+ ),
1856
+ },
1857
+ "videoEmbeddings": video_embeddings,
1858
+ "videoEmbeddingDiagnostics": video_embedding_diagnostics,
1859
+ "entities": [
1860
+ {"name": label, "kind": "object", "mentions": count}
1861
+ for label, count in label_counts.most_common()
1862
+ ]
1863
+ + [
1864
+ {
1865
+ "name": text,
1866
+ "kind": "visible-text",
1867
+ "mentions": len(times),
1868
+ "timestampsMs": times,
1869
+ "confidence": round(
1870
+ text_confidence_totals[text] / max(1, len(times)), 4
1871
+ ),
1872
+ }
1873
+ for text, times in sorted(
1874
+ text_occurrences.items(), key=lambda item: -len(item[1])
1875
+ )
1876
+ if len(times) >= 2
1877
+ or text_confidence_totals[text] / max(1, len(times)) >= 0.9
1878
+ ][:200],
1879
+ "coverage": {
1880
+ "requested": plan.mode,
1881
+ "sourceFrames": source_frames,
1882
+ "decodedFrames": decoded_frames,
1883
+ "analyzedFrames": analyzed_frames,
1884
+ "heavyOperatorsDisabled": skip_heavy_operators,
1885
+ "priorityRanges": [
1886
+ {
1887
+ "startSecs": item.start_secs,
1888
+ "endSecs": item.end_secs,
1889
+ "reason": item.reason,
1890
+ }
1891
+ for item in plan.priority_ranges
1892
+ ],
1893
+ "semanticClips": len(clip_plan),
1894
+ },
1895
+ "answeringGuide": {
1896
+ "goal": brief.get("goal"),
1897
+ "importantEntities": brief.get("knownEntities", []),
1898
+ "questionsToPrepareFor": brief.get("expectedQuestions", []),
1899
+ "extractionFocus": plan.extraction_focus,
1900
+ "instruction": "Answer using timestamped evidence first; use general knowledge only when clearly labeled as an inference.",
1901
+ },
1902
+ }
1903
+ if timestamp_offset_secs:
1904
+ rebase_result_timestamps(result, timestamp_offset_secs)
1905
+ inspected_ranges = normalized_important_ranges(brief, probe.duration_seconds)
1906
+ processed_seconds = (
1907
+ sum(end - start for start, end in inspected_ranges)
1908
+ if inspected_ranges
1909
+ else probe.duration_seconds
1910
+ )
1911
+ return result, processed_seconds / 60