davinci-resolve-mcp 2.23.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/AGENTS.md +85 -0
- package/CHANGELOG.md +802 -0
- package/CLAUDE.md +15 -0
- package/LICENSE +21 -0
- package/README.md +159 -0
- package/SECURITY.md +53 -0
- package/bin/davinci-resolve-mcp.mjs +376 -0
- package/docs/README.md +56 -0
- package/docs/SKILL.md +1145 -0
- package/docs/authoring/fuse-dctl-authoring.md +242 -0
- package/docs/authoring/script-plugin-authoring.md +195 -0
- package/docs/contributing.md +82 -0
- package/docs/guides/color-decision-guide.md +387 -0
- package/docs/guides/editorial-decision-guide.md +136 -0
- package/docs/guides/media-analysis-guide.md +615 -0
- package/docs/guides/multicam-setup-guide.md +138 -0
- package/docs/install.md +198 -0
- package/docs/integrations/workflow-integrations.md +120 -0
- package/docs/kernels/README.md +28 -0
- package/docs/kernels/audio-fairlight-kernel.md +86 -0
- package/docs/kernels/color-grade-kernel.md +103 -0
- package/docs/kernels/extension-authoring-kernel.md +101 -0
- package/docs/kernels/fusion-composition-kernel.md +91 -0
- package/docs/kernels/media-pool-ingest-kernel.md +147 -0
- package/docs/kernels/project-lifecycle-kernel.md +120 -0
- package/docs/kernels/render-deliver-kernel.md +92 -0
- package/docs/kernels/review-annotation-kernel.md +110 -0
- package/docs/kernels/timeline-conform-interchange-kernel.md +99 -0
- package/docs/kernels/timeline-edit-kernel.md +189 -0
- package/docs/notes/codec-plugin-notes.md +136 -0
- package/docs/notes/dctl-notes.md +234 -0
- package/docs/notes/fusion-template-notes.md +136 -0
- package/docs/notes/lut-notes.md +136 -0
- package/docs/notes/openfx-notes.md +120 -0
- package/docs/process/release-process.md +152 -0
- package/docs/reference/api-coverage.md +488 -0
- package/docs/reference/resolve_scripting_api.txt +1012 -0
- package/examples/README.md +53 -0
- package/examples/markers/README.md +81 -0
- package/examples/media/README.md +94 -0
- package/examples/timeline/README.md +98 -0
- package/install.py +1196 -0
- package/package.json +52 -0
- package/scripts/audit_api_parity.py +275 -0
- package/scripts/live_media_analysis_polish_probe.py +65 -0
- package/src/__init__.py +3 -0
- package/src/analysis_dashboard.py +4936 -0
- package/src/control_panel.py +13 -0
- package/src/granular/__init__.py +17 -0
- package/src/granular/common.py +727 -0
- package/src/granular/folder.py +287 -0
- package/src/granular/gallery.py +306 -0
- package/src/granular/graph.py +309 -0
- package/src/granular/media_pool.py +679 -0
- package/src/granular/media_pool_item.py +852 -0
- package/src/granular/media_storage.py +179 -0
- package/src/granular/project.py +1594 -0
- package/src/granular/resolve_control.py +521 -0
- package/src/granular/timeline.py +1074 -0
- package/src/granular/timeline_item.py +2251 -0
- package/src/resolve_mcp_server.py +43 -0
- package/src/server.py +15691 -0
- package/src/utils/__init__.py +3 -0
- package/src/utils/app_control.py +319 -0
- package/src/utils/audio_fairlight_live_probe.py +263 -0
- package/src/utils/cdl.py +20 -0
- package/src/utils/cloud_operations.py +192 -0
- package/src/utils/color_grade_live_probe.py +444 -0
- package/src/utils/dctl_templates.py +368 -0
- package/src/utils/extension_authoring_live_probe.py +292 -0
- package/src/utils/fuse_templates.py +1968 -0
- package/src/utils/fusion_composition_live_probe.py +284 -0
- package/src/utils/layout_presets.py +333 -0
- package/src/utils/mcp_stdio.py +32 -0
- package/src/utils/media_analysis.py +3618 -0
- package/src/utils/media_analysis_jobs.py +796 -0
- package/src/utils/media_pool_ingest_live_probe.py +592 -0
- package/src/utils/multicam.py +393 -0
- package/src/utils/object_inspection.py +287 -0
- package/src/utils/platform.py +157 -0
- package/src/utils/project_lifecycle_live_probe.py +376 -0
- package/src/utils/project_properties.py +601 -0
- package/src/utils/render_deliver_live_probe.py +384 -0
- package/src/utils/resolve_connection.py +77 -0
- package/src/utils/review_annotation_live_probe.py +352 -0
- package/src/utils/script_templates.py +1193 -0
- package/src/utils/sync_detection.py +887 -0
- package/src/utils/timeline_conform_live_probe.py +280 -0
- package/src/utils/timeline_kernel_live_probe.py +1091 -0
- package/src/utils/timeline_kernel_probe.py +185 -0
- package/src/utils/timeline_title_text.py +87 -0
- package/src/utils/update_check.py +610 -0
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
"""Single-user batch jobs for source-safe media analysis.
|
|
2
|
+
|
|
3
|
+
The job layer is deliberately small and durable: SQLite tracks operational
|
|
4
|
+
state, the existing JSON reports remain the analysis source of truth, and each
|
|
5
|
+
runner call processes a bounded slice so agents do not need to hold long chat
|
|
6
|
+
turns open.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import copy
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sqlite3
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
19
|
+
|
|
20
|
+
from src.utils.media_analysis import (
|
|
21
|
+
ANALYSIS_VERSION,
|
|
22
|
+
build_analysis_index,
|
|
23
|
+
build_plan,
|
|
24
|
+
detect_capabilities,
|
|
25
|
+
execute_plan,
|
|
26
|
+
normalize_path,
|
|
27
|
+
resolve_output_root,
|
|
28
|
+
stable_clip_directory,
|
|
29
|
+
summarize_reports,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
JOBS_DB_FILENAME = "jobs.sqlite"
|
|
34
|
+
JOB_SCHEMA_VERSION = 1
|
|
35
|
+
JOB_DIR_NAME = "jobs"
|
|
36
|
+
MEDIA_EXTENSIONS = {
|
|
37
|
+
".3g2",
|
|
38
|
+
".3gp",
|
|
39
|
+
".aac",
|
|
40
|
+
".aif",
|
|
41
|
+
".aiff",
|
|
42
|
+
".ari",
|
|
43
|
+
".arx",
|
|
44
|
+
".braw",
|
|
45
|
+
".cin",
|
|
46
|
+
".crm",
|
|
47
|
+
".dng",
|
|
48
|
+
".dv",
|
|
49
|
+
".exr",
|
|
50
|
+
".flac",
|
|
51
|
+
".m4a",
|
|
52
|
+
".m4v",
|
|
53
|
+
".mkv",
|
|
54
|
+
".mov",
|
|
55
|
+
".mp3",
|
|
56
|
+
".mp4",
|
|
57
|
+
".mxf",
|
|
58
|
+
".ogg",
|
|
59
|
+
".r3d",
|
|
60
|
+
".rmf",
|
|
61
|
+
".wav",
|
|
62
|
+
".webm",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _utc_now() -> str:
|
|
67
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _stable_json(value: Any) -> str:
|
|
71
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _read_json(value: str) -> Dict[str, Any]:
|
|
75
|
+
try:
|
|
76
|
+
payload = json.loads(value or "{}")
|
|
77
|
+
except json.JSONDecodeError:
|
|
78
|
+
return {}
|
|
79
|
+
return payload if isinstance(payload, dict) else {}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _job_id(name: str, target: Dict[str, Any], created_at: str) -> str:
|
|
83
|
+
basis = _stable_json({"name": name, "target": target, "created_at": created_at})
|
|
84
|
+
return "job-" + hashlib.sha1(basis.encode("utf-8")).hexdigest()[:16]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_relative_to(path: str, parent: str) -> bool:
|
|
88
|
+
try:
|
|
89
|
+
return os.path.commonpath([path, parent]) == parent
|
|
90
|
+
except ValueError:
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def job_db_path(project_root: str, path: Optional[Any] = None) -> Tuple[Optional[str], Optional[str]]:
|
|
95
|
+
root = normalize_path(project_root)
|
|
96
|
+
candidate = normalize_path(path) if path else os.path.join(root, JOBS_DB_FILENAME)
|
|
97
|
+
if not _is_relative_to(candidate, root):
|
|
98
|
+
return None, "jobs database path must be under the project analysis root"
|
|
99
|
+
return candidate, None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _connect_jobs(project_root: str, path: Optional[Any] = None) -> sqlite3.Connection:
|
|
103
|
+
db_path, err = job_db_path(project_root, path)
|
|
104
|
+
if err or not db_path:
|
|
105
|
+
raise ValueError(err or "Invalid jobs database path")
|
|
106
|
+
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
|
107
|
+
conn = sqlite3.connect(db_path)
|
|
108
|
+
conn.row_factory = sqlite3.Row
|
|
109
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
110
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
111
|
+
_ensure_schema(conn)
|
|
112
|
+
return conn
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _ensure_schema(conn: sqlite3.Connection) -> None:
|
|
116
|
+
conn.executescript(
|
|
117
|
+
"""
|
|
118
|
+
CREATE TABLE IF NOT EXISTS job_metadata (
|
|
119
|
+
key TEXT PRIMARY KEY,
|
|
120
|
+
value TEXT NOT NULL
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
124
|
+
job_id TEXT PRIMARY KEY,
|
|
125
|
+
name TEXT NOT NULL,
|
|
126
|
+
status TEXT NOT NULL,
|
|
127
|
+
phase TEXT NOT NULL,
|
|
128
|
+
project_name TEXT NOT NULL,
|
|
129
|
+
project_id TEXT,
|
|
130
|
+
project_root TEXT NOT NULL,
|
|
131
|
+
target_json TEXT NOT NULL,
|
|
132
|
+
params_json TEXT NOT NULL,
|
|
133
|
+
plan_json TEXT NOT NULL,
|
|
134
|
+
total_clips INTEGER NOT NULL DEFAULT 0,
|
|
135
|
+
pending_clips INTEGER NOT NULL DEFAULT 0,
|
|
136
|
+
running_clips INTEGER NOT NULL DEFAULT 0,
|
|
137
|
+
succeeded_clips INTEGER NOT NULL DEFAULT 0,
|
|
138
|
+
failed_clips INTEGER NOT NULL DEFAULT 0,
|
|
139
|
+
skipped_clips INTEGER NOT NULL DEFAULT 0,
|
|
140
|
+
last_error TEXT,
|
|
141
|
+
created_at TEXT NOT NULL,
|
|
142
|
+
updated_at TEXT NOT NULL,
|
|
143
|
+
started_at TEXT,
|
|
144
|
+
completed_at TEXT,
|
|
145
|
+
canceled_at TEXT
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
CREATE TABLE IF NOT EXISTS job_clips (
|
|
149
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
150
|
+
job_id TEXT NOT NULL,
|
|
151
|
+
position INTEGER NOT NULL,
|
|
152
|
+
clip_key TEXT NOT NULL,
|
|
153
|
+
status TEXT NOT NULL,
|
|
154
|
+
record_json TEXT NOT NULL,
|
|
155
|
+
clip_plan_json TEXT NOT NULL,
|
|
156
|
+
report_path TEXT,
|
|
157
|
+
marker_plan_path TEXT,
|
|
158
|
+
cache_status TEXT,
|
|
159
|
+
error TEXT,
|
|
160
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
161
|
+
started_at TEXT,
|
|
162
|
+
completed_at TEXT,
|
|
163
|
+
updated_at TEXT NOT NULL,
|
|
164
|
+
UNIQUE(job_id, position),
|
|
165
|
+
FOREIGN KEY (job_id) REFERENCES jobs(job_id) ON DELETE CASCADE
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
CREATE INDEX IF NOT EXISTS idx_job_clips_job_status ON job_clips(job_id, status, position);
|
|
169
|
+
CREATE INDEX IF NOT EXISTS idx_job_clips_clip_key ON job_clips(clip_key);
|
|
170
|
+
|
|
171
|
+
CREATE TABLE IF NOT EXISTS job_events (
|
|
172
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
173
|
+
job_id TEXT NOT NULL,
|
|
174
|
+
event_time TEXT NOT NULL,
|
|
175
|
+
level TEXT NOT NULL,
|
|
176
|
+
message TEXT NOT NULL,
|
|
177
|
+
payload_json TEXT,
|
|
178
|
+
FOREIGN KEY (job_id) REFERENCES jobs(job_id) ON DELETE CASCADE
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
CREATE INDEX IF NOT EXISTS idx_job_events_job_id ON job_events(job_id, id);
|
|
182
|
+
"""
|
|
183
|
+
)
|
|
184
|
+
conn.execute(
|
|
185
|
+
"INSERT OR REPLACE INTO job_metadata (key, value) VALUES (?, ?)",
|
|
186
|
+
("schema_version", str(JOB_SCHEMA_VERSION)),
|
|
187
|
+
)
|
|
188
|
+
conn.execute(
|
|
189
|
+
"INSERT OR REPLACE INTO job_metadata (key, value) VALUES (?, ?)",
|
|
190
|
+
("analysis_version", ANALYSIS_VERSION),
|
|
191
|
+
)
|
|
192
|
+
conn.commit()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _event(conn: sqlite3.Connection, job_id: str, level: str, message: str, payload: Optional[Dict[str, Any]] = None) -> None:
|
|
196
|
+
conn.execute(
|
|
197
|
+
"INSERT INTO job_events (job_id, event_time, level, message, payload_json) VALUES (?, ?, ?, ?, ?)",
|
|
198
|
+
(job_id, _utc_now(), level, message, _stable_json(payload or {}) if payload else None),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _sync_job_counts(conn: sqlite3.Connection, job_id: str) -> Dict[str, int]:
|
|
203
|
+
rows = conn.execute(
|
|
204
|
+
"SELECT status, COUNT(*) AS count FROM job_clips WHERE job_id = ? GROUP BY status",
|
|
205
|
+
(job_id,),
|
|
206
|
+
).fetchall()
|
|
207
|
+
counts = {row["status"]: int(row["count"]) for row in rows}
|
|
208
|
+
payload = {
|
|
209
|
+
"pending_clips": counts.get("pending", 0),
|
|
210
|
+
"running_clips": counts.get("running", 0),
|
|
211
|
+
"succeeded_clips": counts.get("succeeded", 0),
|
|
212
|
+
"failed_clips": counts.get("failed", 0),
|
|
213
|
+
"skipped_clips": counts.get("skipped", 0),
|
|
214
|
+
}
|
|
215
|
+
conn.execute(
|
|
216
|
+
"""
|
|
217
|
+
UPDATE jobs
|
|
218
|
+
SET pending_clips = ?, running_clips = ?, succeeded_clips = ?,
|
|
219
|
+
failed_clips = ?, skipped_clips = ?, updated_at = ?
|
|
220
|
+
WHERE job_id = ?
|
|
221
|
+
""",
|
|
222
|
+
(
|
|
223
|
+
payload["pending_clips"],
|
|
224
|
+
payload["running_clips"],
|
|
225
|
+
payload["succeeded_clips"],
|
|
226
|
+
payload["failed_clips"],
|
|
227
|
+
payload["skipped_clips"],
|
|
228
|
+
_utc_now(),
|
|
229
|
+
job_id,
|
|
230
|
+
),
|
|
231
|
+
)
|
|
232
|
+
return payload
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _row_dict(row: sqlite3.Row) -> Dict[str, Any]:
|
|
236
|
+
return {key: row[key] for key in row.keys()}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _job_paths(project_root: str, job_id: str) -> Dict[str, str]:
|
|
240
|
+
job_dir = os.path.join(normalize_path(project_root), JOB_DIR_NAME, job_id)
|
|
241
|
+
return {
|
|
242
|
+
"job_dir": job_dir,
|
|
243
|
+
"progress_json": os.path.join(job_dir, "progress.json"),
|
|
244
|
+
"events_jsonl": os.path.join(job_dir, "events.jsonl"),
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _write_job_sidecars(conn: sqlite3.Connection, project_root: str, job_id: str) -> None:
|
|
249
|
+
paths = _job_paths(project_root, job_id)
|
|
250
|
+
os.makedirs(paths["job_dir"], exist_ok=True)
|
|
251
|
+
status = batch_job_status(project_root, job_id)
|
|
252
|
+
tmp_progress = f"{paths['progress_json']}.tmp"
|
|
253
|
+
with open(tmp_progress, "w", encoding="utf-8") as handle:
|
|
254
|
+
json.dump(status, handle, indent=2, ensure_ascii=False)
|
|
255
|
+
handle.write("\n")
|
|
256
|
+
os.replace(tmp_progress, paths["progress_json"])
|
|
257
|
+
|
|
258
|
+
rows = conn.execute(
|
|
259
|
+
"""
|
|
260
|
+
SELECT event_time, level, message, payload_json
|
|
261
|
+
FROM job_events
|
|
262
|
+
WHERE job_id = ?
|
|
263
|
+
ORDER BY id
|
|
264
|
+
""",
|
|
265
|
+
(job_id,),
|
|
266
|
+
).fetchall()
|
|
267
|
+
tmp_events = f"{paths['events_jsonl']}.tmp"
|
|
268
|
+
with open(tmp_events, "w", encoding="utf-8") as handle:
|
|
269
|
+
for row in rows:
|
|
270
|
+
payload = {
|
|
271
|
+
"time": row["event_time"],
|
|
272
|
+
"level": row["level"],
|
|
273
|
+
"message": row["message"],
|
|
274
|
+
}
|
|
275
|
+
if row["payload_json"]:
|
|
276
|
+
payload["payload"] = _read_json(row["payload_json"])
|
|
277
|
+
handle.write(json.dumps(payload, ensure_ascii=False, sort_keys=True))
|
|
278
|
+
handle.write("\n")
|
|
279
|
+
os.replace(tmp_events, paths["events_jsonl"])
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _job_summary_from_row(row: sqlite3.Row) -> Dict[str, Any]:
|
|
283
|
+
payload = _row_dict(row)
|
|
284
|
+
total = int(payload.get("total_clips") or 0)
|
|
285
|
+
done = int(payload.get("succeeded_clips") or 0) + int(payload.get("failed_clips") or 0) + int(payload.get("skipped_clips") or 0)
|
|
286
|
+
payload["progress"] = {
|
|
287
|
+
"done_clips": done,
|
|
288
|
+
"total_clips": total,
|
|
289
|
+
"percent": round((done / total) * 100, 2) if total else 0.0,
|
|
290
|
+
}
|
|
291
|
+
for json_key in ("target_json", "params_json"):
|
|
292
|
+
target_key = json_key.replace("_json", "")
|
|
293
|
+
payload[target_key] = _read_json(str(payload.pop(json_key, "{}")))
|
|
294
|
+
payload.pop("plan_json", None)
|
|
295
|
+
return payload
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def records_from_paths(paths: Iterable[Any], *, recursive: bool = True) -> Tuple[List[Dict[str, Any]], List[str]]:
|
|
299
|
+
records: List[Dict[str, Any]] = []
|
|
300
|
+
warnings: List[str] = []
|
|
301
|
+
seen = set()
|
|
302
|
+
for raw_path in paths:
|
|
303
|
+
if raw_path in (None, ""):
|
|
304
|
+
continue
|
|
305
|
+
path = normalize_path(raw_path)
|
|
306
|
+
candidates: List[str] = []
|
|
307
|
+
if os.path.isdir(path):
|
|
308
|
+
walker = os.walk(path)
|
|
309
|
+
for dirpath, dirnames, filenames in walker:
|
|
310
|
+
if not recursive:
|
|
311
|
+
dirnames[:] = []
|
|
312
|
+
for filename in sorted(filenames):
|
|
313
|
+
candidate = os.path.join(dirpath, filename)
|
|
314
|
+
if Path(candidate).suffix.lower() in MEDIA_EXTENSIONS:
|
|
315
|
+
candidates.append(candidate)
|
|
316
|
+
elif os.path.isfile(path):
|
|
317
|
+
if Path(path).suffix.lower() in MEDIA_EXTENSIONS:
|
|
318
|
+
candidates.append(path)
|
|
319
|
+
else:
|
|
320
|
+
warnings.append(f"Skipping unsupported file extension: {path}")
|
|
321
|
+
else:
|
|
322
|
+
warnings.append(f"Path not found: {path}")
|
|
323
|
+
for candidate in sorted(candidates):
|
|
324
|
+
normalized = normalize_path(candidate)
|
|
325
|
+
if normalized in seen:
|
|
326
|
+
continue
|
|
327
|
+
seen.add(normalized)
|
|
328
|
+
records.append(
|
|
329
|
+
{
|
|
330
|
+
"clip_id": None,
|
|
331
|
+
"clip_name": os.path.basename(normalized),
|
|
332
|
+
"bin_path": None,
|
|
333
|
+
"file_path": normalized,
|
|
334
|
+
"media_id": None,
|
|
335
|
+
"duration": None,
|
|
336
|
+
"fps": None,
|
|
337
|
+
"resolution": None,
|
|
338
|
+
"media_type": "file",
|
|
339
|
+
}
|
|
340
|
+
)
|
|
341
|
+
return records, warnings
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def create_batch_job(
|
|
345
|
+
*,
|
|
346
|
+
project_name: Any,
|
|
347
|
+
project_id: Any = None,
|
|
348
|
+
records: List[Dict[str, Any]],
|
|
349
|
+
target: Dict[str, Any],
|
|
350
|
+
params: Optional[Dict[str, Any]] = None,
|
|
351
|
+
capabilities: Optional[Dict[str, Any]] = None,
|
|
352
|
+
name: Optional[str] = None,
|
|
353
|
+
) -> Dict[str, Any]:
|
|
354
|
+
params = dict(params or {})
|
|
355
|
+
params["dry_run"] = False
|
|
356
|
+
params["session_only"] = False
|
|
357
|
+
params["persist"] = True
|
|
358
|
+
params.setdefault("cleanup_frames", True)
|
|
359
|
+
params.setdefault("reuse_existing", True)
|
|
360
|
+
params.setdefault("auto_build_index", True)
|
|
361
|
+
|
|
362
|
+
plan = build_plan(
|
|
363
|
+
project_name=project_name,
|
|
364
|
+
project_id=project_id,
|
|
365
|
+
records=records,
|
|
366
|
+
target=target,
|
|
367
|
+
params=params,
|
|
368
|
+
capabilities=capabilities or detect_capabilities(),
|
|
369
|
+
)
|
|
370
|
+
if not plan.get("success"):
|
|
371
|
+
return plan
|
|
372
|
+
if plan.get("capability_gaps"):
|
|
373
|
+
return {
|
|
374
|
+
"success": False,
|
|
375
|
+
"error": "Cannot create batch job with missing required capabilities",
|
|
376
|
+
"plan": plan,
|
|
377
|
+
"capability_gaps": plan.get("capability_gaps"),
|
|
378
|
+
"install_guidance": plan.get("install_guidance"),
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
project_root = plan["output_root"]["project_root"]
|
|
382
|
+
os.makedirs(project_root, exist_ok=True)
|
|
383
|
+
created_at = _utc_now()
|
|
384
|
+
job_name = str(name or params.get("job_name") or params.get("jobName") or f"{target.get('type', 'analysis')} analysis")
|
|
385
|
+
job_id = _job_id(job_name, target, f"{created_at}-{time.time_ns()}")
|
|
386
|
+
conn = _connect_jobs(project_root)
|
|
387
|
+
try:
|
|
388
|
+
conn.execute(
|
|
389
|
+
"""
|
|
390
|
+
INSERT INTO jobs (
|
|
391
|
+
job_id, name, status, phase, project_name, project_id, project_root,
|
|
392
|
+
target_json, params_json, plan_json, total_clips, pending_clips,
|
|
393
|
+
created_at, updated_at
|
|
394
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
395
|
+
""",
|
|
396
|
+
(
|
|
397
|
+
job_id,
|
|
398
|
+
job_name,
|
|
399
|
+
"queued",
|
|
400
|
+
"created",
|
|
401
|
+
str(project_name or "Project"),
|
|
402
|
+
str(project_id) if project_id is not None else None,
|
|
403
|
+
project_root,
|
|
404
|
+
_stable_json(target),
|
|
405
|
+
_stable_json(params),
|
|
406
|
+
_stable_json(plan),
|
|
407
|
+
len(plan.get("clips") or []),
|
|
408
|
+
len(plan.get("clips") or []),
|
|
409
|
+
created_at,
|
|
410
|
+
created_at,
|
|
411
|
+
),
|
|
412
|
+
)
|
|
413
|
+
for position, clip_plan in enumerate(plan.get("clips") or []):
|
|
414
|
+
record = clip_plan.get("record") or {}
|
|
415
|
+
conn.execute(
|
|
416
|
+
"""
|
|
417
|
+
INSERT INTO job_clips (
|
|
418
|
+
job_id, position, clip_key, status, record_json, clip_plan_json,
|
|
419
|
+
cache_status, updated_at
|
|
420
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
421
|
+
""",
|
|
422
|
+
(
|
|
423
|
+
job_id,
|
|
424
|
+
position,
|
|
425
|
+
stable_clip_directory(record),
|
|
426
|
+
"pending",
|
|
427
|
+
_stable_json(record),
|
|
428
|
+
_stable_json(clip_plan),
|
|
429
|
+
clip_plan.get("cache_status"),
|
|
430
|
+
created_at,
|
|
431
|
+
),
|
|
432
|
+
)
|
|
433
|
+
_event(conn, job_id, "info", "Batch job created", {"clip_count": len(plan.get("clips") or [])})
|
|
434
|
+
conn.commit()
|
|
435
|
+
_write_job_sidecars(conn, project_root, job_id)
|
|
436
|
+
finally:
|
|
437
|
+
conn.close()
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
"success": True,
|
|
441
|
+
"job": batch_job_status(project_root, job_id),
|
|
442
|
+
"plan": {
|
|
443
|
+
"depth": plan.get("depth"),
|
|
444
|
+
"clip_count": plan.get("clip_count"),
|
|
445
|
+
"estimated_seconds_after_reuse": plan.get("estimated_seconds_after_reuse"),
|
|
446
|
+
"analysis_keyframe_budget_per_clip": plan.get("analysis_keyframe_budget_per_clip"),
|
|
447
|
+
"output_root": plan.get("output_root"),
|
|
448
|
+
"reusable_clip_count": plan.get("reusable_clip_count"),
|
|
449
|
+
},
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def create_batch_job_from_paths(
|
|
454
|
+
*,
|
|
455
|
+
project_name: Any,
|
|
456
|
+
project_id: Any = None,
|
|
457
|
+
paths: Iterable[Any],
|
|
458
|
+
analysis_root: Any = None,
|
|
459
|
+
recursive: bool = True,
|
|
460
|
+
params: Optional[Dict[str, Any]] = None,
|
|
461
|
+
name: Optional[str] = None,
|
|
462
|
+
) -> Dict[str, Any]:
|
|
463
|
+
records, warnings = records_from_paths(paths, recursive=recursive)
|
|
464
|
+
if not records:
|
|
465
|
+
return {"success": False, "error": "No analyzable media files found", "warnings": warnings}
|
|
466
|
+
params = dict(params or {})
|
|
467
|
+
if analysis_root:
|
|
468
|
+
params["analysis_root"] = analysis_root
|
|
469
|
+
target = {
|
|
470
|
+
"type": "paths",
|
|
471
|
+
"paths": [record["file_path"] for record in records],
|
|
472
|
+
"recursive": recursive,
|
|
473
|
+
}
|
|
474
|
+
result = create_batch_job(
|
|
475
|
+
project_name=project_name,
|
|
476
|
+
project_id=project_id,
|
|
477
|
+
records=records,
|
|
478
|
+
target=target,
|
|
479
|
+
params=params,
|
|
480
|
+
name=name,
|
|
481
|
+
)
|
|
482
|
+
if warnings:
|
|
483
|
+
result.setdefault("warnings", warnings)
|
|
484
|
+
return result
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def list_batch_jobs(project_root: str, *, limit: Any = 50) -> Dict[str, Any]:
|
|
488
|
+
root = normalize_path(project_root)
|
|
489
|
+
db_path, err = job_db_path(root)
|
|
490
|
+
if err:
|
|
491
|
+
return {"success": False, "error": err}
|
|
492
|
+
if not db_path or not os.path.isfile(db_path):
|
|
493
|
+
return {"success": True, "project_root": root, "jobs": [], "count": 0}
|
|
494
|
+
try:
|
|
495
|
+
max_jobs = max(1, min(int(limit), 200))
|
|
496
|
+
except (TypeError, ValueError):
|
|
497
|
+
max_jobs = 50
|
|
498
|
+
conn = _connect_jobs(root)
|
|
499
|
+
try:
|
|
500
|
+
rows = conn.execute(
|
|
501
|
+
"""
|
|
502
|
+
SELECT *
|
|
503
|
+
FROM jobs
|
|
504
|
+
ORDER BY created_at DESC
|
|
505
|
+
LIMIT ?
|
|
506
|
+
""",
|
|
507
|
+
(max_jobs,),
|
|
508
|
+
).fetchall()
|
|
509
|
+
finally:
|
|
510
|
+
conn.close()
|
|
511
|
+
jobs = [_job_summary_from_row(row) for row in rows]
|
|
512
|
+
return {"success": True, "project_root": root, "jobs": jobs, "count": len(jobs)}
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def batch_job_status(project_root: str, job_id: str, *, include_clips: bool = True, include_events: bool = True) -> Dict[str, Any]:
|
|
516
|
+
root = normalize_path(project_root)
|
|
517
|
+
conn = _connect_jobs(root)
|
|
518
|
+
try:
|
|
519
|
+
job = conn.execute("SELECT * FROM jobs WHERE job_id = ?", (job_id,)).fetchone()
|
|
520
|
+
if not job:
|
|
521
|
+
return {"success": False, "error": f"Batch job not found: {job_id}"}
|
|
522
|
+
_sync_job_counts(conn, job_id)
|
|
523
|
+
conn.commit()
|
|
524
|
+
job = conn.execute("SELECT * FROM jobs WHERE job_id = ?", (job_id,)).fetchone()
|
|
525
|
+
payload = _job_summary_from_row(job)
|
|
526
|
+
payload["success"] = True
|
|
527
|
+
payload["paths"] = _job_paths(root, job_id)
|
|
528
|
+
if include_clips:
|
|
529
|
+
payload["clips"] = [
|
|
530
|
+
_row_dict(row)
|
|
531
|
+
for row in conn.execute(
|
|
532
|
+
"""
|
|
533
|
+
SELECT position, clip_key, status, report_path, marker_plan_path,
|
|
534
|
+
cache_status, error, attempts, started_at, completed_at
|
|
535
|
+
FROM job_clips
|
|
536
|
+
WHERE job_id = ?
|
|
537
|
+
ORDER BY position
|
|
538
|
+
""",
|
|
539
|
+
(job_id,),
|
|
540
|
+
).fetchall()
|
|
541
|
+
]
|
|
542
|
+
if include_events:
|
|
543
|
+
payload["events"] = [
|
|
544
|
+
{
|
|
545
|
+
**{key: row[key] for key in ("event_time", "level", "message")},
|
|
546
|
+
"payload": _read_json(row["payload_json"]) if row["payload_json"] else None,
|
|
547
|
+
}
|
|
548
|
+
for row in conn.execute(
|
|
549
|
+
"""
|
|
550
|
+
SELECT event_time, level, message, payload_json
|
|
551
|
+
FROM job_events
|
|
552
|
+
WHERE job_id = ?
|
|
553
|
+
ORDER BY id DESC
|
|
554
|
+
LIMIT 50
|
|
555
|
+
""",
|
|
556
|
+
(job_id,),
|
|
557
|
+
).fetchall()
|
|
558
|
+
]
|
|
559
|
+
finally:
|
|
560
|
+
conn.close()
|
|
561
|
+
return payload
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _set_job_status(conn: sqlite3.Connection, job_id: str, status: str, phase: str, **extra: Any) -> None:
|
|
565
|
+
fields = ["status = ?", "phase = ?", "updated_at = ?"]
|
|
566
|
+
values: List[Any] = [status, phase, _utc_now()]
|
|
567
|
+
for key, value in extra.items():
|
|
568
|
+
fields.append(f"{key} = ?")
|
|
569
|
+
values.append(value)
|
|
570
|
+
values.append(job_id)
|
|
571
|
+
conn.execute(f"UPDATE jobs SET {', '.join(fields)} WHERE job_id = ?", values)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def cancel_batch_job(project_root: str, job_id: str) -> Dict[str, Any]:
|
|
575
|
+
root = normalize_path(project_root)
|
|
576
|
+
conn = _connect_jobs(root)
|
|
577
|
+
try:
|
|
578
|
+
if not conn.execute("SELECT 1 FROM jobs WHERE job_id = ?", (job_id,)).fetchone():
|
|
579
|
+
return {"success": False, "error": f"Batch job not found: {job_id}"}
|
|
580
|
+
conn.execute(
|
|
581
|
+
"UPDATE job_clips SET status = 'pending', updated_at = ? WHERE job_id = ? AND status = 'running'",
|
|
582
|
+
(_utc_now(), job_id),
|
|
583
|
+
)
|
|
584
|
+
_set_job_status(conn, job_id, "canceled", "canceled", canceled_at=_utc_now())
|
|
585
|
+
_event(conn, job_id, "warning", "Batch job canceled")
|
|
586
|
+
_sync_job_counts(conn, job_id)
|
|
587
|
+
conn.commit()
|
|
588
|
+
_write_job_sidecars(conn, root, job_id)
|
|
589
|
+
finally:
|
|
590
|
+
conn.close()
|
|
591
|
+
return batch_job_status(root, job_id)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def resume_batch_job(project_root: str, job_id: str) -> Dict[str, Any]:
|
|
595
|
+
root = normalize_path(project_root)
|
|
596
|
+
conn = _connect_jobs(root)
|
|
597
|
+
try:
|
|
598
|
+
if not conn.execute("SELECT 1 FROM jobs WHERE job_id = ?", (job_id,)).fetchone():
|
|
599
|
+
return {"success": False, "error": f"Batch job not found: {job_id}"}
|
|
600
|
+
conn.execute(
|
|
601
|
+
"UPDATE job_clips SET status = 'pending', updated_at = ? WHERE job_id = ? AND status = 'running'",
|
|
602
|
+
(_utc_now(), job_id),
|
|
603
|
+
)
|
|
604
|
+
_set_job_status(conn, job_id, "queued", "resumed", canceled_at=None)
|
|
605
|
+
_event(conn, job_id, "info", "Batch job resumed")
|
|
606
|
+
_sync_job_counts(conn, job_id)
|
|
607
|
+
conn.commit()
|
|
608
|
+
_write_job_sidecars(conn, root, job_id)
|
|
609
|
+
finally:
|
|
610
|
+
conn.close()
|
|
611
|
+
return batch_job_status(root, job_id)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _auto_build_index(conn: sqlite3.Connection, root: str, job_id: str, message: str) -> Dict[str, Any]:
|
|
615
|
+
index = build_analysis_index(root)
|
|
616
|
+
_event(conn, job_id, "info" if index.get("success") else "error", message, index)
|
|
617
|
+
conn.commit()
|
|
618
|
+
return index
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _finish_job_if_complete(
|
|
622
|
+
conn: sqlite3.Connection,
|
|
623
|
+
root: str,
|
|
624
|
+
job_id: str,
|
|
625
|
+
params: Dict[str, Any],
|
|
626
|
+
*,
|
|
627
|
+
index_already_refreshed: bool = False,
|
|
628
|
+
) -> None:
|
|
629
|
+
counts = _sync_job_counts(conn, job_id)
|
|
630
|
+
if counts["pending_clips"] or counts["running_clips"]:
|
|
631
|
+
return
|
|
632
|
+
status = "completed_with_errors" if counts["failed_clips"] else "completed"
|
|
633
|
+
_set_job_status(conn, job_id, status, "complete", completed_at=_utc_now())
|
|
634
|
+
_event(conn, job_id, "info", "Batch job completed", counts)
|
|
635
|
+
conn.commit()
|
|
636
|
+
summarize_reports(root)
|
|
637
|
+
if params.get("auto_build_index", True) and not index_already_refreshed:
|
|
638
|
+
_auto_build_index(conn, root, job_id, "Analysis index rebuilt")
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def run_batch_job_slice(
|
|
642
|
+
project_root: str,
|
|
643
|
+
job_id: str,
|
|
644
|
+
*,
|
|
645
|
+
max_clips: Any = 1,
|
|
646
|
+
max_seconds: Optional[Any] = None,
|
|
647
|
+
capabilities: Optional[Dict[str, Any]] = None,
|
|
648
|
+
) -> Dict[str, Any]:
|
|
649
|
+
root = normalize_path(project_root)
|
|
650
|
+
try:
|
|
651
|
+
clip_limit = max(1, min(int(max_clips), 25))
|
|
652
|
+
except (TypeError, ValueError):
|
|
653
|
+
clip_limit = 1
|
|
654
|
+
deadline = None
|
|
655
|
+
if max_seconds not in (None, ""):
|
|
656
|
+
try:
|
|
657
|
+
deadline = time.monotonic() + max(1.0, float(max_seconds))
|
|
658
|
+
except (TypeError, ValueError):
|
|
659
|
+
deadline = None
|
|
660
|
+
|
|
661
|
+
conn = _connect_jobs(root)
|
|
662
|
+
processed: List[Dict[str, Any]] = []
|
|
663
|
+
try:
|
|
664
|
+
job = conn.execute("SELECT * FROM jobs WHERE job_id = ?", (job_id,)).fetchone()
|
|
665
|
+
if not job:
|
|
666
|
+
return {"success": False, "error": f"Batch job not found: {job_id}"}
|
|
667
|
+
if job["status"] == "canceled":
|
|
668
|
+
return {"success": False, "error": f"Batch job is canceled: {job_id}", "job": batch_job_status(root, job_id)}
|
|
669
|
+
params = _read_json(job["params_json"])
|
|
670
|
+
plan = _read_json(job["plan_json"])
|
|
671
|
+
now = _utc_now()
|
|
672
|
+
if job["started_at"] is None:
|
|
673
|
+
conn.execute("UPDATE jobs SET started_at = ? WHERE job_id = ?", (now, job_id))
|
|
674
|
+
_set_job_status(conn, job_id, "running", "analyzing")
|
|
675
|
+
_event(conn, job_id, "info", "Running batch job slice", {"max_clips": clip_limit, "max_seconds": max_seconds})
|
|
676
|
+
conn.commit()
|
|
677
|
+
|
|
678
|
+
rows = conn.execute(
|
|
679
|
+
"""
|
|
680
|
+
SELECT *
|
|
681
|
+
FROM job_clips
|
|
682
|
+
WHERE job_id = ? AND status = 'pending'
|
|
683
|
+
ORDER BY position
|
|
684
|
+
LIMIT ?
|
|
685
|
+
""",
|
|
686
|
+
(job_id, clip_limit),
|
|
687
|
+
).fetchall()
|
|
688
|
+
|
|
689
|
+
for row in rows:
|
|
690
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
691
|
+
break
|
|
692
|
+
clip_plan = _read_json(row["clip_plan_json"])
|
|
693
|
+
mini_plan = copy.deepcopy(plan)
|
|
694
|
+
mini_plan["clips"] = [clip_plan]
|
|
695
|
+
mini_plan["clip_count"] = 1
|
|
696
|
+
mini_plan["dry_run"] = False
|
|
697
|
+
started_at = _utc_now()
|
|
698
|
+
conn.execute(
|
|
699
|
+
"""
|
|
700
|
+
UPDATE job_clips
|
|
701
|
+
SET status = 'running', attempts = attempts + 1, started_at = ?,
|
|
702
|
+
updated_at = ?, error = NULL
|
|
703
|
+
WHERE id = ?
|
|
704
|
+
""",
|
|
705
|
+
(started_at, started_at, row["id"]),
|
|
706
|
+
)
|
|
707
|
+
_sync_job_counts(conn, job_id)
|
|
708
|
+
conn.commit()
|
|
709
|
+
try:
|
|
710
|
+
slice_params = copy.deepcopy(params)
|
|
711
|
+
slice_params["auto_build_index"] = False
|
|
712
|
+
manifest = execute_plan(mini_plan, params=slice_params, capabilities=capabilities or detect_capabilities())
|
|
713
|
+
clip_result = (manifest.get("clips") or [{}])[0] if isinstance(manifest, dict) else {}
|
|
714
|
+
completed_at = _utc_now()
|
|
715
|
+
if manifest.get("success") and clip_result.get("success"):
|
|
716
|
+
status = "skipped" if clip_result.get("reused") else "succeeded"
|
|
717
|
+
conn.execute(
|
|
718
|
+
"""
|
|
719
|
+
UPDATE job_clips
|
|
720
|
+
SET status = ?, report_path = ?, marker_plan_path = ?,
|
|
721
|
+
cache_status = ?, completed_at = ?, updated_at = ?
|
|
722
|
+
WHERE id = ?
|
|
723
|
+
""",
|
|
724
|
+
(
|
|
725
|
+
status,
|
|
726
|
+
clip_result.get("analysis_json"),
|
|
727
|
+
clip_result.get("marker_plan_json"),
|
|
728
|
+
clip_result.get("cache_status"),
|
|
729
|
+
completed_at,
|
|
730
|
+
completed_at,
|
|
731
|
+
row["id"],
|
|
732
|
+
),
|
|
733
|
+
)
|
|
734
|
+
processed.append({"position": row["position"], "status": status, "report_path": clip_result.get("analysis_json")})
|
|
735
|
+
_event(conn, job_id, "info", f"Clip {row['position'] + 1} {status}", {"clip": clip_result.get("record")})
|
|
736
|
+
else:
|
|
737
|
+
error = clip_result.get("error") or manifest.get("error") or "Clip analysis failed"
|
|
738
|
+
conn.execute(
|
|
739
|
+
"""
|
|
740
|
+
UPDATE job_clips
|
|
741
|
+
SET status = 'failed', error = ?, completed_at = ?, updated_at = ?
|
|
742
|
+
WHERE id = ?
|
|
743
|
+
""",
|
|
744
|
+
(error, completed_at, completed_at, row["id"]),
|
|
745
|
+
)
|
|
746
|
+
processed.append({"position": row["position"], "status": "failed", "error": error})
|
|
747
|
+
_event(conn, job_id, "error", f"Clip {row['position'] + 1} failed", {"error": error})
|
|
748
|
+
except Exception as exc: # pragma: no cover - defensive for arbitrary media/tool failures
|
|
749
|
+
completed_at = _utc_now()
|
|
750
|
+
conn.execute(
|
|
751
|
+
"""
|
|
752
|
+
UPDATE job_clips
|
|
753
|
+
SET status = 'failed', error = ?, completed_at = ?, updated_at = ?
|
|
754
|
+
WHERE id = ?
|
|
755
|
+
""",
|
|
756
|
+
(str(exc), completed_at, completed_at, row["id"]),
|
|
757
|
+
)
|
|
758
|
+
processed.append({"position": row["position"], "status": "failed", "error": str(exc)})
|
|
759
|
+
_event(conn, job_id, "error", f"Clip {row['position'] + 1} raised an exception", {"error": str(exc)})
|
|
760
|
+
_sync_job_counts(conn, job_id)
|
|
761
|
+
conn.commit()
|
|
762
|
+
|
|
763
|
+
index_refreshed = False
|
|
764
|
+
if params.get("auto_build_index", True) and any(
|
|
765
|
+
row.get("status") in {"succeeded", "skipped"} for row in processed
|
|
766
|
+
):
|
|
767
|
+
_auto_build_index(conn, root, job_id, "Analysis index refreshed after batch slice")
|
|
768
|
+
index_refreshed = True
|
|
769
|
+
|
|
770
|
+
_finish_job_if_complete(conn, root, job_id, params, index_already_refreshed=index_refreshed)
|
|
771
|
+
_sync_job_counts(conn, job_id)
|
|
772
|
+
conn.commit()
|
|
773
|
+
current = conn.execute("SELECT status FROM jobs WHERE job_id = ?", (job_id,)).fetchone()
|
|
774
|
+
if current and current["status"] == "running":
|
|
775
|
+
_set_job_status(conn, job_id, "queued", "waiting_for_next_slice")
|
|
776
|
+
conn.commit()
|
|
777
|
+
_write_job_sidecars(conn, root, job_id)
|
|
778
|
+
finally:
|
|
779
|
+
conn.close()
|
|
780
|
+
|
|
781
|
+
return {
|
|
782
|
+
"success": True,
|
|
783
|
+
"processed_count": len(processed),
|
|
784
|
+
"processed": processed,
|
|
785
|
+
"job": batch_job_status(root, job_id),
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def project_root_for_dashboard(project_name: Any, project_id: Any = None, analysis_root: Any = None, source_paths: Optional[Iterable[Any]] = None) -> Dict[str, Any]:
|
|
790
|
+
return resolve_output_root(
|
|
791
|
+
project_name=project_name,
|
|
792
|
+
project_id=project_id,
|
|
793
|
+
analysis_root=analysis_root,
|
|
794
|
+
source_paths=source_paths or [],
|
|
795
|
+
create=True,
|
|
796
|
+
)
|