davinci-resolve-mcp 2.52.1 → 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 CHANGED
@@ -2,6 +2,39 @@
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
+
5
38
  ## What's New in v2.52.1
6
39
 
7
40
  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
- [![Version](https://img.shields.io/badge/version-2.52.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.53.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(341%20full)-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.52.1"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.52.1",
3
+ "version": "2.53.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -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), 2000))
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 [500])[0],
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
- resolve_media_inventory(project_root)
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
 
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.52.1"
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.52.1"
14
+ VERSION = "2.53.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 settings
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
- mode = settings.get("syncBy", settings.get("sync_by", settings.get("mode", settings.get(mode_key))))
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
- channel = settings.get("channelNumber", settings.get("channel_number", settings.get("channel", settings.get(channel_key))))
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,16 @@ 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
- for source_key, target_key in (
6008
- ("retainEmbeddedAudio", retain_embedded_key),
6009
- ("retain_embedded_audio", retain_embedded_key),
6010
- ("retainVideoMetadata", retain_metadata_key),
6011
- ("retain_video_metadata", retain_metadata_key),
6012
- ):
6013
- if source_key in settings:
6014
- normalized[target_key] = bool(settings[source_key])
6015
- for key, value in settings.items():
6016
- if key not in {"syncBy", "sync_by", "mode", "channelNumber", "channel_number", "channel", "retainEmbeddedAudio", "retain_embedded_audio", "retainVideoMetadata", "retain_video_metadata"}:
6017
- normalized.setdefault(key, value)
6018
- 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
6019
6041
 
6020
6042
 
6021
6043
  def _transcription_capabilities(mp, p: Dict[str, Any]):
@@ -7033,6 +7055,11 @@ _MEDIA_ANALYSIS_DEFAULT_PREFS = {
7033
7055
  "sampling_frame_ceiling": 80,
7034
7056
  "preferred_analysis_root": None,
7035
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": "",
7036
7063
  "default_post_operation_page": "stay_put",
7037
7064
  "marker_custom_data": "namespaced",
7038
7065
  "metadata_writeback_default": True,
@@ -7286,6 +7313,12 @@ def _media_analysis_effective_preferences() -> Dict[str, Any]:
7286
7313
  ] or list(_MEDIA_ANALYSIS_DEFAULT_PUBLISH_FIELDS)
7287
7314
  effective["include_confidence_scores"] = _media_analysis_bool(effective.get("include_confidence_scores"), True)
7288
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
+ )
7289
7322
  effective["analysis_summary_style"] = _normalize_setup_choice(
7290
7323
  effective.get("analysis_summary_style"),
7291
7324
  ["full", "concise", "creative", "technical"],
@@ -10983,6 +11016,12 @@ def _setup_set_media_analysis_defaults(media_defaults: Dict[str, Any], dry_run:
10983
11016
  "preferredgeneratedmediafolder": "preferred_generated_media_folder",
10984
11017
  "generated_media_folder": "preferred_generated_media_folder",
10985
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",
10986
11025
  "default_post_operation_page": "default_post_operation_page",
10987
11026
  "defaultpostoperationpage": "default_post_operation_page",
10988
11027
  "post_operation_page": "default_post_operation_page",
@@ -11205,6 +11244,16 @@ def _setup_set_media_analysis_defaults(media_defaults: Dict[str, Any], dry_run:
11205
11244
  if normalized is None:
11206
11245
  return _err("Unsupported marker_custom_data. Use namespaced or minimal.")
11207
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))
11208
11257
  elif key in {"preferred_analysis_root", "preferred_generated_media_folder"}:
11209
11258
  path = None if clear_requested(raw_value) else os.path.realpath(os.path.abspath(os.path.expanduser(str(raw_value))))
11210
11259
  set_or_clear(key, raw_value, path)
@@ -11449,6 +11498,8 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
11449
11498
  "media_analysis.report_format": {"values": ["compact", "full", "machine_readable"], "storage": _media_analysis_preferences_path()},
11450
11499
  "media_analysis.preferred_analysis_root": {"values": "absolute or expandable path", "storage": _media_analysis_preferences_path()},
11451
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()},
11452
11503
  "media_analysis.default_post_operation_page": {"values": ["stay_put", "media", "cut", "edit", "fusion", "color", "fairlight", "deliver"], "storage": _media_analysis_preferences_path()},
11453
11504
  "media_analysis.marker_custom_data": {"values": ["namespaced", "minimal"], "storage": _media_analysis_preferences_path()},
11454
11505
  "media_analysis.metadata_writeback_default": {"values": [True, False], "storage": _media_analysis_preferences_path()},
@@ -14770,7 +14821,13 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
14770
14821
  elif action == "auto_sync_audio":
14771
14822
  clips = [_find_clip(root, cid) for cid in p["clip_ids"]]
14772
14823
  clips = [c for c in clips if c]
14773
- return {"success": bool(mp.AutoSyncAudio(clips, p.get("settings", {})))}
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
14774
14831
  elif action == "get_selected":
14775
14832
  sel = mp.GetSelectedClips()
14776
14833
  if not sel: