@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.
- package/.env.example +150 -0
- package/LICENSE +176 -0
- package/README.md +281 -0
- package/compose.gpu.yaml +14 -0
- package/compose.yaml +71 -0
- package/dist/agent.d.ts +131 -0
- package/dist/agent.js +2087 -0
- package/dist/brief.d.ts +2 -0
- package/dist/brief.js +37 -0
- package/dist/client.d.ts +46 -0
- package/dist/client.js +139 -0
- package/dist/contracts.d.ts +331 -0
- package/dist/contracts.js +1 -0
- package/dist/index.d.ts +87 -0
- package/dist/index.js +391 -0
- package/dist/runtime.d.ts +96 -0
- package/dist/runtime.js +592 -0
- package/dist/ui.d.ts +82 -0
- package/dist/ui.js +87 -0
- package/package.json +84 -0
- package/runtime/Dockerfile +119 -0
- package/runtime/app/__init__.py +3 -0
- package/runtime/app/__main__.py +19 -0
- package/runtime/app/api/__init__.py +0 -0
- package/runtime/app/api/deps.py +69 -0
- package/runtime/app/api/v1.py +166 -0
- package/runtime/app/config.py +78 -0
- package/runtime/app/db/__init__.py +0 -0
- package/runtime/app/db/schemas.py +162 -0
- package/runtime/app/db/store.py +466 -0
- package/runtime/app/main.py +27 -0
- package/runtime/app/model_configuration.py +157 -0
- package/runtime/app/services/__init__.py +0 -0
- package/runtime/app/services/brain.py +2221 -0
- package/runtime/app/services/embedding.py +473 -0
- package/runtime/app/services/jobs.py +237 -0
- package/runtime/app/services/motion.py +66 -0
- package/runtime/app/services/pipeline.py +1911 -0
- package/runtime/app/services/scene.py +161 -0
- package/runtime/app/services/storage.py +44 -0
- package/runtime/app/services/transcription.py +667 -0
- package/runtime/app/services/vision.py +1441 -0
- package/runtime/app/utils/__init__.py +0 -0
- package/runtime/app/utils/timing.py +99 -0
- package/runtime/app/worker.py +20 -0
- package/runtime/pyproject.toml +56 -0
- package/runtime/requirements-cpu.txt +15 -0
- package/runtime/requirements-smoke.txt +7 -0
- package/runtime/requirements.txt +14 -0
- package/runtime/uv.lock +3637 -0
- package/scripts/grant-cloud-credits.sh +43 -0
- package/scripts/runtime.mjs +156 -0
- package/scripts/validate-indexing.mjs +168 -0
- 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
|