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