davinci-resolve-mcp 2.51.0 → 2.52.1
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/CHANGELOG.md +44 -0
- package/README.md +1 -1
- package/docs/SKILL.md +9 -5
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/batch_cli.py +2 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +51 -7
- package/src/utils/edit_engine.py +45 -4
- package/src/utils/media_analysis.py +35 -7
- package/src/utils/media_analysis_jobs.py +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,50 @@
|
|
|
2
2
|
|
|
3
3
|
Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
|
|
4
4
|
|
|
5
|
+
## What's New in v2.52.1
|
|
6
|
+
|
|
7
|
+
Analysis reuse no longer blocks on missing capabilities when every clip is
|
|
8
|
+
satisfied by an existing report (issue #68). `build_plan` records
|
|
9
|
+
`capability_gaps` from the *requested* options before the per-clip reuse
|
|
10
|
+
decision runs; when a fully-reused plan only re-keys and imports existing
|
|
11
|
+
reports into the current root, no fresh transcription/vision/ffprobe happens,
|
|
12
|
+
so the missing-capability gate must not fire.
|
|
13
|
+
|
|
14
|
+
- **Fixed** the missing-capability gate is now evaluated against the clips that
|
|
15
|
+
still need fresh analysis, not the requested-options gaps. A clip is exempt
|
|
16
|
+
only when it both skips execution and has an existing report path; any clip
|
|
17
|
+
needing fresh work still enforces the gate.
|
|
18
|
+
- **Fixed** extended the exemption to the entry points that short-circuit
|
|
19
|
+
before `execute_plan_async` and were still blocking fully-reused runs: the
|
|
20
|
+
`media_analysis` analyze action, metadata-publish analysis, and batch-job
|
|
21
|
+
creation. The batch CLI `plan` preview no longer prints "Missing tools" for a
|
|
22
|
+
fully-reusable plan.
|
|
23
|
+
- **Changed** the reuse/capability logic is now a shared
|
|
24
|
+
`plan_requires_capabilities()` / `executing_clips()` helper, replacing the
|
|
25
|
+
duplicated inline comprehensions so every gate stays consistent.
|
|
26
|
+
- Includes PR #68 by @diesdaas, which fixed the inner `execute_plan_async` gate
|
|
27
|
+
and isolated the marker-param and host-vision tests from unrelated
|
|
28
|
+
capability/destructive-hook coupling.
|
|
29
|
+
|
|
30
|
+
## What's New in v2.52.0
|
|
31
|
+
|
|
32
|
+
`edit_engine` tighten now carries audio. Previously `execute_tighten`
|
|
33
|
+
assembled a video-only variant — a speech-driven dead-air cut came out
|
|
34
|
+
silent, with nothing in the preview or readback to warn you (issue #67).
|
|
35
|
+
|
|
36
|
+
- **Fixed** `plan_tighten` mirrors every kept video range onto its linked
|
|
37
|
+
audio track(s) with identical source frames, so the assembled variant
|
|
38
|
+
stays frame-locked and audible. Audio targets the item's detected linked
|
|
39
|
+
audio tracks (same `GetLinkedItems` matching `execute_swap` uses), falling
|
|
40
|
+
back to audio track 1 where a single linked A/V clip's audio lives.
|
|
41
|
+
- **Added** `include_audio` parameter to `plan_tighten` (default `true`).
|
|
42
|
+
Pass `include_audio=false` for the prior video-only assembly.
|
|
43
|
+
- **Added** an `audio_accounting` block to the `execute_tighten` confirm
|
|
44
|
+
preview and readback (planned vs. actual audio/video item counts), so a
|
|
45
|
+
silent variant can no longer ship unnoticed. Old video-only plans
|
|
46
|
+
re-executed against this build still work and are now loudly flagged as
|
|
47
|
+
silent.
|
|
48
|
+
|
|
5
49
|
## What's New in v2.51.0
|
|
6
50
|
|
|
7
51
|
CLAP audio embeddings — the final phase of the post-program improvements.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# DaVinci Resolve MCP Server
|
|
2
2
|
|
|
3
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-blue.svg)](#server-modes)
|
package/docs/SKILL.md
CHANGED
|
@@ -722,11 +722,15 @@ clip-count readback plus `brain_edits` rationale rows.
|
|
|
722
722
|
`execute_selects(plan_id)` creates a NEW selects timeline from the plan's
|
|
723
723
|
per-shot source ranges — additive; nothing existing is touched.
|
|
724
724
|
- `plan_tighten(timeline_name?, target_ratio?, min_pause_seconds?,
|
|
725
|
-
handle_seconds?)` — dead-air lifts from transcript-gap
|
|
726
|
-
timeline item (items without transcripts are reported in
|
|
727
|
-
silently trimmed). `execute_tighten(plan_id)` assembles a
|
|
728
|
-
VARIANT timeline from the plan's keep ranges — true partial
|
|
729
|
-
original timeline is never mutated.
|
|
725
|
+
handle_seconds?, include_audio?)` — dead-air lifts from transcript-gap
|
|
726
|
+
evidence for each timeline item (items without transcripts are reported in
|
|
727
|
+
`skipped`, never silently trimmed). `execute_tighten(plan_id)` assembles a
|
|
728
|
+
tightened VARIANT timeline from the plan's keep ranges — true partial
|
|
729
|
+
trims; the original timeline is never mutated. v2.52.0+: kept ranges mirror
|
|
730
|
+
onto each item's linked audio track(s) so the variant is audible (a
|
|
731
|
+
speech-driven cut was previously silent — #67); pass
|
|
732
|
+
`include_audio=false` for a video-only assembly, and the
|
|
733
|
+
`execute_tighten` readback carries an `audio_accounting` block.
|
|
730
734
|
- `plan_swap(timeline_start_frame | item_name, kind="visual"|"text",
|
|
731
735
|
limit?)` — alternates for one timeline item via the similarity index,
|
|
732
736
|
filtered to shots long enough to fill the slot exactly.
|
package/install.py
CHANGED
|
@@ -35,7 +35,7 @@ from src.utils.update_check import (
|
|
|
35
35
|
|
|
36
36
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
37
37
|
|
|
38
|
-
VERSION = "2.
|
|
38
|
+
VERSION = "2.52.1"
|
|
39
39
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
40
40
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
41
41
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
package/src/batch_cli.py
CHANGED
|
@@ -17,6 +17,7 @@ from typing import Any, Dict, List, Optional
|
|
|
17
17
|
from src.utils.media_analysis import (
|
|
18
18
|
build_plan,
|
|
19
19
|
detect_capabilities,
|
|
20
|
+
plan_requires_capabilities,
|
|
20
21
|
)
|
|
21
22
|
from src.utils.media_analysis_jobs import (
|
|
22
23
|
batch_job_status,
|
|
@@ -187,7 +188,7 @@ def _cmd_plan(args: argparse.Namespace) -> int:
|
|
|
187
188
|
f"Est. seconds : {plan.get('estimated_seconds_after_reuse', '?')}",
|
|
188
189
|
json_mode=False,
|
|
189
190
|
)
|
|
190
|
-
if plan.get("capability_gaps"):
|
|
191
|
+
if plan.get("capability_gaps") and plan_requires_capabilities(plan):
|
|
191
192
|
_emit(f"Missing tools: {plan['capability_gaps']}", json_mode=False)
|
|
192
193
|
for w in warnings:
|
|
193
194
|
_emit(f" warning: {w}", json_mode=False)
|
package/src/granular/common.py
CHANGED
|
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
|
|
|
80
80
|
handlers=[logging.StreamHandler()],
|
|
81
81
|
)
|
|
82
82
|
|
|
83
|
-
VERSION = "2.
|
|
83
|
+
VERSION = "2.52.1"
|
|
84
84
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
85
85
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
86
86
|
logger.info(f"Detected platform: {get_platform()}")
|
package/src/server.py
CHANGED
|
@@ -11,7 +11,7 @@ Usage:
|
|
|
11
11
|
python src/server.py --full # Start the 341-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "2.
|
|
14
|
+
VERSION = "2.52.1"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -77,6 +77,7 @@ from src.utils.media_analysis import (
|
|
|
77
77
|
execute_plan_async as execute_media_analysis_plan_async,
|
|
78
78
|
install_guidance as media_analysis_install_guidance,
|
|
79
79
|
load_report as load_media_analysis_report,
|
|
80
|
+
plan_requires_capabilities as media_analysis_plan_requires_capabilities,
|
|
80
81
|
query_analysis_index,
|
|
81
82
|
resolve_output_root as resolve_media_analysis_output_root,
|
|
82
83
|
short_hash,
|
|
@@ -9226,7 +9227,7 @@ async def _publish_clip_metadata_from_analysis(
|
|
|
9226
9227
|
)
|
|
9227
9228
|
if not plan.get("success"):
|
|
9228
9229
|
return plan
|
|
9229
|
-
if plan.get("capability_gaps"):
|
|
9230
|
+
if plan.get("capability_gaps") and media_analysis_plan_requires_capabilities(plan):
|
|
9230
9231
|
return _media_analysis_missing_capabilities_response(plan, action_label="metadata publish analysis")
|
|
9231
9232
|
|
|
9232
9233
|
manifest = await execute_media_analysis_plan_async(
|
|
@@ -16268,7 +16269,11 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
16268
16269
|
plan["setup_defaults_applied"] = p.get("_setup_defaults_applied")
|
|
16269
16270
|
if warnings:
|
|
16270
16271
|
plan["warnings"] = warnings
|
|
16271
|
-
if
|
|
16272
|
+
if (
|
|
16273
|
+
plan.get("capability_gaps")
|
|
16274
|
+
and not bool(p.get("dry_run", True))
|
|
16275
|
+
and media_analysis_plan_requires_capabilities(plan)
|
|
16276
|
+
):
|
|
16272
16277
|
return _e2_wrap(_media_analysis_missing_capabilities_response(plan))
|
|
16273
16278
|
if not bool(p.get("dry_run", True)):
|
|
16274
16279
|
executed = await execute_media_analysis_plan_async(
|
|
@@ -16592,6 +16597,15 @@ def _edit_engine_collect_items(tl, *, track_index: Optional[int] = None) -> List
|
|
|
16592
16597
|
pass
|
|
16593
16598
|
except Exception:
|
|
16594
16599
|
pass
|
|
16600
|
+
# Linked audio track indices so the planner can mirror kept ranges
|
|
16601
|
+
# onto sound (see #67 — tighten variants were coming out silent).
|
|
16602
|
+
try:
|
|
16603
|
+
audio_indices, _audio_note = _edit_engine_linked_audio_tracks(
|
|
16604
|
+
tl, item, row["timeline_start_frame"], row["timeline_end_frame"]
|
|
16605
|
+
)
|
|
16606
|
+
row["audio_track_indices"] = audio_indices
|
|
16607
|
+
except Exception:
|
|
16608
|
+
row["audio_track_indices"] = []
|
|
16595
16609
|
rows.append(row)
|
|
16596
16610
|
return rows
|
|
16597
16611
|
|
|
@@ -16732,10 +16746,13 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
16732
16746
|
- execute_selects(plan_id, confirm_token?) — creates a NEW selects timeline
|
|
16733
16747
|
from the plan's clip in/out ranges. Nothing existing is touched.
|
|
16734
16748
|
- plan_tighten(timeline_name?, target_ratio?, min_pause_seconds?,
|
|
16735
|
-
handle_seconds?) — dead-air lifts from transcript-gap
|
|
16736
|
-
current (or named) timeline.
|
|
16737
|
-
|
|
16738
|
-
|
|
16749
|
+
handle_seconds?, include_audio?) — dead-air lifts from transcript-gap
|
|
16750
|
+
evidence for the current (or named) timeline. Kept ranges mirror onto the
|
|
16751
|
+
items' linked audio tracks by default so the variant is audible; pass
|
|
16752
|
+
include_audio=false for a video-only (silent) assembly.
|
|
16753
|
+
- execute_tighten(plan_id, confirm_token?) — assembles a tightened VARIANT
|
|
16754
|
+
timeline from the plan's keep ranges (video + mirrored audio), never
|
|
16755
|
+
mutating the original. Readback includes an audio_accounting block.
|
|
16739
16756
|
- plan_swap(track_index?, timeline_start_frame | item_name, kind?, limit?)
|
|
16740
16757
|
— alternates for one timeline item via the similarity index.
|
|
16741
16758
|
- execute_swap(plan_id, alternate_index, confirm_token?) — replaces the
|
|
@@ -16807,6 +16824,7 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
16807
16824
|
target_ratio=p.get("target_ratio") or p.get("targetRatio"),
|
|
16808
16825
|
min_pause_seconds=float(p.get("min_pause_seconds") or p.get("minPauseSeconds") or _edit_engine_mod.DEFAULT_MIN_PAUSE_SECONDS),
|
|
16809
16826
|
handle_seconds=float(p.get("handle_seconds") or p.get("handleSeconds") or _edit_engine_mod.DEFAULT_HANDLE_SECONDS),
|
|
16827
|
+
include_audio=str(p.get("include_audio", p.get("includeAudio", True))).strip().lower() not in {"false", "0", "no", "none", "off"},
|
|
16810
16828
|
)
|
|
16811
16829
|
|
|
16812
16830
|
if action == "plan_swap":
|
|
@@ -16951,6 +16969,8 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
16951
16969
|
keep_ranges = plan.get("keep_ranges") or []
|
|
16952
16970
|
if not keep_ranges:
|
|
16953
16971
|
return _err("plan has no keep_ranges — re-plan with this version")
|
|
16972
|
+
audio_keep_ranges = sum(1 for r in keep_ranges if str(r.get("track_type", "video")).lower() == "audio")
|
|
16973
|
+
video_keep_ranges = len(keep_ranges) - audio_keep_ranges
|
|
16954
16974
|
if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
|
|
16955
16975
|
return _issue_confirm_token(
|
|
16956
16976
|
action="edit_engine.execute_tighten", params=p,
|
|
@@ -16963,6 +16983,13 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
16963
16983
|
"timeline_name": plan.get("timeline_name"),
|
|
16964
16984
|
"lift_count": len(lifts),
|
|
16965
16985
|
"keep_range_count": len(keep_ranges),
|
|
16986
|
+
"video_keep_range_count": video_keep_ranges,
|
|
16987
|
+
"audio_keep_range_count": audio_keep_ranges,
|
|
16988
|
+
"audio_note": (
|
|
16989
|
+
f"Variant carries audio ({audio_keep_ranges} audio ranges mirror the video cuts)."
|
|
16990
|
+
if audio_keep_ranges
|
|
16991
|
+
else "WARNING: variant is VIDEO-ONLY (silent) — plan has no audio ranges."
|
|
16992
|
+
),
|
|
16966
16993
|
"estimated_removed_seconds": sum(l.get("duration_seconds") or 0 for l in lifts),
|
|
16967
16994
|
},
|
|
16968
16995
|
)
|
|
@@ -17039,6 +17066,23 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
17039
17066
|
else None
|
|
17040
17067
|
),
|
|
17041
17068
|
"structural_diff": structural_diff,
|
|
17069
|
+
"audio_accounting": {
|
|
17070
|
+
"planned_audio_ranges": audio_keep_ranges,
|
|
17071
|
+
"planned_video_ranges": video_keep_ranges,
|
|
17072
|
+
"variant_audio_items": sum(
|
|
17073
|
+
1 for it in (variant.get("items") or [])
|
|
17074
|
+
if (it.get("range") or {}).get("media_type") == 2
|
|
17075
|
+
),
|
|
17076
|
+
"variant_video_items": sum(
|
|
17077
|
+
1 for it in (variant.get("items") or [])
|
|
17078
|
+
if (it.get("range") or {}).get("media_type") == 1
|
|
17079
|
+
),
|
|
17080
|
+
"note": (
|
|
17081
|
+
"Variant carries audio mirrored from the video cuts."
|
|
17082
|
+
if audio_keep_ranges
|
|
17083
|
+
else "Variant is VIDEO-ONLY (silent) — re-plan with include_audio=True for sound."
|
|
17084
|
+
),
|
|
17085
|
+
},
|
|
17042
17086
|
},
|
|
17043
17087
|
"plan_id": plan.get("plan_id"),
|
|
17044
17088
|
}
|
package/src/utils/edit_engine.py
CHANGED
|
@@ -348,13 +348,23 @@ def plan_tighten(
|
|
|
348
348
|
target_ratio: Optional[float] = None,
|
|
349
349
|
min_pause_seconds: float = DEFAULT_MIN_PAUSE_SECONDS,
|
|
350
350
|
handle_seconds: float = DEFAULT_HANDLE_SECONDS,
|
|
351
|
+
include_audio: bool = True,
|
|
351
352
|
) -> Dict[str, Any]:
|
|
352
353
|
"""Propose dead-air lifts for a timeline.
|
|
353
354
|
|
|
354
355
|
`items` rows come from the server (Resolve read): each needs
|
|
355
356
|
{timeline_start_frame, timeline_end_frame, source_start_frame,
|
|
356
|
-
media_ref (clip id / path / hash), item_name?}.
|
|
357
|
-
|
|
357
|
+
media_ref (clip id / path / hash), item_name?}. Optionally each row may
|
|
358
|
+
carry {audio_track_indices: [int, ...]} naming the audio tracks that hold
|
|
359
|
+
the item's linked audio. Lifts are returned in timeline frames, latest-first
|
|
360
|
+
ready.
|
|
361
|
+
|
|
362
|
+
When ``include_audio`` (the default), each kept video range is mirrored onto
|
|
363
|
+
matching audio range(s) so the assembled variant carries sound — a
|
|
364
|
+
speech-driven cut would otherwise come out silent (see #67). Audio is
|
|
365
|
+
mirrored to the item's detected ``audio_track_indices``; absent that, it
|
|
366
|
+
falls back to audio track 1, which is where a single linked A/V clip's audio
|
|
367
|
+
lives.
|
|
358
368
|
"""
|
|
359
369
|
if not items:
|
|
360
370
|
return {"success": False, "error": "No timeline items supplied"}
|
|
@@ -475,6 +485,11 @@ def plan_tighten(
|
|
|
475
485
|
cursor = max(cursor, lift_end_sec)
|
|
476
486
|
if spec["src_end_sec"] - cursor > 0.05:
|
|
477
487
|
segments.append((cursor, spec["src_end_sec"]))
|
|
488
|
+
audio_indices: List[int] = []
|
|
489
|
+
if include_audio:
|
|
490
|
+
audio_indices = [int(i) for i in (item.get("audio_track_indices") or []) if int(i) > 0]
|
|
491
|
+
if not audio_indices:
|
|
492
|
+
audio_indices = [1]
|
|
478
493
|
for seg_start, seg_end in segments:
|
|
479
494
|
start_frame = int(round(seg_start * clip_fps))
|
|
480
495
|
end_frame = max(start_frame + 1, int(round(seg_end * clip_fps)) - 1)
|
|
@@ -485,23 +500,40 @@ def plan_tighten(
|
|
|
485
500
|
"track_type": "video",
|
|
486
501
|
"track_index": int(item.get("track_index") or 1),
|
|
487
502
|
})
|
|
503
|
+
# Mirror each kept video range onto its linked audio track(s) with
|
|
504
|
+
# identical source frames so the variant stays frame-locked and
|
|
505
|
+
# audible. mediaType 2 pulls the same media-pool item's audio.
|
|
506
|
+
for audio_index in audio_indices:
|
|
507
|
+
keep_ranges.append({
|
|
508
|
+
"clip_id": spec["resolve_clip_id"],
|
|
509
|
+
"start_frame": start_frame,
|
|
510
|
+
"end_frame": end_frame,
|
|
511
|
+
"track_type": "audio",
|
|
512
|
+
"media_type": 2,
|
|
513
|
+
"track_index": audio_index,
|
|
514
|
+
})
|
|
488
515
|
|
|
489
516
|
removed_frames = sum(l["timeline_end_frame"] - l["timeline_start_frame"] for l in lifts)
|
|
517
|
+
audio_keep_range_count = sum(1 for r in keep_ranges if r.get("track_type") == "audio")
|
|
518
|
+
video_keep_range_count = len(keep_ranges) - audio_keep_range_count
|
|
490
519
|
plan = save_plan(project_root, {
|
|
491
520
|
"kind": "tighten",
|
|
492
521
|
"timeline_name": timeline_name,
|
|
493
522
|
"timeline_fps": fps,
|
|
494
523
|
"lifts": lifts,
|
|
495
524
|
"keep_ranges": keep_ranges,
|
|
525
|
+
"include_audio": bool(include_audio),
|
|
496
526
|
"skipped": skipped,
|
|
497
527
|
"summary": (
|
|
498
528
|
f"{len(lifts)} dead-air lifts, ~{round(removed_frames / fps, 1)}s removed "
|
|
499
|
-
f"from '{timeline_name}' (assembled as a tightened variant
|
|
529
|
+
f"from '{timeline_name}' (assembled as a tightened variant"
|
|
530
|
+
f"{', video + audio' if include_audio else ', video only'})"
|
|
500
531
|
),
|
|
501
532
|
"settings": {
|
|
502
533
|
"target_ratio": target_ratio,
|
|
503
534
|
"min_pause_seconds": min_pause_seconds,
|
|
504
535
|
"handle_seconds": handle_seconds,
|
|
536
|
+
"include_audio": bool(include_audio),
|
|
505
537
|
},
|
|
506
538
|
})
|
|
507
539
|
return {
|
|
@@ -514,11 +546,20 @@ def plan_tighten(
|
|
|
514
546
|
"estimated_removed_seconds": round(removed_frames / fps, 2),
|
|
515
547
|
"lifts": lifts,
|
|
516
548
|
"keep_range_count": len(keep_ranges),
|
|
549
|
+
"video_keep_range_count": video_keep_range_count,
|
|
550
|
+
"audio_keep_range_count": audio_keep_range_count,
|
|
551
|
+
"include_audio": bool(include_audio),
|
|
517
552
|
"skipped": skipped,
|
|
518
553
|
"note": (
|
|
519
554
|
"Dry-run plan. Execute with edit_engine(action='execute_tighten', "
|
|
520
555
|
"params={plan_id}) — a tightened VARIANT timeline is assembled from "
|
|
521
|
-
"the keep ranges; the original timeline is never mutated."
|
|
556
|
+
"the keep ranges; the original timeline is never mutated. "
|
|
557
|
+
+ (
|
|
558
|
+
f"Audio is mirrored onto matching tracks ({audio_keep_range_count} "
|
|
559
|
+
"audio ranges) so the variant is audible."
|
|
560
|
+
if include_audio
|
|
561
|
+
else "include_audio=False: the variant will be VIDEO-ONLY (silent)."
|
|
562
|
+
)
|
|
522
563
|
),
|
|
523
564
|
}
|
|
524
565
|
|
|
@@ -5117,6 +5117,37 @@ async def _maybe_run_vision_analysis(
|
|
|
5117
5117
|
return _vision_analysis(record, motion, options, artifacts, capabilities)
|
|
5118
5118
|
|
|
5119
5119
|
|
|
5120
|
+
def _clip_is_reused(clip: Any) -> bool:
|
|
5121
|
+
"""A clip is satisfied by an existing report and runs no fresh analysis."""
|
|
5122
|
+
return bool(
|
|
5123
|
+
isinstance(clip, dict)
|
|
5124
|
+
and clip.get("skip_execution")
|
|
5125
|
+
and (clip.get("existing_report") or {}).get("path")
|
|
5126
|
+
)
|
|
5127
|
+
|
|
5128
|
+
|
|
5129
|
+
def executing_clips(plan: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
5130
|
+
"""Clips in ``plan`` that still require fresh analysis (not pure reuse)."""
|
|
5131
|
+
return [
|
|
5132
|
+
clip
|
|
5133
|
+
for clip in plan.get("clips", [])
|
|
5134
|
+
if isinstance(clip, dict) and not _clip_is_reused(clip)
|
|
5135
|
+
]
|
|
5136
|
+
|
|
5137
|
+
|
|
5138
|
+
def plan_requires_capabilities(plan: Dict[str, Any]) -> bool:
|
|
5139
|
+
"""True when at least one clip needs fresh analysis.
|
|
5140
|
+
|
|
5141
|
+
``build_plan`` records ``capability_gaps`` from the *requested* options
|
|
5142
|
+
before the per-clip reuse decision runs. When every clip is satisfied by an
|
|
5143
|
+
existing reusable report, execution only re-keys/imports those reports into
|
|
5144
|
+
the current root and performs no fresh transcription/vision/ffprobe — so the
|
|
5145
|
+
missing-capability gate must not fire. Callers gate with
|
|
5146
|
+
``plan.get("capability_gaps") and plan_requires_capabilities(plan)``.
|
|
5147
|
+
"""
|
|
5148
|
+
return bool(executing_clips(plan))
|
|
5149
|
+
|
|
5150
|
+
|
|
5120
5151
|
async def execute_plan_async(
|
|
5121
5152
|
plan: Dict[str, Any],
|
|
5122
5153
|
params: Optional[Dict[str, Any]] = None,
|
|
@@ -5155,7 +5186,8 @@ async def execute_plan_async(
|
|
|
5155
5186
|
for clip in blocked
|
|
5156
5187
|
],
|
|
5157
5188
|
}
|
|
5158
|
-
|
|
5189
|
+
fresh_clips = executing_clips(plan)
|
|
5190
|
+
if plan.get("capability_gaps") and fresh_clips:
|
|
5159
5191
|
return {
|
|
5160
5192
|
"success": False,
|
|
5161
5193
|
"error": "Cannot execute analysis with missing required capabilities",
|
|
@@ -5204,17 +5236,13 @@ async def execute_plan_async(
|
|
|
5204
5236
|
and vision_uses_chat_context(options, caps)
|
|
5205
5237
|
and not _coerce_bool(params.get("confirm_deep") or params.get("confirmDeep"), default=False)
|
|
5206
5238
|
):
|
|
5207
|
-
|
|
5208
|
-
clip for clip in plan.get("clips", [])
|
|
5209
|
-
if not (clip.get("skip_execution") and (clip.get("existing_report") or {}).get("path"))
|
|
5210
|
-
]
|
|
5211
|
-
estimated_frames = sum(int(c.get("analysis_keyframe_budget") or 0) for c in executing)
|
|
5239
|
+
estimated_frames = sum(int(c.get("analysis_keyframe_budget") or 0) for c in fresh_clips)
|
|
5212
5240
|
return {
|
|
5213
5241
|
"success": True,
|
|
5214
5242
|
"status": "confirmation_required",
|
|
5215
5243
|
"reason": "deep_depth_cost_estimate",
|
|
5216
5244
|
"estimate": {
|
|
5217
|
-
"clip_count": len(
|
|
5245
|
+
"clip_count": len(fresh_clips),
|
|
5218
5246
|
"estimated_frames": estimated_frames,
|
|
5219
5247
|
"estimated_vision_tokens": estimated_frames * AVG_VISION_TOKENS_PER_FRAME,
|
|
5220
5248
|
"tokens_per_frame_assumption": AVG_VISION_TOKENS_PER_FRAME,
|
|
@@ -24,6 +24,7 @@ from src.utils.media_analysis import (
|
|
|
24
24
|
detect_capabilities,
|
|
25
25
|
execute_plan,
|
|
26
26
|
normalize_path,
|
|
27
|
+
plan_requires_capabilities,
|
|
27
28
|
resolve_output_root,
|
|
28
29
|
stable_clip_directory,
|
|
29
30
|
summarize_reports,
|
|
@@ -369,7 +370,7 @@ def create_batch_job(
|
|
|
369
370
|
)
|
|
370
371
|
if not plan.get("success"):
|
|
371
372
|
return plan
|
|
372
|
-
if plan.get("capability_gaps"):
|
|
373
|
+
if plan.get("capability_gaps") and plan_requires_capabilities(plan):
|
|
373
374
|
return {
|
|
374
375
|
"success": False,
|
|
375
376
|
"status": "missing_required_capabilities",
|