@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,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}")