@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,161 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class ClipBounds:
|
|
10
|
+
clip_id: str
|
|
11
|
+
start_secs: float
|
|
12
|
+
end_secs: float
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SceneDetector:
|
|
16
|
+
"""Hybrid scene-cut + fixed-window clip planner covering every requested range.
|
|
17
|
+
|
|
18
|
+
Scene cuts alone leave a long static shot (a talking-head take or a static
|
|
19
|
+
dashboard) as one giant clip, averaging out any action inside it.
|
|
20
|
+
Uniform fixed windows alone ignore real scene boundaries. This takes
|
|
21
|
+
scene cuts where PySceneDetect finds them, then forces a split (with a
|
|
22
|
+
short overlap, so a boundary event is never cut in half) on anything
|
|
23
|
+
longer than `max_clip_secs`. Every second of every requested range ends
|
|
24
|
+
up in exactly one clip, aside from the deliberate overlap seams.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
max_clip_secs: float = 8.0,
|
|
31
|
+
overlap_secs: float = 1.0,
|
|
32
|
+
min_clip_secs: float = 2.0,
|
|
33
|
+
detect_scene_cuts: bool = True,
|
|
34
|
+
) -> None:
|
|
35
|
+
self.max_clip_secs = max_clip_secs
|
|
36
|
+
self.overlap_secs = overlap_secs
|
|
37
|
+
self.min_clip_secs = min_clip_secs
|
|
38
|
+
self.detect_scene_cuts = detect_scene_cuts
|
|
39
|
+
|
|
40
|
+
def plan_clips(
|
|
41
|
+
self,
|
|
42
|
+
path: Path,
|
|
43
|
+
ranges: list[tuple[float, float]],
|
|
44
|
+
priority_ranges: list[tuple[float, float]] | None = None,
|
|
45
|
+
) -> list[ClipBounds]:
|
|
46
|
+
# A bounded live investigation already has a question-selected range.
|
|
47
|
+
# Fixed overlapping windows keep its request count and latency stable;
|
|
48
|
+
# scene cuts are still valuable for unconstrained offline indexing.
|
|
49
|
+
cut_points = self._scene_cut_points(path) if self.detect_scene_cuts else []
|
|
50
|
+
raw: list[ClipBounds] = []
|
|
51
|
+
for range_start, range_end in ranges:
|
|
52
|
+
if range_end <= range_start:
|
|
53
|
+
continue
|
|
54
|
+
priority_boundaries = {
|
|
55
|
+
point
|
|
56
|
+
for priority_start, priority_end in priority_ranges or []
|
|
57
|
+
for point in (priority_start, priority_end)
|
|
58
|
+
if range_start < point < range_end
|
|
59
|
+
}
|
|
60
|
+
boundaries = sorted(
|
|
61
|
+
{range_start, range_end}
|
|
62
|
+
| {cut for cut in cut_points if range_start < cut < range_end}
|
|
63
|
+
| priority_boundaries
|
|
64
|
+
)
|
|
65
|
+
for start, end in zip(boundaries, boundaries[1:]):
|
|
66
|
+
prioritized = any(
|
|
67
|
+
start < priority_end and end > priority_start
|
|
68
|
+
for priority_start, priority_end in priority_ranges or []
|
|
69
|
+
)
|
|
70
|
+
raw.extend(
|
|
71
|
+
self._split_bounded(
|
|
72
|
+
start,
|
|
73
|
+
end,
|
|
74
|
+
max_clip_secs=(
|
|
75
|
+
max(self.min_clip_secs, self.max_clip_secs / 2)
|
|
76
|
+
if prioritized
|
|
77
|
+
else self.max_clip_secs
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
target_clips = sum(
|
|
82
|
+
max(1, math.ceil((end - start) / self.max_clip_secs))
|
|
83
|
+
for start, end in ranges
|
|
84
|
+
if end > start
|
|
85
|
+
)
|
|
86
|
+
while len(raw) > target_clips:
|
|
87
|
+
candidates: list[tuple[float, int]] = []
|
|
88
|
+
for index, (left, right) in enumerate(zip(raw, raw[1:])):
|
|
89
|
+
if right.start_secs > left.end_secs + self.overlap_secs + 1e-6:
|
|
90
|
+
continue
|
|
91
|
+
start, end = left.start_secs, right.end_secs
|
|
92
|
+
prioritized = any(
|
|
93
|
+
start < priority_end and end > priority_start
|
|
94
|
+
for priority_start, priority_end in priority_ranges or []
|
|
95
|
+
)
|
|
96
|
+
allowed = (
|
|
97
|
+
max(self.min_clip_secs, self.max_clip_secs / 2)
|
|
98
|
+
if prioritized
|
|
99
|
+
else self.max_clip_secs
|
|
100
|
+
)
|
|
101
|
+
if end - start <= allowed + 1e-6:
|
|
102
|
+
candidates.append((end - start, index))
|
|
103
|
+
if not candidates:
|
|
104
|
+
break
|
|
105
|
+
_, index = min(candidates)
|
|
106
|
+
left, right = raw[index], raw[index + 1]
|
|
107
|
+
raw[index : index + 2] = [
|
|
108
|
+
ClipBounds("", left.start_secs, right.end_secs)
|
|
109
|
+
]
|
|
110
|
+
merged: list[ClipBounds] = []
|
|
111
|
+
for clip in raw:
|
|
112
|
+
if merged and clip.end_secs - clip.start_secs < self.min_clip_secs:
|
|
113
|
+
previous = merged[-1]
|
|
114
|
+
merged[-1] = ClipBounds(previous.clip_id, previous.start_secs, clip.end_secs)
|
|
115
|
+
continue
|
|
116
|
+
merged.append(clip)
|
|
117
|
+
return [
|
|
118
|
+
ClipBounds(f"clip_{index:05d}", clip.start_secs, clip.end_secs)
|
|
119
|
+
for index, clip in enumerate(merged)
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
def _split_bounded(
|
|
123
|
+
self, start: float, end: float, max_clip_secs: float | None = None
|
|
124
|
+
) -> list[ClipBounds]:
|
|
125
|
+
window_secs = max_clip_secs or self.max_clip_secs
|
|
126
|
+
duration = end - start
|
|
127
|
+
if duration <= window_secs:
|
|
128
|
+
return [ClipBounds("", start, end)]
|
|
129
|
+
windows: list[ClipBounds] = []
|
|
130
|
+
cursor = start
|
|
131
|
+
while cursor < end:
|
|
132
|
+
window_end = min(end, cursor + window_secs)
|
|
133
|
+
windows.append(ClipBounds("", cursor, window_end))
|
|
134
|
+
if window_end >= end:
|
|
135
|
+
break
|
|
136
|
+
cursor = window_end - self.overlap_secs
|
|
137
|
+
return windows
|
|
138
|
+
|
|
139
|
+
def _scene_cut_points(self, path: Path) -> list[float]:
|
|
140
|
+
"""Best-effort scene boundaries; an empty list just means pure fixed-window clipping."""
|
|
141
|
+
try:
|
|
142
|
+
from scenedetect import ContentDetector, SceneManager, open_video
|
|
143
|
+
except ImportError:
|
|
144
|
+
return []
|
|
145
|
+
try:
|
|
146
|
+
video = open_video(str(path))
|
|
147
|
+
manager = SceneManager()
|
|
148
|
+
manager.add_detector(ContentDetector())
|
|
149
|
+
# PySceneDetect auto-downscales detection frames by default, so this
|
|
150
|
+
# second pass over the file stays cheap relative to the main decode.
|
|
151
|
+
manager.detect_scenes(video)
|
|
152
|
+
return [scene[0].get_seconds() for scene in manager.get_scene_list()[1:]]
|
|
153
|
+
except Exception:
|
|
154
|
+
return []
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def semantic_clip_window_secs(indexing_mode: str) -> float:
|
|
158
|
+
"""Keep interactive visual reasoning broad without fanning out requests."""
|
|
159
|
+
return {"fast": 30.0, "balanced": 15.0, "thorough": 8.0}.get(
|
|
160
|
+
indexing_mode, 15.0
|
|
161
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Uploads a frame so a remote model (the AI Gateway) can fetch it by URL.
|
|
2
|
+
|
|
3
|
+
Needed because the gateway calls out over HTTP and cannot reach a local
|
|
4
|
+
file -- when a bucket is configured it needs a real URL, not a path. This
|
|
5
|
+
stays pluggable by provider name (only `s3` today) so a future non-AWS
|
|
6
|
+
deploy target can add its own without the vision service that calls it
|
|
7
|
+
knowing or caring which one is active.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FrameUploader(ABC):
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def upload(self, payload: bytes, key: str, content_type: str) -> str:
|
|
20
|
+
"""Stores `payload` at `key` and returns a URL a remote HTTP caller can fetch it from."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class S3FrameUploader(FrameUploader):
|
|
24
|
+
def __init__(self, bucket: str, region: str) -> None:
|
|
25
|
+
import boto3
|
|
26
|
+
|
|
27
|
+
self.bucket = bucket
|
|
28
|
+
self._client: Any = boto3.client("s3", region_name=region)
|
|
29
|
+
|
|
30
|
+
def upload(self, payload: bytes, key: str, content_type: str) -> str:
|
|
31
|
+
self._client.put_object(Bucket=self.bucket, Key=key, Body=payload, ContentType=content_type)
|
|
32
|
+
return self._client.generate_presigned_url(
|
|
33
|
+
"get_object", Params={"Bucket": self.bucket, "Key": key}, ExpiresIn=900
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_frame_uploader(bucket: str | None) -> FrameUploader | None:
|
|
38
|
+
"""None when no bucket is configured (local Docker: frames go inline as base64 instead)."""
|
|
39
|
+
if not bucket:
|
|
40
|
+
return None
|
|
41
|
+
provider = os.getenv("LARKUP_VIDEO_FRAME_STORAGE", "s3")
|
|
42
|
+
if provider == "s3":
|
|
43
|
+
return S3FrameUploader(bucket, os.getenv("AWS_REGION", "eu-central-1"))
|
|
44
|
+
raise ValueError(f"unknown frame storage provider: {provider!r}")
|