@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,473 @@
|
|
|
1
|
+
"""Video-clip embeddings for cross-modal search -- "find the clip where this
|
|
2
|
+
happens" from a free-text query. Distinct from vision.py's captions: a
|
|
3
|
+
caption describes a clip in words, which can miss an action a viewer would
|
|
4
|
+
recognize but a VLM never put into text. An embedding catches that by
|
|
5
|
+
comparing meaning directly, not words.
|
|
6
|
+
|
|
7
|
+
Qwen3-VL-Embedding can use DashScope's multimodal API or dedicated RunPod
|
|
8
|
+
and Hugging Face Inference Endpoint deployments.
|
|
9
|
+
`disabled` (the default) turns this off with zero cost;
|
|
10
|
+
`get_video_embedding_provider()` swaps providers by env var alone.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import os
|
|
17
|
+
import threading
|
|
18
|
+
from abc import ABC, abstractmethod
|
|
19
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Callable, ClassVar
|
|
22
|
+
|
|
23
|
+
import cv2
|
|
24
|
+
import numpy as np
|
|
25
|
+
import requests
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class VideoClipInput:
|
|
30
|
+
clip_id: str
|
|
31
|
+
start_ms: int
|
|
32
|
+
end_ms: int
|
|
33
|
+
frames: list[tuple[int, np.ndarray]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class VideoClipEmbedding:
|
|
38
|
+
clip_id: str
|
|
39
|
+
start_ms: int
|
|
40
|
+
end_ms: int
|
|
41
|
+
vector: list[float]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class VideoEmbeddingProviderError(RuntimeError):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class VideoEmbeddingProvider(ABC):
|
|
49
|
+
name: ClassVar[str]
|
|
50
|
+
dimensions: ClassVar[int]
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def embed_clips(
|
|
54
|
+
self,
|
|
55
|
+
clips: list[VideoClipInput],
|
|
56
|
+
on_progress: Callable[[int, int], None] | None = None,
|
|
57
|
+
) -> list[VideoClipEmbedding]:
|
|
58
|
+
"""One embedding per clip that had at least one frame."""
|
|
59
|
+
|
|
60
|
+
@abstractmethod
|
|
61
|
+
def embed_query(self, text: str) -> list[float]:
|
|
62
|
+
"""Embeds free text into the same vector space as embed_clips, for cross-modal search."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class DisabledVideoEmbeddingProvider(VideoEmbeddingProvider):
|
|
66
|
+
name = "disabled"
|
|
67
|
+
dimensions = 0
|
|
68
|
+
|
|
69
|
+
def embed_clips(
|
|
70
|
+
self,
|
|
71
|
+
clips: list[VideoClipInput],
|
|
72
|
+
on_progress: Callable[[int, int], None] | None = None,
|
|
73
|
+
) -> list[VideoClipEmbedding]:
|
|
74
|
+
if on_progress:
|
|
75
|
+
on_progress(0, 0)
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
def embed_query(self, text: str) -> list[float]:
|
|
79
|
+
raise VideoEmbeddingProviderError(
|
|
80
|
+
"Video embedding is disabled (set LARKUP_VIDEO_EMBEDDING_PROVIDER=qwen3-vl-embedding to enable it)."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class QwenVLEmbeddingProvider(VideoEmbeddingProvider):
|
|
85
|
+
"""DashScope's multimodal-embedding API.
|
|
86
|
+
|
|
87
|
+
qwen3-vl-embedding is not served on DashScope's shared endpoint
|
|
88
|
+
(dashscope[-intl].aliyuncs.com) -- it only responds on a workspace-
|
|
89
|
+
dedicated domain, `https://{workspace_id}.{region}.maas.aliyuncs.com`,
|
|
90
|
+
with the workspace ID and region both taken from the Model Studio
|
|
91
|
+
console (Workspace Details page). `LARKUP_VIDEO_DASHSCOPE_BASE_URL` can
|
|
92
|
+
override the computed URL entirely, e.g. to point at a different API
|
|
93
|
+
path or a non-production workspace.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
name = "qwen3-vl-embedding"
|
|
97
|
+
dimensions = 1024
|
|
98
|
+
|
|
99
|
+
API_PATH = "/api/v1/services/embeddings/multimodal-embedding/multimodal-embedding"
|
|
100
|
+
DEFAULT_MODEL = "qwen3-vl-embedding"
|
|
101
|
+
|
|
102
|
+
def __init__(self) -> None:
|
|
103
|
+
self.api_key = os.getenv("DASHSCOPE_API_KEY", "")
|
|
104
|
+
self.workspace_id = os.getenv("DASHSCOPE_WORKSPACE_ID", "")
|
|
105
|
+
self.region = os.getenv("DASHSCOPE_REGION", "")
|
|
106
|
+
override_url = os.getenv("LARKUP_VIDEO_DASHSCOPE_BASE_URL", "")
|
|
107
|
+
if override_url:
|
|
108
|
+
self.base_url = override_url
|
|
109
|
+
elif self.workspace_id and self.region:
|
|
110
|
+
self.base_url = f"https://{self.workspace_id}.{self.region}.maas.aliyuncs.com{self.API_PATH}"
|
|
111
|
+
else:
|
|
112
|
+
self.base_url = ""
|
|
113
|
+
self.model = os.getenv("LARKUP_VIDEO_EMBEDDING_MODEL", self.DEFAULT_MODEL)
|
|
114
|
+
self.dimensions = int(os.getenv("LARKUP_VIDEO_EMBEDDING_DIMENSION", str(self.dimensions)))
|
|
115
|
+
self.frames_per_clip = int(os.getenv("LARKUP_VIDEO_EMBEDDING_FRAMES_PER_CLIP", "4"))
|
|
116
|
+
self._session = requests.Session()
|
|
117
|
+
|
|
118
|
+
def _frame_content(self, frame: np.ndarray) -> dict:
|
|
119
|
+
ok, buffer = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
|
|
120
|
+
if not ok:
|
|
121
|
+
raise ValueError("could not encode frame as JPEG")
|
|
122
|
+
return {"image": "data:image/jpeg;base64," + base64.b64encode(buffer.tobytes()).decode("ascii")}
|
|
123
|
+
|
|
124
|
+
def _embed(self, contents: list[dict]) -> list[float]:
|
|
125
|
+
if not self.api_key:
|
|
126
|
+
raise VideoEmbeddingProviderError("DASHSCOPE_API_KEY is not configured")
|
|
127
|
+
if not self.base_url:
|
|
128
|
+
raise VideoEmbeddingProviderError(
|
|
129
|
+
"DASHSCOPE_WORKSPACE_ID and DASHSCOPE_REGION are not configured "
|
|
130
|
+
"(or set LARKUP_VIDEO_DASHSCOPE_BASE_URL directly)"
|
|
131
|
+
)
|
|
132
|
+
payload = {
|
|
133
|
+
"model": self.model,
|
|
134
|
+
"input": {"contents": contents},
|
|
135
|
+
"parameters": {"enable_fusion": len(contents) > 1, "dimension": self.dimensions},
|
|
136
|
+
}
|
|
137
|
+
headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
|
|
138
|
+
response = self._session.post(self.base_url, json=payload, headers=headers, timeout=60)
|
|
139
|
+
if not response.ok:
|
|
140
|
+
raise VideoEmbeddingProviderError(
|
|
141
|
+
f"DashScope embedding request failed {response.status_code}: {response.text[:300]}"
|
|
142
|
+
)
|
|
143
|
+
body = response.json()
|
|
144
|
+
try:
|
|
145
|
+
return list(body["output"]["embeddings"][0]["embedding"])
|
|
146
|
+
except (KeyError, IndexError, TypeError) as error:
|
|
147
|
+
raise VideoEmbeddingProviderError(
|
|
148
|
+
f"unexpected DashScope response shape: {str(body)[:500]}"
|
|
149
|
+
) from error
|
|
150
|
+
|
|
151
|
+
def embed_clips(
|
|
152
|
+
self,
|
|
153
|
+
clips: list[VideoClipInput],
|
|
154
|
+
on_progress: Callable[[int, int], None] | None = None,
|
|
155
|
+
) -> list[VideoClipEmbedding]:
|
|
156
|
+
embeddings: list[VideoClipEmbedding] = []
|
|
157
|
+
usable = [clip for clip in clips if clip.frames]
|
|
158
|
+
for completed, clip in enumerate(usable, start=1):
|
|
159
|
+
frames = clip.frames[: self.frames_per_clip]
|
|
160
|
+
contents = [self._frame_content(frame) for _, frame in frames]
|
|
161
|
+
vector = self._embed(contents)
|
|
162
|
+
embeddings.append(
|
|
163
|
+
VideoClipEmbedding(clip_id=clip.clip_id, start_ms=clip.start_ms, end_ms=clip.end_ms, vector=vector)
|
|
164
|
+
)
|
|
165
|
+
if on_progress:
|
|
166
|
+
on_progress(completed, len(usable))
|
|
167
|
+
return embeddings
|
|
168
|
+
|
|
169
|
+
def embed_query(self, text: str) -> list[float]:
|
|
170
|
+
return self._embed([{"text": text}])
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class RunpodQwenVLEmbeddingProvider(VideoEmbeddingProvider):
|
|
174
|
+
"""Dedicated Qwen/Qwen3-VL-Embedding-8B RunPod Serverless worker."""
|
|
175
|
+
|
|
176
|
+
name = "runpod-qwen3-vl-embedding"
|
|
177
|
+
dimensions = 1024
|
|
178
|
+
|
|
179
|
+
def __init__(self) -> None:
|
|
180
|
+
self.api_key = os.getenv("LARKUP_VIDEO_RUNPOD_EMBEDDING_API_KEY", os.getenv("RUNPOD_API_KEY", ""))
|
|
181
|
+
self.endpoint_id = os.getenv("LARKUP_VIDEO_RUNPOD_EMBEDDING_ENDPOINT_ID", "")
|
|
182
|
+
self.base_url = os.getenv("LARKUP_VIDEO_RUNPOD_EMBEDDING_BASE_URL", "")
|
|
183
|
+
if not self.base_url and self.endpoint_id:
|
|
184
|
+
self.base_url = f"https://api.runpod.ai/v2/{self.endpoint_id}/runsync"
|
|
185
|
+
self.dimensions = int(os.getenv("LARKUP_VIDEO_EMBEDDING_DIMENSION", str(self.dimensions)))
|
|
186
|
+
self.instruction = os.getenv("LARKUP_VIDEO_EMBEDDING_INSTRUCTION", "")
|
|
187
|
+
self._session = requests.Session()
|
|
188
|
+
|
|
189
|
+
def _embed(self, inputs: list[dict]) -> list[list[float]]:
|
|
190
|
+
if not self.api_key:
|
|
191
|
+
raise VideoEmbeddingProviderError("RUNPOD_API_KEY is not configured")
|
|
192
|
+
if not self.base_url:
|
|
193
|
+
raise VideoEmbeddingProviderError("LARKUP_VIDEO_RUNPOD_EMBEDDING_ENDPOINT_ID is not configured")
|
|
194
|
+
payload = {"input": {"inputs": inputs, "dimensions": self.dimensions}}
|
|
195
|
+
if self.instruction:
|
|
196
|
+
payload["input"]["instruction"] = self.instruction
|
|
197
|
+
response = self._session.post(
|
|
198
|
+
self.base_url,
|
|
199
|
+
json=payload,
|
|
200
|
+
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
|
|
201
|
+
timeout=300,
|
|
202
|
+
)
|
|
203
|
+
if not response.ok:
|
|
204
|
+
raise VideoEmbeddingProviderError(
|
|
205
|
+
f"RunPod embedding request failed {response.status_code}: {response.text[:300]}"
|
|
206
|
+
)
|
|
207
|
+
body = response.json()
|
|
208
|
+
output = body.get("output", body)
|
|
209
|
+
try:
|
|
210
|
+
embeddings = output["embeddings"]
|
|
211
|
+
if not isinstance(embeddings, list) or not all(isinstance(vector, list) for vector in embeddings):
|
|
212
|
+
raise TypeError("embeddings is not a list of vectors")
|
|
213
|
+
return [[float(value) for value in vector] for vector in embeddings]
|
|
214
|
+
except (KeyError, TypeError, ValueError) as error:
|
|
215
|
+
raise VideoEmbeddingProviderError(
|
|
216
|
+
f"unexpected RunPod embedding response shape: {str(body)[:500]}"
|
|
217
|
+
) from error
|
|
218
|
+
|
|
219
|
+
def _frame_content(self, frame: np.ndarray) -> str:
|
|
220
|
+
ok, buffer = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
|
|
221
|
+
if not ok:
|
|
222
|
+
raise ValueError("could not encode frame as JPEG")
|
|
223
|
+
return "data:image/jpeg;base64," + base64.b64encode(buffer.tobytes()).decode("ascii")
|
|
224
|
+
|
|
225
|
+
def embed_clips(
|
|
226
|
+
self,
|
|
227
|
+
clips: list[VideoClipInput],
|
|
228
|
+
on_progress: Callable[[int, int], None] | None = None,
|
|
229
|
+
) -> list[VideoClipEmbedding]:
|
|
230
|
+
usable = [clip for clip in clips if clip.frames]
|
|
231
|
+
if not usable:
|
|
232
|
+
return []
|
|
233
|
+
# The deployment API represents one embedding input as text and/or one
|
|
234
|
+
# image. A midpoint frame keeps clip embeddings and query embeddings in
|
|
235
|
+
# the same model space without multiplying requests by frame count.
|
|
236
|
+
vectors = self._embed(
|
|
237
|
+
[{"image": self._frame_content(clip.frames[len(clip.frames) // 2][1])} for clip in usable]
|
|
238
|
+
)
|
|
239
|
+
if len(vectors) != len(usable):
|
|
240
|
+
raise VideoEmbeddingProviderError("RunPod returned a different number of embeddings than inputs")
|
|
241
|
+
if on_progress:
|
|
242
|
+
on_progress(len(usable), len(usable))
|
|
243
|
+
return [
|
|
244
|
+
VideoClipEmbedding(clip_id=clip.clip_id, start_ms=clip.start_ms, end_ms=clip.end_ms, vector=vector)
|
|
245
|
+
for clip, vector in zip(usable, vectors, strict=True)
|
|
246
|
+
]
|
|
247
|
+
|
|
248
|
+
def embed_query(self, text: str) -> list[float]:
|
|
249
|
+
return self._embed([{"text": text}])[0]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class HuggingFaceQwenVLEmbeddingProvider(VideoEmbeddingProvider):
|
|
253
|
+
"""Dedicated Qwen3-VL custom Hugging Face Inference Endpoint."""
|
|
254
|
+
|
|
255
|
+
name = "huggingface-qwen3-vl-embedding"
|
|
256
|
+
dimensions = 1024
|
|
257
|
+
|
|
258
|
+
def __init__(self) -> None:
|
|
259
|
+
self.base_url = os.getenv("LARKUP_VIDEO_HF_EMBEDDING_URL", "").rstrip("/")
|
|
260
|
+
self.api_key = os.getenv("LARKUP_VIDEO_HF_EMBEDDING_API_KEY", os.getenv("HF_TOKEN", ""))
|
|
261
|
+
self.dimensions = int(os.getenv("LARKUP_VIDEO_EMBEDDING_DIMENSION", str(self.dimensions)))
|
|
262
|
+
self.instruction = os.getenv("LARKUP_VIDEO_EMBEDDING_INSTRUCTION", "")
|
|
263
|
+
self._session = requests.Session()
|
|
264
|
+
|
|
265
|
+
def _embed(self, inputs: list[dict]) -> list[list[float]]:
|
|
266
|
+
if not self.base_url:
|
|
267
|
+
raise VideoEmbeddingProviderError("LARKUP_VIDEO_HF_EMBEDDING_URL is not configured")
|
|
268
|
+
if not self.api_key:
|
|
269
|
+
raise VideoEmbeddingProviderError("HF_TOKEN is not configured")
|
|
270
|
+
payload = {"inputs": inputs, "dimensions": self.dimensions}
|
|
271
|
+
if self.instruction:
|
|
272
|
+
payload["instruction"] = self.instruction
|
|
273
|
+
response = self._session.post(
|
|
274
|
+
self.base_url,
|
|
275
|
+
json=payload,
|
|
276
|
+
headers={"Authorization": f"Bearer {self.api_key}"},
|
|
277
|
+
timeout=300,
|
|
278
|
+
)
|
|
279
|
+
if not response.ok:
|
|
280
|
+
raise VideoEmbeddingProviderError(
|
|
281
|
+
f"Hugging Face embedding request failed {response.status_code}: {response.text[:300]}"
|
|
282
|
+
)
|
|
283
|
+
body = response.json()
|
|
284
|
+
try:
|
|
285
|
+
embeddings = body["embeddings"]
|
|
286
|
+
if not isinstance(embeddings, list) or not all(isinstance(vector, list) for vector in embeddings):
|
|
287
|
+
raise TypeError("embeddings is not a list of vectors")
|
|
288
|
+
return [[float(value) for value in vector] for vector in embeddings]
|
|
289
|
+
except (KeyError, TypeError, ValueError) as error:
|
|
290
|
+
raise VideoEmbeddingProviderError(
|
|
291
|
+
f"unexpected Hugging Face embedding response shape: {str(body)[:500]}"
|
|
292
|
+
) from error
|
|
293
|
+
|
|
294
|
+
def _frame_content(self, frame: np.ndarray) -> str:
|
|
295
|
+
ok, buffer = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
|
|
296
|
+
if not ok:
|
|
297
|
+
raise ValueError("could not encode frame as JPEG")
|
|
298
|
+
return "data:image/jpeg;base64," + base64.b64encode(buffer.tobytes()).decode("ascii")
|
|
299
|
+
|
|
300
|
+
def embed_clips(
|
|
301
|
+
self,
|
|
302
|
+
clips: list[VideoClipInput],
|
|
303
|
+
on_progress: Callable[[int, int], None] | None = None,
|
|
304
|
+
) -> list[VideoClipEmbedding]:
|
|
305
|
+
usable = [clip for clip in clips if clip.frames]
|
|
306
|
+
if not usable:
|
|
307
|
+
return []
|
|
308
|
+
vectors = self._embed(
|
|
309
|
+
[{"image": self._frame_content(clip.frames[len(clip.frames) // 2][1])} for clip in usable]
|
|
310
|
+
)
|
|
311
|
+
if len(vectors) != len(usable):
|
|
312
|
+
raise VideoEmbeddingProviderError("Hugging Face returned a different number of embeddings than inputs")
|
|
313
|
+
if on_progress:
|
|
314
|
+
on_progress(len(usable), len(usable))
|
|
315
|
+
return [
|
|
316
|
+
VideoClipEmbedding(clip_id=clip.clip_id, start_ms=clip.start_ms, end_ms=clip.end_ms, vector=vector)
|
|
317
|
+
for clip, vector in zip(usable, vectors, strict=True)
|
|
318
|
+
]
|
|
319
|
+
|
|
320
|
+
def embed_query(self, text: str) -> list[float]:
|
|
321
|
+
return self._embed([{"text": text}])[0]
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class GatewayGeminiMultimodalEmbeddingProvider(VideoEmbeddingProvider):
|
|
325
|
+
"""Gemini Embedding 2 through Vercel AI Gateway's model-native API.
|
|
326
|
+
|
|
327
|
+
Unlike text-only embedding endpoints, this keeps a text query and JPEG
|
|
328
|
+
frame in one multimodal vector space. It replaces the dedicated Qwen
|
|
329
|
+
embedding endpoint for source indexing without introducing a GPU cold
|
|
330
|
+
start. The Gateway API differs from its OpenAI-compatible `/v1/embeddings`
|
|
331
|
+
route: model-native embedding calls use `/v4/ai/embedding-model`.
|
|
332
|
+
"""
|
|
333
|
+
|
|
334
|
+
name = "gateway-gemini-embedding-2"
|
|
335
|
+
dimensions = 3072
|
|
336
|
+
|
|
337
|
+
def __init__(self) -> None:
|
|
338
|
+
self.api_key = os.getenv("AI_GATEWAY_API_KEY") or ""
|
|
339
|
+
self.base_url = os.getenv(
|
|
340
|
+
"LARKUP_VIDEO_GATEWAY_EMBEDDING_BASE_URL", "https://ai-gateway.vercel.sh/v4/ai"
|
|
341
|
+
).rstrip("/")
|
|
342
|
+
self.model = os.getenv("LARKUP_VIDEO_GATEWAY_EMBEDDING_MODEL", "google/gemini-embedding-2")
|
|
343
|
+
self.dimensions = int(os.getenv("LARKUP_VIDEO_EMBEDDING_DIMENSION", str(self.dimensions)))
|
|
344
|
+
self.batch_size = max(1, min(6, int(os.getenv("LARKUP_VIDEO_GATEWAY_EMBEDDING_BATCH_SIZE", "6"))))
|
|
345
|
+
self.max_concurrency = max(
|
|
346
|
+
1, min(6, int(os.getenv("LARKUP_VIDEO_GATEWAY_EMBEDDING_CONCURRENCY", "6")))
|
|
347
|
+
)
|
|
348
|
+
self._sessions = threading.local()
|
|
349
|
+
|
|
350
|
+
def _session(self) -> requests.Session:
|
|
351
|
+
session = getattr(self._sessions, "session", None)
|
|
352
|
+
if session is None:
|
|
353
|
+
session = requests.Session()
|
|
354
|
+
self._sessions.session = session
|
|
355
|
+
return session
|
|
356
|
+
|
|
357
|
+
def _embed(
|
|
358
|
+
self,
|
|
359
|
+
values: list[str],
|
|
360
|
+
*,
|
|
361
|
+
content: list[list[dict] | None] | None = None,
|
|
362
|
+
task_type: str,
|
|
363
|
+
) -> list[list[float]]:
|
|
364
|
+
if not self.api_key:
|
|
365
|
+
raise VideoEmbeddingProviderError("AI_GATEWAY_API_KEY is not configured")
|
|
366
|
+
payload: dict = {"values": values}
|
|
367
|
+
google_options: dict = {"taskType": task_type}
|
|
368
|
+
if content is not None:
|
|
369
|
+
google_options["content"] = content
|
|
370
|
+
payload["providerOptions"] = {"google": google_options}
|
|
371
|
+
response = self._session().post(
|
|
372
|
+
f"{self.base_url}/embedding-model",
|
|
373
|
+
json=payload,
|
|
374
|
+
headers={
|
|
375
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
376
|
+
"Content-Type": "application/json",
|
|
377
|
+
"ai-gateway-protocol-version": "0.0.1",
|
|
378
|
+
"ai-gateway-auth-method": "api-key",
|
|
379
|
+
"ai-embedding-model-specification-version": "4",
|
|
380
|
+
"ai-model-id": self.model,
|
|
381
|
+
},
|
|
382
|
+
timeout=60,
|
|
383
|
+
)
|
|
384
|
+
if not response.ok:
|
|
385
|
+
raise VideoEmbeddingProviderError(
|
|
386
|
+
f"AI Gateway embedding request failed {response.status_code}: {response.text[:300]}"
|
|
387
|
+
)
|
|
388
|
+
body = response.json()
|
|
389
|
+
try:
|
|
390
|
+
vectors = body["embeddings"]
|
|
391
|
+
if not isinstance(vectors, list) or not all(isinstance(vector, list) for vector in vectors):
|
|
392
|
+
raise TypeError("embeddings is not a list of vectors")
|
|
393
|
+
return [[float(value) for value in vector] for vector in vectors]
|
|
394
|
+
except (KeyError, TypeError, ValueError) as error:
|
|
395
|
+
raise VideoEmbeddingProviderError(
|
|
396
|
+
f"unexpected AI Gateway embedding response shape: {str(body)[:500]}"
|
|
397
|
+
) from error
|
|
398
|
+
|
|
399
|
+
@staticmethod
|
|
400
|
+
def _frame_content(frame: np.ndarray) -> dict:
|
|
401
|
+
ok, buffer = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
|
|
402
|
+
if not ok:
|
|
403
|
+
raise ValueError("could not encode frame as JPEG")
|
|
404
|
+
return {
|
|
405
|
+
"inlineData": {
|
|
406
|
+
"mimeType": "image/jpeg",
|
|
407
|
+
"data": base64.b64encode(buffer.tobytes()).decode("ascii"),
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
def embed_clips(
|
|
412
|
+
self,
|
|
413
|
+
clips: list[VideoClipInput],
|
|
414
|
+
on_progress: Callable[[int, int], None] | None = None,
|
|
415
|
+
) -> list[VideoClipEmbedding]:
|
|
416
|
+
usable = [clip for clip in clips if clip.frames]
|
|
417
|
+
if not usable:
|
|
418
|
+
return []
|
|
419
|
+
batches = [usable[offset : offset + self.batch_size] for offset in range(0, len(usable), self.batch_size)]
|
|
420
|
+
|
|
421
|
+
def embed_batch(batch: list[VideoClipInput]) -> list[VideoClipEmbedding]:
|
|
422
|
+
vectors = self._embed(
|
|
423
|
+
["timestamped video clip" for _ in batch],
|
|
424
|
+
content=[
|
|
425
|
+
[self._frame_content(clip.frames[len(clip.frames) // 2][1])]
|
|
426
|
+
for clip in batch
|
|
427
|
+
],
|
|
428
|
+
task_type="RETRIEVAL_DOCUMENT",
|
|
429
|
+
)
|
|
430
|
+
if len(vectors) != len(batch):
|
|
431
|
+
raise VideoEmbeddingProviderError("AI Gateway returned a different number of embeddings than inputs")
|
|
432
|
+
return [
|
|
433
|
+
VideoClipEmbedding(clip_id=clip.clip_id, start_ms=clip.start_ms, end_ms=clip.end_ms, vector=vector)
|
|
434
|
+
for clip, vector in zip(batch, vectors, strict=True)
|
|
435
|
+
]
|
|
436
|
+
|
|
437
|
+
by_clip_id: dict[str, VideoClipEmbedding] = {}
|
|
438
|
+
completed = 0
|
|
439
|
+
with ThreadPoolExecutor(max_workers=min(self.max_concurrency, len(batches))) as pool:
|
|
440
|
+
futures = {pool.submit(embed_batch, batch): len(batch) for batch in batches}
|
|
441
|
+
for future in as_completed(futures):
|
|
442
|
+
embedded_batch = future.result()
|
|
443
|
+
by_clip_id.update({embedding.clip_id: embedding for embedding in embedded_batch})
|
|
444
|
+
completed += futures[future]
|
|
445
|
+
if on_progress:
|
|
446
|
+
on_progress(completed, len(usable))
|
|
447
|
+
return [by_clip_id[clip.clip_id] for clip in usable]
|
|
448
|
+
|
|
449
|
+
def embed_query(self, text: str) -> list[float]:
|
|
450
|
+
return self._embed([text], task_type="RETRIEVAL_QUERY")[0]
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
_PROVIDERS: dict[str, type[VideoEmbeddingProvider]] = {
|
|
454
|
+
"disabled": DisabledVideoEmbeddingProvider,
|
|
455
|
+
"gateway-gemini-embedding-2": GatewayGeminiMultimodalEmbeddingProvider,
|
|
456
|
+
"huggingface-qwen3-vl-embedding": HuggingFaceQwenVLEmbeddingProvider,
|
|
457
|
+
"qwen3-vl-embedding": QwenVLEmbeddingProvider,
|
|
458
|
+
"runpod-qwen3-vl-embedding": RunpodQwenVLEmbeddingProvider,
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def available_video_embedding_providers() -> tuple[str, ...]:
|
|
463
|
+
return tuple(sorted(_PROVIDERS))
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def get_video_embedding_provider(name: str | None = None) -> VideoEmbeddingProvider:
|
|
467
|
+
key = (name or os.getenv("LARKUP_VIDEO_EMBEDDING_PROVIDER", "disabled")).strip().lower()
|
|
468
|
+
provider_cls = _PROVIDERS.get(key)
|
|
469
|
+
if provider_cls is None:
|
|
470
|
+
raise VideoEmbeddingProviderError(
|
|
471
|
+
f"Unknown video embedding provider {key!r}; available: {available_video_embedding_providers()}"
|
|
472
|
+
)
|
|
473
|
+
return provider_cls()
|