davinci-resolve-mcp 2.98.4 → 2.98.6

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,130 @@
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.98.6
6
+
7
+ **Correcting the scope of the v2.98.5 Fusion fix, and covering all six paths
8
+ with a render.** v2.98.5 removed a `Comp.Lock()` from six Fusion value writes and
9
+ described all six as the same bug. Only two of them were proven with a render at
10
+ the time; the other four were changed by inference. Extending the live harness to
11
+ cover the remaining four showed that inference was too broad.
12
+
13
+ Every site was mutation-checked by reintroducing the lock and re-rendering on
14
+ Studio 19.1.3.7:
15
+
16
+ | call path | with the lock back |
17
+ | --- | --- |
18
+ | `set_input` | **PSNR inf — suppressed** |
19
+ | `safe_set_inputs` | **PSNR inf — suppressed** |
20
+ | `bulk_set_inputs` | unchanged, still rendered |
21
+ | `bulk_set_expressions` | unchanged, still rendered |
22
+ | `add_fusion_mask` | unchanged, still rendered |
23
+ | `set_text_plus` | unchanged, still rendered |
24
+
25
+ The two that break are the two where the locked write is the only thing the call
26
+ does. The four that survive each do something else in the same call that appears
27
+ to invalidate the graph anyway — `bulk_set_inputs` and `bulk_set_expressions`
28
+ wrap the write in `StartUndo`/`EndUndo`, `add_fusion_mask` performs an `AddTool`,
29
+ and `set_text_plus` writes a string rather than a number. **Which of those is the
30
+ rescuing mechanism is not established** — only that the four do not reproduce.
31
+
32
+ No code changed back. Removing the lock from a single write buys nothing and
33
+ costs nothing, and treating the four as merely unexplained rather than proven
34
+ safe is the conservative reading. What changed is the claim: `api_truth`,
35
+ `docs/SKILL.md` and the AST guard's failure message now state the measured scope
36
+ instead of "any value write".
37
+
38
+ If you read the v2.98.5 notes and concluded every Fusion parameter this server
39
+ ever wrote was ignored at render, that was overstated — it was true for
40
+ `set_input` and `safe_set_inputs`.
41
+
42
+ ### Tests
43
+
44
+ `tests/live_fusion_value_write_validation.py` grows from two cases to six,
45
+ covering every site the fix touched:
46
+
47
+ ```
48
+ set_input: PSNR 24.375987 -> APPLIED at render
49
+ safe_set_inputs: PSNR 24.375987 -> APPLIED at render
50
+ set_text_plus: PSNR 13.33844 -> APPLIED at render
51
+ bulk_set_expressions: PSNR 24.375987 -> APPLIED at render
52
+ bulk_set_inputs: PSNR 24.375987 -> APPLIED at render
53
+ add_fusion_mask: PSNR 30.369794 -> APPLIED at render
54
+ ```
55
+
56
+ The `set_text_plus` case builds a rooted `MediaIn -> Merge -> MediaOut` graph
57
+ with the Text+ in the foreground — a comp whose MediaOut is fed only by a Text+
58
+ is bypassed at render for an unrelated reason and would have failed for the
59
+ wrong cause. The `add_fusion_mask` case cannot use a baseline/after comparison,
60
+ because a default-sized mask still changes the render; it builds the same graph
61
+ twice, once with a default mask and once with an explicitly tiny one, and
62
+ requires the two renders to differ.
63
+
64
+ Four of the six cases do not discriminate the lock. They are kept because they
65
+ still prove the write reaches the render — the property that matters, and the
66
+ one no readback can check.
67
+
68
+ ## What's New in v2.98.5
69
+
70
+ **Every Fusion parameter this server wrote was ignored at render.** A value
71
+ write (`SetInput` / `SetExpression`) wrapped in `Comp.Lock()`/`Unlock()` is
72
+ stored in the graph and reads back correctly — `GetInput` returns it, and so
73
+ did this server's own `get_input` — while the delivered render ignores it
74
+ completely. Found on 2026-08-21 while re-running a Fusion isolation on Studio
75
+ 19.1.3.7 to settle a conflicting measurement reported in
76
+ [#156](https://github.com/samuelgursky/davinci-resolve-mcp/pull/156).
77
+
78
+ Measured on Studio 19.1.3.7 with `MediaIn -> Blur(XBlurSize 20) -> MediaOut` on
79
+ a media-backed clip, rendering the same 48 frames to H.264 each time:
80
+
81
+ | value written via | render vs no-comp baseline |
82
+ | --- | --- |
83
+ | `fusion_comp set_input` (write inside `Comp.Lock()`) | PSNR **inf** — bit-identical, ignored |
84
+ | the same write, lock removed | PSNR **24.38 dB**, 2.0 MB → 727 KB |
85
+ | raw `tool.XBlurSize = 20.0` | PSNR **24.38 dB** |
86
+ | raw `tool.SetInput("XBlurSize", 20)` | PSNR **24.38 dB** |
87
+
88
+ The variable was isolated against the comp handle (`AddFusionComp`,
89
+ `GetFusionCompByIndex` and `GetFusionCompByName` all render), the node name, and
90
+ the write form. Only the lock around the write decides it. **Structural** edits
91
+ are unaffected — `AddTool` and `ConnectInput` inside a lock render normally — so
92
+ this is not "Lock is unsafe"; the lock suppresses the parameter-change
93
+ invalidation that a value write depends on.
94
+
95
+ ### Fixed
96
+
97
+ - **Six value-write sites no longer hold a comp lock across the write:**
98
+ `fusion_comp set_input`, `fusion_comp safe_set_inputs`, `bulk_set_inputs`,
99
+ `bulk_set_expressions`, the Text+ writer behind `set_text`, and
100
+ `add_mask` — where the lock spanned `AddTool` *and* every input write, so a
101
+ mask was created at default size and position and every parameter the caller
102
+ passed did nothing. Structural work keeps its lock; in `add_mask` the lock now
103
+ closes after the node is created and renamed.
104
+
105
+ ### Why this went unnoticed
106
+
107
+ Every readback the API offers agreed with the value that was written. This is
108
+ the failure mode the repo's own guidance describes — prove a Fusion or grade
109
+ claim with a rendered frame, never with readback — except the cause was ours,
110
+ not Resolve's. It also explains an unknown share of past "the comp was ignored"
111
+ reports, which look identical from the API side.
112
+
113
+ ### Tests
114
+
115
+ - `tests/live_fusion_value_write_validation.py` — renders a baseline, writes a
116
+ blur size through the compound tool, renders again, and asserts PSNR actually
117
+ moved. Disposable project, synthetic media, restores the previous project.
118
+ - `tests/test_fusion_value_write_lock.py` — AST guard failing any value write
119
+ that sits inside a `Comp.Lock()`/`Unlock()` region, with a self-check that the
120
+ guard can still see a known-bad shape.
121
+
122
+ Both were mutation-checked against the pre-fix code: reintroducing the lock in
123
+ `set_input` makes the live harness report `PSNR inf -> IGNORED at render` and
124
+ fails the offline guard.
125
+
126
+ - `api_truth`: new `Composition.Lock` entry; the `AddFusionComp` entry records
127
+ that its 2026-08-02 rooted-comp result **reproduced** on 19.1.3.7 (PSNR 24.38 dB).
128
+
5
129
  ## What's New in v2.98.4
6
130
 
7
131
  **Setup reported success over an install that could never work.** Reported and
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-2.98.4-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.98.6-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-35%20(353%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-2.98.4-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.98.6-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-35%20(353%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
- > 本翻译对应 v2.98.4 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.98.6 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
package/docs/SKILL.md CHANGED
@@ -1690,6 +1690,20 @@ Target a comp either from a timeline item (pass `clip_id`, `timeline_item_id`, o
1690
1690
  `timeline_item={track_type, track_index, item_index}`) or from the active Fusion
1691
1691
  page comp (omit timeline scope).
1692
1692
 
1693
+ READBACK IS NOT PROOF FOR FUSION PARAMETERS. Up to v2.98.4 every value write
1694
+ here ran inside a `Comp.Lock()`. For `set_input` and `safe_set_inputs` that was
1695
+ load-bearing: the value is stored in the graph and returned by `get_input` while
1696
+ the RENDER ignores it entirely (Studio 19.1.3.7: PSNR inf vs the no-comp
1697
+ baseline — the delivered file was bit-identical to no comp at all). The other
1698
+ four locked paths did not reproduce it, so the blast radius was narrower than
1699
+ first reported; the locks came off all six regardless. Fixed in v2.98.5
1700
+ (measurement corrected in v2.98.6), and guarded by
1701
+ `tests/test_fusion_value_write_lock.py` plus the rendered-frame harness
1702
+ `tests/live_fusion_value_write_validation.py`. The lesson outlives the bug: a
1703
+ Fusion parameter that reads back correctly has proven nothing about the output,
1704
+ so confirm any Fusion look with a rendered frame (`gallery_stills
1705
+ grab_and_export` or a frame from a delivered render), never with `get_input`.
1706
+
1693
1707
  Key actions:
1694
1708
  - `add_tool(tool_type, x?, y?, name?)` — common types: `Merge`, `Background`,
1695
1709
  `TextPlus`, `Transform`, `Blur`, `ColorCorrector`, `RectangleMask`,
@@ -12,7 +12,7 @@ that none exists).
12
12
 
13
13
  **Verified on:** DaVinci Resolve Studio 21.0.2
14
14
 
15
- **Totals:** 27 missing capabilities, 35 bugs / unreliable behaviors.
15
+ **Totals:** 27 missing capabilities, 36 bugs / unreliable behaviors.
16
16
 
17
17
  The authoritative source is the runtime-queryable `api_truth` ledger
18
18
  (`resolve_control api_truth "<query>"`); this document is generated from
@@ -339,10 +339,18 @@ values, or automation-hostile modal prompts.
339
339
  - **Workaround / current handling:** Author OTIO for Resolve by mirroring what Resolve itself exports, and give every event its media timecode origin. editorial.convert_to_interchange (target 'otio') does this and reports any event whose origin had to be assumed in `mediaOriginAssumed` — a non-empty list means the file will only import if that media really starts at 00:00:00:00. To debug a refusal, export any timeline with EXPORT_OTIO and diff your document against it; do NOT chase missing media or reach for sanitize_media, which cannot even parse a .otio (it is JSON, not XML).
340
340
  - **Tags:** timeline, import, interchange, otio, silent-failure, conform
341
341
 
342
+ ### Composition.Lock (suppresses render invalidation for value writes)
343
+
344
+ - **Object:** `Composition (Fusion, via TimelineItem comps)`
345
+ - **Signature:** `Lock() / Unlock()`
346
+ - **Behavior:** A numeric tool.SetInput() performed between Comp.Lock() and Comp.Unlock(), when that write is the only thing the call does, is stored in the graph and reads back correctly from GetInput() but is NOT applied when the timeline is rendered. Measured live on Studio 19.1.3.7 (2026-08-21) with MediaIn -> Blur(XBlurSize 20) -> MediaOut on a media-backed clip: written under the lock the delivered H.264 render is bit-identical to the no-comp baseline (ffmpeg PSNR inf); the identical write with the lock removed renders at PSNR 24.38 dB and the file shrinks 2.0 MB -> 727 KB, as a blur should. The variable was isolated against the comp handle (AddFusionComp, GetFusionCompByIndex and GetFusionCompByName all render), the node name, and the write form (attribute assignment and SetInput both render unlocked). STRUCTURAL edits are unaffected: AddTool and ConnectInput inside a lock render normally, so the lock is not broadly unsafe — it suppresses the parameter-change invalidation that a value write depends on. Lock() is widely recommended for batching Fusion edits, which is how this reaches production code. SCOPE, measured by reintroducing the lock at each site and re-rendering: it reproduces for a bare numeric SetInput (2 of 6 call paths tested) and does NOT reproduce when the same call also wraps the write in StartUndo/EndUndo, performs an AddTool, or writes a string to StyledText (4 of 6). Which of those rescues the write is not established — only that they do. Treat the safe cases as unexplained rather than proven safe, and keep value writes outside the lock everywhere.
347
+ - **Workaround / current handling:** Never hold a comp lock across a value write. Lock only structural work (AddTool/ConnectInput) and set inputs outside it. Because every readback the API offers agrees with the value that was written, this failure is invisible without a render — prove Fusion parameter changes with a delivered frame or gallery_stills grab_and_export, never with GetInput.
348
+ - **Tags:** fusion, silent-failure, render, readback
349
+
342
350
  ### TimelineItem.AddFusionComp / LoadFusionCompByName
343
351
 
344
352
  - **Object:** `TimelineItem (media-backed clip)`
345
- - **Behavior:** A Fusion composition created on a media clip through the API is not applied at render WHEN MEDIAOUT HAS NO PATH FROM MEDIAIN. The original blanket form of this entry — 'never applied at render' — was too broad and was corrected on 2026-08-02: a comp wired MediaIn -> Blur -> MediaOut, created entirely through the API on an ordinary media clip, DOES render. PSNR between the plain and Fusion renders of the same timeline was 22.7 dB (identical would be infinite), the file shrank 22.5 MB -> 14.8 MB as a blur should, and the output was frame-for-frame identical in GUI and headless. A first attempt that wired ONLY MediaOut -> Blur, leaving the Blur with no source, made the render job come back 'Failed' with an 887-byte file — so an unrooted graph does not merely get bypassed, it can take the render down. What still stands is the original observation for the configuration it actually tested, which is retained below and has NOT been re-measured: AddFusionComp() returns the comp, AddTool/Connect/SetInput all succeed, and the whole graph reads back correctly (GetCompCount 1, MediaOut1.Input wired to the new tool, StyledText returning the value just set) — but the rendered output is byte-for-byte the untouched source media. Verified live on Studio 19.1.3.7 with the strongest form of the test: MediaOut1 fed ONLY by a Text+, with no path from MediaIn at all, still rendered the unmodified clip. LoadFusionCompByName on the sole comp does not activate it either. Contrast InsertFusionTitleIntoTimeline, whose comp DOES render — text set via SetInput('StyledText') appears in the output — so this is specific to comps attached to media-backed clips, not to Fusion through the API generally.
353
+ - **Behavior:** A Fusion composition created on a media clip through the API is not applied at render WHEN MEDIAOUT HAS NO PATH FROM MEDIAIN. The original blanket form of this entry — 'never applied at render' — was too broad and was corrected on 2026-08-02: a comp wired MediaIn -> Blur -> MediaOut, created entirely through the API on an ordinary media clip, DOES render. PSNR between the plain and Fusion renders of the same timeline was 22.7 dB (identical would be infinite), the file shrank 22.5 MB -> 14.8 MB as a blur should, and the output was frame-for-frame identical in GUI and headless. A first attempt that wired ONLY MediaOut -> Blur, leaving the Blur with no source, made the render job come back 'Failed' with an 887-byte file — so an unrooted graph does not merely get bypassed, it can take the render down. What still stands is the original observation for the configuration it actually tested, which is retained below and has NOT been re-measured: AddFusionComp() returns the comp, AddTool/Connect/SetInput all succeed, and the whole graph reads back correctly (GetCompCount 1, MediaOut1.Input wired to the new tool, StyledText returning the value just set) — but the rendered output is byte-for-byte the untouched source media. Verified live on Studio 19.1.3.7 with the strongest form of the test: MediaOut1 fed ONLY by a Text+, with no path from MediaIn at all, still rendered the unmodified clip. LoadFusionCompByName on the sole comp does not activate it either. Contrast InsertFusionTitleIntoTimeline, whose comp DOES render — text set via SetInput('StyledText') appears in the output — so this is specific to comps attached to media-backed clips, not to Fusion through the API generally. REPRODUCED 2026-08-21 on Studio 19.1.3.7: a rooted MediaIn -> Blur -> MediaOut comp built entirely through the API renders (PSNR 24.38 dB vs the no-comp baseline), so the 2026-08-02 correction stands. Note that an important share of 'the comp was ignored' readings are NOT this entry at all but the Composition.Lock bug above — a parameter written under a comp lock reads back correctly and never reaches the render, which looks identical from the API side.
346
354
  - **Workaround / current handling:** Wire the graph so MediaOut descends from MediaIn — that is the difference between a comp that renders and one that is silently bypassed, and it is what made this look like 'Fusion never renders from the API'. Never leave a tool unrooted: a MediaOut fed by a tool with no source failed the render job outright. For text or effects over picture, insert a Fusion title/generator as its own timeline clip and set its Text+ (fusion_comp set_text_plus), rather than attaching a comp to the media clip. Note the destination track cannot be chosen from the API (see the Track Selector entry), so overlaying onto an existing clip's track is not currently reachable end-to-end. Building the comp in the Fusion page UI works; only the API-created comp is ignored.
347
355
  - **Tags:** fusion, silent-failure, render
348
356
 
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.98.4"
40
+ VERSION = "2.98.6"
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": "2.98.4",
3
+ "version": "2.98.6",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.98.4"
90
+ VERSION = "2.98.6"
91
91
  logger = logging.getLogger("davinci-resolve-mcp")
92
92
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
93
93
  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 353-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.98.4"
14
+ VERSION = "2.98.6"
15
15
 
16
16
  import base64
17
17
  import os
@@ -25841,6 +25841,35 @@ def _fusion_group_settings_splice_inputs(p: Dict[str, Any]) -> Dict[str, Any]:
25841
25841
  }
25842
25842
 
25843
25843
 
25844
+ # Why no comp.Lock() around Fusion VALUE writes.
25845
+ #
25846
+ # Wrapping a value write (SetInput / SetExpression) in comp.Lock()/Unlock()
25847
+ # leaves the value fully readable — GetInput returns it, and so does this
25848
+ # server's own get_input — while the RENDER ignores it entirely. Measured live
25849
+ # on Studio 19.1.3.7 with a MediaIn -> Blur(XBlurSize 20) -> MediaOut comp on a
25850
+ # media-backed clip: identical graph, identical readback, delivered render
25851
+ # bit-identical to the no-comp baseline (ffmpeg PSNR inf). Removing the lock
25852
+ # from the write renders at PSNR 24.38 dB and the file shrinks 2.0 MB -> 727 KB,
25853
+ # as a blur should. The variable was isolated against the comp handle
25854
+ # (AddFusionComp / GetFusionCompByIndex / GetFusionCompByName all render), the
25855
+ # node name, and the write form (attribute assignment and SetInput both render
25856
+ # unlocked) — only the lock around the write decides it.
25857
+ #
25858
+ # STRUCTURAL edits are a different case and KEEP their lock: AddTool,
25859
+ # ConnectInput and friends invalidate the render through another path and were
25860
+ # verified to render while locked. So this is not "Lock is unsafe", it is
25861
+ # "Lock suppresses the parameter-change invalidation that a value write needs".
25862
+ #
25863
+ # This is why grade/Fusion claims are proven with a rendered frame and never
25864
+ # with comp readback: every readback the API offers agreed the value was set.
25865
+ _FUSION_VALUE_WRITE_NOTE = (
25866
+ "Fusion value writes (SetInput/SetExpression) must not be wrapped in "
25867
+ "comp.Lock()/Unlock(): the value reads back correctly but is ignored at "
25868
+ "render (Studio 19.1.3.7, PSNR inf vs baseline). Structural edits "
25869
+ "(AddTool/ConnectInput) are unaffected and keep their lock."
25870
+ )
25871
+
25872
+
25844
25873
  def _fusion_group_settings_load(comp, p: Dict[str, Any]) -> Dict[str, Any]:
25845
25874
  group_name = p.get("group_name")
25846
25875
  if not group_name:
@@ -25946,16 +25975,13 @@ def _fusion_comp_bulk_set_expressions(p: Dict[str, Any]) -> Dict[str, Any]:
25946
25975
  undo_started = True
25947
25976
  except Exception:
25948
25977
  undo_started = False
25949
- comp.Lock()
25950
- try:
25951
- time = op.get("time", 0)
25952
- inp = tool[op["input_name"]]
25953
- if not inp:
25954
- raise ValueError(f"Input {op['input_name']!r} not found on {op['tool_name']!r}")
25955
- inp.SetExpression(str(op["expression"]), time)
25956
- keep_undo = True
25957
- finally:
25958
- comp.Unlock()
25978
+ # No comp.Lock() around a value write — see _FUSION_VALUE_WRITE_NOTE.
25979
+ time = op.get("time", 0)
25980
+ inp = tool[op["input_name"]]
25981
+ if not inp:
25982
+ raise ValueError(f"Input {op['input_name']!r} not found on {op['tool_name']!r}")
25983
+ inp.SetExpression(str(op["expression"]), time)
25984
+ keep_undo = True
25959
25985
  except Exception as exc:
25960
25986
  error_message = str(exc)
25961
25987
  finally:
@@ -26074,15 +26100,12 @@ def _fusion_comp_bulk_set_inputs(p: Dict[str, Any]) -> Dict[str, Any]:
26074
26100
  undo_started = True
26075
26101
  except Exception:
26076
26102
  undo_started = False
26077
- comp.Lock()
26078
- try:
26079
- if "time" in op:
26080
- tool.SetInput(op["input_name"], op["value"], op["time"])
26081
- else:
26082
- tool.SetInput(op["input_name"], op["value"])
26083
- keep_undo = True
26084
- finally:
26085
- comp.Unlock()
26103
+ # No comp.Lock() around a value write — see _FUSION_VALUE_WRITE_NOTE.
26104
+ if "time" in op:
26105
+ tool.SetInput(op["input_name"], op["value"], op["time"])
26106
+ else:
26107
+ tool.SetInput(op["input_name"], op["value"])
26108
+ keep_undo = True
26086
26109
  except Exception as exc:
26087
26110
  error_message = str(exc)
26088
26111
  finally:
@@ -26255,25 +26278,22 @@ def _safe_set_fusion_inputs(comp, p: Dict[str, Any]):
26255
26278
  if p.get("dry_run"):
26256
26279
  return _ok(tool_name=tool_name, inputs=inputs, would_set=True)
26257
26280
  results = {}
26258
- comp.Lock()
26259
- try:
26260
- for input_name, value in inputs.items():
26261
- try:
26262
- if "time" in p:
26263
- tool.SetInput(input_name, value, p["time"])
26264
- else:
26265
- tool.SetInput(input_name, value)
26266
- row = {"success": True}
26267
- if p.get("readback", True):
26268
- try:
26269
- row["value"] = _ser(tool.GetInput(input_name, p["time"])) if "time" in p else _ser(tool.GetInput(input_name))
26270
- except Exception as exc:
26271
- row["readback_error"] = str(exc)
26272
- results[input_name] = row
26273
- except Exception as exc:
26274
- results[input_name] = {"success": False, "error": str(exc)}
26275
- finally:
26276
- comp.Unlock()
26281
+ # No comp.Lock() around value writes — see _FUSION_VALUE_WRITE_NOTE.
26282
+ for input_name, value in inputs.items():
26283
+ try:
26284
+ if "time" in p:
26285
+ tool.SetInput(input_name, value, p["time"])
26286
+ else:
26287
+ tool.SetInput(input_name, value)
26288
+ row = {"success": True}
26289
+ if p.get("readback", True):
26290
+ try:
26291
+ row["value"] = _ser(tool.GetInput(input_name, p["time"])) if "time" in p else _ser(tool.GetInput(input_name))
26292
+ except Exception as exc:
26293
+ row["readback_error"] = str(exc)
26294
+ results[input_name] = row
26295
+ except Exception as exc:
26296
+ results[input_name] = {"success": False, "error": str(exc)}
26277
26297
  return {"success": all(row.get("success") for row in results.values()), "tool_name": tool_name, "results": results}
26278
26298
 
26279
26299
 
@@ -26438,6 +26458,11 @@ def _fusion_add_mask(comp, p: Dict[str, Any]) -> Dict[str, Any]:
26438
26458
  y = p.get("y", -1)
26439
26459
  readback = bool(p.get("readback", True))
26440
26460
 
26461
+ # The lock covers only the STRUCTURAL half (AddTool + rename). The input
26462
+ # writes below must run outside it — see _FUSION_VALUE_WRITE_NOTE: a value
26463
+ # written under the lock reads back correctly and is ignored at render,
26464
+ # which on a mask means the shape exists at default size and position and
26465
+ # every parameter the caller passed silently does nothing.
26441
26466
  comp.Lock()
26442
26467
  try:
26443
26468
  tool = comp.AddTool(tool_type, x, y)
@@ -26449,83 +26474,84 @@ def _fusion_add_mask(comp, p: Dict[str, Any]) -> Dict[str, Any]:
26449
26474
  name = p.get("name")
26450
26475
  if name:
26451
26476
  tool.SetAttrs({"TOOLS_Name": str(name)})
26452
- attrs = tool.GetAttrs() or {}
26453
- tool_name = attrs.get("TOOLS_Name", "")
26454
-
26455
- results: List[Dict[str, Any]] = []
26456
-
26457
- # Center: accept center=[x,y]/{1:x,2:y}, or center_x / center_y.
26458
- center = p.get("center")
26459
- cx, cy = p.get("center_x"), p.get("center_y")
26460
- if center is None and (cx is not None or cy is not None):
26461
- center = [cx if cx is not None else 0.5, cy if cy is not None else 0.5]
26462
- if center is not None:
26463
- ok, err, applied = _fusion_set_point_input(tool, "Center", center)
26464
- rec = {"input": "Center", "value": center, "success": ok}
26465
- if not ok:
26466
- rec["error"] = err
26467
- elif readback:
26477
+ finally:
26478
+ comp.Unlock()
26479
+
26480
+ attrs = tool.GetAttrs() or {}
26481
+ tool_name = attrs.get("TOOLS_Name", "")
26482
+
26483
+ results: List[Dict[str, Any]] = []
26484
+
26485
+ # Center: accept center=[x,y]/{1:x,2:y}, or center_x / center_y.
26486
+ center = p.get("center")
26487
+ cx, cy = p.get("center_x"), p.get("center_y")
26488
+ if center is None and (cx is not None or cy is not None):
26489
+ center = [cx if cx is not None else 0.5, cy if cy is not None else 0.5]
26490
+ if center is not None:
26491
+ ok, err, applied = _fusion_set_point_input(tool, "Center", center)
26492
+ rec = {"input": "Center", "value": center, "success": ok}
26493
+ if not ok:
26494
+ rec["error"] = err
26495
+ elif readback:
26496
+ try:
26497
+ rec["readback"] = _ser(tool.GetInput("Center"))
26498
+ except Exception as exc:
26499
+ rec["readback_error"] = str(exc)
26500
+ results.append(rec)
26501
+
26502
+ # Scalar inputs (friendly aliases) + any raw passthrough inputs.
26503
+ to_set: List[tuple] = []
26504
+ for friendly, fusion_id in _MASK_INPUT_ALIASES.items():
26505
+ if friendly in p:
26506
+ to_set.append((fusion_id, p[friendly]))
26507
+ raw_inputs = p.get("inputs")
26508
+ if isinstance(raw_inputs, dict):
26509
+ for k, v in raw_inputs.items():
26510
+ to_set.append((str(k), v))
26511
+
26512
+ for fusion_id, value in to_set:
26513
+ rec = {"input": fusion_id, "value": value}
26514
+ try:
26515
+ tool.SetInput(fusion_id, value)
26516
+ rec["success"] = True
26517
+ if readback:
26468
26518
  try:
26469
- rec["readback"] = _ser(tool.GetInput("Center"))
26519
+ rec["readback"] = _ser(tool.GetInput(fusion_id))
26470
26520
  except Exception as exc:
26471
26521
  rec["readback_error"] = str(exc)
26472
- results.append(rec)
26473
-
26474
- # Scalar inputs (friendly aliases) + any raw passthrough inputs.
26475
- to_set: List[tuple] = []
26476
- for friendly, fusion_id in _MASK_INPUT_ALIASES.items():
26477
- if friendly in p:
26478
- to_set.append((fusion_id, p[friendly]))
26479
- raw_inputs = p.get("inputs")
26480
- if isinstance(raw_inputs, dict):
26481
- for k, v in raw_inputs.items():
26482
- to_set.append((str(k), v))
26483
-
26484
- for fusion_id, value in to_set:
26485
- rec = {"input": fusion_id, "value": value}
26486
- try:
26487
- tool.SetInput(fusion_id, value)
26488
- rec["success"] = True
26489
- if readback:
26490
- try:
26491
- rec["readback"] = _ser(tool.GetInput(fusion_id))
26492
- except Exception as exc:
26493
- rec["readback_error"] = str(exc)
26494
- except Exception as exc:
26495
- rec["success"] = False
26496
- rec["error"] = str(exc)
26497
- results.append(rec)
26522
+ except Exception as exc:
26523
+ rec["success"] = False
26524
+ rec["error"] = str(exc)
26525
+ results.append(rec)
26498
26526
 
26499
- out: Dict[str, Any] = {
26500
- "success": True,
26501
- "tool_name": tool_name,
26502
- "tool_type": attrs.get("TOOLS_RegID", tool_type),
26503
- "inputs_set": results,
26504
- }
26527
+ out: Dict[str, Any] = {
26528
+ "success": True,
26529
+ "tool_name": tool_name,
26530
+ "tool_type": attrs.get("TOOLS_RegID", tool_type),
26531
+ "inputs_set": results,
26532
+ }
26505
26533
 
26506
- # Optional wiring: connect this mask into a tool's mask input.
26507
- connect_to = p.get("connect_to")
26508
- if connect_to:
26509
- input_name = p.get("connect_input", "EffectMask")
26510
- target = comp.FindTool(str(connect_to))
26511
- if not target:
26534
+ # Optional wiring: connect this mask into a tool's mask input.
26535
+ connect_to = p.get("connect_to")
26536
+ if connect_to:
26537
+ input_name = p.get("connect_input", "EffectMask")
26538
+ target = comp.FindTool(str(connect_to))
26539
+ if not target:
26540
+ out["connection"] = {
26541
+ "success": False,
26542
+ "error": f"connect_to tool '{connect_to}' not found",
26543
+ }
26544
+ else:
26545
+ try:
26546
+ ok = bool(target.ConnectInput(input_name, tool))
26512
26547
  out["connection"] = {
26513
- "success": False,
26514
- "error": f"connect_to tool '{connect_to}' not found",
26548
+ "success": ok,
26549
+ "target": str(connect_to),
26550
+ "input_name": input_name,
26515
26551
  }
26516
- else:
26517
- try:
26518
- ok = bool(target.ConnectInput(input_name, tool))
26519
- out["connection"] = {
26520
- "success": ok,
26521
- "target": str(connect_to),
26522
- "input_name": input_name,
26523
- }
26524
- except Exception as exc:
26525
- out["connection"] = {"success": False, "error": str(exc)}
26526
- return out
26527
- finally:
26528
- comp.Unlock()
26552
+ except Exception as exc:
26553
+ out["connection"] = {"success": False, "error": str(exc)}
26554
+ return out
26529
26555
 
26530
26556
 
26531
26557
  def _fusion_find_text_tool(comp, p: Dict[str, Any]):
@@ -26561,25 +26587,22 @@ def _fusion_set_text_plus(comp, p: Dict[str, Any]) -> Dict[str, Any]:
26561
26587
  return err
26562
26588
  input_id = p.get("input_name", "StyledText")
26563
26589
  readback = bool(p.get("readback", True))
26564
- comp.Lock()
26590
+ # No comp.Lock() around a value write — see _FUSION_VALUE_WRITE_NOTE.
26565
26591
  try:
26592
+ tool.SetInput(input_id, text)
26593
+ except Exception as exc:
26594
+ return _err(f"SetInput({input_id!r}) failed: {exc}")
26595
+ out = {
26596
+ "success": True,
26597
+ "tool_name": (tool.GetAttrs() or {}).get("TOOLS_Name", ""),
26598
+ "input_name": input_id,
26599
+ }
26600
+ if readback:
26566
26601
  try:
26567
- tool.SetInput(input_id, text)
26602
+ out["readback"] = _ser(tool.GetInput(input_id))
26568
26603
  except Exception as exc:
26569
- return _err(f"SetInput({input_id!r}) failed: {exc}")
26570
- out = {
26571
- "success": True,
26572
- "tool_name": (tool.GetAttrs() or {}).get("TOOLS_Name", ""),
26573
- "input_name": input_id,
26574
- }
26575
- if readback:
26576
- try:
26577
- out["readback"] = _ser(tool.GetInput(input_id))
26578
- except Exception as exc:
26579
- out["readback_error"] = str(exc)
26580
- return out
26581
- finally:
26582
- comp.Unlock()
26604
+ out["readback_error"] = str(exc)
26605
+ return out
26583
26606
 
26584
26607
 
26585
26608
  def _fusion_keyframe_frames(inp) -> List[float]:
@@ -26956,15 +26979,12 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
26956
26979
  tool = comp.FindTool(p["tool_name"])
26957
26980
  if not tool:
26958
26981
  return _err(f"Tool '{p['tool_name']}' not found")
26959
- comp.Lock()
26960
- try:
26961
- if "time" in p:
26962
- tool.SetInput(p["input_name"], p["value"], p["time"])
26963
- else:
26964
- tool.SetInput(p["input_name"], p["value"])
26965
- return _ok()
26966
- finally:
26967
- comp.Unlock()
26982
+ # No comp.Lock() around a value write — see _FUSION_VALUE_WRITE_NOTE.
26983
+ if "time" in p:
26984
+ tool.SetInput(p["input_name"], p["value"], p["time"])
26985
+ else:
26986
+ tool.SetInput(p["input_name"], p["value"])
26987
+ return _ok()
26968
26988
 
26969
26989
  elif action == "get_input":
26970
26990
  tool = comp.FindTool(p["tool_name"])
@@ -403,6 +403,52 @@ API_TRUTH: List[Dict[str, Any]] = [
403
403
  "tags": ["timeline", "import", "interchange", "otio", "silent-failure", "conform"],
404
404
  "submit": "bug",
405
405
  },
406
+ {
407
+ "symbol": "Composition.Lock (suppresses render invalidation for value writes)",
408
+ "object": "Composition (Fusion, via TimelineItem comps)",
409
+ "signature": "Lock() / Unlock()",
410
+ "reality": "A numeric tool.SetInput() performed between Comp.Lock() "
411
+ "and Comp.Unlock(), when that write is the only thing the "
412
+ "call does, is stored in the graph and reads back correctly "
413
+ "from GetInput() but is NOT applied when the timeline is "
414
+ "rendered. Measured live on Studio 19.1.3.7 (2026-08-21) "
415
+ "with MediaIn -> Blur(XBlurSize 20) -> MediaOut on a "
416
+ "media-backed clip: written under the lock the delivered "
417
+ "H.264 render is bit-identical to the no-comp baseline "
418
+ "(ffmpeg PSNR inf); the identical write with the lock "
419
+ "removed renders at PSNR 24.38 dB and the file shrinks "
420
+ "2.0 MB -> 727 KB, as a blur should. The variable was "
421
+ "isolated against the comp handle (AddFusionComp, "
422
+ "GetFusionCompByIndex and GetFusionCompByName all render), "
423
+ "the node name, and the write form (attribute assignment "
424
+ "and SetInput both render unlocked). STRUCTURAL edits are "
425
+ "unaffected: AddTool and ConnectInput inside a lock render "
426
+ "normally, so the lock is not broadly unsafe — it "
427
+ "suppresses the parameter-change invalidation that a value "
428
+ "write depends on. Lock() is widely recommended for "
429
+ "batching Fusion edits, which is how this reaches "
430
+ "production code. SCOPE, measured by reintroducing the lock "
431
+ "at each site and re-rendering: it reproduces for a bare "
432
+ "numeric SetInput (2 of 6 call paths tested) and does NOT "
433
+ "reproduce when the same call also wraps the write in "
434
+ "StartUndo/EndUndo, performs an AddTool, or writes a string "
435
+ "to StyledText (4 of 6). Which of those rescues the write is "
436
+ "not established — only that they do. Treat the safe cases as "
437
+ "unexplained rather than proven safe, and keep value writes "
438
+ "outside the lock everywhere.",
439
+ "recommended": "Never hold a comp lock across a value write. Lock only "
440
+ "structural work (AddTool/ConnectInput) and set inputs "
441
+ "outside it. Because every readback the API offers "
442
+ "agrees with the value that was written, this failure "
443
+ "is invisible without a render — prove Fusion "
444
+ "parameter changes with a delivered frame or "
445
+ "gallery_stills grab_and_export, never with GetInput.",
446
+ "tags": ["fusion", "silent-failure", "render", "readback"],
447
+ "submit": "bug",
448
+ "mitigation": ["_FUSION_VALUE_WRITE_NOTE",
449
+ "tests/live_fusion_value_write_validation.py",
450
+ "tests/test_fusion_value_write_lock.py"],
451
+ },
406
452
  {
407
453
  "symbol": "TimelineItem.AddFusionComp / LoadFusionCompByName",
408
454
  "object": "TimelineItem (media-backed clip)",
@@ -434,7 +480,16 @@ API_TRUTH: List[Dict[str, Any]] = [
434
480
  "either. Contrast InsertFusionTitleIntoTimeline, whose comp "
435
481
  "DOES render — text set via SetInput('StyledText') appears in "
436
482
  "the output — so this is specific to comps attached to "
437
- "media-backed clips, not to Fusion through the API generally.",
483
+ "media-backed clips, not to Fusion through the API generally. "
484
+ "REPRODUCED 2026-08-21 on Studio 19.1.3.7: a rooted "
485
+ "MediaIn -> Blur -> MediaOut comp built entirely through "
486
+ "the API renders (PSNR 24.38 dB vs the no-comp baseline), "
487
+ "so the 2026-08-02 correction stands. Note that an "
488
+ "important share of 'the comp was ignored' readings are "
489
+ "NOT this entry at all but the Composition.Lock bug above "
490
+ "— a parameter written under a comp lock reads back "
491
+ "correctly and never reaches the render, which looks "
492
+ "identical from the API side.",
438
493
  "recommended": "Wire the graph so MediaOut descends from MediaIn — that "
439
494
  "is the difference between a comp that renders and one "
440
495
  "that is silently bypassed, and it is what made this look "