@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,162 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
6
+
7
+
8
+ class ApiModel(BaseModel):
9
+ model_config = ConfigDict(alias_generator=lambda value: _to_camel(value), populate_by_name=True)
10
+
11
+
12
+ def _to_camel(value: str) -> str:
13
+ head, *tail = value.split("_")
14
+ return head + "".join(part.capitalize() for part in tail)
15
+
16
+
17
+ class VideoIndexingBrief(ApiModel):
18
+ goal: str | None = Field(default=None, max_length=2_000)
19
+ # Descriptive metadata only. The agent selects modalities from source
20
+ # evidence and the user's goal, never from a fixed genre branch.
21
+ content_type: str = Field(default="general", max_length=120)
22
+ known_entities: list[str] = Field(default_factory=list, max_length=50)
23
+ expected_questions: list[str] = Field(default_factory=list, max_length=20)
24
+ language: str = Field(default="auto", max_length=32)
25
+ important_ranges: list[dict[str, Any]] = Field(default_factory=list, max_length=20)
26
+ indexing_mode: Literal["fast", "balanced", "thorough"] = "balanced"
27
+ processing_authority_confirmed: bool = False
28
+ retain_source_hours: int = Field(default=0, ge=0, le=720)
29
+ skip_transcription: bool = False
30
+ # A live bounded inspection publishes timestamped evidence immediately;
31
+ # its clip vectors would not be stored or queried during that same turn.
32
+ # Skip the optional retrieval index there to keep an answer independent of
33
+ # an embedding worker cold start.
34
+ skip_video_embeddings: bool = False
35
+ # Semantic vision can answer ordinary visual questions directly. Reserve
36
+ # CPU OCR/detection for requests that actually need those operators.
37
+ skip_heavy_operators: bool = False
38
+ # A bounded chat verification already decided that fresh visual evidence
39
+ # is required. The runtime planner may tune sampling, but cannot disable
40
+ # the only remaining visual evidence source for that pass.
41
+ require_semantic_vision: bool = False
42
+ # Read the requested range as one chronological sequence instead of
43
+ # independent clips, so a before/after relationship inside it survives.
44
+ continuous_sequence: bool = False
45
+ # The retrieval agent may spend a denser visual budget on a bounded close
46
+ # read. Whole-source indexing leaves this unset and follows the plan.
47
+ max_frames: int | None = Field(default=None, ge=1, le=24)
48
+ # A bounded conversational verification is already planned by the host.
49
+ # It uses the deterministic fast lane in the worker and never changes the
50
+ # behavior of a normal media-indexing request.
51
+ interactive: bool = False
52
+ # A selected external transcription provider can supply timestamped speech
53
+ # to the semantic reader without forcing the worker to transcribe twice.
54
+ transcript_context: list[dict[str, Any]] = Field(default_factory=list, max_length=2_000)
55
+
56
+ @field_validator("goal", "language", "content_type")
57
+ @classmethod
58
+ def strip_optional_text(cls, value: str | None) -> str | None:
59
+ if value is None:
60
+ return None
61
+ stripped = value.strip()
62
+ return stripped or None
63
+
64
+ @field_validator("known_entities", "expected_questions")
65
+ @classmethod
66
+ def normalize_lists(cls, value: list[str]) -> list[str]:
67
+ normalized: list[str] = []
68
+ seen: set[str] = set()
69
+ for item in value:
70
+ candidate = item.strip()
71
+ key = candidate.casefold()
72
+ if candidate and key not in seen:
73
+ normalized.append(candidate[:200])
74
+ seen.add(key)
75
+ return normalized
76
+
77
+ class VideoSource(ApiModel):
78
+ upload_id: str
79
+ file_name: str | None = None
80
+
81
+
82
+ class ProviderModelCredential(ApiModel):
83
+ provider: str = Field(min_length=1, max_length=64)
84
+ api_key: str = Field(min_length=1, max_length=2_048)
85
+ model: str = Field(min_length=1, max_length=256)
86
+
87
+
88
+ class JobModelConfiguration(ApiModel):
89
+ audio: ProviderModelCredential
90
+ brain: ProviderModelCredential
91
+ vision: ProviderModelCredential
92
+
93
+
94
+ class CreateJobRequest(ApiModel):
95
+ source: VideoSource
96
+ brief: VideoIndexingBrief = Field(default_factory=VideoIndexingBrief)
97
+ # Custom-remote runtimes accept the same transient BYOK contract as
98
+ # managed cloud. Local calls omit it and use process-scoped settings.
99
+ model_configuration: JobModelConfiguration | None = None
100
+
101
+
102
+ class JobProgress(ApiModel):
103
+ stage: Literal[
104
+ "queued", "prepare", "probe", "decode", "transcribe", "ocr", "detect", "synthesize", "complete"
105
+ ]
106
+ percent: int = Field(ge=0, le=100)
107
+ message: str
108
+ # How far through `stage` alone the job is. A host that renders one bar
109
+ # per step reads this instead of keeping its own copy of the pipeline's
110
+ # phase budget, which would break silently whenever that budget changed.
111
+ stage_percent: int | None = Field(default=None, ge=0, le=100)
112
+ sequence: int | None = Field(default=None, ge=0)
113
+ elapsed_seconds: int | None = Field(default=None, ge=0)
114
+ estimated_remaining_seconds: int | None = Field(default=None, ge=0)
115
+ current: int | None = Field(default=None, ge=0)
116
+ total: int | None = Field(default=None, ge=0)
117
+ unit: str | None = Field(default=None, max_length=80)
118
+
119
+
120
+ class UsageSummary(ApiModel):
121
+ period_start: str
122
+ period_end: str
123
+ source_minutes_used: float
124
+ source_minutes_limit: float | None
125
+ active_jobs: int
126
+ concurrent_jobs_limit: int
127
+
128
+
129
+ class JobResponse(ApiModel):
130
+ id: str
131
+ status: Literal["queued", "running", "completed", "failed", "cancelled"]
132
+ created_at: str
133
+ updated_at: str
134
+ progress: JobProgress
135
+ estimated_source_minutes: float
136
+ result: dict[str, Any] | None = None
137
+ error: str | None = None
138
+
139
+
140
+ class RedeemAccessCodeRequest(ApiModel):
141
+ code: str = Field(min_length=4, max_length=128)
142
+ label: str | None = Field(default=None, max_length=120)
143
+
144
+
145
+ class RedeemAccessCodeResponse(ApiModel):
146
+ api_key: str
147
+ entitlement: dict[str, Any]
148
+
149
+
150
+ class CreateAccessCodeRequest(ApiModel):
151
+ label: str = Field(min_length=1, max_length=120)
152
+ source_minutes_per_month: float = Field(default=600, ge=1, le=1_000_000)
153
+ max_concurrent_jobs: int = Field(default=1, ge=1, le=100)
154
+ max_uses: int = Field(default=1, ge=1, le=100_000)
155
+ expires_at: str | None = None
156
+
157
+
158
+ class CreateAccessCodeResponse(ApiModel):
159
+ code: str
160
+ label: str
161
+ max_uses: int
162
+ expires_at: str | None
@@ -0,0 +1,466 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import secrets
6
+ import sqlite3
7
+ from contextlib import contextmanager
8
+ from dataclasses import dataclass
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any, Iterator
12
+
13
+
14
+ DEFAULT_LOCAL_ENTITLEMENT = {
15
+ "sourceMinutesPerMonth": None,
16
+ "maxConcurrentJobs": 4,
17
+ "plan": "local",
18
+ }
19
+
20
+
21
+ class StoreError(RuntimeError):
22
+ pass
23
+
24
+
25
+ class AuthenticationError(StoreError):
26
+ pass
27
+
28
+
29
+ class QuotaExceededError(StoreError):
30
+ pass
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Principal:
35
+ id: str
36
+ entitlement: dict[str, Any]
37
+
38
+
39
+ def utc_now() -> str:
40
+ return datetime.now(timezone.utc).isoformat()
41
+
42
+
43
+ def _digest(value: str) -> str:
44
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
45
+
46
+
47
+ def _period() -> tuple[str, str]:
48
+ now = datetime.now(timezone.utc)
49
+ start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
50
+ if start.month == 12:
51
+ end = start.replace(year=start.year + 1, month=1)
52
+ else:
53
+ end = start.replace(month=start.month + 1)
54
+ return start.isoformat(), end.isoformat()
55
+
56
+
57
+ class Store:
58
+ def __init__(self, path: Path):
59
+ self.path = path
60
+ self.path.parent.mkdir(parents=True, exist_ok=True)
61
+ self._initialize()
62
+
63
+ @contextmanager
64
+ def connect(self) -> Iterator[sqlite3.Connection]:
65
+ connection = sqlite3.connect(self.path, timeout=30)
66
+ connection.row_factory = sqlite3.Row
67
+ connection.execute("PRAGMA foreign_keys = ON")
68
+ connection.execute("PRAGMA journal_mode = WAL")
69
+ try:
70
+ yield connection
71
+ connection.commit()
72
+ except Exception:
73
+ connection.rollback()
74
+ raise
75
+ finally:
76
+ connection.close()
77
+
78
+ def _initialize(self) -> None:
79
+ with self.connect() as connection:
80
+ connection.executescript(
81
+ """
82
+ CREATE TABLE IF NOT EXISTS principals (
83
+ id TEXT PRIMARY KEY,
84
+ label TEXT NOT NULL,
85
+ entitlement_json TEXT NOT NULL,
86
+ created_at TEXT NOT NULL
87
+ );
88
+ CREATE TABLE IF NOT EXISTS api_keys (
89
+ key_hash TEXT PRIMARY KEY,
90
+ principal_id TEXT NOT NULL REFERENCES principals(id),
91
+ created_at TEXT NOT NULL,
92
+ revoked_at TEXT
93
+ );
94
+ CREATE TABLE IF NOT EXISTS access_codes (
95
+ code_hash TEXT PRIMARY KEY,
96
+ label TEXT NOT NULL,
97
+ entitlement_json TEXT NOT NULL,
98
+ max_uses INTEGER NOT NULL,
99
+ uses INTEGER NOT NULL DEFAULT 0,
100
+ expires_at TEXT,
101
+ created_at TEXT NOT NULL
102
+ );
103
+ CREATE TABLE IF NOT EXISTS uploads (
104
+ id TEXT PRIMARY KEY,
105
+ principal_id TEXT NOT NULL,
106
+ file_name TEXT NOT NULL,
107
+ path TEXT NOT NULL,
108
+ size_bytes INTEGER NOT NULL,
109
+ created_at TEXT NOT NULL
110
+ );
111
+ CREATE TABLE IF NOT EXISTS jobs (
112
+ id TEXT PRIMARY KEY,
113
+ principal_id TEXT NOT NULL,
114
+ upload_id TEXT NOT NULL REFERENCES uploads(id),
115
+ request_json TEXT NOT NULL,
116
+ status TEXT NOT NULL,
117
+ progress_json TEXT NOT NULL,
118
+ estimated_minutes REAL NOT NULL,
119
+ result_json TEXT,
120
+ error TEXT,
121
+ created_at TEXT NOT NULL,
122
+ updated_at TEXT NOT NULL
123
+ );
124
+ CREATE INDEX IF NOT EXISTS jobs_principal_status
125
+ ON jobs(principal_id, status);
126
+ CREATE TABLE IF NOT EXISTS usage_events (
127
+ id TEXT PRIMARY KEY,
128
+ principal_id TEXT NOT NULL,
129
+ job_id TEXT NOT NULL UNIQUE,
130
+ metric TEXT NOT NULL,
131
+ amount REAL NOT NULL,
132
+ created_at TEXT NOT NULL
133
+ );
134
+ """
135
+ )
136
+
137
+ def resolve_principal(
138
+ self, api_key: str | None, require_auth: bool, shared_api_key: str | None = None
139
+ ) -> Principal:
140
+ if shared_api_key and api_key and secrets.compare_digest(api_key, shared_api_key):
141
+ return Principal("local", DEFAULT_LOCAL_ENTITLEMENT.copy())
142
+ if not require_auth and not shared_api_key:
143
+ return Principal("local", DEFAULT_LOCAL_ENTITLEMENT.copy())
144
+ if not api_key:
145
+ raise AuthenticationError("missing API key")
146
+ with self.connect() as connection:
147
+ row = connection.execute(
148
+ """
149
+ SELECT p.id, p.entitlement_json
150
+ FROM api_keys k JOIN principals p ON p.id = k.principal_id
151
+ WHERE k.key_hash = ? AND k.revoked_at IS NULL
152
+ """,
153
+ (_digest(api_key),),
154
+ ).fetchone()
155
+ if row is None:
156
+ raise AuthenticationError("invalid API key")
157
+ return Principal(row["id"], json.loads(row["entitlement_json"]))
158
+
159
+ def create_access_code(
160
+ self,
161
+ *,
162
+ label: str,
163
+ entitlement: dict[str, Any],
164
+ max_uses: int,
165
+ expires_at: str | None,
166
+ ) -> str:
167
+ code = "lvi_code_" + secrets.token_urlsafe(18)
168
+ with self.connect() as connection:
169
+ connection.execute(
170
+ """
171
+ INSERT INTO access_codes
172
+ (code_hash, label, entitlement_json, max_uses, expires_at, created_at)
173
+ VALUES (?, ?, ?, ?, ?, ?)
174
+ """,
175
+ (_digest(code), label, json.dumps(entitlement), max_uses, expires_at, utc_now()),
176
+ )
177
+ return code
178
+
179
+ def redeem_access_code(self, code: str, label: str | None) -> tuple[str, dict[str, Any]]:
180
+ now = utc_now()
181
+ api_key = "lvi_" + secrets.token_urlsafe(30)
182
+ principal_id = "usr_" + secrets.token_hex(12)
183
+ with self.connect() as connection:
184
+ connection.execute("BEGIN IMMEDIATE")
185
+ row = connection.execute(
186
+ "SELECT * FROM access_codes WHERE code_hash = ?", (_digest(code),)
187
+ ).fetchone()
188
+ if row is None or row["uses"] >= row["max_uses"]:
189
+ raise AuthenticationError("invalid or exhausted access code")
190
+ if row["expires_at"] and row["expires_at"] <= now:
191
+ raise AuthenticationError("expired access code")
192
+ entitlement = json.loads(row["entitlement_json"])
193
+ connection.execute(
194
+ "UPDATE access_codes SET uses = uses + 1 WHERE code_hash = ?",
195
+ (_digest(code),),
196
+ )
197
+ connection.execute(
198
+ "INSERT INTO principals VALUES (?, ?, ?, ?)",
199
+ (principal_id, label or row["label"], json.dumps(entitlement), now),
200
+ )
201
+ connection.execute(
202
+ "INSERT INTO api_keys VALUES (?, ?, ?, NULL)",
203
+ (_digest(api_key), principal_id, now),
204
+ )
205
+ return api_key, entitlement
206
+
207
+ def create_upload(
208
+ self, principal_id: str, upload_id: str, file_name: str, path: Path, size_bytes: int
209
+ ) -> None:
210
+ with self.connect() as connection:
211
+ connection.execute(
212
+ "INSERT INTO uploads VALUES (?, ?, ?, ?, ?, ?)",
213
+ (upload_id, principal_id, file_name, str(path), size_bytes, utc_now()),
214
+ )
215
+
216
+ def get_upload(self, principal_id: str, upload_id: str) -> dict[str, Any]:
217
+ with self.connect() as connection:
218
+ row = connection.execute(
219
+ "SELECT * FROM uploads WHERE id = ? AND principal_id = ?",
220
+ (upload_id, principal_id),
221
+ ).fetchone()
222
+ if row is None:
223
+ raise StoreError("upload not found")
224
+ return dict(row)
225
+
226
+ def create_job(
227
+ self,
228
+ principal: Principal,
229
+ job_id: str,
230
+ upload_id: str,
231
+ request: dict[str, Any],
232
+ estimated_minutes: float,
233
+ ) -> None:
234
+ entitlement = principal.entitlement
235
+ period_start, period_end = _period()
236
+ with self.connect() as connection:
237
+ connection.execute("BEGIN IMMEDIATE")
238
+ upload = connection.execute(
239
+ "SELECT 1 FROM uploads WHERE id = ? AND principal_id = ?",
240
+ (upload_id, principal.id),
241
+ ).fetchone()
242
+ if upload is None:
243
+ raise StoreError("upload not found")
244
+ active = connection.execute(
245
+ "SELECT COUNT(*) FROM jobs WHERE principal_id = ? AND status IN ('queued', 'running')",
246
+ (principal.id,),
247
+ ).fetchone()[0]
248
+ if active >= int(entitlement.get("maxConcurrentJobs", 1)):
249
+ raise QuotaExceededError("concurrent job limit reached")
250
+ used = connection.execute(
251
+ """
252
+ SELECT COALESCE(SUM(amount), 0) FROM usage_events
253
+ WHERE principal_id = ? AND metric = 'source-minute'
254
+ AND created_at >= ? AND created_at < ?
255
+ """,
256
+ (principal.id, period_start, period_end),
257
+ ).fetchone()[0]
258
+ reserved = connection.execute(
259
+ """
260
+ SELECT COALESCE(SUM(estimated_minutes), 0) FROM jobs
261
+ WHERE principal_id = ? AND status IN ('queued', 'running')
262
+ """,
263
+ (principal.id,),
264
+ ).fetchone()[0]
265
+ limit = entitlement.get("sourceMinutesPerMonth")
266
+ if limit is not None and used + reserved + estimated_minutes > float(limit):
267
+ raise QuotaExceededError("monthly source-minute limit reached")
268
+ now = utc_now()
269
+ progress = {"stage": "queued", "percent": 0, "message": "Waiting for a worker"}
270
+ connection.execute(
271
+ """
272
+ INSERT INTO jobs
273
+ (id, principal_id, upload_id, request_json, status, progress_json,
274
+ estimated_minutes, created_at, updated_at)
275
+ VALUES (?, ?, ?, ?, 'queued', ?, ?, ?, ?)
276
+ """,
277
+ (
278
+ job_id,
279
+ principal.id,
280
+ upload_id,
281
+ json.dumps(request),
282
+ json.dumps(progress),
283
+ estimated_minutes,
284
+ now,
285
+ now,
286
+ ),
287
+ )
288
+
289
+ def get_job(self, principal_id: str, job_id: str) -> dict[str, Any]:
290
+ with self.connect() as connection:
291
+ row = connection.execute(
292
+ "SELECT * FROM jobs WHERE id = ? AND principal_id = ?", (job_id, principal_id)
293
+ ).fetchone()
294
+ if row is None:
295
+ raise StoreError("job not found")
296
+ return self._job_dict(row)
297
+
298
+ def get_job_for_worker(self, job_id: str) -> dict[str, Any]:
299
+ with self.connect() as connection:
300
+ row = connection.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
301
+ if row is None:
302
+ raise StoreError("job not found")
303
+ result = self._job_dict(row)
304
+ result["request"] = json.loads(row["request_json"])
305
+ result["principal_id"] = row["principal_id"]
306
+ result["upload_id"] = row["upload_id"]
307
+ return result
308
+
309
+ @staticmethod
310
+ def _job_dict(row: sqlite3.Row) -> dict[str, Any]:
311
+ return {
312
+ "id": row["id"],
313
+ "status": row["status"],
314
+ "createdAt": row["created_at"],
315
+ "updatedAt": row["updated_at"],
316
+ "progress": json.loads(row["progress_json"]),
317
+ "estimatedSourceMinutes": row["estimated_minutes"],
318
+ "result": json.loads(row["result_json"]) if row["result_json"] else None,
319
+ "error": row["error"],
320
+ }
321
+
322
+ def update_job(
323
+ self,
324
+ job_id: str,
325
+ stage: str,
326
+ percent: int,
327
+ message: str,
328
+ stage_percent: int | None = None,
329
+ details: dict[str, int | float | str] | None = None,
330
+ ) -> None:
331
+ with self.connect() as connection:
332
+ connection.execute("BEGIN IMMEDIATE")
333
+ row = connection.execute(
334
+ "SELECT progress_json FROM jobs WHERE id = ? AND status IN ('queued', 'running')",
335
+ (job_id,),
336
+ ).fetchone()
337
+ if row is None:
338
+ return
339
+ try:
340
+ previous_percent = int(json.loads(row["progress_json"]).get("percent", 0))
341
+ except (TypeError, ValueError, json.JSONDecodeError):
342
+ previous_percent = 0
343
+ # Decode and transcription can overlap. Their independently
344
+ # measured callbacks are valid, but a poller has only one overall
345
+ # progress value; never make that value move backwards when the
346
+ # later callback belongs to work already running in parallel.
347
+ percent = max(previous_percent, min(99, max(0, int(percent))))
348
+ connection.execute(
349
+ """
350
+ UPDATE jobs SET status = 'running', progress_json = ?, updated_at = ?
351
+ WHERE id = ? AND status IN ('queued', 'running')
352
+ """,
353
+ (
354
+ json.dumps(
355
+ {
356
+ "stage": stage,
357
+ "percent": percent,
358
+ "message": message,
359
+ # A host drawing one bar per step reads this
360
+ # instead of re-deriving the stage's band.
361
+ **({"stagePercent": stage_percent} if stage_percent is not None else {}),
362
+ **(details or {}),
363
+ }
364
+ ),
365
+ utc_now(),
366
+ job_id,
367
+ ),
368
+ )
369
+
370
+ def finish_job(self, job_id: str, result: dict[str, Any], actual_minutes: float) -> None:
371
+ with self.connect() as connection:
372
+ connection.execute("BEGIN IMMEDIATE")
373
+ row = connection.execute(
374
+ "SELECT principal_id, status FROM jobs WHERE id = ?", (job_id,)
375
+ ).fetchone()
376
+ if row is None or row["status"] == "cancelled":
377
+ return
378
+ now = utc_now()
379
+ connection.execute(
380
+ """
381
+ UPDATE jobs SET status = 'completed', progress_json = ?, result_json = ?, updated_at = ?
382
+ WHERE id = ?
383
+ """,
384
+ (
385
+ json.dumps({"stage": "complete", "percent": 100, "message": "Index ready"}),
386
+ json.dumps(result),
387
+ now,
388
+ job_id,
389
+ ),
390
+ )
391
+ connection.execute(
392
+ "INSERT OR IGNORE INTO usage_events VALUES (?, ?, ?, 'source-minute', ?, ?)",
393
+ ("evt_" + secrets.token_hex(12), row["principal_id"], job_id, actual_minutes, now),
394
+ )
395
+
396
+ def fail_job(self, job_id: str, error: str) -> None:
397
+ with self.connect() as connection:
398
+ connection.execute(
399
+ """
400
+ UPDATE jobs SET status = 'failed', error = ?, updated_at = ?
401
+ WHERE id = ? AND status != 'cancelled'
402
+ """,
403
+ (error[:2_000], utc_now(), job_id),
404
+ )
405
+
406
+ def cancel_job(self, principal_id: str, job_id: str) -> None:
407
+ with self.connect() as connection:
408
+ cursor = connection.execute(
409
+ """
410
+ UPDATE jobs SET status = 'cancelled', updated_at = ?
411
+ WHERE id = ? AND principal_id = ? AND status IN ('queued', 'running')
412
+ """,
413
+ (utc_now(), job_id, principal_id),
414
+ )
415
+ if cursor.rowcount == 0:
416
+ raise StoreError("job cannot be cancelled")
417
+
418
+ def purge_job_data(self, principal_id: str, job_id: str) -> None:
419
+ """Irreversibly remove one local job's result and uploaded source.
420
+
421
+ This is intentionally separate from cancellation: a running worker is
422
+ first cancelled by the caller, then this method removes the durable
423
+ cache only once the job is terminal. A deleted Larkup media asset
424
+ must not remain readable through an old local-runtime job id.
425
+ """
426
+ with self.connect() as connection:
427
+ row = connection.execute(
428
+ """
429
+ SELECT j.status, j.upload_id, u.path
430
+ FROM jobs j JOIN uploads u ON u.id = j.upload_id
431
+ WHERE j.id = ? AND j.principal_id = ?
432
+ """,
433
+ (job_id, principal_id),
434
+ ).fetchone()
435
+ if row is None:
436
+ raise StoreError("job not found")
437
+ if row["status"] in {"queued", "running"}:
438
+ raise StoreError("job must be cancelled before its data can be removed")
439
+ connection.execute("DELETE FROM jobs WHERE id = ? AND principal_id = ?", (job_id, principal_id))
440
+ connection.execute("DELETE FROM uploads WHERE id = ? AND principal_id = ?", (row["upload_id"], principal_id))
441
+ Path(row["path"]).unlink(missing_ok=True)
442
+
443
+ def usage(self, principal: Principal) -> dict[str, Any]:
444
+ period_start, period_end = _period()
445
+ with self.connect() as connection:
446
+ used = connection.execute(
447
+ """
448
+ SELECT COALESCE(SUM(amount), 0) FROM usage_events
449
+ WHERE principal_id = ? AND metric = 'source-minute'
450
+ AND created_at >= ? AND created_at < ?
451
+ """,
452
+ (principal.id, period_start, period_end),
453
+ ).fetchone()[0]
454
+ active = connection.execute(
455
+ "SELECT COUNT(*) FROM jobs WHERE principal_id = ? AND status IN ('queued', 'running')",
456
+ (principal.id,),
457
+ ).fetchone()[0]
458
+ entitlement = principal.entitlement
459
+ return {
460
+ "periodStart": period_start,
461
+ "periodEnd": period_end,
462
+ "sourceMinutesUsed": round(float(used), 3),
463
+ "sourceMinutesLimit": entitlement.get("sourceMinutesPerMonth"),
464
+ "activeJobs": active,
465
+ "concurrentJobsLimit": entitlement.get("maxConcurrentJobs", 1),
466
+ }
@@ -0,0 +1,27 @@
1
+ """FastAPI app entry point: creates the app, wires CORS and exception
2
+ handlers, and mounts the v1 API. Run with `uv run larkup-video-runtime`.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from fastapi import FastAPI
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+
10
+ from app.api.deps import settings
11
+ from app.api.v1 import register_exception_handlers, router
12
+
13
+ app = FastAPI(
14
+ title="Larkup Video Intelligence Runtime",
15
+ version="0.1.0",
16
+ docs_url="/docs" if not settings.require_auth else None,
17
+ redoc_url=None,
18
+ )
19
+ app.add_middleware(
20
+ CORSMiddleware,
21
+ allow_origins=list(settings.allowed_origins),
22
+ allow_credentials=False,
23
+ allow_methods=["GET", "POST", "DELETE"],
24
+ allow_headers=["Authorization", "Content-Type", "X-Larkup-Admin-Token"],
25
+ )
26
+ register_exception_handlers(app)
27
+ app.include_router(router)