@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,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]
|