davinci-resolve-mcp 2.52.1 → 2.54.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 +60 -0
- package/README.md +6 -1
- package/docs/SKILL.md +14 -3
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/analysis_dashboard.py +43 -3
- package/src/granular/common.py +1 -1
- package/src/server.py +268 -29
- package/src/utils/api_truth.py +29 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,66 @@
|
|
|
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.54.0
|
|
6
|
+
|
|
7
|
+
Hardens the rest of the enum-keyed settings APIs against the same silent-failure
|
|
8
|
+
class fixed for `AutoSyncAudio` in v2.53.0 (issue #70). Several Resolve methods
|
|
9
|
+
key their settings dict by `resolve.<CONST>` enum attributes and silently reject
|
|
10
|
+
the whole call when handed plain string keys — returning `False`/`None` with
|
|
11
|
+
nothing applied and no error.
|
|
12
|
+
|
|
13
|
+
- **Fixed** `CreateSubtitlesFromAudio` (both `timeline_ai.create_subtitles` and
|
|
14
|
+
`timeline.subtitle_generation_probe`) now resolves human-readable
|
|
15
|
+
`autoCaptionSettings` — `language`, `preset`, `line_break`, `chars_per_line`,
|
|
16
|
+
`gap` — into live `SUBTITLE_*`/`AUTO_CAPTION_*` enum keys/values. Unknown keys
|
|
17
|
+
and unresolvable values are dropped and reported in `ignored_settings` instead
|
|
18
|
+
of poisoning the call, and generation is read-back verified against the
|
|
19
|
+
timeline's subtitle track count (the boolean return is unreliable).
|
|
20
|
+
- **Fixed** the `ProjectManager` CloudProject family (`create`/`load`/
|
|
21
|
+
`import_project`/`restore`) resolves `{cloudSettings}` into live
|
|
22
|
+
`CLOUD_SETTING_*`/`CLOUD_SYNC_*` enums (`project_name`, `media_path`,
|
|
23
|
+
`is_collab`, `sync_mode`, `is_camera_access`), dropping unknown keys into
|
|
24
|
+
`ignored_settings`.
|
|
25
|
+
- **Changed** the string→enum resolution is now a shared `_resolve_enum_settings`
|
|
26
|
+
primitive driven by per-field specs, so the next enum-keyed API gets the same
|
|
27
|
+
treatment without re-implementing it.
|
|
28
|
+
- **Added** `api_truth` entries for `Timeline.CreateSubtitlesFromAudio` and the
|
|
29
|
+
CloudProject family, documenting the silent-rejection + unreliable-return
|
|
30
|
+
behavior alongside the existing `AutoSyncAudio` entry.
|
|
31
|
+
|
|
32
|
+
## What's New in v2.53.0
|
|
33
|
+
|
|
34
|
+
Two improvements to audio sync reliability and inventory control.
|
|
35
|
+
|
|
36
|
+
`safe_auto_sync_audio` / `media_pool.auto_sync_audio` no longer fail silently on
|
|
37
|
+
human-readable settings (issue #70). The previous normalizer recognized
|
|
38
|
+
`syncBy`/`mode` but not `method`, and it forwarded *every* unrecognized key
|
|
39
|
+
(`group_id`, `primary_clip_id`, …) straight into `MediaPool.AutoSyncAudio` —
|
|
40
|
+
which silently rejects the whole call when it sees a key it doesn't understand,
|
|
41
|
+
returning `False` with nothing linked and no error.
|
|
42
|
+
|
|
43
|
+
- **Fixed** `method` (matching the tool's own parameter naming) is now accepted
|
|
44
|
+
as an alias for the sync mode alongside `syncBy`/`sync_by`/`mode`/`syncMode`.
|
|
45
|
+
- **Fixed** unrecognized settings keys are dropped instead of forwarded, so a
|
|
46
|
+
stray `group_id`/`primary_clip_id` no longer poisons the call. Dropped keys
|
|
47
|
+
are reported back in a new `ignored_settings` field on the response, so a
|
|
48
|
+
rejection is no longer invisible.
|
|
49
|
+
- **Fixed** the raw `media_pool.auto_sync_audio` action now routes settings
|
|
50
|
+
through the same live `AUDIO_SYNC_*` enum resolution as the safe wrapper
|
|
51
|
+
(previously it passed strings straight through).
|
|
52
|
+
|
|
53
|
+
The Media Pool inventory walk is now configurable:
|
|
54
|
+
|
|
55
|
+
- **Added** `media_analysis.inventory_exclude_bins` — a comma-separated list of
|
|
56
|
+
folder names to skip entirely during the inventory walk (recursively). Empty
|
|
57
|
+
by default, so every folder is indexed unless you opt out.
|
|
58
|
+
- **Added** `media_analysis.inventory_limit` — the maximum clips to index per
|
|
59
|
+
walk, configurable from the control panel and `setup`. The hard ceiling is
|
|
60
|
+
raised from 2000 to 10000.
|
|
61
|
+
- Adapted from PR #69 by @rgxdev; the default was changed to exclude nothing
|
|
62
|
+
(the PR defaulted to excluding an `assets` bin, which would have silently
|
|
63
|
+
stopped indexing existing `assets` folders on upgrade).
|
|
64
|
+
|
|
5
65
|
## What's New in v2.52.1
|
|
6
66
|
|
|
7
67
|
Analysis reuse no longer blocks on missing capabilities when every clip is
|
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)
|
|
@@ -163,3 +163,8 @@ Samuel Gursky (samgursky@gmail.com)
|
|
|
163
163
|
- Blackmagic Design for DaVinci Resolve and its scripting API
|
|
164
164
|
- The Model Context Protocol team for enabling AI assistant integration
|
|
165
165
|
- Anthropic for Claude Code, used extensively in development and testing
|
|
166
|
+
|
|
167
|
+
### Community contributions
|
|
168
|
+
|
|
169
|
+
- [@rgxdev](https://github.com/rgxdev) — configurable Media Pool inventory walk
|
|
170
|
+
(exclude bins + inventory limit), [#69](https://github.com/samuelgursky/davinci-resolve-mcp/pull/69) (v2.53.0)
|
package/docs/SKILL.md
CHANGED
|
@@ -999,9 +999,18 @@ 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
|
-
- `subtitle_generation_probe(settings?, allow_generate?)`
|
|
1008
|
+
- `subtitle_generation_probe(settings?, allow_generate?)` — `settings` accepts
|
|
1009
|
+
human-readable keys resolved to live `SUBTITLE_*`/`AUTO_CAPTION_*` enums:
|
|
1010
|
+
`language` (e.g. `english`, `korean`), `preset` (`default`|`teletext`|`netflix`),
|
|
1011
|
+
`line_break` (`single`|`double`), `chars_per_line` (1–60), `gap` (0–10).
|
|
1012
|
+
Unrecognized keys/values are dropped and echoed in `ignored_settings`; generation
|
|
1013
|
+
is read-back verified against the subtitle track count.
|
|
1005
1014
|
- `fairlight_boundary_report`
|
|
1006
1015
|
|
|
1007
1016
|
**`timeline_markers`** — Markers and playhead on the current timeline.
|
|
@@ -1032,7 +1041,9 @@ file to disk.
|
|
|
1032
1041
|
|
|
1033
1042
|
**`timeline_ai`** — AI/ML analysis on the current timeline.
|
|
1034
1043
|
|
|
1035
|
-
Key actions: `create_subtitles(settings?)
|
|
1044
|
+
Key actions: `create_subtitles(settings?)` (human-readable settings resolved to
|
|
1045
|
+
`SUBTITLE_*`/`AUTO_CAPTION_*` enums + read-back verified — see
|
|
1046
|
+
`subtitle_generation_probe`), `detect_scene_cuts`,
|
|
1036
1047
|
`grab_still`, `grab_all_stills(source?)`, `analyze_dolby_vision`
|
|
1037
1048
|
|
|
1038
1049
|
---
|
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.54.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/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.54.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.54.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -5932,9 +5932,9 @@ def _safe_auto_sync_audio(mp, p: Dict[str, Any]):
|
|
|
5932
5932
|
# until populated and can be reset to None by a mid-call reconnect. A None
|
|
5933
5933
|
# here makes _normalize_auto_sync_settings fall back to string enum keys,
|
|
5934
5934
|
# which AutoSyncAudio silently rejects (returns False).
|
|
5935
|
-
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())
|
|
5936
5936
|
if p.get("dry_run", True):
|
|
5937
|
-
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)
|
|
5938
5938
|
# Read-back verification: AutoSyncAudio's boolean return is unreliable, so
|
|
5939
5939
|
# capture each clip's "Synced Audio" linkage before and after and report the
|
|
5940
5940
|
# delta. Trust `linked`/`newly_linked`, not `success`.
|
|
@@ -5969,6 +5969,7 @@ def _safe_auto_sync_audio(mp, p: Dict[str, Any]):
|
|
|
5969
5969
|
"count": len(clips),
|
|
5970
5970
|
"missing": missing,
|
|
5971
5971
|
"settings": settings,
|
|
5972
|
+
"ignored_settings": ignored_settings,
|
|
5972
5973
|
}
|
|
5973
5974
|
|
|
5974
5975
|
|
|
@@ -5979,14 +5980,36 @@ def _resolve_audio_constant(resolve_obj, name: str, fallback):
|
|
|
5979
5980
|
|
|
5980
5981
|
|
|
5981
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
|
+
"""
|
|
5982
5993
|
if not settings:
|
|
5983
|
-
return
|
|
5994
|
+
return {}, []
|
|
5984
5995
|
normalized = {}
|
|
5985
5996
|
mode_key = _resolve_audio_constant(resolve_obj, "AUDIO_SYNC_MODE", "syncMode")
|
|
5986
5997
|
channel_key = _resolve_audio_constant(resolve_obj, "AUDIO_SYNC_CHANNEL_NUMBER", "channelNumber")
|
|
5987
5998
|
retain_embedded_key = _resolve_audio_constant(resolve_obj, "AUDIO_SYNC_RETAIN_EMBEDDED_AUDIO", "retainEmbeddedAudio")
|
|
5988
5999
|
retain_metadata_key = _resolve_audio_constant(resolve_obj, "AUDIO_SYNC_RETAIN_VIDEO_METADATA", "retainVideoMetadata")
|
|
5989
|
-
|
|
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)
|
|
5990
6013
|
if isinstance(mode, str):
|
|
5991
6014
|
mode_norm = mode.strip().lower()
|
|
5992
6015
|
if mode_norm in {"waveform", "audio_waveform", "audio_sync_waveform"}:
|
|
@@ -5995,7 +6018,8 @@ def _normalize_auto_sync_settings(settings: Dict[str, Any], resolve_obj=None):
|
|
|
5995
6018
|
mode = _resolve_audio_constant(resolve_obj, "AUDIO_SYNC_TIMECODE", mode)
|
|
5996
6019
|
if mode is not None:
|
|
5997
6020
|
normalized[mode_key] = mode
|
|
5998
|
-
|
|
6021
|
+
|
|
6022
|
+
channel = _pick("channelNumber", "channel_number", "channel", channel_key)
|
|
5999
6023
|
if isinstance(channel, str):
|
|
6000
6024
|
channel_norm = channel.strip().lower()
|
|
6001
6025
|
if channel_norm in {"auto", "automatic"}:
|
|
@@ -6004,18 +6028,192 @@ def _normalize_auto_sync_settings(settings: Dict[str, Any], resolve_obj=None):
|
|
|
6004
6028
|
channel = _resolve_audio_constant(resolve_obj, "AUDIO_SYNC_CHANNEL_MIX", -2)
|
|
6005
6029
|
if channel is not None:
|
|
6006
6030
|
normalized[channel_key] = channel
|
|
6007
|
-
|
|
6008
|
-
|
|
6009
|
-
|
|
6010
|
-
(
|
|
6011
|
-
|
|
6012
|
-
|
|
6013
|
-
|
|
6014
|
-
|
|
6015
|
-
for key
|
|
6016
|
-
|
|
6017
|
-
|
|
6018
|
-
|
|
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
|
|
6041
|
+
|
|
6042
|
+
|
|
6043
|
+
def _resolve_enum_settings(resolve_obj, settings, field_specs):
|
|
6044
|
+
"""Resolve a human-readable settings dict into the live enum-keyed dict that
|
|
6045
|
+
enum-keyed Resolve APIs require.
|
|
6046
|
+
|
|
6047
|
+
Several Resolve methods (``MediaPool.AutoSyncAudio``,
|
|
6048
|
+
``Timeline.CreateSubtitlesFromAudio``, the ``ProjectManager`` CloudProject
|
|
6049
|
+
family) key their settings dict by ``resolve.<CONST>`` enum attributes — and
|
|
6050
|
+
for many fields expect ``resolve.<CONST>`` enum *values* too. They silently
|
|
6051
|
+
reject the WHOLE call when handed a plain string key (returning False/None
|
|
6052
|
+
with nothing applied), so unrecognized keys must be dropped, not forwarded.
|
|
6053
|
+
See utils/api_truth.py for the catalogued symbols.
|
|
6054
|
+
|
|
6055
|
+
``field_specs`` is an iterable of dicts, one per supported field:
|
|
6056
|
+
``aliases`` human keys accepted for this field (case-insensitive)
|
|
6057
|
+
``key_const`` name of the ``resolve`` enum attribute used as the dict key
|
|
6058
|
+
``key_fallback`` key used when the live handle can't resolve ``key_const``
|
|
6059
|
+
(defaults to ``key_const``); keeps offline behavior readable
|
|
6060
|
+
``values`` optional ``{lowercased_str: enum_const_name}`` value map
|
|
6061
|
+
``coerce`` optional ``"int"`` | ``"bool"`` cast for plain values
|
|
6062
|
+
``bounds`` optional ``(min, max)`` clamp applied after int coercion
|
|
6063
|
+
|
|
6064
|
+
Returns ``(normalized, ignored)``. ``ignored`` lists input keys that no spec
|
|
6065
|
+
claimed AND recognized keys whose value could not be resolved (sorted) — so a
|
|
6066
|
+
field that silently fails to apply is reported rather than hidden.
|
|
6067
|
+
"""
|
|
6068
|
+
if not settings:
|
|
6069
|
+
return {}, []
|
|
6070
|
+
normalized = {}
|
|
6071
|
+
consumed = set()
|
|
6072
|
+
lower = {str(k).strip().lower(): k for k in settings}
|
|
6073
|
+
|
|
6074
|
+
for spec in field_specs:
|
|
6075
|
+
src_key = next((lower[a.lower()] for a in spec["aliases"] if a.lower() in lower), None)
|
|
6076
|
+
if src_key is None:
|
|
6077
|
+
continue
|
|
6078
|
+
raw = settings[src_key]
|
|
6079
|
+
key = _resolve_audio_constant(resolve_obj, spec["key_const"], spec.get("key_fallback", spec["key_const"]))
|
|
6080
|
+
values_map = spec.get("values")
|
|
6081
|
+
if values_map is not None and isinstance(raw, str):
|
|
6082
|
+
const_name = values_map.get(raw.strip().lower())
|
|
6083
|
+
if const_name is None:
|
|
6084
|
+
continue # unknown enum value — leave src_key unconsumed -> ignored
|
|
6085
|
+
value = _resolve_audio_constant(resolve_obj, const_name, raw)
|
|
6086
|
+
elif spec.get("coerce") == "int":
|
|
6087
|
+
try:
|
|
6088
|
+
value = int(raw) if not isinstance(raw, bool) else int(raw)
|
|
6089
|
+
except (TypeError, ValueError):
|
|
6090
|
+
continue
|
|
6091
|
+
lo, hi = spec.get("bounds", (None, None))
|
|
6092
|
+
if lo is not None:
|
|
6093
|
+
value = max(lo, value)
|
|
6094
|
+
if hi is not None:
|
|
6095
|
+
value = min(hi, value)
|
|
6096
|
+
elif spec.get("coerce") == "bool":
|
|
6097
|
+
value = bool(raw)
|
|
6098
|
+
else:
|
|
6099
|
+
value = raw
|
|
6100
|
+
normalized[key] = value
|
|
6101
|
+
consumed.add(src_key)
|
|
6102
|
+
|
|
6103
|
+
ignored = sorted(str(key) for key in settings if key not in consumed)
|
|
6104
|
+
return normalized, ignored
|
|
6105
|
+
|
|
6106
|
+
|
|
6107
|
+
# Timeline.CreateSubtitlesFromAudio({autoCaptionSettings}) — enum-keyed exactly
|
|
6108
|
+
# like AutoSyncAudio (docs/reference/resolve_scripting_api.txt lines 733-771).
|
|
6109
|
+
_AUTO_CAPTION_LANGUAGES = {
|
|
6110
|
+
"auto": "AUTO_CAPTION_AUTO",
|
|
6111
|
+
"danish": "AUTO_CAPTION_DANISH",
|
|
6112
|
+
"dutch": "AUTO_CAPTION_DUTCH",
|
|
6113
|
+
"english": "AUTO_CAPTION_ENGLISH",
|
|
6114
|
+
"french": "AUTO_CAPTION_FRENCH",
|
|
6115
|
+
"german": "AUTO_CAPTION_GERMAN",
|
|
6116
|
+
"italian": "AUTO_CAPTION_ITALIAN",
|
|
6117
|
+
"japanese": "AUTO_CAPTION_JAPANESE",
|
|
6118
|
+
"korean": "AUTO_CAPTION_KOREAN",
|
|
6119
|
+
"mandarin_simplified": "AUTO_CAPTION_MANDARIN_SIMPLIFIED",
|
|
6120
|
+
"mandarin_traditional": "AUTO_CAPTION_MANDARIN_TRADITIONAL",
|
|
6121
|
+
"norwegian": "AUTO_CAPTION_NORWEGIAN",
|
|
6122
|
+
"portuguese": "AUTO_CAPTION_PORTUGUESE",
|
|
6123
|
+
"russian": "AUTO_CAPTION_RUSSIAN",
|
|
6124
|
+
"spanish": "AUTO_CAPTION_SPANISH",
|
|
6125
|
+
"swedish": "AUTO_CAPTION_SWEDISH",
|
|
6126
|
+
}
|
|
6127
|
+
_AUTO_CAPTION_PRESETS = {
|
|
6128
|
+
"default": "AUTO_CAPTION_SUBTITLE_DEFAULT",
|
|
6129
|
+
"subtitle_default": "AUTO_CAPTION_SUBTITLE_DEFAULT",
|
|
6130
|
+
"teletext": "AUTO_CAPTION_TELETEXT",
|
|
6131
|
+
"netflix": "AUTO_CAPTION_NETFLIX",
|
|
6132
|
+
}
|
|
6133
|
+
_AUTO_CAPTION_LINE_BREAKS = {
|
|
6134
|
+
"single": "AUTO_CAPTION_LINE_SINGLE",
|
|
6135
|
+
"double": "AUTO_CAPTION_LINE_DOUBLE",
|
|
6136
|
+
}
|
|
6137
|
+
_AUTO_CAPTION_FIELD_SPECS = (
|
|
6138
|
+
{"aliases": ["language", "lang", "SUBTITLE_LANGUAGE"], "key_const": "SUBTITLE_LANGUAGE", "key_fallback": "language", "values": _AUTO_CAPTION_LANGUAGES},
|
|
6139
|
+
{"aliases": ["preset", "caption_preset", "SUBTITLE_CAPTION_PRESET"], "key_const": "SUBTITLE_CAPTION_PRESET", "key_fallback": "captionPreset", "values": _AUTO_CAPTION_PRESETS},
|
|
6140
|
+
{"aliases": ["line_break", "linebreak", "SUBTITLE_LINE_BREAK"], "key_const": "SUBTITLE_LINE_BREAK", "key_fallback": "lineBreak", "values": _AUTO_CAPTION_LINE_BREAKS},
|
|
6141
|
+
{"aliases": ["chars_per_line", "charsperline", "SUBTITLE_CHARS_PER_LINE"], "key_const": "SUBTITLE_CHARS_PER_LINE", "key_fallback": "charsPerLine", "coerce": "int", "bounds": (1, 60)},
|
|
6142
|
+
{"aliases": ["gap", "SUBTITLE_GAP"], "key_const": "SUBTITLE_GAP", "key_fallback": "gap", "coerce": "int", "bounds": (0, 10)},
|
|
6143
|
+
)
|
|
6144
|
+
|
|
6145
|
+
|
|
6146
|
+
def _normalize_auto_caption_settings(settings, resolve_obj=None):
|
|
6147
|
+
"""Translate human-readable subtitle settings (e.g. ``{"language": "korean"}``)
|
|
6148
|
+
into live ``SUBTITLE_*``/``AUTO_CAPTION_*`` enum keys/values for
|
|
6149
|
+
``CreateSubtitlesFromAudio``. Returns ``(normalized, ignored)``."""
|
|
6150
|
+
return _resolve_enum_settings(resolve_obj, dict(settings or {}), _AUTO_CAPTION_FIELD_SPECS)
|
|
6151
|
+
|
|
6152
|
+
|
|
6153
|
+
# ProjectManager CloudProject family — {cloudSettings} is keyed by
|
|
6154
|
+
# resolve.CLOUD_SETTING_* constants with resolve.CLOUD_SYNC_* sync-mode values
|
|
6155
|
+
# (docs/reference/resolve_scripting_api.txt lines 588-603). Same silent-rejection
|
|
6156
|
+
# failure mode as AutoSyncAudio when handed plain string keys.
|
|
6157
|
+
_CLOUD_SYNC_MODES = {
|
|
6158
|
+
"none": "CLOUD_SYNC_NONE",
|
|
6159
|
+
"proxy_only": "CLOUD_SYNC_PROXY_ONLY",
|
|
6160
|
+
"proxy": "CLOUD_SYNC_PROXY_ONLY",
|
|
6161
|
+
"proxy_and_orig": "CLOUD_SYNC_PROXY_AND_ORIG",
|
|
6162
|
+
"proxy_and_original": "CLOUD_SYNC_PROXY_AND_ORIG",
|
|
6163
|
+
}
|
|
6164
|
+
_CLOUD_SETTINGS_FIELD_SPECS = (
|
|
6165
|
+
{"aliases": ["project_name", "name", "CLOUD_SETTING_PROJECT_NAME"], "key_const": "CLOUD_SETTING_PROJECT_NAME", "key_fallback": "projectName"},
|
|
6166
|
+
{"aliases": ["project_media_path", "media_path", "CLOUD_SETTING_PROJECT_MEDIA_PATH"], "key_const": "CLOUD_SETTING_PROJECT_MEDIA_PATH", "key_fallback": "projectMediaPath"},
|
|
6167
|
+
{"aliases": ["is_collab", "collab", "CLOUD_SETTING_IS_COLLAB"], "key_const": "CLOUD_SETTING_IS_COLLAB", "key_fallback": "isCollab", "coerce": "bool"},
|
|
6168
|
+
{"aliases": ["sync_mode", "syncmode", "CLOUD_SETTING_SYNC_MODE"], "key_const": "CLOUD_SETTING_SYNC_MODE", "key_fallback": "syncMode", "values": _CLOUD_SYNC_MODES},
|
|
6169
|
+
{"aliases": ["is_camera_access", "camera_access", "CLOUD_SETTING_IS_CAMERA_ACCESS"], "key_const": "CLOUD_SETTING_IS_CAMERA_ACCESS", "key_fallback": "isCameraAccess", "coerce": "bool"},
|
|
6170
|
+
)
|
|
6171
|
+
|
|
6172
|
+
|
|
6173
|
+
def _normalize_cloud_settings(settings, resolve_obj=None):
|
|
6174
|
+
"""Translate human-readable cloud-project settings (e.g.
|
|
6175
|
+
``{"project_name": "X", "sync_mode": "proxy_only"}``) into live
|
|
6176
|
+
``CLOUD_SETTING_*``/``CLOUD_SYNC_*`` enum keys/values. Returns
|
|
6177
|
+
``(normalized, ignored)``."""
|
|
6178
|
+
return _resolve_enum_settings(resolve_obj, dict(settings or {}), _CLOUD_SETTINGS_FIELD_SPECS)
|
|
6179
|
+
|
|
6180
|
+
|
|
6181
|
+
def _safe_create_subtitles(tl, p: Dict[str, Any]):
|
|
6182
|
+
"""CreateSubtitlesFromAudio with enum-resolved settings + readback.
|
|
6183
|
+
|
|
6184
|
+
The raw boolean is unreliable (like AutoSyncAudio), so we verify by reading
|
|
6185
|
+
the timeline's subtitle track count before/after and report the real delta.
|
|
6186
|
+
"""
|
|
6187
|
+
settings, ignored = _normalize_auto_caption_settings(p.get("settings"), get_resolve())
|
|
6188
|
+
if p.get("dry_run", False):
|
|
6189
|
+
return _ok(would_create_subtitles=True, settings=settings, ignored_settings=ignored)
|
|
6190
|
+
|
|
6191
|
+
def _subtitle_track_count():
|
|
6192
|
+
try:
|
|
6193
|
+
return int(tl.GetTrackCount("subtitle") or 0)
|
|
6194
|
+
except Exception:
|
|
6195
|
+
return 0
|
|
6196
|
+
|
|
6197
|
+
res = verify_by_readback(
|
|
6198
|
+
mutate=lambda: tl.CreateSubtitlesFromAudio(settings),
|
|
6199
|
+
observe=_subtitle_track_count,
|
|
6200
|
+
snapshot=_subtitle_track_count,
|
|
6201
|
+
compare=lambda before, after: {
|
|
6202
|
+
"verified": bool((after or 0) > (before or 0)),
|
|
6203
|
+
"subtitle_tracks_before": before,
|
|
6204
|
+
"subtitle_tracks_after": after,
|
|
6205
|
+
},
|
|
6206
|
+
label="create_subtitles_from_audio",
|
|
6207
|
+
intent={"settings_keys": sorted(settings.keys()) if settings else []},
|
|
6208
|
+
)
|
|
6209
|
+
return {
|
|
6210
|
+
"success": res["success_raw"],
|
|
6211
|
+
"verified": res["verified"],
|
|
6212
|
+
"subtitle_tracks_before": res.get("subtitle_tracks_before"),
|
|
6213
|
+
"subtitle_tracks_after": res.get("subtitle_tracks_after"),
|
|
6214
|
+
"settings": settings,
|
|
6215
|
+
"ignored_settings": ignored,
|
|
6216
|
+
}
|
|
6019
6217
|
|
|
6020
6218
|
|
|
6021
6219
|
def _transcription_capabilities(mp, p: Dict[str, Any]):
|
|
@@ -6063,13 +6261,14 @@ def _transcription_capabilities(mp, p: Dict[str, Any]):
|
|
|
6063
6261
|
|
|
6064
6262
|
|
|
6065
6263
|
def _subtitle_generation_probe(tl, p: Dict[str, Any]):
|
|
6066
|
-
settings =
|
|
6264
|
+
settings, ignored = _normalize_auto_caption_settings(p.get("settings"), get_resolve())
|
|
6067
6265
|
if not p.get("allow_generate", False):
|
|
6068
|
-
return _ok(would_generate=True, settings=settings,
|
|
6266
|
+
return _ok(would_generate=True, settings=settings, ignored_settings=ignored,
|
|
6267
|
+
note="Pass allow_generate=True to call CreateSubtitlesFromAudio.")
|
|
6069
6268
|
if not _has_method(tl, "CreateSubtitlesFromAudio"):
|
|
6070
6269
|
return _err("CreateSubtitlesFromAudio unavailable")
|
|
6071
6270
|
with long_resolve_op("timeline.create_subtitles_from_audio"):
|
|
6072
|
-
return
|
|
6271
|
+
return _safe_create_subtitles(tl, p)
|
|
6073
6272
|
|
|
6074
6273
|
|
|
6075
6274
|
def _fairlight_boundary_report(proj, mp, tl, p: Dict[str, Any]):
|
|
@@ -7033,6 +7232,11 @@ _MEDIA_ANALYSIS_DEFAULT_PREFS = {
|
|
|
7033
7232
|
"sampling_frame_ceiling": 80,
|
|
7034
7233
|
"preferred_analysis_root": None,
|
|
7035
7234
|
"preferred_generated_media_folder": None,
|
|
7235
|
+
# Inventory walk tuning. inventory_limit caps how many clips the Media Pool
|
|
7236
|
+
# walk indexes (clamped 1..10000). inventory_exclude_bins is a comma-separated
|
|
7237
|
+
# list of folder names to skip entirely; empty means index every folder.
|
|
7238
|
+
"inventory_limit": 500,
|
|
7239
|
+
"inventory_exclude_bins": "",
|
|
7036
7240
|
"default_post_operation_page": "stay_put",
|
|
7037
7241
|
"marker_custom_data": "namespaced",
|
|
7038
7242
|
"metadata_writeback_default": True,
|
|
@@ -7286,6 +7490,12 @@ def _media_analysis_effective_preferences() -> Dict[str, Any]:
|
|
|
7286
7490
|
] or list(_MEDIA_ANALYSIS_DEFAULT_PUBLISH_FIELDS)
|
|
7287
7491
|
effective["include_confidence_scores"] = _media_analysis_bool(effective.get("include_confidence_scores"), True)
|
|
7288
7492
|
effective["include_source_time_notes"] = _media_analysis_bool(effective.get("include_source_time_notes"), True)
|
|
7493
|
+
effective["inventory_limit"] = _setup_positive_int(effective.get("inventory_limit"), 500, 1, 10000)
|
|
7494
|
+
effective["inventory_exclude_bins"] = (
|
|
7495
|
+
str(effective.get("inventory_exclude_bins")).strip()
|
|
7496
|
+
if effective.get("inventory_exclude_bins") is not None
|
|
7497
|
+
else ""
|
|
7498
|
+
)
|
|
7289
7499
|
effective["analysis_summary_style"] = _normalize_setup_choice(
|
|
7290
7500
|
effective.get("analysis_summary_style"),
|
|
7291
7501
|
["full", "concise", "creative", "technical"],
|
|
@@ -10983,6 +11193,12 @@ def _setup_set_media_analysis_defaults(media_defaults: Dict[str, Any], dry_run:
|
|
|
10983
11193
|
"preferredgeneratedmediafolder": "preferred_generated_media_folder",
|
|
10984
11194
|
"generated_media_folder": "preferred_generated_media_folder",
|
|
10985
11195
|
"generatedmediafolder": "preferred_generated_media_folder",
|
|
11196
|
+
"inventory_limit": "inventory_limit",
|
|
11197
|
+
"inventorylimit": "inventory_limit",
|
|
11198
|
+
"inventory_exclude_bins": "inventory_exclude_bins",
|
|
11199
|
+
"inventoryexcludebins": "inventory_exclude_bins",
|
|
11200
|
+
"exclude_bins": "inventory_exclude_bins",
|
|
11201
|
+
"excludebins": "inventory_exclude_bins",
|
|
10986
11202
|
"default_post_operation_page": "default_post_operation_page",
|
|
10987
11203
|
"defaultpostoperationpage": "default_post_operation_page",
|
|
10988
11204
|
"post_operation_page": "default_post_operation_page",
|
|
@@ -11205,6 +11421,16 @@ def _setup_set_media_analysis_defaults(media_defaults: Dict[str, Any], dry_run:
|
|
|
11205
11421
|
if normalized is None:
|
|
11206
11422
|
return _err("Unsupported marker_custom_data. Use namespaced or minimal.")
|
|
11207
11423
|
set_or_clear(key, raw_value, normalized)
|
|
11424
|
+
elif key == "inventory_limit":
|
|
11425
|
+
try:
|
|
11426
|
+
n = int(raw_value) if not isinstance(raw_value, bool) else 500
|
|
11427
|
+
except (TypeError, ValueError):
|
|
11428
|
+
return _err("inventory_limit must be an integer between 1 and 10000.")
|
|
11429
|
+
set_or_clear(key, raw_value, max(1, min(10000, n)))
|
|
11430
|
+
elif key == "inventory_exclude_bins":
|
|
11431
|
+
# Normalize a comma-separated list of folder names; empty excludes nothing.
|
|
11432
|
+
parts = [part.strip() for part in str(raw_value).split(",") if part.strip()]
|
|
11433
|
+
set_or_clear(key, raw_value, ",".join(parts))
|
|
11208
11434
|
elif key in {"preferred_analysis_root", "preferred_generated_media_folder"}:
|
|
11209
11435
|
path = None if clear_requested(raw_value) else os.path.realpath(os.path.abspath(os.path.expanduser(str(raw_value))))
|
|
11210
11436
|
set_or_clear(key, raw_value, path)
|
|
@@ -11449,6 +11675,8 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
11449
11675
|
"media_analysis.report_format": {"values": ["compact", "full", "machine_readable"], "storage": _media_analysis_preferences_path()},
|
|
11450
11676
|
"media_analysis.preferred_analysis_root": {"values": "absolute or expandable path", "storage": _media_analysis_preferences_path()},
|
|
11451
11677
|
"media_analysis.preferred_generated_media_folder": {"values": "absolute or expandable path", "storage": _media_analysis_preferences_path()},
|
|
11678
|
+
"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()},
|
|
11679
|
+
"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()},
|
|
11452
11680
|
"media_analysis.default_post_operation_page": {"values": ["stay_put", "media", "cut", "edit", "fusion", "color", "fairlight", "deliver"], "storage": _media_analysis_preferences_path()},
|
|
11453
11681
|
"media_analysis.marker_custom_data": {"values": ["namespaced", "minimal"], "storage": _media_analysis_preferences_path()},
|
|
11454
11682
|
"media_analysis.metadata_writeback_default": {"values": [True, False], "storage": _media_analysis_preferences_path()},
|
|
@@ -13660,16 +13888,21 @@ def project_manager_cloud(action: str, params: Optional[Dict[str, Any]] = None)
|
|
|
13660
13888
|
return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.")
|
|
13661
13889
|
pm = r.GetProjectManager()
|
|
13662
13890
|
|
|
13891
|
+
# cloudSettings is enum-keyed (CLOUD_SETTING_*/CLOUD_SYNC_*); resolve string
|
|
13892
|
+
# keys against the live handle so human-readable settings aren't silently
|
|
13893
|
+
# rejected. Unrecognized keys are dropped and reported in ignored_settings.
|
|
13894
|
+
settings, ignored = _normalize_cloud_settings(p.get("settings"), r)
|
|
13895
|
+
|
|
13663
13896
|
if action == "create":
|
|
13664
|
-
proj = pm.CreateCloudProject(
|
|
13665
|
-
return _ok(name=proj.GetName()) if proj else _err("Failed to create cloud project")
|
|
13897
|
+
proj = pm.CreateCloudProject(settings)
|
|
13898
|
+
return _ok(name=proj.GetName(), ignored_settings=ignored) if proj else _err("Failed to create cloud project")
|
|
13666
13899
|
elif action == "load":
|
|
13667
|
-
proj = pm.LoadCloudProject(
|
|
13668
|
-
return _ok(name=proj.GetName()) if proj else _err("Failed to load cloud project")
|
|
13900
|
+
proj = pm.LoadCloudProject(settings)
|
|
13901
|
+
return _ok(name=proj.GetName(), ignored_settings=ignored) if proj else _err("Failed to load cloud project")
|
|
13669
13902
|
elif action == "import_project":
|
|
13670
|
-
return {"success": bool(pm.ImportCloudProject(p["path"],
|
|
13903
|
+
return {"success": bool(pm.ImportCloudProject(p["path"], settings)), "ignored_settings": ignored}
|
|
13671
13904
|
elif action == "restore":
|
|
13672
|
-
return {"success": bool(pm.RestoreCloudProject(p["folder_path"],
|
|
13905
|
+
return {"success": bool(pm.RestoreCloudProject(p["folder_path"], settings)), "ignored_settings": ignored}
|
|
13673
13906
|
return _unknown(action, ["create","load","import_project","restore"])
|
|
13674
13907
|
|
|
13675
13908
|
|
|
@@ -14770,7 +15003,13 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
14770
15003
|
elif action == "auto_sync_audio":
|
|
14771
15004
|
clips = [_find_clip(root, cid) for cid in p["clip_ids"]]
|
|
14772
15005
|
clips = [c for c in clips if c]
|
|
14773
|
-
|
|
15006
|
+
# Normalize string settings into live AUDIO_SYNC_* enum keys; passing raw
|
|
15007
|
+
# human-readable keys makes AutoSyncAudio silently reject the call.
|
|
15008
|
+
settings, ignored = _normalize_auto_sync_settings(dict(p.get("settings") or {}), get_resolve())
|
|
15009
|
+
result = {"success": bool(mp.AutoSyncAudio(clips, settings))}
|
|
15010
|
+
if ignored:
|
|
15011
|
+
result["ignored_settings"] = ignored
|
|
15012
|
+
return result
|
|
14774
15013
|
elif action == "get_selected":
|
|
14775
15014
|
sel = mp.GetSelectedClips()
|
|
14776
15015
|
if not sel:
|
|
@@ -18061,7 +18300,7 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
18061
18300
|
|
|
18062
18301
|
if action == "create_subtitles":
|
|
18063
18302
|
with long_resolve_op("timeline_ai.create_subtitles"):
|
|
18064
|
-
return
|
|
18303
|
+
return _safe_create_subtitles(tl, p)
|
|
18065
18304
|
elif action == "detect_scene_cuts":
|
|
18066
18305
|
with long_resolve_op("timeline_ai.detect_scene_cuts"):
|
|
18067
18306
|
return {"success": bool(tl.DetectSceneCuts())}
|
package/src/utils/api_truth.py
CHANGED
|
@@ -29,6 +29,35 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
29
29
|
"'Synced Audio' property (see verify_by_readback).",
|
|
30
30
|
"tags": ["unreliable-return", "silent-failure", "audio", "enum"],
|
|
31
31
|
},
|
|
32
|
+
{
|
|
33
|
+
"symbol": "Timeline.CreateSubtitlesFromAudio",
|
|
34
|
+
"object": "Timeline",
|
|
35
|
+
"signature": "(autoCaptionSettings) -> bool",
|
|
36
|
+
"reality": "Same failure mode as AutoSyncAudio: the autoCaptionSettings "
|
|
37
|
+
"dict is keyed by resolve.SUBTITLE_* enum constants with "
|
|
38
|
+
"resolve.AUTO_CAPTION_* enum values, so plain string keys like "
|
|
39
|
+
"{'language': 'korean'} are silently rejected (returns False, "
|
|
40
|
+
"no subtitle track created). The boolean is also unreliable.",
|
|
41
|
+
"recommended": "Resolve the SUBTITLE_*/AUTO_CAPTION_* constants via the "
|
|
42
|
+
"live resolve handle (server._normalize_auto_caption_settings) "
|
|
43
|
+
"and verify by reading the timeline's subtitle track count "
|
|
44
|
+
"before/after (server._safe_create_subtitles).",
|
|
45
|
+
"tags": ["unreliable-return", "silent-failure", "subtitle", "enum"],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"symbol": "ProjectManager CloudProject family (Create/Load/Import/RestoreCloudProject)",
|
|
49
|
+
"object": "ProjectManager",
|
|
50
|
+
"signature": "(..., cloudSettings) -> Project | bool",
|
|
51
|
+
"reality": "All four take an enum-keyed {cloudSettings} dict "
|
|
52
|
+
"(resolve.CLOUD_SETTING_* keys, resolve.CLOUD_SYNC_* sync-mode "
|
|
53
|
+
"values). Plain string keys are silently rejected, so a settings "
|
|
54
|
+
"dict built from human-readable keys yields no project / False.",
|
|
55
|
+
"recommended": "Resolve the CLOUD_SETTING_*/CLOUD_SYNC_* constants via the "
|
|
56
|
+
"live resolve handle (server._normalize_cloud_settings) "
|
|
57
|
+
"before calling, and treat the bool return from "
|
|
58
|
+
"Import/RestoreCloudProject as advisory.",
|
|
59
|
+
"tags": ["silent-failure", "project", "cloud", "enum"],
|
|
60
|
+
},
|
|
32
61
|
{
|
|
33
62
|
"symbol": "Composition.Paste",
|
|
34
63
|
"object": "Fusion Composition",
|