davinci-resolve-mcp 2.52.0 → 2.53.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/CHANGELOG.md +58 -0
- package/README.md +1 -1
- package/docs/SKILL.md +5 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/analysis_dashboard.py +43 -3
- package/src/batch_cli.py +2 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +83 -21
- package/src/utils/media_analysis.py +35 -7
- package/src/utils/media_analysis_jobs.py +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,64 @@
|
|
|
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.53.0
|
|
6
|
+
|
|
7
|
+
Two improvements to audio sync reliability and inventory control.
|
|
8
|
+
|
|
9
|
+
`safe_auto_sync_audio` / `media_pool.auto_sync_audio` no longer fail silently on
|
|
10
|
+
human-readable settings (issue #70). The previous normalizer recognized
|
|
11
|
+
`syncBy`/`mode` but not `method`, and it forwarded *every* unrecognized key
|
|
12
|
+
(`group_id`, `primary_clip_id`, …) straight into `MediaPool.AutoSyncAudio` —
|
|
13
|
+
which silently rejects the whole call when it sees a key it doesn't understand,
|
|
14
|
+
returning `False` with nothing linked and no error.
|
|
15
|
+
|
|
16
|
+
- **Fixed** `method` (matching the tool's own parameter naming) is now accepted
|
|
17
|
+
as an alias for the sync mode alongside `syncBy`/`sync_by`/`mode`/`syncMode`.
|
|
18
|
+
- **Fixed** unrecognized settings keys are dropped instead of forwarded, so a
|
|
19
|
+
stray `group_id`/`primary_clip_id` no longer poisons the call. Dropped keys
|
|
20
|
+
are reported back in a new `ignored_settings` field on the response, so a
|
|
21
|
+
rejection is no longer invisible.
|
|
22
|
+
- **Fixed** the raw `media_pool.auto_sync_audio` action now routes settings
|
|
23
|
+
through the same live `AUDIO_SYNC_*` enum resolution as the safe wrapper
|
|
24
|
+
(previously it passed strings straight through).
|
|
25
|
+
|
|
26
|
+
The Media Pool inventory walk is now configurable:
|
|
27
|
+
|
|
28
|
+
- **Added** `media_analysis.inventory_exclude_bins` — a comma-separated list of
|
|
29
|
+
folder names to skip entirely during the inventory walk (recursively). Empty
|
|
30
|
+
by default, so every folder is indexed unless you opt out.
|
|
31
|
+
- **Added** `media_analysis.inventory_limit` — the maximum clips to index per
|
|
32
|
+
walk, configurable from the control panel and `setup`. The hard ceiling is
|
|
33
|
+
raised from 2000 to 10000.
|
|
34
|
+
- Adapted from PR #69 by @rgxdev; the default was changed to exclude nothing
|
|
35
|
+
(the PR defaulted to excluding an `assets` bin, which would have silently
|
|
36
|
+
stopped indexing existing `assets` folders on upgrade).
|
|
37
|
+
|
|
38
|
+
## What's New in v2.52.1
|
|
39
|
+
|
|
40
|
+
Analysis reuse no longer blocks on missing capabilities when every clip is
|
|
41
|
+
satisfied by an existing report (issue #68). `build_plan` records
|
|
42
|
+
`capability_gaps` from the *requested* options before the per-clip reuse
|
|
43
|
+
decision runs; when a fully-reused plan only re-keys and imports existing
|
|
44
|
+
reports into the current root, no fresh transcription/vision/ffprobe happens,
|
|
45
|
+
so the missing-capability gate must not fire.
|
|
46
|
+
|
|
47
|
+
- **Fixed** the missing-capability gate is now evaluated against the clips that
|
|
48
|
+
still need fresh analysis, not the requested-options gaps. A clip is exempt
|
|
49
|
+
only when it both skips execution and has an existing report path; any clip
|
|
50
|
+
needing fresh work still enforces the gate.
|
|
51
|
+
- **Fixed** extended the exemption to the entry points that short-circuit
|
|
52
|
+
before `execute_plan_async` and were still blocking fully-reused runs: the
|
|
53
|
+
`media_analysis` analyze action, metadata-publish analysis, and batch-job
|
|
54
|
+
creation. The batch CLI `plan` preview no longer prints "Missing tools" for a
|
|
55
|
+
fully-reusable plan.
|
|
56
|
+
- **Changed** the reuse/capability logic is now a shared
|
|
57
|
+
`plan_requires_capabilities()` / `executing_clips()` helper, replacing the
|
|
58
|
+
duplicated inline comprehensions so every gate stays consistent.
|
|
59
|
+
- Includes PR #68 by @diesdaas, which fixed the inner `execute_plan_async` gate
|
|
60
|
+
and isolated the marker-param and host-vision tests from unrelated
|
|
61
|
+
capability/destructive-hook coupling.
|
|
62
|
+
|
|
5
63
|
## What's New in v2.52.0
|
|
6
64
|
|
|
7
65
|
`edit_engine` tighten now carries audio. Previously `execute_tighten`
|
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
|
@@ -999,7 +999,11 @@ helpers:
|
|
|
999
999
|
- `audio_mix_capability_report(...)`
|
|
1000
1000
|
- `voice_isolation_capabilities(track_index?, track_type?, item_index?)`
|
|
1001
1001
|
- `audio_mapping_report(clip_ids?)`
|
|
1002
|
-
- `safe_auto_sync_audio(clip_ids|selected, settings?, dry_run?)`
|
|
1002
|
+
- `safe_auto_sync_audio(clip_ids|selected, settings?, dry_run?)` — `settings`
|
|
1003
|
+
accepts human-readable keys: `method`/`mode` (`waveform`|`timecode`),
|
|
1004
|
+
`channel` (`auto`|`mix`|int), `retain_embedded_audio`, `retain_video_metadata`.
|
|
1005
|
+
Unrecognized keys are dropped and echoed back in `ignored_settings` rather than
|
|
1006
|
+
silently failing the call.
|
|
1003
1007
|
- `transcription_capabilities(clip_ids?|selected?)`
|
|
1004
1008
|
- `subtitle_generation_probe(settings?, allow_generate?)`
|
|
1005
1009
|
- `fairlight_boundary_report`
|
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.53.0"
|
|
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
|
@@ -4790,6 +4790,12 @@ HTML = r"""<!doctype html>
|
|
|
4790
4790
|
<button type="button" class="secondary path-browse" data-browse-target="prefPreferredGeneratedMediaFolder">Browse…</button>
|
|
4791
4791
|
</div>
|
|
4792
4792
|
</label>
|
|
4793
|
+
<label>Inventory limit
|
|
4794
|
+
<input id="prefInventoryLimit" type="number" min="1" max="10000" placeholder="Default 500">
|
|
4795
|
+
</label>
|
|
4796
|
+
<label>Inventory exclude bins (comma separated)
|
|
4797
|
+
<input id="prefInventoryExcludeBins" type="text" placeholder="None — index every folder">
|
|
4798
|
+
</label>
|
|
4793
4799
|
<label>Post-operation page
|
|
4794
4800
|
<select id="prefPostOperationPage">
|
|
4795
4801
|
<option value="stay_put">stay put</option>
|
|
@@ -5088,6 +5094,8 @@ HTML = r"""<!doctype html>
|
|
|
5088
5094
|
prefDryRunFirstDefault: 'Prefers a preview pass before committing metadata or marker changes.',
|
|
5089
5095
|
prefPreferredAnalysisRoot: 'Optional absolute path for analysis databases and reports. Empty uses the project analysis root.',
|
|
5090
5096
|
prefPreferredGeneratedMediaFolder: 'Optional folder for generated sidecars or scratch outputs when a workflow needs them.',
|
|
5097
|
+
prefInventoryLimit: 'Maximum number of clips to index during the Media Pool inventory walk (1–10000).',
|
|
5098
|
+
prefInventoryExcludeBins: 'Comma-separated folder names to skip entirely during the inventory walk. Empty indexes every folder.',
|
|
5091
5099
|
prefPostOperationPage: 'Resolve page to open after an operation completes, or stay put.',
|
|
5092
5100
|
prefUpdateMode: 'Controls update checks for the MCP package.',
|
|
5093
5101
|
prefUpdateIntervalHours: 'Minimum time between best-effort release checks.',
|
|
@@ -10233,6 +10241,8 @@ HTML = r"""<!doctype html>
|
|
|
10233
10241
|
setControlChecked('prefDryRunFirstDefault', media.dry_run_first_default);
|
|
10234
10242
|
setControlValue('prefPreferredAnalysisRoot', media.preferred_analysis_root || '');
|
|
10235
10243
|
setControlValue('prefPreferredGeneratedMediaFolder', media.preferred_generated_media_folder || '');
|
|
10244
|
+
setControlValue('prefInventoryLimit', media.inventory_limit || 500);
|
|
10245
|
+
setControlValue('prefInventoryExcludeBins', media.inventory_exclude_bins || '');
|
|
10236
10246
|
setControlValue('prefPostOperationPage', media.default_post_operation_page);
|
|
10237
10247
|
setControlValue('prefUpdateMode', updates.mode);
|
|
10238
10248
|
setControlValue('prefUpdateIntervalHours', updates.check_interval_hours);
|
|
@@ -10361,6 +10371,8 @@ HTML = r"""<!doctype html>
|
|
|
10361
10371
|
dry_run_first_default: $('prefDryRunFirstDefault').checked,
|
|
10362
10372
|
preferred_analysis_root: $('prefPreferredAnalysisRoot').value.trim() || 'clear',
|
|
10363
10373
|
preferred_generated_media_folder: $('prefPreferredGeneratedMediaFolder').value.trim() || 'clear',
|
|
10374
|
+
inventory_limit: parseInt($('prefInventoryLimit').value, 10) || 500,
|
|
10375
|
+
inventory_exclude_bins: $('prefInventoryExcludeBins').value.trim(),
|
|
10364
10376
|
default_post_operation_page: $('prefPostOperationPage').value,
|
|
10365
10377
|
},
|
|
10366
10378
|
updates: {
|
|
@@ -12013,6 +12025,7 @@ def _append_folder_media(
|
|
|
12013
12025
|
records: List[Dict[str, Any]],
|
|
12014
12026
|
warnings: List[str],
|
|
12015
12027
|
limit: int,
|
|
12028
|
+
exclude_bins: Optional[set] = None,
|
|
12016
12029
|
) -> bool:
|
|
12017
12030
|
clips, clip_err = _safe_call(folder, "GetClipList")
|
|
12018
12031
|
if clip_err:
|
|
@@ -12033,6 +12046,8 @@ def _append_folder_media(
|
|
|
12033
12046
|
if len(records) >= limit:
|
|
12034
12047
|
return True
|
|
12035
12048
|
child_name = _safe_name(subfolder, "Unnamed")
|
|
12049
|
+
if exclude_bins and child_name in exclude_bins:
|
|
12050
|
+
continue
|
|
12036
12051
|
truncated = _append_folder_media(
|
|
12037
12052
|
subfolder,
|
|
12038
12053
|
bin_path=f"{bin_path}/{child_name}",
|
|
@@ -12041,6 +12056,7 @@ def _append_folder_media(
|
|
|
12041
12056
|
records=records,
|
|
12042
12057
|
warnings=warnings,
|
|
12043
12058
|
limit=limit,
|
|
12059
|
+
exclude_bins=exclude_bins,
|
|
12044
12060
|
)
|
|
12045
12061
|
if truncated:
|
|
12046
12062
|
return True
|
|
@@ -12256,12 +12272,13 @@ def resolve_media_inventory(
|
|
|
12256
12272
|
project_root: str,
|
|
12257
12273
|
*,
|
|
12258
12274
|
limit: Any = 500,
|
|
12275
|
+
exclude_bins: Optional[set] = None,
|
|
12259
12276
|
recursive: bool = True,
|
|
12260
12277
|
probe_paths: bool = True,
|
|
12261
12278
|
reuse_cached: bool = False,
|
|
12262
12279
|
) -> Dict[str, Any]:
|
|
12263
12280
|
try:
|
|
12264
|
-
max_items = max(1, min(int(limit),
|
|
12281
|
+
max_items = max(1, min(int(limit), 10000))
|
|
12265
12282
|
except (TypeError, ValueError):
|
|
12266
12283
|
max_items = 500
|
|
12267
12284
|
|
|
@@ -12349,6 +12366,7 @@ def resolve_media_inventory(
|
|
|
12349
12366
|
records=records,
|
|
12350
12367
|
warnings=warnings,
|
|
12351
12368
|
limit=max_items,
|
|
12369
|
+
exclude_bins=exclude_bins,
|
|
12352
12370
|
)
|
|
12353
12371
|
project_info = {
|
|
12354
12372
|
"name": _safe_name(project, "Resolve Project"),
|
|
@@ -14655,6 +14673,25 @@ def _setup_defaults(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
14655
14673
|
return server_setup(action, params or {})
|
|
14656
14674
|
|
|
14657
14675
|
|
|
14676
|
+
def _inventory_prefs() -> Tuple[int, Optional[set]]:
|
|
14677
|
+
"""Return (limit, exclude_bins) for the inventory walk from media-analysis
|
|
14678
|
+
preferences. ``exclude_bins`` is None when nothing is configured, so the walk
|
|
14679
|
+
indexes every folder by default."""
|
|
14680
|
+
try:
|
|
14681
|
+
from src.server import _media_analysis_effective_preferences
|
|
14682
|
+
|
|
14683
|
+
prefs = _media_analysis_effective_preferences()
|
|
14684
|
+
except Exception: # noqa: BLE001 — fall back to built-in defaults
|
|
14685
|
+
prefs = {}
|
|
14686
|
+
try:
|
|
14687
|
+
limit = max(1, min(int(prefs.get("inventory_limit", 500)), 10000))
|
|
14688
|
+
except (TypeError, ValueError):
|
|
14689
|
+
limit = 500
|
|
14690
|
+
raw = prefs.get("inventory_exclude_bins")
|
|
14691
|
+
exclude = {part.strip() for part in str(raw).split(",") if part.strip()} if raw else None
|
|
14692
|
+
return limit, (exclude or None)
|
|
14693
|
+
|
|
14694
|
+
|
|
14658
14695
|
class Handler(BaseHTTPRequestHandler):
|
|
14659
14696
|
state: DashboardState
|
|
14660
14697
|
|
|
@@ -14838,10 +14875,12 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
14838
14875
|
self._json(_setup_defaults("get_defaults"))
|
|
14839
14876
|
return
|
|
14840
14877
|
if path == "/api/resolve/media":
|
|
14878
|
+
pref_limit, exclude_bins = _inventory_prefs()
|
|
14841
14879
|
self._json_etag(
|
|
14842
14880
|
resolve_media_inventory(
|
|
14843
14881
|
self.state.project_root,
|
|
14844
|
-
limit=(query.get("limit") or [
|
|
14882
|
+
limit=(query.get("limit") or [pref_limit])[0],
|
|
14883
|
+
exclude_bins=exclude_bins,
|
|
14845
14884
|
recursive=(query.get("recursive") or ["true"])[0].lower() not in {"0", "false", "no"},
|
|
14846
14885
|
probe_paths=(query.get("probe") or ["1"])[0].lower() not in {"0", "false", "no"},
|
|
14847
14886
|
reuse_cached=(query.get("reuse") or ["0"])[0].lower() in {"1", "true", "yes"},
|
|
@@ -15429,7 +15468,8 @@ def _warm_inventory_cache(project_root: str) -> None:
|
|
|
15429
15468
|
first real request builds normally.
|
|
15430
15469
|
"""
|
|
15431
15470
|
try:
|
|
15432
|
-
|
|
15471
|
+
pref_limit, exclude_bins = _inventory_prefs()
|
|
15472
|
+
resolve_media_inventory(project_root, limit=pref_limit, exclude_bins=exclude_bins)
|
|
15433
15473
|
except Exception: # noqa: BLE001 — warm-up must never crash startup
|
|
15434
15474
|
pass
|
|
15435
15475
|
|
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.53.0"
|
|
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.53.0"
|
|
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,
|
|
@@ -5931,9 +5932,9 @@ def _safe_auto_sync_audio(mp, p: Dict[str, Any]):
|
|
|
5931
5932
|
# until populated and can be reset to None by a mid-call reconnect. A None
|
|
5932
5933
|
# here makes _normalize_auto_sync_settings fall back to string enum keys,
|
|
5933
5934
|
# which AutoSyncAudio silently rejects (returns False).
|
|
5934
|
-
settings = _normalize_auto_sync_settings(dict(p.get("settings") or {}), get_resolve())
|
|
5935
|
+
settings, ignored_settings = _normalize_auto_sync_settings(dict(p.get("settings") or {}), get_resolve())
|
|
5935
5936
|
if p.get("dry_run", True):
|
|
5936
|
-
return _ok(would_auto_sync=True, clips=_clip_summaries(clips), missing=missing, settings=settings)
|
|
5937
|
+
return _ok(would_auto_sync=True, clips=_clip_summaries(clips), missing=missing, settings=settings, ignored_settings=ignored_settings)
|
|
5937
5938
|
# Read-back verification: AutoSyncAudio's boolean return is unreliable, so
|
|
5938
5939
|
# capture each clip's "Synced Audio" linkage before and after and report the
|
|
5939
5940
|
# delta. Trust `linked`/`newly_linked`, not `success`.
|
|
@@ -5968,6 +5969,7 @@ def _safe_auto_sync_audio(mp, p: Dict[str, Any]):
|
|
|
5968
5969
|
"count": len(clips),
|
|
5969
5970
|
"missing": missing,
|
|
5970
5971
|
"settings": settings,
|
|
5972
|
+
"ignored_settings": ignored_settings,
|
|
5971
5973
|
}
|
|
5972
5974
|
|
|
5973
5975
|
|
|
@@ -5978,14 +5980,36 @@ def _resolve_audio_constant(resolve_obj, name: str, fallback):
|
|
|
5978
5980
|
|
|
5979
5981
|
|
|
5980
5982
|
def _normalize_auto_sync_settings(settings: Dict[str, Any], resolve_obj=None):
|
|
5983
|
+
"""Translate human-readable AutoSyncAudio settings into live ``AUDIO_SYNC_*``
|
|
5984
|
+
enum keys/values.
|
|
5985
|
+
|
|
5986
|
+
Returns ``(normalized, ignored)`` where ``ignored`` is the sorted list of
|
|
5987
|
+
settings keys AutoSyncAudio does not understand (e.g. ``group_id``,
|
|
5988
|
+
``primary_clip_id``). Those are dropped rather than forwarded: passing any
|
|
5989
|
+
unrecognized key makes ``MediaPool.AutoSyncAudio`` silently reject the whole
|
|
5990
|
+
call (returns ``False``, nothing links). Callers surface ``ignored`` so the
|
|
5991
|
+
rejection is no longer invisible. See utils/api_truth.py: MediaPool.AutoSyncAudio.
|
|
5992
|
+
"""
|
|
5981
5993
|
if not settings:
|
|
5982
|
-
return
|
|
5994
|
+
return {}, []
|
|
5983
5995
|
normalized = {}
|
|
5984
5996
|
mode_key = _resolve_audio_constant(resolve_obj, "AUDIO_SYNC_MODE", "syncMode")
|
|
5985
5997
|
channel_key = _resolve_audio_constant(resolve_obj, "AUDIO_SYNC_CHANNEL_NUMBER", "channelNumber")
|
|
5986
5998
|
retain_embedded_key = _resolve_audio_constant(resolve_obj, "AUDIO_SYNC_RETAIN_EMBEDDED_AUDIO", "retainEmbeddedAudio")
|
|
5987
5999
|
retain_metadata_key = _resolve_audio_constant(resolve_obj, "AUDIO_SYNC_RETAIN_VIDEO_METADATA", "retainVideoMetadata")
|
|
5988
|
-
|
|
6000
|
+
|
|
6001
|
+
consumed: set = set()
|
|
6002
|
+
|
|
6003
|
+
def _pick(*keys):
|
|
6004
|
+
for key in keys:
|
|
6005
|
+
if key in settings:
|
|
6006
|
+
consumed.add(key)
|
|
6007
|
+
return settings[key]
|
|
6008
|
+
return None
|
|
6009
|
+
|
|
6010
|
+
# "method" is a natural alias for the sync mode (the tool advertises
|
|
6011
|
+
# method="waveform"); accept it alongside syncBy/mode and a pre-resolved key.
|
|
6012
|
+
mode = _pick("syncBy", "sync_by", "mode", "method", "syncMode", "sync_mode", mode_key)
|
|
5989
6013
|
if isinstance(mode, str):
|
|
5990
6014
|
mode_norm = mode.strip().lower()
|
|
5991
6015
|
if mode_norm in {"waveform", "audio_waveform", "audio_sync_waveform"}:
|
|
@@ -5994,7 +6018,8 @@ def _normalize_auto_sync_settings(settings: Dict[str, Any], resolve_obj=None):
|
|
|
5994
6018
|
mode = _resolve_audio_constant(resolve_obj, "AUDIO_SYNC_TIMECODE", mode)
|
|
5995
6019
|
if mode is not None:
|
|
5996
6020
|
normalized[mode_key] = mode
|
|
5997
|
-
|
|
6021
|
+
|
|
6022
|
+
channel = _pick("channelNumber", "channel_number", "channel", channel_key)
|
|
5998
6023
|
if isinstance(channel, str):
|
|
5999
6024
|
channel_norm = channel.strip().lower()
|
|
6000
6025
|
if channel_norm in {"auto", "automatic"}:
|
|
@@ -6003,18 +6028,16 @@ def _normalize_auto_sync_settings(settings: Dict[str, Any], resolve_obj=None):
|
|
|
6003
6028
|
channel = _resolve_audio_constant(resolve_obj, "AUDIO_SYNC_CHANNEL_MIX", -2)
|
|
6004
6029
|
if channel is not None:
|
|
6005
6030
|
normalized[channel_key] = channel
|
|
6006
|
-
|
|
6007
|
-
|
|
6008
|
-
|
|
6009
|
-
(
|
|
6010
|
-
|
|
6011
|
-
|
|
6012
|
-
|
|
6013
|
-
|
|
6014
|
-
for key
|
|
6015
|
-
|
|
6016
|
-
normalized.setdefault(key, value)
|
|
6017
|
-
return normalized
|
|
6031
|
+
|
|
6032
|
+
retain_embedded = _pick("retainEmbeddedAudio", "retain_embedded_audio")
|
|
6033
|
+
if retain_embedded is not None:
|
|
6034
|
+
normalized[retain_embedded_key] = bool(retain_embedded)
|
|
6035
|
+
retain_metadata = _pick("retainVideoMetadata", "retain_video_metadata")
|
|
6036
|
+
if retain_metadata is not None:
|
|
6037
|
+
normalized[retain_metadata_key] = bool(retain_metadata)
|
|
6038
|
+
|
|
6039
|
+
ignored = sorted(str(key) for key in settings if key not in consumed)
|
|
6040
|
+
return normalized, ignored
|
|
6018
6041
|
|
|
6019
6042
|
|
|
6020
6043
|
def _transcription_capabilities(mp, p: Dict[str, Any]):
|
|
@@ -7032,6 +7055,11 @@ _MEDIA_ANALYSIS_DEFAULT_PREFS = {
|
|
|
7032
7055
|
"sampling_frame_ceiling": 80,
|
|
7033
7056
|
"preferred_analysis_root": None,
|
|
7034
7057
|
"preferred_generated_media_folder": None,
|
|
7058
|
+
# Inventory walk tuning. inventory_limit caps how many clips the Media Pool
|
|
7059
|
+
# walk indexes (clamped 1..10000). inventory_exclude_bins is a comma-separated
|
|
7060
|
+
# list of folder names to skip entirely; empty means index every folder.
|
|
7061
|
+
"inventory_limit": 500,
|
|
7062
|
+
"inventory_exclude_bins": "",
|
|
7035
7063
|
"default_post_operation_page": "stay_put",
|
|
7036
7064
|
"marker_custom_data": "namespaced",
|
|
7037
7065
|
"metadata_writeback_default": True,
|
|
@@ -7285,6 +7313,12 @@ def _media_analysis_effective_preferences() -> Dict[str, Any]:
|
|
|
7285
7313
|
] or list(_MEDIA_ANALYSIS_DEFAULT_PUBLISH_FIELDS)
|
|
7286
7314
|
effective["include_confidence_scores"] = _media_analysis_bool(effective.get("include_confidence_scores"), True)
|
|
7287
7315
|
effective["include_source_time_notes"] = _media_analysis_bool(effective.get("include_source_time_notes"), True)
|
|
7316
|
+
effective["inventory_limit"] = _setup_positive_int(effective.get("inventory_limit"), 500, 1, 10000)
|
|
7317
|
+
effective["inventory_exclude_bins"] = (
|
|
7318
|
+
str(effective.get("inventory_exclude_bins")).strip()
|
|
7319
|
+
if effective.get("inventory_exclude_bins") is not None
|
|
7320
|
+
else ""
|
|
7321
|
+
)
|
|
7288
7322
|
effective["analysis_summary_style"] = _normalize_setup_choice(
|
|
7289
7323
|
effective.get("analysis_summary_style"),
|
|
7290
7324
|
["full", "concise", "creative", "technical"],
|
|
@@ -9226,7 +9260,7 @@ async def _publish_clip_metadata_from_analysis(
|
|
|
9226
9260
|
)
|
|
9227
9261
|
if not plan.get("success"):
|
|
9228
9262
|
return plan
|
|
9229
|
-
if plan.get("capability_gaps"):
|
|
9263
|
+
if plan.get("capability_gaps") and media_analysis_plan_requires_capabilities(plan):
|
|
9230
9264
|
return _media_analysis_missing_capabilities_response(plan, action_label="metadata publish analysis")
|
|
9231
9265
|
|
|
9232
9266
|
manifest = await execute_media_analysis_plan_async(
|
|
@@ -10982,6 +11016,12 @@ def _setup_set_media_analysis_defaults(media_defaults: Dict[str, Any], dry_run:
|
|
|
10982
11016
|
"preferredgeneratedmediafolder": "preferred_generated_media_folder",
|
|
10983
11017
|
"generated_media_folder": "preferred_generated_media_folder",
|
|
10984
11018
|
"generatedmediafolder": "preferred_generated_media_folder",
|
|
11019
|
+
"inventory_limit": "inventory_limit",
|
|
11020
|
+
"inventorylimit": "inventory_limit",
|
|
11021
|
+
"inventory_exclude_bins": "inventory_exclude_bins",
|
|
11022
|
+
"inventoryexcludebins": "inventory_exclude_bins",
|
|
11023
|
+
"exclude_bins": "inventory_exclude_bins",
|
|
11024
|
+
"excludebins": "inventory_exclude_bins",
|
|
10985
11025
|
"default_post_operation_page": "default_post_operation_page",
|
|
10986
11026
|
"defaultpostoperationpage": "default_post_operation_page",
|
|
10987
11027
|
"post_operation_page": "default_post_operation_page",
|
|
@@ -11204,6 +11244,16 @@ def _setup_set_media_analysis_defaults(media_defaults: Dict[str, Any], dry_run:
|
|
|
11204
11244
|
if normalized is None:
|
|
11205
11245
|
return _err("Unsupported marker_custom_data. Use namespaced or minimal.")
|
|
11206
11246
|
set_or_clear(key, raw_value, normalized)
|
|
11247
|
+
elif key == "inventory_limit":
|
|
11248
|
+
try:
|
|
11249
|
+
n = int(raw_value) if not isinstance(raw_value, bool) else 500
|
|
11250
|
+
except (TypeError, ValueError):
|
|
11251
|
+
return _err("inventory_limit must be an integer between 1 and 10000.")
|
|
11252
|
+
set_or_clear(key, raw_value, max(1, min(10000, n)))
|
|
11253
|
+
elif key == "inventory_exclude_bins":
|
|
11254
|
+
# Normalize a comma-separated list of folder names; empty excludes nothing.
|
|
11255
|
+
parts = [part.strip() for part in str(raw_value).split(",") if part.strip()]
|
|
11256
|
+
set_or_clear(key, raw_value, ",".join(parts))
|
|
11207
11257
|
elif key in {"preferred_analysis_root", "preferred_generated_media_folder"}:
|
|
11208
11258
|
path = None if clear_requested(raw_value) else os.path.realpath(os.path.abspath(os.path.expanduser(str(raw_value))))
|
|
11209
11259
|
set_or_clear(key, raw_value, path)
|
|
@@ -11448,6 +11498,8 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
11448
11498
|
"media_analysis.report_format": {"values": ["compact", "full", "machine_readable"], "storage": _media_analysis_preferences_path()},
|
|
11449
11499
|
"media_analysis.preferred_analysis_root": {"values": "absolute or expandable path", "storage": _media_analysis_preferences_path()},
|
|
11450
11500
|
"media_analysis.preferred_generated_media_folder": {"values": "absolute or expandable path", "storage": _media_analysis_preferences_path()},
|
|
11501
|
+
"media_analysis.inventory_limit": {"description": "Maximum clips indexed during the Media Pool inventory walk.", "values": "integer 1..10000 (default 500)", "storage": _media_analysis_preferences_path()},
|
|
11502
|
+
"media_analysis.inventory_exclude_bins": {"description": "Comma-separated folder names to skip entirely during the inventory walk. Empty indexes every folder.", "values": "comma-separated folder names (default none)", "storage": _media_analysis_preferences_path()},
|
|
11451
11503
|
"media_analysis.default_post_operation_page": {"values": ["stay_put", "media", "cut", "edit", "fusion", "color", "fairlight", "deliver"], "storage": _media_analysis_preferences_path()},
|
|
11452
11504
|
"media_analysis.marker_custom_data": {"values": ["namespaced", "minimal"], "storage": _media_analysis_preferences_path()},
|
|
11453
11505
|
"media_analysis.metadata_writeback_default": {"values": [True, False], "storage": _media_analysis_preferences_path()},
|
|
@@ -14769,7 +14821,13 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
14769
14821
|
elif action == "auto_sync_audio":
|
|
14770
14822
|
clips = [_find_clip(root, cid) for cid in p["clip_ids"]]
|
|
14771
14823
|
clips = [c for c in clips if c]
|
|
14772
|
-
|
|
14824
|
+
# Normalize string settings into live AUDIO_SYNC_* enum keys; passing raw
|
|
14825
|
+
# human-readable keys makes AutoSyncAudio silently reject the call.
|
|
14826
|
+
settings, ignored = _normalize_auto_sync_settings(dict(p.get("settings") or {}), get_resolve())
|
|
14827
|
+
result = {"success": bool(mp.AutoSyncAudio(clips, settings))}
|
|
14828
|
+
if ignored:
|
|
14829
|
+
result["ignored_settings"] = ignored
|
|
14830
|
+
return result
|
|
14773
14831
|
elif action == "get_selected":
|
|
14774
14832
|
sel = mp.GetSelectedClips()
|
|
14775
14833
|
if not sel:
|
|
@@ -16268,7 +16326,11 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
16268
16326
|
plan["setup_defaults_applied"] = p.get("_setup_defaults_applied")
|
|
16269
16327
|
if warnings:
|
|
16270
16328
|
plan["warnings"] = warnings
|
|
16271
|
-
if
|
|
16329
|
+
if (
|
|
16330
|
+
plan.get("capability_gaps")
|
|
16331
|
+
and not bool(p.get("dry_run", True))
|
|
16332
|
+
and media_analysis_plan_requires_capabilities(plan)
|
|
16333
|
+
):
|
|
16272
16334
|
return _e2_wrap(_media_analysis_missing_capabilities_response(plan))
|
|
16273
16335
|
if not bool(p.get("dry_run", True)):
|
|
16274
16336
|
executed = await execute_media_analysis_plan_async(
|
|
@@ -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",
|