davinci-resolve-mcp 4.8.15 → 4.8.17

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,35 @@
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 v4.8.17 — `dry_run="false"` runs the real operation
6
+
7
+ ### Fixed
8
+
9
+ - **Handlers read `dry_run` with bare truthiness.** 61 reads in `src/server.py` and
10
+ the `script_plugin` media-rules generator treated `dry_run="false"` (or `"no"`,
11
+ `"0"`, `"off"`) as true and returned a preview, even though the destructive-op hook,
12
+ which already coerced the flag, had gated the call as a real mutation. Every
13
+ handler read now goes through `_coerce_bool` with the handler's own default, so
14
+ actions that preview by default keep doing so when the flag is omitted or `None`.
15
+ Reads that already used the media-analysis and setup coercers are unchanged.
16
+ Follow-up to [#266](https://github.com/samuelgursky/davinci-resolve-mcp/pull/266).
17
+ Guard test: `organize_clips` and `safe_import_folder` act on five false spellings
18
+ (ten subtests failing on the previous code), plus an AST ratchet that fails on any
19
+ `p.get("dry_run")` in `server.py` not wrapped in a coercer.
20
+
21
+ ## What's New in v4.8.16 — `organize_clips` honours `create_missing="false"`
22
+
23
+ ### Fixed
24
+
25
+ - **`organize_clips` decided whether to create missing target folders with bare
26
+ truthiness on `create_missing`.** ([#266](https://github.com/samuelgursky/davinci-resolve-mcp/pull/266), @Dev-next-gen)
27
+ A caller sending `create_missing="false"` (or `"no"`, `"0"`, `"off"`) to move clips
28
+ into a folder that did not exist got the folder path created anyway instead of a
29
+ "Target folder not found" error. The flag now goes through `_coerce_bool`, like the
30
+ other boolean reads fixed this month. Real booleans and an omitted key are
31
+ unchanged. Guard test: six false spellings, all failing on the previous code, plus
32
+ `True`-creates and omitted-does-not-create checks.
33
+
5
34
  ## What's New in v4.8.15 — `background="false"` runs synchronously
6
35
 
7
36
  ### Fixed
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-4.8.15-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.8.17-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(389%20full)-blue.svg)](#server-modes)
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-4.8.15-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.8.17-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(389%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v4.8.15 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v4.8.17 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "4.8.15"
40
+ VERSION = "4.8.17"
41
41
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
42
42
  # Resolve's scripting bridge loads into newer interpreters on recent builds
43
43
  # (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": "4.8.15",
3
+ "version": "4.8.17",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -93,7 +93,7 @@ if not logging.getLogger().handlers:
93
93
  handlers=[logging.StreamHandler()],
94
94
  )
95
95
 
96
- VERSION = "4.8.15"
96
+ VERSION = "4.8.17"
97
97
  logger = logging.getLogger("davinci-resolve-mcp")
98
98
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
99
99
  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 377-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "4.8.15"
14
+ VERSION = "4.8.17"
15
15
 
16
16
  import base64
17
17
  import os
@@ -5351,7 +5351,7 @@ def _timeline_ripple_insert_impl(proj, tl, p: Dict[str, Any], *, resolve=None) -
5351
5351
  if locked_tracks:
5352
5352
  reasons.append(f"locked track(s) hold items that must shift: {', '.join(locked_tracks)}")
5353
5353
  plan["infeasible_reasons"] = reasons
5354
- if bool(p.get("dry_run", True)):
5354
+ if _coerce_bool(p.get("dry_run"), True):
5355
5355
  return {"success": feasible, "dry_run": True, "plan": plan}
5356
5356
  if not feasible:
5357
5357
  return {
@@ -6507,7 +6507,7 @@ def _timeline_bulk_set_item_properties(tl, p: Dict[str, Any]) -> Dict[str, Any]:
6507
6507
  ops = p.get("ops")
6508
6508
  if not isinstance(ops, list) or not ops:
6509
6509
  return _err("bulk_set_item_properties requires params.ops: non-empty list of objects")
6510
- dry_run = bool(p.get("dry_run", False))
6510
+ dry_run = _coerce_bool(p.get("dry_run"), False)
6511
6511
  readback = bool(p.get("readback", False))
6512
6512
  results = []
6513
6513
  for index, op in enumerate(ops):
@@ -6601,7 +6601,7 @@ def _timeline_apply_look_to_items(tl, p: Dict[str, Any]) -> Dict[str, Any]:
6601
6601
  return _err("apply_look_to_items requires target_ids: non-empty list of video timeline item IDs")
6602
6602
  targets, missing = _timeline_items_by_ids_report(tl, target_ids, track_types=("video",))
6603
6603
  target_summaries = [_timeline_item_summary(item) for item in targets]
6604
- dry_run = bool(p.get("dry_run", False))
6604
+ dry_run = _coerce_bool(p.get("dry_run"), False)
6605
6605
  out: Dict[str, Any] = {
6606
6606
  "targets": target_summaries,
6607
6607
  "missing": missing,
@@ -6860,7 +6860,7 @@ def _timeline_create_variant_from_ranges(proj, source_tl, p: Dict[str, Any]) ->
6860
6860
  if clip_err:
6861
6861
  return clip_err
6862
6862
  append_infos.append(clip_info)
6863
- if p.get("dry_run", False):
6863
+ if _coerce_bool(p.get("dry_run"), False):
6864
6864
  return {
6865
6865
  "success": True,
6866
6866
  "dry_run": True,
@@ -7350,7 +7350,7 @@ def _export_timeline_checked(tl, p: Dict[str, Any]):
7350
7350
  "EXPORT_FCPXML_1_8/1_9/1_10, EXPORT_OTIO (subtypes EXPORT_NONE, "
7351
7351
  "EXPORT_AAF_NEW, EXPORT_AAF_EXISTING, EXPORT_CDL, EXPORT_SDL, EXPORT_MISSING_CLIPS)."
7352
7352
  )
7353
- if p.get("dry_run"):
7353
+ if _coerce_bool(p.get("dry_run")):
7354
7354
  return _ok(path=path, would_export=True, spec={k: v for k, v in spec.items() if k != "export_type"})
7355
7355
 
7356
7356
  def _work():
@@ -7769,7 +7769,7 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
7769
7769
  if sequence_name_rewritten:
7770
7770
  import_path = rewritten_path
7771
7771
 
7772
- if p.get("dry_run"):
7772
+ if _coerce_bool(p.get("dry_run")):
7773
7773
  out = _ok(path=path, import_path=import_path, options=options, would_import=True)
7774
7774
  if sanitize_report is not None:
7775
7775
  out["sanitize"] = sanitize_report
@@ -8167,7 +8167,7 @@ def _import_from_drp(proj, mp, p: Dict[str, Any]):
8167
8167
  entry.update(success=False, error=f"extract failed: {e}")
8168
8168
  results.append(entry)
8169
8169
  continue
8170
- if p.get("dry_run"):
8170
+ if _coerce_bool(p.get("dry_run")):
8171
8171
  entry.update(success=True, would_import=True, drt_path=drt_path)
8172
8172
  results.append(entry)
8173
8173
  continue
@@ -8190,15 +8190,15 @@ def _import_from_drp(proj, mp, p: Dict[str, Any]):
8190
8190
  imported=imported_count,
8191
8191
  available=available,
8192
8192
  results=results,
8193
- dry_run=bool(p.get("dry_run")),
8193
+ dry_run=_coerce_bool(p.get("dry_run")),
8194
8194
  )
8195
- if not p.get("dry_run") and selected and imported_count == 0:
8195
+ if not _coerce_bool(p.get("dry_run")) and selected and imported_count == 0:
8196
8196
  out["success"] = False
8197
8197
  out["error"] = (
8198
8198
  f"None of the {len(selected)} selected timeline(s) imported — "
8199
8199
  "see results[].error for each cause."
8200
8200
  )
8201
- elif not p.get("dry_run") and imported_count < len(selected):
8201
+ elif not _coerce_bool(p.get("dry_run")) and imported_count < len(selected):
8202
8202
  out["partial"] = True
8203
8203
  out["warning"] = (
8204
8204
  f"{imported_count} of {len(selected)} selected timeline(s) "
@@ -8902,7 +8902,7 @@ def _safe_set_audio_properties(tl, p: Dict[str, Any]):
8902
8902
  original[key] = item.GetProperty(key)
8903
8903
  except Exception as exc:
8904
8904
  original[key] = {"error": str(exc)}
8905
- if p.get("dry_run"):
8905
+ if _coerce_bool(p.get("dry_run")):
8906
8906
  return _ok(would_set=properties, original=original)
8907
8907
  results = {}
8908
8908
  for key, value in properties.items():
@@ -9071,7 +9071,7 @@ def _safe_auto_sync_audio(mp, p: Dict[str, Any]):
9071
9071
  # here makes _normalize_auto_sync_settings fall back to string enum keys,
9072
9072
  # which AutoSyncAudio silently rejects (returns False).
9073
9073
  settings, ignored_settings = _normalize_auto_sync_settings(dict(p.get("settings") or {}), get_resolve())
9074
- if p.get("dry_run", True):
9074
+ if _coerce_bool(p.get("dry_run"), True):
9075
9075
  return _ok(would_auto_sync=True, clips=_clip_summaries(clips), missing=missing, settings=settings, ignored_settings=ignored_settings)
9076
9076
  # Read-back verification: AutoSyncAudio's boolean return is unreliable, so
9077
9077
  # capture each clip's "Synced Audio" linkage before and after and report the
@@ -9321,7 +9321,7 @@ def _safe_create_subtitles(tl, p: Dict[str, Any]):
9321
9321
  the timeline's subtitle track count before/after and report the real delta.
9322
9322
  """
9323
9323
  settings, ignored = _normalize_auto_caption_settings(p.get("settings"), get_resolve())
9324
- if p.get("dry_run", False):
9324
+ if _coerce_bool(p.get("dry_run"), False):
9325
9325
  return _ok(would_create_subtitles=True, settings=settings, ignored_settings=ignored)
9326
9326
 
9327
9327
  def _subtitle_track_count():
@@ -9603,7 +9603,7 @@ def _setup_multicam_timeline(proj, mp, p: Dict[str, Any]):
9603
9603
  plan, plan_err = build_multicam_setup_plan(root, p, _find_clip)
9604
9604
  if plan_err:
9605
9605
  return plan_err
9606
- if p.get("dry_run", False):
9606
+ if _coerce_bool(p.get("dry_run"), False):
9607
9607
  return {
9608
9608
  **plan,
9609
9609
  "dry_run": True,
@@ -13504,7 +13504,7 @@ def _safe_import_media(mp, p: Dict[str, Any]):
13504
13504
  errors.append(path_err)
13505
13505
  if errors:
13506
13506
  return _err("; ".join(errors))
13507
- if p.get("dry_run"):
13507
+ if _coerce_bool(p.get("dry_run")):
13508
13508
  return _ok(would_import=paths, target_folder=p.get("target_folder"))
13509
13509
  previous, folder_err = _set_current_folder_temporarily(mp, p.get("target_folder"))
13510
13510
  if folder_err:
@@ -13534,7 +13534,7 @@ def _safe_import_sequence(mp, p: Dict[str, Any]):
13534
13534
  suffix = "" if len(missing) <= 5 else f" (+{len(missing) - 5} more)"
13535
13535
  return _err(f"Missing sequence frames: {sample}{suffix}")
13536
13536
  info = {"FilePath": pattern, "StartIndex": start, "EndIndex": end}
13537
- if p.get("dry_run"):
13537
+ if _coerce_bool(p.get("dry_run")):
13538
13538
  return _ok(would_import=[info], target_folder=p.get("target_folder"))
13539
13539
  previous, folder_err = _set_current_folder_temporarily(mp, p.get("target_folder"))
13540
13540
  if folder_err:
@@ -13550,7 +13550,7 @@ def _safe_import_folder(mp, p: Dict[str, Any]):
13550
13550
  path_err = _path_error(path, must_be_dir=True)
13551
13551
  if path_err:
13552
13552
  return _err(path_err)
13553
- if p.get("dry_run"):
13553
+ if _coerce_bool(p.get("dry_run")):
13554
13554
  return _ok(would_import_folder=path, source_clips_path=p.get("source_clips_path", ""))
13555
13555
  return {"success": bool(mp.ImportFolderFromFile(path, p.get("source_clips_path", "")))}
13556
13556
 
@@ -13559,7 +13559,7 @@ def _organize_clips(mp, root, p: Dict[str, Any]):
13559
13559
  target_path = p.get("target_path")
13560
13560
  if not target_path:
13561
13561
  return _err("target_path is required")
13562
- if p.get("create_missing"):
13562
+ if _coerce_bool(p.get("create_missing")):
13563
13563
  target, target_err = _ensure_folder_path(mp, target_path)
13564
13564
  else:
13565
13565
  target = _navigate_folder(mp, target_path)
@@ -13570,7 +13570,7 @@ def _organize_clips(mp, root, p: Dict[str, Any]):
13570
13570
  if err:
13571
13571
  return err
13572
13572
  clips, missing = resolved
13573
- if p.get("dry_run"):
13573
+ if _coerce_bool(p.get("dry_run")):
13574
13574
  return _ok(target_path=target_path, clips=_clip_summaries(clips), missing=missing)
13575
13575
  return {"success": bool(mp.MoveClips(clips, target)), "moved": len(clips), "missing": missing}
13576
13576
 
@@ -13595,7 +13595,7 @@ def _copy_metadata(root, p: Dict[str, Any]):
13595
13595
  if not target:
13596
13596
  results.append({"clip_id": target_id, "success": False, "error": "Clip not found"})
13597
13597
  continue
13598
- if p.get("dry_run"):
13598
+ if _coerce_bool(p.get("dry_run")):
13599
13599
  results.append({"clip_id": target_id, "success": True, "metadata_keys": sorted(metadata.keys()), "third_party_keys": sorted(third_party.keys())})
13600
13600
  continue
13601
13601
  ok = bool(target.SetMetadata(metadata)) if metadata else True
@@ -13620,7 +13620,7 @@ def _normalize_metadata(root, mp, p: Dict[str, Any]):
13620
13620
  results = []
13621
13621
  for clip in clips:
13622
13622
  clip_id = _safe_media_pool_item_id(clip)
13623
- if p.get("dry_run"):
13623
+ if _coerce_bool(p.get("dry_run")):
13624
13624
  results.append({"clip_id": clip_id, "success": True, "metadata_keys": sorted(metadata.keys()), "third_party_keys": sorted(third_party.keys())})
13625
13625
  continue
13626
13626
  ok = bool(clip.SetMetadata(metadata)) if metadata else True
@@ -13758,7 +13758,7 @@ def _safe_relink(mp, root, p: Dict[str, Any]):
13758
13758
  if err:
13759
13759
  return err
13760
13760
  clips, missing = resolved
13761
- if p.get("dry_run"):
13761
+ if _coerce_bool(p.get("dry_run")):
13762
13762
  return _ok(folder_path=folder_path, clips=_clip_summaries(clips), missing=missing)
13763
13763
  return {"success": bool(mp.RelinkClips(clips, folder_path)), "count": len(clips), "missing": missing}
13764
13764
 
@@ -13768,7 +13768,7 @@ def _safe_unlink(mp, root, p: Dict[str, Any]):
13768
13768
  if err:
13769
13769
  return err
13770
13770
  clips, missing = resolved
13771
- if p.get("dry_run"):
13771
+ if _coerce_bool(p.get("dry_run")):
13772
13772
  return _ok(clips=_clip_summaries(clips), missing=missing)
13773
13773
  return {"success": bool(mp.UnlinkClips(clips)), "count": len(clips), "missing": missing}
13774
13774
 
@@ -14327,7 +14327,7 @@ def _link_proxy_checked(root, p: Dict[str, Any]):
14327
14327
  requested = {
14328
14328
  "clip_id": p.get("clip_id"),
14329
14329
  "proxy_path": proxy_path,
14330
- "dry_run": bool(p.get("dry_run")),
14330
+ "dry_run": _coerce_bool(p.get("dry_run")),
14331
14331
  "check_compatibility": check_compatibility,
14332
14332
  "require_compatible": bool(p.get("require_compatible")),
14333
14333
  "expected_codec": p.get("expected_codec") or p.get("codec"),
@@ -14342,7 +14342,7 @@ def _link_proxy_checked(root, p: Dict[str, Any]):
14342
14342
  }
14343
14343
  if compatibility is not None:
14344
14344
  preflight["compatible"] = bool(compatibility.get("compatible"))
14345
- if p.get("dry_run"):
14345
+ if _coerce_bool(p.get("dry_run")):
14346
14346
  out = _ok(clip=_media_pool_item_summary(clip), proxy_path=proxy_path)
14347
14347
  if compatibility is not None:
14348
14348
  out["compatibility"] = compatibility
@@ -14435,7 +14435,7 @@ def _link_full_resolution_checked(root, p: Dict[str, Any]):
14435
14435
  path_err = _path_error(path, must_be_file=True)
14436
14436
  if path_err:
14437
14437
  return _err(path_err)
14438
- if p.get("dry_run"):
14438
+ if _coerce_bool(p.get("dry_run")):
14439
14439
  return _ok(clip=_media_pool_item_summary(clip), path=path)
14440
14440
  return {"success": bool(clip.LinkFullResolutionMedia(path))}
14441
14441
 
@@ -14453,7 +14453,7 @@ def _set_clip_marks(root, mp, p: Dict[str, Any]):
14453
14453
  results = []
14454
14454
  for clip in clips:
14455
14455
  clip_id = _safe_media_pool_item_id(clip)
14456
- if p.get("dry_run"):
14456
+ if _coerce_bool(p.get("dry_run")):
14457
14457
  results.append({"clip_id": clip_id, "success": True, "mark_in": mark_in, "mark_out": mark_out})
14458
14458
  continue
14459
14459
  results.append({"clip_id": clip_id, "success": bool(clip.SetMarkInOut(mark_in, mark_out, p.get("type", "all")))})
@@ -14640,7 +14640,7 @@ def _clear_clip_marks(root, mp, p: Dict[str, Any]):
14640
14640
  results = []
14641
14641
  for clip in clips:
14642
14642
  clip_id = _safe_media_pool_item_id(clip)
14643
- if p.get("dry_run"):
14643
+ if _coerce_bool(p.get("dry_run")):
14644
14644
  results.append({"clip_id": clip_id, "success": True})
14645
14645
  continue
14646
14646
  results.append({"clip_id": clip_id, "success": bool(clip.ClearMarkInOut(p.get("type", "all")))})
@@ -14666,7 +14666,7 @@ def _copy_clip_annotations(root, p: Dict[str, Any]):
14666
14666
  if not target:
14667
14667
  results.append({"clip_id": target_id, "success": False, "error": "Clip not found"})
14668
14668
  continue
14669
- if p.get("dry_run"):
14669
+ if _coerce_bool(p.get("dry_run")):
14670
14670
  results.append({"clip_id": target_id, "success": True, "markers": len(markers), "flags": len(flags), "clip_color": color})
14671
14671
  continue
14672
14672
  ok = True
@@ -18573,7 +18573,7 @@ def _probe_project_settings(project, p: Dict[str, Any]) -> Dict[str, Any]:
18573
18573
  out["write_restore_probe"] = _safe_set_project_settings(project, {
18574
18574
  "settings": settings,
18575
18575
  "restore": True,
18576
- "dry_run": bool(p.get("dry_run", False)),
18576
+ "dry_run": _coerce_bool(p.get("dry_run"), False),
18577
18577
  })
18578
18578
  return out
18579
18579
 
@@ -18590,7 +18590,7 @@ def _safe_set_project_settings(project, p: Dict[str, Any]) -> Dict[str, Any]:
18590
18590
  original[key] = _ser(project.GetSetting(key))
18591
18591
  except Exception as exc:
18592
18592
  original[key] = {"error": str(exc)}
18593
- if p.get("dry_run"):
18593
+ if _coerce_bool(p.get("dry_run")):
18594
18594
  return _ok(would_set=settings, original=original, restore=p.get("restore", True))
18595
18595
  results: Dict[str, Any] = {}
18596
18596
  for key, value in settings.items():
@@ -18632,7 +18632,7 @@ def _safe_project_create(pm, resolve_obj, p: Dict[str, Any]) -> Dict[str, Any]:
18632
18632
  version = resolve_obj.GetVersion() or [0]
18633
18633
  if version[0] < 20 or (version[0] == 20 and len(version) > 2 and (version[1], version[2]) < (2, 2)):
18634
18634
  return _err("ProjectManager.CreateProject media_location_path requires DaVinci Resolve 20.2.2+")
18635
- if p.get("dry_run"):
18635
+ if _coerce_bool(p.get("dry_run")):
18636
18636
  return _ok(would_create=True, name=name, media_location_path=media_location_path)
18637
18637
  project = pm.CreateProject(name, media_location_path) if media_location_path else pm.CreateProject(name)
18638
18638
  return _ok(project=_project_object_summary(project), name=project.GetName()) if project else _err(f"Failed to create '{name}'")
@@ -18649,7 +18649,7 @@ def _safe_project_export(pm, p: Dict[str, Any]) -> Dict[str, Any]:
18649
18649
  return path_err
18650
18650
  os.makedirs(_project_path_parent(path), exist_ok=True)
18651
18651
  with_stills = bool(p.get("with_stills_and_luts", False))
18652
- if p.get("dry_run"):
18652
+ if _coerce_bool(p.get("dry_run")):
18653
18653
  return _ok(would_export=True, name=name, path=path, with_stills_and_luts=with_stills)
18654
18654
  return {"success": bool(pm.ExportProject(name, path, with_stills))}
18655
18655
 
@@ -18665,7 +18665,7 @@ def _safe_project_import(pm, p: Dict[str, Any]) -> Dict[str, Any]:
18665
18665
  invalid = _require_disposable_project_name(name, allow_non_mcp_name=p.get("allow_non_mcp_name", False))
18666
18666
  if invalid:
18667
18667
  return invalid
18668
- if p.get("dry_run"):
18668
+ if _coerce_bool(p.get("dry_run")):
18669
18669
  return _ok(would_import=True, path=path, name=name)
18670
18670
  return {"success": bool(pm.ImportProject(path, name))}
18671
18671
 
@@ -18692,7 +18692,7 @@ def _safe_project_archive(pm, p: Dict[str, Any]) -> Dict[str, Any]:
18692
18692
  if refused:
18693
18693
  return refused
18694
18694
  os.makedirs(_project_path_parent(path), exist_ok=True)
18695
- if p.get("dry_run"):
18695
+ if _coerce_bool(p.get("dry_run")):
18696
18696
  return _ok(
18697
18697
  would_archive=True,
18698
18698
  name=name,
@@ -18716,7 +18716,7 @@ def _safe_project_restore(pm, p: Dict[str, Any]) -> Dict[str, Any]:
18716
18716
  invalid = _require_disposable_project_name(name, allow_non_mcp_name=p.get("allow_non_mcp_name", False))
18717
18717
  if invalid:
18718
18718
  return invalid
18719
- if p.get("dry_run"):
18719
+ if _coerce_bool(p.get("dry_run")):
18720
18720
  return _ok(would_restore=True, path=path, name=name)
18721
18721
  return {"success": bool(pm.RestoreProject(path, name))}
18722
18722
 
@@ -18726,7 +18726,7 @@ def _safe_project_delete(pm, p: Dict[str, Any]) -> Dict[str, Any]:
18726
18726
  invalid = _require_disposable_project_name(name, allow_non_mcp_name=p.get("allow_non_mcp_name", False))
18727
18727
  if invalid:
18728
18728
  return invalid
18729
- if p.get("dry_run"):
18729
+ if _coerce_bool(p.get("dry_run")):
18730
18730
  return _ok(would_delete=True, name=name)
18731
18731
  current = pm.GetCurrentProject()
18732
18732
  # Route through delete_project_safely: DeleteProject silently returns False
@@ -18780,7 +18780,7 @@ def _safe_set_current_database(pm, p: Dict[str, Any]) -> Dict[str, Any]:
18780
18780
  if not isinstance(db_info, dict) or not db_info.get("DbType") or not db_info.get("DbName"):
18781
18781
  return _err("db_info must include DbType and DbName")
18782
18782
  current = _ser(pm.GetCurrentDatabase()) if _has_method(pm, "GetCurrentDatabase") else None
18783
- dry_run = p.get("dry_run", True) or not _coerce_bool(p.get("allow_switch"))
18783
+ dry_run = _coerce_bool(p.get("dry_run"), True) or not _coerce_bool(p.get("allow_switch"))
18784
18784
  if dry_run:
18785
18785
  return _ok(
18786
18786
  would_switch=True,
@@ -19116,7 +19116,7 @@ def _spec_action(r, pm, action: str, p: Dict[str, Any]) -> Dict[str, Any]:
19116
19116
  try:
19117
19117
  result = _project_spec.apply_spec(
19118
19118
  spec, executor,
19119
- dry_run=bool(p.get("dry_run", False)),
19119
+ dry_run=_coerce_bool(p.get("dry_run"), False),
19120
19120
  run_hooks=run_hooks,
19121
19121
  continue_on_error=bool(p.get("continue_on_error", False)),
19122
19122
  run_hook=_make_spec_hook_runner() if run_hooks else None,
@@ -20244,7 +20244,7 @@ def _safe_set_render_settings(proj, p: Dict[str, Any]):
20244
20244
  return err
20245
20245
  if not validation["valid"]:
20246
20246
  return {"success": False, "validation": validation}
20247
- if p.get("dry_run"):
20247
+ if _coerce_bool(p.get("dry_run")):
20248
20248
  return _ok(validation=validation)
20249
20249
  before = _render_settings_snapshot(proj)
20250
20250
  success = bool(proj.SetRenderSettings(settings))
@@ -20278,7 +20278,7 @@ def _prepare_render_job(proj, p: Dict[str, Any]):
20278
20278
  return err
20279
20279
  if not validation["valid"]:
20280
20280
  return {"success": False, "validation": validation}
20281
- if p.get("dry_run"):
20281
+ if _coerce_bool(p.get("dry_run")):
20282
20282
  return _ok(validation=validation, format=p.get("format"), codec=p.get("codec"))
20283
20283
  before = _render_settings_snapshot(proj)
20284
20284
  # Pin the base render state before layering explicit settings on top. Without
@@ -20624,7 +20624,7 @@ def _safe_quick_export(proj, p: Dict[str, Any]):
20624
20624
  return err
20625
20625
  if not validation["valid"]:
20626
20626
  return {"success": False, "validation": validation}
20627
- if p.get("dry_run") or not _coerce_bool(p.get("allow_render")):
20627
+ if _coerce_bool(p.get("dry_run")) or not _coerce_bool(p.get("allow_render")):
20628
20628
  return _ok(would_render=False, preset=preset, params=params, validation=validation)
20629
20629
  before = set()
20630
20630
  if target_dir and os.path.isdir(target_dir):
@@ -22672,7 +22672,7 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
22672
22672
  try:
22673
22673
  # Dry run by default: the ladder can spend a dozen ffmpeg decodes per clip,
22674
22674
  # and the plan names that budget before anyone commits to it.
22675
- if p.get("dry_run", True):
22675
+ if _coerce_bool(p.get("dry_run"), True):
22676
22676
  return _ok(**_grade_loop_mod.plan(source, lut, **kwargs))
22677
22677
  return _ok(**_grade_loop_mod.run(
22678
22678
  source, lut,
@@ -22724,7 +22724,7 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
22724
22724
  release_s=float(p.get("release_s", _mix_plan_mod.DEFAULT_RELEASE_S)),
22725
22725
  hold_s=float(p.get("hold_s", _mix_plan_mod.DEFAULT_HOLD_S)),
22726
22726
  )
22727
- if p.get("dry_run", True):
22727
+ if _coerce_bool(p.get("dry_run"), True):
22728
22728
  return _ok(**_mix_plan_mod.plan(dialogue, **kwargs))
22729
22729
  return _ok(**_mix_plan_mod.render(
22730
22730
  dialogue,
@@ -23616,11 +23616,11 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
23616
23616
  plan["warnings"] = warnings
23617
23617
  if (
23618
23618
  plan.get("capability_gaps")
23619
- and not bool(p.get("dry_run", True))
23619
+ and not _coerce_bool(p.get("dry_run"), True)
23620
23620
  and media_analysis_plan_requires_capabilities(plan)
23621
23621
  ):
23622
23622
  return _e2_wrap(_media_analysis_missing_capabilities_response(plan))
23623
- if not bool(p.get("dry_run", True)):
23623
+ if not _coerce_bool(p.get("dry_run"), True):
23624
23624
  executed = await execute_media_analysis_plan_async(
23625
23625
  plan,
23626
23626
  params=p,
@@ -26112,7 +26112,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
26112
26112
  applicable.sort(key=lambda c: c["span"]["start"], reverse=True)
26113
26113
  plan = [{"action": c["action"], "span": c["span"], "kind": c.get("kind")} for c in applicable]
26114
26114
 
26115
- if p.get("dry_run", True):
26115
+ if _coerce_bool(p.get("dry_run"), True):
26116
26116
  return {
26117
26117
  "dry_run": True,
26118
26118
  "would_apply": len(applicable),
@@ -27502,7 +27502,7 @@ def _safe_set_cdl(item, p: Dict[str, Any]):
27502
27502
  return {"success": False, "validation": validation}
27503
27503
  normalized = _normalize_cdl(validation["cdl"])
27504
27504
  node_ok, preflight = _cdl_node_preflight(item, validation["cdl"]["NodeIndex"])
27505
- if p.get("dry_run"):
27505
+ if _coerce_bool(p.get("dry_run")):
27506
27506
  return _ok(validation=validation, normalized=normalized, node_preflight=preflight)
27507
27507
  if not node_ok:
27508
27508
  return {
@@ -27548,7 +27548,7 @@ def _safe_copy_grade(item, p: Dict[str, Any]):
27548
27548
  return err
27549
27549
  targets, missing = _timeline_items_for_grade_copy(tl, target_ids)
27550
27550
  target_summaries = [_timeline_item_summary(target) for target in targets]
27551
- if p.get("dry_run"):
27551
+ if _coerce_bool(p.get("dry_run")):
27552
27552
  return _ok(targets=target_summaries, missing=missing, would_copy=not missing)
27553
27553
  if missing:
27554
27554
  return {"success": False, "targets": target_summaries, "missing": missing}
@@ -27582,7 +27582,7 @@ def _safe_export_lut(item, p: Dict[str, Any]):
27582
27582
  export_type, type_err = _resolve_lut_export_type(p.get("type", "33ptcube"), resolve_obj)
27583
27583
  if type_err:
27584
27584
  return type_err
27585
- if p.get("dry_run"):
27585
+ if _coerce_bool(p.get("dry_run")):
27586
27586
  return _ok(path=path, type=export_type, would_export=True)
27587
27587
  before_exists = os.path.exists(path)
27588
27588
  success = bool(item.ExportLUT(export_type, path))
@@ -27611,7 +27611,7 @@ def _safe_apply_drx(proj, item, p: Dict[str, Any]):
27611
27611
  if not _has_method(graph, "ApplyGradeFromDRX"):
27612
27612
  return _err(f"{source} graph does not expose ApplyGradeFromDRX",
27613
27613
  code="APPLY_DRX_UNSUPPORTED", category="unsupported")
27614
- if p.get("dry_run"):
27614
+ if _coerce_bool(p.get("dry_run")):
27615
27615
  return _ok(path=path, source=source, grade_mode=p.get("grade_mode", p.get("mode", 0)), would_apply=True)
27616
27616
  # B2 — Whole-grade replacement is catastrophic; require confirmation.
27617
27617
  if "confirm_token" not in p and "confirmToken" not in p:
@@ -27828,7 +27828,7 @@ def _apply_trace_plan(p: Dict[str, Any]) -> Dict[str, Any]:
27828
27828
  attention = [r for r in public if (r["status"] != "apply" and r["reason"] not in bulk)
27829
27829
  or (r["status"] == "apply" and r["ambiguous"])]
27830
27830
 
27831
- if p.get("dry_run"):
27831
+ if _coerce_bool(p.get("dry_run")):
27832
27832
  report_path = _trace_report(p, plan, "dry-run", {"summary": summary, "resolution": public})
27833
27833
  return _ok(dry_run=True, timeline=live_name, plan_source=plan.get("source"),
27834
27834
  summary=summary, report_path=report_path,
@@ -27945,7 +27945,7 @@ def _grade_version_restore(item, p: Dict[str, Any]):
27945
27945
  names = snapshot["local"] if version_type == 0 else snapshot["remote"]
27946
27946
  if name not in names:
27947
27947
  return {"success": False, "snapshot": snapshot, "error": f"Version '{name}' not found"}
27948
- if p.get("dry_run"):
27948
+ if _coerce_bool(p.get("dry_run")):
27949
27949
  return _ok(snapshot=snapshot, would_load=name, type=version_type)
27950
27950
  return {"success": bool(item.LoadVersionByName(name, version_type)), "snapshot": snapshot}
27951
27951
 
@@ -28353,7 +28353,7 @@ def _bulk_match_to_hero(proj, p: Dict[str, Any]) -> Dict[str, Any]:
28353
28353
  remediation="Use method='copy_grade' or call safe_set_cdl per target with hand-rolled values.",
28354
28354
  )
28355
28355
 
28356
- dry_run = bool(p.get("dry_run", True))
28356
+ dry_run = _coerce_bool(p.get("dry_run"), True)
28357
28357
  payload: Dict[str, Any] = {
28358
28358
  "hero": hero_summary,
28359
28359
  "proposals": proposals,
@@ -30157,7 +30157,7 @@ def _safe_add_fusion_tool(comp, p: Dict[str, Any]):
30157
30157
  tool_type = p.get("tool_type")
30158
30158
  if not tool_type:
30159
30159
  return _err("tool_type is required")
30160
- if p.get("dry_run"):
30160
+ if _coerce_bool(p.get("dry_run")):
30161
30161
  return _ok(tool_type=tool_type, name=p.get("name"), would_add=True)
30162
30162
  comp.Lock()
30163
30163
  try:
@@ -30191,7 +30191,7 @@ def _safe_set_fusion_inputs(comp, p: Dict[str, Any]):
30191
30191
  tool = comp.FindTool(tool_name)
30192
30192
  if not tool:
30193
30193
  return _err(f"Tool '{tool_name}' not found")
30194
- if p.get("dry_run"):
30194
+ if _coerce_bool(p.get("dry_run")):
30195
30195
  return _ok(tool_name=tool_name, inputs=inputs, would_set=True)
30196
30196
  results = {}
30197
30197
  # No comp.Lock() around value writes — see _FUSION_VALUE_WRITE_NOTE.
@@ -30225,7 +30225,7 @@ def _safe_connect_fusion_tools(comp, p: Dict[str, Any]):
30225
30225
  source = comp.FindTool(source_name)
30226
30226
  if not source:
30227
30227
  return _err(f"Source tool '{source_name}' not found")
30228
- if p.get("dry_run"):
30228
+ if _coerce_bool(p.get("dry_run")):
30229
30229
  return _ok(target_tool=target_name, source_tool=source_name, input_name=input_name, would_connect=True)
30230
30230
  comp.Lock()
30231
30231
  try:
@@ -31763,7 +31763,7 @@ def lut(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
31763
31763
  if action == "install":
31764
31764
  if not p.get("name"):
31765
31765
  return _err("install requires name")
31766
- if p.get("dry_run"):
31766
+ if _coerce_bool(p.get("dry_run")):
31767
31767
  return _ok(would_install=p["name"], writable_dir=lut_files.writable_dir())
31768
31768
  return lut_files.install_lut(
31769
31769
  p["name"], source=p.get("source"), source_path=p.get("source_path"),
@@ -31771,14 +31771,14 @@ def lut(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
31771
31771
  if action == "remove":
31772
31772
  if not p.get("name"):
31773
31773
  return _err("remove requires name")
31774
- if p.get("dry_run"):
31774
+ if _coerce_bool(p.get("dry_run")):
31775
31775
  return _ok(would_remove=p["name"], writable_dir=lut_files.writable_dir())
31776
31776
  return lut_files.remove_lut(p["name"])
31777
31777
  if action == "attenuate":
31778
31778
  for required in ("source", "strength", "name"):
31779
31779
  if p.get(required) is None:
31780
31780
  return _err(f"attenuate requires {required}")
31781
- if p.get("dry_run"):
31781
+ if _coerce_bool(p.get("dry_run")):
31782
31782
  return _ok(would_write=p["name"], source=p["source"], strength=p["strength"])
31783
31783
  return lut_files.attenuate_lut(p["source"], p["strength"], p["name"])
31784
31784
  except lut_files.LutPathError as exc:
@@ -32324,7 +32324,7 @@ def _safe_install_extension(p: Dict[str, Any]) -> Dict[str, Any]:
32324
32324
  marker = {"fuse": _FUSE_MARKER, "dctl": _DCTL_MARKER, "script": _SCRIPT_MARKER}[extension_type]
32325
32325
  if p.get("require_marker", True) and not _source_has_marker(source, marker):
32326
32326
  return _err(f"source must include {marker} unless require_marker=False")
32327
- if p.get("dry_run"):
32327
+ if _coerce_bool(p.get("dry_run")):
32328
32328
  return _ok(
32329
32329
  would_install=True,
32330
32330
  extension_type=extension_type,
@@ -32404,7 +32404,7 @@ def _safe_remove_extension(p: Dict[str, Any]) -> Dict[str, Any]:
32404
32404
  return _err(f"No {extension_type} extension named '{name}' at {path}")
32405
32405
  if p.get("require_marker", True) and not _file_has_marker(path, marker):
32406
32406
  return _err(f"Refusing to remove unmarked file at {path}; pass require_marker=False only if you intend this")
32407
- if p.get("dry_run"):
32407
+ if _coerce_bool(p.get("dry_run")):
32408
32408
  return _ok(would_remove=True, extension_type=extension_type, name=name, path=path)
32409
32409
  try:
32410
32410
  os.unlink(path)
@@ -18,6 +18,8 @@ See docs/authoring/script-plugin-authoring.md for the full DSL spec.
18
18
 
19
19
  from typing import Any, Dict, Optional
20
20
 
21
+ from src.utils.bool_params import coerce_bool
22
+
21
23
 
22
24
  def header(name: str, kind: str, language: str) -> str:
23
25
  """MCP marker comment placed at the top of generated scripts."""
@@ -236,7 +238,7 @@ def media_rules(name: str, options: Optional[Dict[str, Any]] = None) -> str:
236
238
  if language not in VALID_LANGUAGES:
237
239
  raise ValueError(f"Invalid language '{language}'. "
238
240
  f"Valid: {list(VALID_LANGUAGES)}")
239
- dry_run = bool(options.get("dry_run", False))
241
+ dry_run = coerce_bool(options.get("dry_run"), False)
240
242
 
241
243
  if language == "lua":
242
244
  return _media_rules_lua(name, options, dry_run)