davinci-resolve-mcp 2.53.0 → 2.54.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,55 @@
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.1
6
+
7
+ One more instance of the enum-keyed silent-failure class (issue #70), plus a
8
+ guard so the next one can't ship unnoticed.
9
+
10
+ - **Fixed** the raw `timeline.export` action passed `type`/`subtype` straight to
11
+ `Timeline.Export`, which needs resolved `resolve.EXPORT_*` enum *values* — a
12
+ JSON/MCP caller can't pass a live enum, so the action silently wrote nothing
13
+ for every caller. It now resolves friendly format names (and `EXPORT_*`
14
+ constant names) via the same `_timeline_export_spec` resolver that
15
+ `export_timeline_checked` uses, and reports the resolved `export_type`/
16
+ `export_subtype`.
17
+ - **Fixed** `export_timeline_checked` resolved enum constants against the module
18
+ global `resolve` (which can be `None` and silently degrade the `EXPORT_*` args
19
+ to strings); it now uses `get_resolve()`, matching the issue-#70 lesson.
20
+ - **Added** an `api_truth` ↔ mitigation guard test: every `enum`-tagged catalog
21
+ entry must declare a `mitigation` (the resolver/wrapper functions), each of
22
+ which must exist in `src.server`. The next raw enum passthrough — a documented
23
+ symbol with no real resolver, or a renamed/removed resolver — now fails CI.
24
+ Added a `Timeline.Export` catalog entry and wired `mitigation` onto the
25
+ `AutoSyncAudio`, `CreateSubtitlesFromAudio`, and CloudProject entries.
26
+
27
+ ## What's New in v2.54.0
28
+
29
+ Hardens the rest of the enum-keyed settings APIs against the same silent-failure
30
+ class fixed for `AutoSyncAudio` in v2.53.0 (issue #70). Several Resolve methods
31
+ key their settings dict by `resolve.<CONST>` enum attributes and silently reject
32
+ the whole call when handed plain string keys — returning `False`/`None` with
33
+ nothing applied and no error.
34
+
35
+ - **Fixed** `CreateSubtitlesFromAudio` (both `timeline_ai.create_subtitles` and
36
+ `timeline.subtitle_generation_probe`) now resolves human-readable
37
+ `autoCaptionSettings` — `language`, `preset`, `line_break`, `chars_per_line`,
38
+ `gap` — into live `SUBTITLE_*`/`AUTO_CAPTION_*` enum keys/values. Unknown keys
39
+ and unresolvable values are dropped and reported in `ignored_settings` instead
40
+ of poisoning the call, and generation is read-back verified against the
41
+ timeline's subtitle track count (the boolean return is unreliable).
42
+ - **Fixed** the `ProjectManager` CloudProject family (`create`/`load`/
43
+ `import_project`/`restore`) resolves `{cloudSettings}` into live
44
+ `CLOUD_SETTING_*`/`CLOUD_SYNC_*` enums (`project_name`, `media_path`,
45
+ `is_collab`, `sync_mode`, `is_camera_access`), dropping unknown keys into
46
+ `ignored_settings`.
47
+ - **Changed** the string→enum resolution is now a shared `_resolve_enum_settings`
48
+ primitive driven by per-field specs, so the next enum-keyed API gets the same
49
+ treatment without re-implementing it.
50
+ - **Added** `api_truth` entries for `Timeline.CreateSubtitlesFromAudio` and the
51
+ CloudProject family, documenting the silent-rejection + unreliable-return
52
+ behavior alongside the existing `AutoSyncAudio` entry.
53
+
5
54
  ## What's New in v2.53.0
6
55
 
7
56
  Two improvements to audio sync reliability and inventory control.
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.53.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.54.1-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)
@@ -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
@@ -1005,7 +1005,12 @@ helpers:
1005
1005
  Unrecognized keys are dropped and echoed back in `ignored_settings` rather than
1006
1006
  silently failing the call.
1007
1007
  - `transcription_capabilities(clip_ids?|selected?)`
1008
- - `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.
1009
1014
  - `fairlight_boundary_report`
1010
1015
 
1011
1016
  **`timeline_markers`** — Markers and playhead on the current timeline.
@@ -1036,7 +1041,9 @@ file to disk.
1036
1041
 
1037
1042
  **`timeline_ai`** — AI/ML analysis on the current timeline.
1038
1043
 
1039
- Key actions: `create_subtitles(settings?)`, `detect_scene_cuts`,
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`,
1040
1047
  `grab_still`, `grab_all_stills(source?)`, `analyze_dolby_vision`
1041
1048
 
1042
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.53.0"
38
+ VERSION = "2.54.1"
39
39
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
40
40
  # Resolve's scripting bridge loads into newer interpreters on recent builds
41
41
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.53.0",
3
+ "version": "2.54.1",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.53.0"
83
+ VERSION = "2.54.1"
84
84
  logger = logging.getLogger("davinci-resolve-mcp")
85
85
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
86
86
  logger.info(f"Detected platform: {get_platform()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 341-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.53.0"
14
+ VERSION = "2.54.1"
15
15
 
16
16
  import base64
17
17
  import os
@@ -5042,7 +5042,10 @@ def _export_timeline_checked(tl, p: Dict[str, Any]):
5042
5042
  folder = os.path.dirname(os.path.abspath(path))
5043
5043
  if folder:
5044
5044
  os.makedirs(folder, exist_ok=True)
5045
- spec = _timeline_export_spec(p, resolve)
5045
+ # Resolve enum constants via get_resolve(), not the module global `resolve`
5046
+ # (which can be None and silently degrade the EXPORT_* args to strings — the
5047
+ # same failure class as issue #70).
5048
+ spec = _timeline_export_spec(p, get_resolve())
5046
5049
  if p.get("dry_run"):
5047
5050
  return _ok(path=path, would_export=True, spec={k: v for k, v in spec.items() if k != "export_type"})
5048
5051
  with long_resolve_op("timeline.export_timeline_checked"):
@@ -6040,6 +6043,182 @@ def _normalize_auto_sync_settings(settings: Dict[str, Any], resolve_obj=None):
6040
6043
  return normalized, ignored
6041
6044
 
6042
6045
 
6046
+ def _resolve_enum_settings(resolve_obj, settings, field_specs):
6047
+ """Resolve a human-readable settings dict into the live enum-keyed dict that
6048
+ enum-keyed Resolve APIs require.
6049
+
6050
+ Several Resolve methods (``MediaPool.AutoSyncAudio``,
6051
+ ``Timeline.CreateSubtitlesFromAudio``, the ``ProjectManager`` CloudProject
6052
+ family) key their settings dict by ``resolve.<CONST>`` enum attributes — and
6053
+ for many fields expect ``resolve.<CONST>`` enum *values* too. They silently
6054
+ reject the WHOLE call when handed a plain string key (returning False/None
6055
+ with nothing applied), so unrecognized keys must be dropped, not forwarded.
6056
+ See utils/api_truth.py for the catalogued symbols.
6057
+
6058
+ ``field_specs`` is an iterable of dicts, one per supported field:
6059
+ ``aliases`` human keys accepted for this field (case-insensitive)
6060
+ ``key_const`` name of the ``resolve`` enum attribute used as the dict key
6061
+ ``key_fallback`` key used when the live handle can't resolve ``key_const``
6062
+ (defaults to ``key_const``); keeps offline behavior readable
6063
+ ``values`` optional ``{lowercased_str: enum_const_name}`` value map
6064
+ ``coerce`` optional ``"int"`` | ``"bool"`` cast for plain values
6065
+ ``bounds`` optional ``(min, max)`` clamp applied after int coercion
6066
+
6067
+ Returns ``(normalized, ignored)``. ``ignored`` lists input keys that no spec
6068
+ claimed AND recognized keys whose value could not be resolved (sorted) — so a
6069
+ field that silently fails to apply is reported rather than hidden.
6070
+ """
6071
+ if not settings:
6072
+ return {}, []
6073
+ normalized = {}
6074
+ consumed = set()
6075
+ lower = {str(k).strip().lower(): k for k in settings}
6076
+
6077
+ for spec in field_specs:
6078
+ src_key = next((lower[a.lower()] for a in spec["aliases"] if a.lower() in lower), None)
6079
+ if src_key is None:
6080
+ continue
6081
+ raw = settings[src_key]
6082
+ key = _resolve_audio_constant(resolve_obj, spec["key_const"], spec.get("key_fallback", spec["key_const"]))
6083
+ values_map = spec.get("values")
6084
+ if values_map is not None and isinstance(raw, str):
6085
+ const_name = values_map.get(raw.strip().lower())
6086
+ if const_name is None:
6087
+ continue # unknown enum value — leave src_key unconsumed -> ignored
6088
+ value = _resolve_audio_constant(resolve_obj, const_name, raw)
6089
+ elif spec.get("coerce") == "int":
6090
+ try:
6091
+ value = int(raw) if not isinstance(raw, bool) else int(raw)
6092
+ except (TypeError, ValueError):
6093
+ continue
6094
+ lo, hi = spec.get("bounds", (None, None))
6095
+ if lo is not None:
6096
+ value = max(lo, value)
6097
+ if hi is not None:
6098
+ value = min(hi, value)
6099
+ elif spec.get("coerce") == "bool":
6100
+ value = bool(raw)
6101
+ else:
6102
+ value = raw
6103
+ normalized[key] = value
6104
+ consumed.add(src_key)
6105
+
6106
+ ignored = sorted(str(key) for key in settings if key not in consumed)
6107
+ return normalized, ignored
6108
+
6109
+
6110
+ # Timeline.CreateSubtitlesFromAudio({autoCaptionSettings}) — enum-keyed exactly
6111
+ # like AutoSyncAudio (docs/reference/resolve_scripting_api.txt lines 733-771).
6112
+ _AUTO_CAPTION_LANGUAGES = {
6113
+ "auto": "AUTO_CAPTION_AUTO",
6114
+ "danish": "AUTO_CAPTION_DANISH",
6115
+ "dutch": "AUTO_CAPTION_DUTCH",
6116
+ "english": "AUTO_CAPTION_ENGLISH",
6117
+ "french": "AUTO_CAPTION_FRENCH",
6118
+ "german": "AUTO_CAPTION_GERMAN",
6119
+ "italian": "AUTO_CAPTION_ITALIAN",
6120
+ "japanese": "AUTO_CAPTION_JAPANESE",
6121
+ "korean": "AUTO_CAPTION_KOREAN",
6122
+ "mandarin_simplified": "AUTO_CAPTION_MANDARIN_SIMPLIFIED",
6123
+ "mandarin_traditional": "AUTO_CAPTION_MANDARIN_TRADITIONAL",
6124
+ "norwegian": "AUTO_CAPTION_NORWEGIAN",
6125
+ "portuguese": "AUTO_CAPTION_PORTUGUESE",
6126
+ "russian": "AUTO_CAPTION_RUSSIAN",
6127
+ "spanish": "AUTO_CAPTION_SPANISH",
6128
+ "swedish": "AUTO_CAPTION_SWEDISH",
6129
+ }
6130
+ _AUTO_CAPTION_PRESETS = {
6131
+ "default": "AUTO_CAPTION_SUBTITLE_DEFAULT",
6132
+ "subtitle_default": "AUTO_CAPTION_SUBTITLE_DEFAULT",
6133
+ "teletext": "AUTO_CAPTION_TELETEXT",
6134
+ "netflix": "AUTO_CAPTION_NETFLIX",
6135
+ }
6136
+ _AUTO_CAPTION_LINE_BREAKS = {
6137
+ "single": "AUTO_CAPTION_LINE_SINGLE",
6138
+ "double": "AUTO_CAPTION_LINE_DOUBLE",
6139
+ }
6140
+ _AUTO_CAPTION_FIELD_SPECS = (
6141
+ {"aliases": ["language", "lang", "SUBTITLE_LANGUAGE"], "key_const": "SUBTITLE_LANGUAGE", "key_fallback": "language", "values": _AUTO_CAPTION_LANGUAGES},
6142
+ {"aliases": ["preset", "caption_preset", "SUBTITLE_CAPTION_PRESET"], "key_const": "SUBTITLE_CAPTION_PRESET", "key_fallback": "captionPreset", "values": _AUTO_CAPTION_PRESETS},
6143
+ {"aliases": ["line_break", "linebreak", "SUBTITLE_LINE_BREAK"], "key_const": "SUBTITLE_LINE_BREAK", "key_fallback": "lineBreak", "values": _AUTO_CAPTION_LINE_BREAKS},
6144
+ {"aliases": ["chars_per_line", "charsperline", "SUBTITLE_CHARS_PER_LINE"], "key_const": "SUBTITLE_CHARS_PER_LINE", "key_fallback": "charsPerLine", "coerce": "int", "bounds": (1, 60)},
6145
+ {"aliases": ["gap", "SUBTITLE_GAP"], "key_const": "SUBTITLE_GAP", "key_fallback": "gap", "coerce": "int", "bounds": (0, 10)},
6146
+ )
6147
+
6148
+
6149
+ def _normalize_auto_caption_settings(settings, resolve_obj=None):
6150
+ """Translate human-readable subtitle settings (e.g. ``{"language": "korean"}``)
6151
+ into live ``SUBTITLE_*``/``AUTO_CAPTION_*`` enum keys/values for
6152
+ ``CreateSubtitlesFromAudio``. Returns ``(normalized, ignored)``."""
6153
+ return _resolve_enum_settings(resolve_obj, dict(settings or {}), _AUTO_CAPTION_FIELD_SPECS)
6154
+
6155
+
6156
+ # ProjectManager CloudProject family — {cloudSettings} is keyed by
6157
+ # resolve.CLOUD_SETTING_* constants with resolve.CLOUD_SYNC_* sync-mode values
6158
+ # (docs/reference/resolve_scripting_api.txt lines 588-603). Same silent-rejection
6159
+ # failure mode as AutoSyncAudio when handed plain string keys.
6160
+ _CLOUD_SYNC_MODES = {
6161
+ "none": "CLOUD_SYNC_NONE",
6162
+ "proxy_only": "CLOUD_SYNC_PROXY_ONLY",
6163
+ "proxy": "CLOUD_SYNC_PROXY_ONLY",
6164
+ "proxy_and_orig": "CLOUD_SYNC_PROXY_AND_ORIG",
6165
+ "proxy_and_original": "CLOUD_SYNC_PROXY_AND_ORIG",
6166
+ }
6167
+ _CLOUD_SETTINGS_FIELD_SPECS = (
6168
+ {"aliases": ["project_name", "name", "CLOUD_SETTING_PROJECT_NAME"], "key_const": "CLOUD_SETTING_PROJECT_NAME", "key_fallback": "projectName"},
6169
+ {"aliases": ["project_media_path", "media_path", "CLOUD_SETTING_PROJECT_MEDIA_PATH"], "key_const": "CLOUD_SETTING_PROJECT_MEDIA_PATH", "key_fallback": "projectMediaPath"},
6170
+ {"aliases": ["is_collab", "collab", "CLOUD_SETTING_IS_COLLAB"], "key_const": "CLOUD_SETTING_IS_COLLAB", "key_fallback": "isCollab", "coerce": "bool"},
6171
+ {"aliases": ["sync_mode", "syncmode", "CLOUD_SETTING_SYNC_MODE"], "key_const": "CLOUD_SETTING_SYNC_MODE", "key_fallback": "syncMode", "values": _CLOUD_SYNC_MODES},
6172
+ {"aliases": ["is_camera_access", "camera_access", "CLOUD_SETTING_IS_CAMERA_ACCESS"], "key_const": "CLOUD_SETTING_IS_CAMERA_ACCESS", "key_fallback": "isCameraAccess", "coerce": "bool"},
6173
+ )
6174
+
6175
+
6176
+ def _normalize_cloud_settings(settings, resolve_obj=None):
6177
+ """Translate human-readable cloud-project settings (e.g.
6178
+ ``{"project_name": "X", "sync_mode": "proxy_only"}``) into live
6179
+ ``CLOUD_SETTING_*``/``CLOUD_SYNC_*`` enum keys/values. Returns
6180
+ ``(normalized, ignored)``."""
6181
+ return _resolve_enum_settings(resolve_obj, dict(settings or {}), _CLOUD_SETTINGS_FIELD_SPECS)
6182
+
6183
+
6184
+ def _safe_create_subtitles(tl, p: Dict[str, Any]):
6185
+ """CreateSubtitlesFromAudio with enum-resolved settings + readback.
6186
+
6187
+ The raw boolean is unreliable (like AutoSyncAudio), so we verify by reading
6188
+ the timeline's subtitle track count before/after and report the real delta.
6189
+ """
6190
+ settings, ignored = _normalize_auto_caption_settings(p.get("settings"), get_resolve())
6191
+ if p.get("dry_run", False):
6192
+ return _ok(would_create_subtitles=True, settings=settings, ignored_settings=ignored)
6193
+
6194
+ def _subtitle_track_count():
6195
+ try:
6196
+ return int(tl.GetTrackCount("subtitle") or 0)
6197
+ except Exception:
6198
+ return 0
6199
+
6200
+ res = verify_by_readback(
6201
+ mutate=lambda: tl.CreateSubtitlesFromAudio(settings),
6202
+ observe=_subtitle_track_count,
6203
+ snapshot=_subtitle_track_count,
6204
+ compare=lambda before, after: {
6205
+ "verified": bool((after or 0) > (before or 0)),
6206
+ "subtitle_tracks_before": before,
6207
+ "subtitle_tracks_after": after,
6208
+ },
6209
+ label="create_subtitles_from_audio",
6210
+ intent={"settings_keys": sorted(settings.keys()) if settings else []},
6211
+ )
6212
+ return {
6213
+ "success": res["success_raw"],
6214
+ "verified": res["verified"],
6215
+ "subtitle_tracks_before": res.get("subtitle_tracks_before"),
6216
+ "subtitle_tracks_after": res.get("subtitle_tracks_after"),
6217
+ "settings": settings,
6218
+ "ignored_settings": ignored,
6219
+ }
6220
+
6221
+
6043
6222
  def _transcription_capabilities(mp, p: Dict[str, Any]):
6044
6223
  root = mp.GetRootFolder()
6045
6224
  clips = []
@@ -6085,13 +6264,14 @@ def _transcription_capabilities(mp, p: Dict[str, Any]):
6085
6264
 
6086
6265
 
6087
6266
  def _subtitle_generation_probe(tl, p: Dict[str, Any]):
6088
- settings = dict(p.get("settings") or {})
6267
+ settings, ignored = _normalize_auto_caption_settings(p.get("settings"), get_resolve())
6089
6268
  if not p.get("allow_generate", False):
6090
- return _ok(would_generate=True, settings=settings, note="Pass allow_generate=True to call CreateSubtitlesFromAudio.")
6269
+ return _ok(would_generate=True, settings=settings, ignored_settings=ignored,
6270
+ note="Pass allow_generate=True to call CreateSubtitlesFromAudio.")
6091
6271
  if not _has_method(tl, "CreateSubtitlesFromAudio"):
6092
6272
  return _err("CreateSubtitlesFromAudio unavailable")
6093
6273
  with long_resolve_op("timeline.create_subtitles_from_audio"):
6094
- return {"success": bool(tl.CreateSubtitlesFromAudio(settings)), "settings": settings}
6274
+ return _safe_create_subtitles(tl, p)
6095
6275
 
6096
6276
 
6097
6277
  def _fairlight_boundary_report(proj, mp, tl, p: Dict[str, Any]):
@@ -13711,16 +13891,21 @@ def project_manager_cloud(action: str, params: Optional[Dict[str, Any]] = None)
13711
13891
  return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.")
13712
13892
  pm = r.GetProjectManager()
13713
13893
 
13894
+ # cloudSettings is enum-keyed (CLOUD_SETTING_*/CLOUD_SYNC_*); resolve string
13895
+ # keys against the live handle so human-readable settings aren't silently
13896
+ # rejected. Unrecognized keys are dropped and reported in ignored_settings.
13897
+ settings, ignored = _normalize_cloud_settings(p.get("settings"), r)
13898
+
13714
13899
  if action == "create":
13715
- proj = pm.CreateCloudProject(p["settings"])
13716
- return _ok(name=proj.GetName()) if proj else _err("Failed to create cloud project")
13900
+ proj = pm.CreateCloudProject(settings)
13901
+ return _ok(name=proj.GetName(), ignored_settings=ignored) if proj else _err("Failed to create cloud project")
13717
13902
  elif action == "load":
13718
- proj = pm.LoadCloudProject(p["settings"])
13719
- return _ok(name=proj.GetName()) if proj else _err("Failed to load cloud project")
13903
+ proj = pm.LoadCloudProject(settings)
13904
+ return _ok(name=proj.GetName(), ignored_settings=ignored) if proj else _err("Failed to load cloud project")
13720
13905
  elif action == "import_project":
13721
- return {"success": bool(pm.ImportCloudProject(p["path"], p["settings"]))}
13906
+ return {"success": bool(pm.ImportCloudProject(p["path"], settings)), "ignored_settings": ignored}
13722
13907
  elif action == "restore":
13723
- return {"success": bool(pm.RestoreCloudProject(p["folder_path"], p["settings"]))}
13908
+ return {"success": bool(pm.RestoreCloudProject(p["folder_path"], settings)), "ignored_settings": ignored}
13724
13909
  return _unknown(action, ["create","load","import_project","restore"])
13725
13910
 
13726
13911
 
@@ -17669,8 +17854,18 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
17669
17854
  elif action == "import_into_timeline":
17670
17855
  return {"success": bool(tl.ImportIntoTimeline(p["path"], p.get("options", {})))}
17671
17856
  elif action == "export":
17857
+ # Timeline.Export needs resolved resolve.EXPORT_* enum *values*, which a
17858
+ # JSON/MCP caller cannot pass — handing it a string silently fails. Resolve
17859
+ # friendly type/subtype names (or EXPORT_* constant names) the same way
17860
+ # export_timeline_checked does. This raw action keeps no path sandbox.
17861
+ spec = _timeline_export_spec(p, get_resolve())
17672
17862
  with long_resolve_op("timeline.export"):
17673
- return {"success": bool(tl.Export(p["path"], p["type"], p.get("subtype", "")))}
17863
+ success = bool(tl.Export(p["path"], spec["export_type"], spec["export_subtype"]))
17864
+ return {
17865
+ "success": success,
17866
+ "export_type": spec["export_type_name"],
17867
+ "export_subtype": spec["export_subtype_name"],
17868
+ }
17674
17869
  elif action == "get_setting":
17675
17870
  return {"settings": _ser(tl.GetSetting(p.get("name", "")))}
17676
17871
  elif action == "set_setting":
@@ -18118,7 +18313,7 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
18118
18313
 
18119
18314
  if action == "create_subtitles":
18120
18315
  with long_resolve_op("timeline_ai.create_subtitles"):
18121
- return {"success": bool(tl.CreateSubtitlesFromAudio(p.get("settings", {})))}
18316
+ return _safe_create_subtitles(tl, p)
18122
18317
  elif action == "detect_scene_cuts":
18123
18318
  with long_resolve_op("timeline_ai.detect_scene_cuts"):
18124
18319
  return {"success": bool(tl.DetectSceneCuts())}
@@ -28,6 +28,54 @@ API_TRUTH: List[Dict[str, Any]] = [
28
28
  "resolve handle, and verify by reading each clip's "
29
29
  "'Synced Audio' property (see verify_by_readback).",
30
30
  "tags": ["unreliable-return", "silent-failure", "audio", "enum"],
31
+ "mitigation": ["_normalize_auto_sync_settings", "_safe_auto_sync_audio"],
32
+ },
33
+ {
34
+ "symbol": "Timeline.CreateSubtitlesFromAudio",
35
+ "object": "Timeline",
36
+ "signature": "(autoCaptionSettings) -> bool",
37
+ "reality": "Same failure mode as AutoSyncAudio: the autoCaptionSettings "
38
+ "dict is keyed by resolve.SUBTITLE_* enum constants with "
39
+ "resolve.AUTO_CAPTION_* enum values, so plain string keys like "
40
+ "{'language': 'korean'} are silently rejected (returns False, "
41
+ "no subtitle track created). The boolean is also unreliable.",
42
+ "recommended": "Resolve the SUBTITLE_*/AUTO_CAPTION_* constants via the "
43
+ "live resolve handle (server._normalize_auto_caption_settings) "
44
+ "and verify by reading the timeline's subtitle track count "
45
+ "before/after (server._safe_create_subtitles).",
46
+ "tags": ["unreliable-return", "silent-failure", "subtitle", "enum"],
47
+ "mitigation": ["_normalize_auto_caption_settings", "_safe_create_subtitles"],
48
+ },
49
+ {
50
+ "symbol": "ProjectManager CloudProject family (Create/Load/Import/RestoreCloudProject)",
51
+ "object": "ProjectManager",
52
+ "signature": "(..., cloudSettings) -> Project | bool",
53
+ "reality": "All four take an enum-keyed {cloudSettings} dict "
54
+ "(resolve.CLOUD_SETTING_* keys, resolve.CLOUD_SYNC_* sync-mode "
55
+ "values). Plain string keys are silently rejected, so a settings "
56
+ "dict built from human-readable keys yields no project / False.",
57
+ "recommended": "Resolve the CLOUD_SETTING_*/CLOUD_SYNC_* constants via the "
58
+ "live resolve handle (server._normalize_cloud_settings) "
59
+ "before calling, and treat the bool return from "
60
+ "Import/RestoreCloudProject as advisory.",
61
+ "tags": ["silent-failure", "project", "cloud", "enum"],
62
+ "mitigation": ["_normalize_cloud_settings"],
63
+ },
64
+ {
65
+ "symbol": "Timeline.Export",
66
+ "object": "Timeline",
67
+ "signature": "(fileName, exportType, exportSubtype) -> bool",
68
+ "reality": "exportType/exportSubtype must be resolve.EXPORT_* enum *values* "
69
+ "resolved from the live handle. A JSON/MCP caller cannot pass a "
70
+ "live enum, and a plain string ('fcpxml', or even the constant "
71
+ "name 'EXPORT_FCPXML_1_10') is silently rejected with no file "
72
+ "written.",
73
+ "recommended": "Map a friendly format/subtype to the EXPORT_* constant and "
74
+ "resolve it against the live handle "
75
+ "(server._timeline_export_spec) before calling; verify the "
76
+ "output file exists afterward.",
77
+ "tags": ["silent-failure", "timeline", "export", "enum"],
78
+ "mitigation": ["_timeline_export_spec", "_timeline_export_value"],
31
79
  },
32
80
  {
33
81
  "symbol": "Composition.Paste",