@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,237 @@
1
+ """Runs a queued job in a background thread pool and reports progress into
2
+ the local SQLite store. This is the local-Docker execution path only --
3
+ managed-cloud jobs are dispatched to a GPU provider instead (see
4
+ deploy/gpu_providers/), never through this service.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import subprocess
11
+ from concurrent.futures import ThreadPoolExecutor
12
+ from pathlib import Path
13
+ from typing import Callable
14
+
15
+ from app.config import Settings
16
+ from app.db.store import Store
17
+ from app.model_configuration import temporary_model_environment, unchanged_model_environment
18
+ from app.services.pipeline import run_pipeline
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ PreparationProgress = Callable[[int, str], None]
24
+
25
+ # Only an incompatible upload needs transcoding before analysis. When it does,
26
+ # it takes this much of the bar and the pipeline is compressed into what is
27
+ # left; when it does not, the pipeline owns the bar outright rather than
28
+ # starting a quarter of the way along it.
29
+ PREPARATION_CEILING_PERCENT = 25
30
+
31
+
32
+ def analysis_source(
33
+ path: Path, job_id: str, progress: PreparationProgress | None = None
34
+ ) -> tuple[Path, Path | None]:
35
+ """Return an OpenCV-compatible source, transcoding AV1 locally when needed.
36
+
37
+ OpenCV builds used by the CPU runtime cannot reliably decode browser AV1
38
+ uploads. ffmpeg handles them, so make a temporary H.264 copy only for
39
+ those uploads and leave every other format untouched.
40
+ """
41
+ try:
42
+ probe = subprocess.run(
43
+ [
44
+ "ffprobe",
45
+ "-v",
46
+ "error",
47
+ "-select_streams",
48
+ "v:0",
49
+ "-show_entries",
50
+ "stream=codec_name",
51
+ "-of",
52
+ "default=noprint_wrappers=1:nokey=1",
53
+ str(path),
54
+ ],
55
+ check=True,
56
+ capture_output=True,
57
+ text=True,
58
+ timeout=30,
59
+ )
60
+ except (OSError, subprocess.SubprocessError) as error:
61
+ raise RuntimeError("Could not inspect this video's codec locally.") from error
62
+ if probe.stdout.strip().lower() != "av1":
63
+ return path, None
64
+
65
+ try:
66
+ duration_probe = subprocess.run(
67
+ [
68
+ "ffprobe",
69
+ "-v",
70
+ "error",
71
+ "-show_entries",
72
+ "format=duration",
73
+ "-of",
74
+ "default=noprint_wrappers=1:nokey=1",
75
+ str(path),
76
+ ],
77
+ check=True,
78
+ capture_output=True,
79
+ text=True,
80
+ timeout=30,
81
+ )
82
+ duration_secs = max(0.0, float(duration_probe.stdout.strip()))
83
+ except (ValueError, OSError, subprocess.SubprocessError):
84
+ duration_secs = 0.0
85
+
86
+ normalized = path.with_name(f"{path.stem}.larkup-{job_id}.h264.mp4")
87
+ if progress:
88
+ progress(1, "Making this video ready for local analysis (0%)")
89
+ try:
90
+ process = subprocess.Popen(
91
+ [
92
+ "ffmpeg",
93
+ "-y",
94
+ "-v",
95
+ "error",
96
+ "-nostats",
97
+ "-progress",
98
+ "pipe:1",
99
+ "-i",
100
+ str(path),
101
+ "-map",
102
+ "0:v:0",
103
+ "-map",
104
+ "0:a?",
105
+ # This is an analysis-only copy. Ultrafast H.264 is far less
106
+ # CPU-intensive than ffmpeg's default preset and remains
107
+ # compatible with OpenCV while avoiding a multi-minute stall.
108
+ "-c:v",
109
+ "libx264",
110
+ "-preset",
111
+ "ultrafast",
112
+ "-crf",
113
+ "28",
114
+ "-tune",
115
+ "fastdecode",
116
+ "-pix_fmt",
117
+ "yuv420p",
118
+ "-c:a",
119
+ "aac",
120
+ "-movflags",
121
+ "+faststart",
122
+ str(normalized),
123
+ ],
124
+ stdout=subprocess.PIPE,
125
+ stderr=subprocess.PIPE,
126
+ text=True,
127
+ bufsize=1,
128
+ )
129
+ last_percent = 0
130
+ if process.stdout:
131
+ for line in process.stdout:
132
+ key, separator, value = line.strip().partition("=")
133
+ if not separator or key not in {"out_time_us", "out_time_ms"}:
134
+ continue
135
+ try:
136
+ processed_secs = int(value) / 1_000_000
137
+ except ValueError:
138
+ continue
139
+ if duration_secs <= 0:
140
+ continue
141
+ percent = min(99, max(0, round((processed_secs / duration_secs) * 100)))
142
+ if progress and percent > last_percent:
143
+ last_percent = percent
144
+ progress(
145
+ 1 + round(percent * (PREPARATION_CEILING_PERCENT - 1) / 100),
146
+ f"Making this video ready for local analysis ({percent}%)",
147
+ )
148
+ if process.wait(timeout=60 * 60) != 0:
149
+ error = process.stderr.read() if process.stderr else ""
150
+ raise subprocess.SubprocessError(error.strip() or "ffmpeg failed")
151
+ except (OSError, subprocess.SubprocessError) as error:
152
+ normalized.unlink(missing_ok=True)
153
+ raise RuntimeError(
154
+ "This AV1 video could not be prepared for local analysis. Try exporting it as H.264 MP4."
155
+ ) from error
156
+ if progress:
157
+ progress(PREPARATION_CEILING_PERCENT, "Video is ready for local analysis")
158
+ return normalized, normalized
159
+
160
+
161
+ class JobService:
162
+ def __init__(self, settings: Settings, store: Store):
163
+ self.settings = settings
164
+ self.store = store
165
+ self.executor = ThreadPoolExecutor(max_workers=settings.workers, thread_name_prefix="video-job")
166
+
167
+ def submit(self, job_id: str) -> None:
168
+ self.executor.submit(self.run, job_id)
169
+
170
+ def run(self, job_id: str, model_configuration: dict[str, object] | None = None) -> None:
171
+ try:
172
+ payload = {"modelConfiguration": model_configuration} if model_configuration else None
173
+ with temporary_model_environment(payload) if payload else unchanged_model_environment():
174
+ self._run(job_id)
175
+ except Exception as error:
176
+ logger.exception("Video indexing job %s failed", job_id)
177
+ self.store.fail_job(job_id, str(error))
178
+
179
+ def _run(self, job_id: str) -> None:
180
+ job = self.store.get_job_for_worker(job_id)
181
+ upload = self.store.get_upload(job["principal_id"], job["upload_id"])
182
+ self.store.update_job(job_id, "probe", 1, "Preparing video for local analysis")
183
+ transcoded = False
184
+
185
+ def preparation_progress(percent: int, message: str) -> None:
186
+ nonlocal transcoded
187
+ transcoded = True
188
+ self.store.update_job(job_id, "probe", percent, message)
189
+
190
+ source_path, temporary_source = analysis_source(
191
+ Path(upload["path"]), job_id, preparation_progress
192
+ )
193
+ try:
194
+ def pipeline_progress(
195
+ stage: str,
196
+ percent: int,
197
+ message: str,
198
+ stage_percent: int,
199
+ details: dict[str, int | float | str],
200
+ ) -> None:
201
+ # The pipeline reports 0-99 over its own work. It owns the
202
+ # whole bar unless transcoding already used the front of
203
+ # it, in which case it is scaled into the remainder.
204
+ bounded = max(0, min(99, percent))
205
+ mapped_percent = (
206
+ PREPARATION_CEILING_PERCENT
207
+ + round(bounded * (99 - PREPARATION_CEILING_PERCENT) / 99)
208
+ if transcoded
209
+ else bounded
210
+ )
211
+ self.store.update_job(
212
+ job_id,
213
+ stage,
214
+ min(99, mapped_percent),
215
+ message,
216
+ stage_percent,
217
+ details,
218
+ )
219
+
220
+ settings = Settings.from_env()
221
+ result, actual_minutes = run_pipeline(
222
+ source_path,
223
+ job["request"]["brief"],
224
+ settings.model_dir,
225
+ settings.device,
226
+ pipeline_progress,
227
+ settings.disable_heavy_operators,
228
+ settings.semantic_vision_enabled,
229
+ settings.semantic_vision_model,
230
+ )
231
+ finally:
232
+ if temporary_source:
233
+ temporary_source.unlink(missing_ok=True)
234
+ result["jobId"] = job_id
235
+ self.store.finish_job(job_id, result, actual_minutes)
236
+ if job["request"]["brief"].get("retainSourceHours", 0) == 0:
237
+ Path(upload["path"]).unlink(missing_ok=True)
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import cv2
6
+ import numpy as np
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ScoredFrame:
11
+ time_ms: int
12
+ frame: np.ndarray
13
+ motion_score: float
14
+
15
+
16
+ class MotionSampler:
17
+ """Picks the frames most likely to matter within a clip or bounded range.
18
+
19
+ Scores each frame by grayscale difference from its predecessor -- a
20
+ cheap proxy for "something changed here" (an action, a reveal, a state
21
+ transition) -- and biases selection toward high-scoring frames while
22
+ still guaranteeing even coverage, so a slow deliberate action that
23
+ produces a low frame-diff is never dropped entirely.
24
+ """
25
+
26
+ @staticmethod
27
+ def to_gray(frame: np.ndarray) -> np.ndarray:
28
+ return cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
29
+
30
+ def score_frame(self, frame: np.ndarray, previous_gray: np.ndarray | None) -> tuple[float, np.ndarray]:
31
+ """Returns (motion_score, this frame's grayscale) for incremental/streaming use."""
32
+ gray = self.to_gray(frame)
33
+ score = 0.0 if previous_gray is None else float(np.mean(cv2.absdiff(gray, previous_gray)))
34
+ return score, gray
35
+
36
+ def score_sequence(self, frames: list[tuple[int, np.ndarray]]) -> list[ScoredFrame]:
37
+ scored: list[ScoredFrame] = []
38
+ previous_gray: np.ndarray | None = None
39
+ for time_ms, frame in frames:
40
+ score, gray = self.score_frame(frame, previous_gray)
41
+ scored.append(ScoredFrame(time_ms=time_ms, frame=frame, motion_score=score))
42
+ previous_gray = gray
43
+ return scored
44
+
45
+ def select_adaptive(
46
+ self, frames: list[tuple[int, np.ndarray]], target_count: int, *, coverage_floor: float = 0.4
47
+ ) -> list[tuple[int, np.ndarray]]:
48
+ """`coverage_floor` reserves a fraction of the budget for evenly time-spread
49
+ picks; the rest goes to the highest-motion frames."""
50
+ if len(frames) <= target_count:
51
+ return frames
52
+ scored = self.score_sequence(frames)
53
+
54
+ coverage_budget = max(1, round(target_count * coverage_floor))
55
+ step = len(scored) / coverage_budget
56
+ coverage_indices = {round(i * step) for i in range(coverage_budget)}
57
+ coverage_indices = {min(index, len(scored) - 1) for index in coverage_indices}
58
+
59
+ remaining_budget = max(0, target_count - len(coverage_indices))
60
+ motion_ranked = sorted(
61
+ (index for index in range(len(scored)) if index not in coverage_indices),
62
+ key=lambda index: -scored[index].motion_score,
63
+ )
64
+ selected_indices = coverage_indices | set(motion_ranked[:remaining_budget])
65
+ ordered = sorted(selected_indices)
66
+ return [(scored[index].time_ms, scored[index].frame) for index in ordered]