davinci-resolve-mcp 4.8.27 → 4.8.29

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,83 @@
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.29 — optional granular arguments accept an explicit null
6
+
7
+ ### Fixed
8
+
9
+ - **Sixteen optional arguments on nine granular tools rejected `null`.**
10
+ They were annotated `str` / `float` / `bool` / `int` with a `None`
11
+ default, so the generated schema advertised `"default": null` on a field
12
+ whose type excluded null. Clients that fill every optional argument with
13
+ its advertised default sent `null`, and the call failed argument
14
+ validation before the tool ran. Affected: `create_project`
15
+ (`media_location_path`), `set_color_space_tool` (`gamma`),
16
+ `import_layout_preset_tool` and `import_user_preferences_preset`
17
+ (`preset_name`), `set_timeline_item_composite` (`composite_mode`,
18
+ `opacity`), `set_timeline_item_retime` (`speed`, `process`),
19
+ `set_timeline_item_stabilization` (`enabled`, `method`, `strength`),
20
+ `set_timeline_item_audio` (`volume`, `pan`, `eq_enabled`) and
21
+ `modify_keyframe` (`new_value`, `new_frame`). They are now `Optional[T]`;
22
+ callers that omit them see no change. Only the granular server (`--full`)
23
+ was affected. Contributed by @vishalhabib99 (#274).
24
+
25
+ ### Tests
26
+
27
+ - `tests/test_tool_schema_null_defaults.py` (new) lists every tool on the
28
+ compound and granular servers offline and fails on any argument whose
29
+ default is null but whose schema rejects null. On v4.8.28 it names all
30
+ sixteen.
31
+
32
+ ### Validation
33
+
34
+ - Schema-only change; no Resolve scripting call changed, so no live run was
35
+ required.
36
+
37
+ ## What's New in v4.8.28 — ffmpeg can no longer hang on the protocol stream; a cancelled call no longer kills the server
38
+
39
+ ### Fixed
40
+
41
+ - **ffmpeg could hang indefinitely while reading the server's stdin.** Every
42
+ ffmpeg/ffprobe analysis pass (`edit_engine plan_silence_ripple`, media
43
+ analysis, sync detection, sound density and the rest) ran through a
44
+ runner that let the child inherit the server's stdin. Over stdio that is
45
+ the JSON-RPC stream. ffmpeg polls stdin for keyboard commands; the `c` in
46
+ `"jsonrpc"` opens its interactive command prompt, which then waits for a
47
+ newline while consuming the protocol bytes it reads. Measured with ffmpeg
48
+ 9.0.2: a partial frame arriving mid-pass hung a silencedetect run until it
49
+ was killed, and the same run with `stdin=subprocess.DEVNULL` finished
50
+ normally. The runner, and all 29 other call sites under `src/` that spawned
51
+ a child without `stdin=`, now pass `stdin=subprocess.DEVNULL`. Reported on
52
+ Windows with the free 21.0.2 bridge (#272); the mechanism was reproduced on
53
+ macOS, and the Windows build was not available to test.
54
+ - **A tool call cancelled by the client could take the whole session down.**
55
+ Tool bodies run in a worker thread, and the wait for that thread is
56
+ shielded from cancellation. When the client cancelled (for example after
57
+ its own timeout) and the body finished later, its result came back as if
58
+ nothing had happened. The SDK then tried to answer a request it had already
59
+ answered "cancelled", and its `Request already responded to` assertion
60
+ closed the connection. The pending cancellation is now raised once the
61
+ body returns, which the SDK treats as the cancellation it already
62
+ acknowledged. The body still runs to completion, so Resolve is never left
63
+ half-mutated; only its result is discarded.
64
+
65
+ ### Tests
66
+
67
+ - `tests/test_subprocess_stdin_discipline.py` (new) fails the suite on any
68
+ `subprocess.run/Popen/call/check_call/check_output` under `src/` that does
69
+ not pass `stdin=` or `input=`, in any import spelling. With the fix
70
+ reverted it names all 30 sites (the runner plus the 29 others).
71
+ - `tests/test_threaded_tool_dispatch.py` gains an end-to-end case over the
72
+ real SDK's in-memory transport: it cancels a call mid-body, lets the body
73
+ finish, and requires the next call to succeed. With the fix reverted, it
74
+ fails with the reported `AssertionError`.
75
+
76
+ ### Validation
77
+
78
+ - No Resolve scripting call changed. Both mechanisms were reproduced before
79
+ the fix and confirmed fixed after it, on macOS with ffmpeg 9.0.2 and
80
+ mcp 1.30.0. Not tested on Windows.
81
+
5
82
  ## What's New in v4.8.27 — Linux exports no longer redirected away from /tmp-named folders
6
83
 
7
84
  ### 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.27-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.8.29-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.27-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.8.29-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.27 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v4.8.29 版 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.27"
40
+ VERSION = "4.8.29"
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.27",
3
+ "version": "4.8.29",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -14738,6 +14738,7 @@ def _launch_claude_code_terminal() -> Dict[str, Any]:
14738
14738
  try:
14739
14739
  check = subprocess.run(
14740
14740
  ["osascript", "-e", 'application "iTerm" is running'],
14741
+ stdin=subprocess.DEVNULL,
14741
14742
  capture_output=True, text=True, encoding="utf-8", errors="replace",
14742
14743
  timeout=8,
14743
14744
  )
@@ -14762,6 +14763,7 @@ def _launch_claude_code_terminal() -> Dict[str, Any]:
14762
14763
  try:
14763
14764
  proc = subprocess.run(
14764
14765
  ["osascript", "-e", script],
14766
+ stdin=subprocess.DEVNULL,
14765
14767
  capture_output=True, text=True, encoding="utf-8", errors="replace",
14766
14768
  timeout=15,
14767
14769
  )
@@ -14794,6 +14796,7 @@ def _native_directory_picker(initial: Optional[str] = None) -> Dict[str, Any]:
14794
14796
  import subprocess
14795
14797
  proc = subprocess.run(
14796
14798
  ["osascript", "-e", script],
14799
+ stdin=subprocess.DEVNULL,
14797
14800
  capture_output=True, text=True, encoding="utf-8", errors="replace",
14798
14801
  timeout=120,
14799
14802
  )
@@ -91,7 +91,7 @@ if not logging.getLogger().handlers:
91
91
  handlers=[logging.StreamHandler()],
92
92
  )
93
93
 
94
- VERSION = "4.8.27"
94
+ VERSION = "4.8.29"
95
95
  logger = logging.getLogger("davinci-resolve-mcp")
96
96
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
97
97
  logger.info(f"Detected platform: {get_platform()}")
@@ -140,7 +140,7 @@ def open_project(name: str) -> str:
140
140
 
141
141
 
142
142
  @mcp.tool()
143
- def create_project(name: str, media_location_path: str = None) -> str:
143
+ def create_project(name: str, media_location_path: Optional[str] = None) -> str:
144
144
  """Create a new project with the given name.
145
145
 
146
146
  Args:
@@ -728,7 +728,7 @@ def set_color_science_mode_tool(mode: str) -> str:
728
728
 
729
729
  @mcp.tool()
730
730
  @granular_destructive_op()
731
- def set_color_space_tool(color_space: str, gamma: str = None) -> str:
731
+ def set_color_space_tool(color_space: str, gamma: Optional[str] = None) -> str:
732
732
  """Set timeline color space and gamma.
733
733
 
734
734
  Args:
@@ -262,7 +262,7 @@ def export_layout_preset_tool(preset_name: str, export_path: str) -> Dict[str, A
262
262
 
263
263
 
264
264
  @mcp.tool()
265
- def import_layout_preset_tool(import_path: str, preset_name: str = None) -> Dict[str, Any]:
265
+ def import_layout_preset_tool(import_path: str, preset_name: Optional[str] = None) -> Dict[str, Any]:
266
266
  """Import a layout preset from a file.
267
267
 
268
268
  Calls Resolve.ImportLayoutPreset() to import a preset from disk.
@@ -701,7 +701,7 @@ def delete_user_preferences_preset(preset_name: str) -> Dict[str, Any]:
701
701
 
702
702
 
703
703
  @mcp.tool()
704
- def import_user_preferences_preset(import_path: str, preset_name: str = None) -> Dict[str, Any]:
704
+ def import_user_preferences_preset(import_path: str, preset_name: Optional[str] = None) -> Dict[str, Any]:
705
705
  """Import a user-preferences preset from a file (Resolve 21.0.4+).
706
706
 
707
707
  The imported preset is NOT auto-loaded; it takes its name from the file
@@ -358,8 +358,8 @@ def set_timeline_item_crop(timeline_item_id: str,
358
358
  @mcp.tool()
359
359
  @granular_destructive_op()
360
360
  def set_timeline_item_composite(timeline_item_id: str,
361
- composite_mode: str = None,
362
- opacity: float = None) -> str:
361
+ composite_mode: Optional[str] = None,
362
+ opacity: Optional[float] = None) -> str:
363
363
  """Set composite properties for a timeline item.
364
364
 
365
365
  Args:
@@ -446,8 +446,8 @@ def set_timeline_item_composite(timeline_item_id: str,
446
446
  @mcp.tool()
447
447
  @granular_destructive_op()
448
448
  def set_timeline_item_retime(timeline_item_id: str,
449
- speed: float = None,
450
- process: str = None) -> str:
449
+ speed: Optional[float] = None,
450
+ process: Optional[str] = None) -> str:
451
451
  """Set retiming properties for a timeline item.
452
452
 
453
453
  Args:
@@ -525,9 +525,9 @@ def set_timeline_item_retime(timeline_item_id: str,
525
525
  @mcp.tool()
526
526
  @granular_destructive_op()
527
527
  def set_timeline_item_stabilization(timeline_item_id: str,
528
- enabled: bool = None,
529
- method: str = None,
530
- strength: float = None) -> str:
528
+ enabled: Optional[bool] = None,
529
+ method: Optional[str] = None,
530
+ strength: Optional[float] = None) -> str:
531
531
  """Set stabilization properties for a timeline item.
532
532
 
533
533
  Args:
@@ -617,9 +617,9 @@ def set_timeline_item_stabilization(timeline_item_id: str,
617
617
  @mcp.tool()
618
618
  @granular_destructive_op()
619
619
  def set_timeline_item_audio(timeline_item_id: str,
620
- volume: float = None,
621
- pan: float = None,
622
- eq_enabled: bool = None) -> str:
620
+ volume: Optional[float] = None,
621
+ pan: Optional[float] = None,
622
+ eq_enabled: Optional[bool] = None) -> str:
623
623
  """Set audio properties for a timeline item.
624
624
 
625
625
  Args:
@@ -947,7 +947,7 @@ def add_keyframe(timeline_item_id: str, property_name: str, frame: int, value: f
947
947
 
948
948
 
949
949
  @mcp.tool()
950
- def modify_keyframe(timeline_item_id: str, property_name: str, frame: int, new_value: float = None, new_frame: int = None) -> str:
950
+ def modify_keyframe(timeline_item_id: str, property_name: str, frame: int, new_value: Optional[float] = None, new_frame: Optional[int] = None) -> str:
951
951
  """Modify an existing keyframe by changing its value or frame position.
952
952
 
953
953
  Args:
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.27"
14
+ VERSION = "4.8.29"
15
15
 
16
16
  import base64
17
17
  import os
@@ -1919,7 +1919,8 @@ def _activate_resolve_window() -> Dict[str, Any]:
1919
1919
  proc = subprocess.run(
1920
1920
  ["osascript", "-e", 'tell application "DaVinci Resolve" to activate'],
1921
1921
  capture_output=True, text=True, encoding="utf-8", errors="replace",
1922
- timeout=5, stdin=subprocess.DEVNULL,
1922
+ timeout=5,
1923
+ stdin=subprocess.DEVNULL,
1923
1924
  )
1924
1925
  return {
1925
1926
  "activated": proc.returncode == 0,
@@ -1933,7 +1934,8 @@ def _activate_resolve_window() -> Dict[str, Any]:
1933
1934
  "$s = New-Object -ComObject WScript.Shell; "
1934
1935
  "$null = $s.AppActivate('DaVinci Resolve')"],
1935
1936
  capture_output=True, text=True, encoding="utf-8", errors="replace",
1936
- timeout=5, stdin=subprocess.DEVNULL,
1937
+ timeout=5,
1938
+ stdin=subprocess.DEVNULL,
1937
1939
  )
1938
1940
  return {
1939
1941
  "activated": proc.returncode == 0,
@@ -1946,7 +1948,8 @@ def _activate_resolve_window() -> Dict[str, Any]:
1946
1948
  proc = subprocess.run(
1947
1949
  ["wmctrl", "-a", "DaVinci Resolve"],
1948
1950
  capture_output=True, text=True, encoding="utf-8", errors="replace",
1949
- timeout=5, stdin=subprocess.DEVNULL,
1951
+ timeout=5,
1952
+ stdin=subprocess.DEVNULL,
1950
1953
  )
1951
1954
  return {
1952
1955
  "activated": proc.returncode == 0,
@@ -1957,7 +1960,8 @@ def _activate_resolve_window() -> Dict[str, Any]:
1957
1960
  proc = subprocess.run(
1958
1961
  ["xdotool", "search", "--name", "DaVinci Resolve", "windowactivate"],
1959
1962
  capture_output=True, text=True, encoding="utf-8", errors="replace",
1960
- timeout=5, stdin=subprocess.DEVNULL,
1963
+ timeout=5,
1964
+ stdin=subprocess.DEVNULL,
1961
1965
  )
1962
1966
  return {
1963
1967
  "activated": proc.returncode == 0,
@@ -1989,7 +1993,8 @@ def _send_resolve_keystroke_go_to_mark_in() -> Dict[str, Any]:
1989
1993
  proc = subprocess.run(
1990
1994
  ["osascript", "-e", script],
1991
1995
  capture_output=True, text=True, encoding="utf-8", errors="replace",
1992
- timeout=5, stdin=subprocess.DEVNULL,
1996
+ timeout=5,
1997
+ stdin=subprocess.DEVNULL,
1993
1998
  )
1994
1999
  return {
1995
2000
  "sent": proc.returncode == 0,
@@ -2005,7 +2010,8 @@ def _send_resolve_keystroke_go_to_mark_in() -> Dict[str, Any]:
2005
2010
  "Start-Sleep -Milliseconds 150; "
2006
2011
  "[System.Windows.Forms.SendKeys]::SendWait('+i')"],
2007
2012
  capture_output=True, text=True, encoding="utf-8", errors="replace",
2008
- timeout=5, stdin=subprocess.DEVNULL,
2013
+ timeout=5,
2014
+ stdin=subprocess.DEVNULL,
2009
2015
  )
2010
2016
  return {
2011
2017
  "sent": proc.returncode == 0,
@@ -2019,7 +2025,8 @@ def _send_resolve_keystroke_go_to_mark_in() -> Dict[str, Any]:
2019
2025
  proc = subprocess.run(
2020
2026
  ["xdotool", "search", "--name", "DaVinci Resolve", "key", "--window", "%@", "shift+i"],
2021
2027
  capture_output=True, text=True, encoding="utf-8", errors="replace",
2022
- timeout=5, stdin=subprocess.DEVNULL,
2028
+ timeout=5,
2029
+ stdin=subprocess.DEVNULL,
2023
2030
  )
2024
2031
  return {"sent": proc.returncode == 0, "platform": "linux", "tool": "xdotool", "shortcut": "Shift+I"}
2025
2032
  return {"sent": False, "platform": sys.platform, "note": "no key-send tool found"}
@@ -13887,7 +13894,8 @@ def _ffprobe_media_summary(path: str) -> Optional[Dict[str, Any]]:
13887
13894
  "format=duration,size:stream=codec_type,codec_name",
13888
13895
  "-of", "json", path],
13889
13896
  capture_output=True, encoding="utf-8", errors="replace",
13890
- timeout=20, stdin=subprocess.DEVNULL,
13897
+ timeout=20,
13898
+ stdin=subprocess.DEVNULL,
13891
13899
  )
13892
13900
  except Exception:
13893
13901
  return None
@@ -14848,7 +14856,7 @@ def _ffmpeg_scale_to_bytes(src_path: str, max_width: Optional[int], out_format:
14848
14856
  # -2 keeps the height even (required by some encoders) and preserves AR.
14849
14857
  args += ["-vf", f"scale='min({int(max_width)},iw)':-2:flags=lanczos"]
14850
14858
  args += ["-frames:v", "1", tmp_out]
14851
- proc = subprocess.run(args, capture_output=True, timeout=120)
14859
+ proc = subprocess.run(args, stdin=subprocess.DEVNULL, capture_output=True, timeout=120)
14852
14860
  if proc.returncode != 0:
14853
14861
  return None, (proc.stderr.decode("utf-8", "replace").strip() or "ffmpeg failed")[:400]
14854
14862
  with open(tmp_out, "rb") as handle:
@@ -33363,14 +33371,16 @@ def _install_threaded_tool_dispatch(fastmcp) -> int:
33363
33371
  to a worker thread keeps the event loop servicing the transport. Bodies are
33364
33372
  serialized on _bridge_lock so the single-threaded Resolve bridge is never
33365
33373
  entered concurrently. A body that outlives a client cancellation runs to
33366
- completion (the bridge is never left half-mutated) still holding the lock.
33374
+ completion (the bridge is never left half-mutated) still holding the lock,
33375
+ and its result is then discarded rather than sent.
33367
33376
 
33368
33377
  Couples to mcp SDK private attrs (ToolManager._tools, Tool.fn /
33369
- Tool.is_async; verified on mcp 1.27). Best-effort: if that shape changes,
33370
- leave the tools as-is, falling back to the current inline behavior.
33378
+ Tool.is_async; verified on mcp 1.27 and 1.30). Best-effort: if that shape
33379
+ changes, leave the tools as-is, falling back to the current inline behavior.
33371
33380
  """
33372
33381
  import functools
33373
33382
  import anyio
33383
+ from anyio.lowlevel import checkpoint_if_cancelled
33374
33384
 
33375
33385
  manager = getattr(fastmcp, "_tool_manager", None)
33376
33386
  tools = getattr(manager, "_tools", None)
@@ -33383,7 +33393,18 @@ def _install_threaded_tool_dispatch(fastmcp) -> int:
33383
33393
  def call():
33384
33394
  with _bridge_lock:
33385
33395
  return fn(**kwargs)
33386
- return await anyio.to_thread.run_sync(call)
33396
+ try:
33397
+ return await anyio.to_thread.run_sync(call)
33398
+ finally:
33399
+ # run_sync shields its wait, so a client cancellation that lands
33400
+ # while the body runs is not raised there: the result comes back
33401
+ # as if nothing happened, the SDK tries to answer a request it
33402
+ # already answered "cancelled", and its "Request already
33403
+ # responded to" assert takes the whole session down (#272).
33404
+ # Raise the pending cancellation instead — the SDK expects it
33405
+ # and suppresses the duplicate response. Also covers a body
33406
+ # that raised after the cancel.
33407
+ await checkpoint_if_cancelled()
33387
33408
  return run_off_thread
33388
33409
 
33389
33410
  wrapped = 0
@@ -32,6 +32,7 @@ def _run_app_command(
32
32
  try:
33
33
  result = subprocess.run(
34
34
  cmd,
35
+ stdin=subprocess.DEVNULL,
35
36
  check=False,
36
37
  capture_output=True,
37
38
  text=True,
@@ -258,11 +259,11 @@ def restart_resolve_app(resolve_obj, wait_seconds: int = 5) -> bool:
258
259
  logger.info("Attempting to start Resolve")
259
260
 
260
261
  if platform.system().lower() == 'darwin':
261
- subprocess.Popen(['open', resolve_path])
262
+ subprocess.Popen(['open', resolve_path], stdin=subprocess.DEVNULL)
262
263
  elif platform.system().lower() == 'windows':
263
- subprocess.Popen([resolve_path])
264
+ subprocess.Popen([resolve_path], stdin=subprocess.DEVNULL)
264
265
  elif platform.system().lower() == 'linux':
265
- subprocess.Popen([resolve_path])
266
+ subprocess.Popen([resolve_path], stdin=subprocess.DEVNULL)
266
267
 
267
268
  return True
268
269
  except Exception as e:
@@ -48,7 +48,7 @@ def _record_tool_result(
48
48
 
49
49
 
50
50
  def _run_ffmpeg(args: list[str]) -> None:
51
- subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], check=True, timeout=120)
51
+ subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], stdin=subprocess.DEVNULL, check=True, timeout=120)
52
52
 
53
53
 
54
54
  def _make_synthetic_media(work_dir: Path) -> Dict[str, Path]:
@@ -91,7 +91,7 @@ def _call_confirmed(tool, action: str, params: Dict[str, Any]) -> Dict[str, Any]
91
91
 
92
92
 
93
93
  def _run_ffmpeg(args: list[str]) -> None:
94
- subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], check=True)
94
+ subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], stdin=subprocess.DEVNULL, check=True)
95
95
 
96
96
 
97
97
  def _make_synthetic_video(work_dir: Path) -> Path:
@@ -358,7 +358,7 @@ def _extract_audio_window(file_path: str, start_seconds: float, duration_seconds
358
358
  "-f", "f32le", "-",
359
359
  ]
360
360
  try:
361
- proc = subprocess.run(command, capture_output=True, timeout=120, check=False)
361
+ proc = subprocess.run(command, stdin=subprocess.DEVNULL, capture_output=True, timeout=120, check=False)
362
362
  except (OSError, subprocess.TimeoutExpired):
363
363
  return None
364
364
  if proc.returncode != 0 or not proc.stdout:
@@ -60,7 +60,7 @@ def _record_tool_result(
60
60
 
61
61
 
62
62
  def _run_ffmpeg(args: list[str]) -> None:
63
- subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], check=True)
63
+ subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], stdin=subprocess.DEVNULL, check=True)
64
64
 
65
65
 
66
66
  def _make_synthetic_video(work_dir: Path) -> Path:
@@ -170,7 +170,7 @@ def _decode_frame(
170
170
  "-f", "rawvideo", "-",
171
171
  ]
172
172
  try:
173
- proc = subprocess.run(args, capture_output=True, timeout=120, check=False)
173
+ proc = subprocess.run(args, stdin=subprocess.DEVNULL, capture_output=True, timeout=120, check=False)
174
174
  except (subprocess.TimeoutExpired, OSError) as exc:
175
175
  logger.debug("frame decode failed for %s: %s", path, exc)
176
176
  return None
@@ -189,7 +189,7 @@ def _probe_color_transfer(path: str) -> Optional[str]:
189
189
  "-show_entries", "stream=color_transfer", "-of", "default=nw=1:nk=1", path,
190
190
  ]
191
191
  try:
192
- proc = subprocess.run(args, capture_output=True, text=True, encoding="utf-8",
192
+ proc = subprocess.run(args, stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8",
193
193
  errors="replace", timeout=30, check=False)
194
194
  except (subprocess.TimeoutExpired, OSError):
195
195
  return None
@@ -2489,6 +2489,7 @@ def _kill_process_tree(pid: int) -> None:
2489
2489
  if os.name == "nt":
2490
2490
  subprocess.run(
2491
2491
  ["taskkill", "/F", "/T", "/PID", str(pid)],
2492
+ stdin=subprocess.DEVNULL,
2492
2493
  capture_output=True,
2493
2494
  check=False,
2494
2495
  )
@@ -2535,6 +2536,11 @@ def _run_command(
2535
2536
  try:
2536
2537
  proc = subprocess.Popen(
2537
2538
  args,
2539
+ # Never the server's stdin: under stdio that is the JSON-RPC stream,
2540
+ # and ffmpeg reads it as keyboard commands. The "c" in "jsonrpc"
2541
+ # opens ffmpeg's command prompt, which then blocks waiting for a
2542
+ # newline while eating protocol bytes (#272).
2543
+ stdin=subprocess.DEVNULL,
2538
2544
  stdout=subprocess.PIPE,
2539
2545
  stderr=subprocess.PIPE,
2540
2546
  env=env,
@@ -3473,7 +3479,7 @@ def _raw_frame(path: str, time_seconds: float, width: int = 96, height: int = 54
3473
3479
  "-",
3474
3480
  ]
3475
3481
  try:
3476
- proc = subprocess.run(args, capture_output=True, timeout=180, check=False)
3482
+ proc = subprocess.run(args, stdin=subprocess.DEVNULL, capture_output=True, timeout=180, check=False)
3477
3483
  except (subprocess.TimeoutExpired, OSError):
3478
3484
  return None
3479
3485
  expected = width * height * 3
@@ -78,7 +78,7 @@ def _record_tool_result(
78
78
 
79
79
 
80
80
  def _run_ffmpeg(args: list[str]) -> None:
81
- subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], check=True)
81
+ subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], stdin=subprocess.DEVNULL, check=True)
82
82
 
83
83
 
84
84
  def _make_synthetic_assets(work_dir: Path) -> Dict[str, Path]:
@@ -208,7 +208,7 @@ def measure_frame_levels(media_path: str, at_seconds: float = 0.0) -> Dict[str,
208
208
  "-vf", f"scale={width}:{height}", "-f", "rawvideo", "-pix_fmt", "rgb24", "-",
209
209
  ]
210
210
  try:
211
- proc = subprocess.run(cmd, capture_output=True, timeout=30)
211
+ proc = subprocess.run(cmd, stdin=subprocess.DEVNULL, capture_output=True, timeout=30)
212
212
  except (subprocess.SubprocessError, OSError) as exc:
213
213
  return {"success": False, "error": f"frame extraction failed: {exc}"}
214
214
  expected = width * height * 3
@@ -36,6 +36,7 @@ def _restrict_windows_acl(path: str) -> None:
36
36
  try:
37
37
  subprocess.run(
38
38
  ["icacls", path, "/inheritance:r", "/grant:r", f"{user}:F"],
39
+ stdin=subprocess.DEVNULL,
39
40
  capture_output=True, timeout=5, check=False,
40
41
  )
41
42
  except (OSError, subprocess.TimeoutExpired):
@@ -60,7 +60,7 @@ def _record_tool_result(
60
60
 
61
61
 
62
62
  def _run_ffmpeg(args: list[str]) -> None:
63
- subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], check=True, timeout=120)
63
+ subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], stdin=subprocess.DEVNULL, check=True, timeout=120)
64
64
 
65
65
 
66
66
  def _make_synthetic_video(work_dir: Path) -> Path:
@@ -219,6 +219,7 @@ def _process_name(pid: int) -> str:
219
219
 
220
220
  out = subprocess.run(
221
221
  ["ps", "-p", str(pid), "-o", "comm="],
222
+ stdin=subprocess.DEVNULL,
222
223
  capture_output=True, text=True, encoding="utf-8", errors="replace",
223
224
  timeout=5, check=False,
224
225
  )
@@ -72,7 +72,7 @@ def _run_ps(columns: str) -> Optional[List[str]]:
72
72
  """
73
73
  try:
74
74
  out = subprocess.run(
75
- ["ps", "-Awwo", columns], capture_output=True, text=True,
75
+ ["ps", "-Awwo", columns], stdin=subprocess.DEVNULL, capture_output=True, text=True,
76
76
  encoding="utf-8", errors="replace", timeout=10, check=False,
77
77
  )
78
78
  except Exception: # pragma: no cover - defensive; an unknown answer is None
@@ -211,7 +211,7 @@ def _process_table() -> Optional[List[Dict[str, Optional[str]]]]:
211
211
  for reader, parse in WINDOWS_PROCESS_READERS:
212
212
  try:
213
213
  out = subprocess.run(
214
- reader, capture_output=True, text=True, encoding="utf-8",
214
+ reader, stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8",
215
215
  errors="replace", timeout=10, check=False,
216
216
  )
217
217
  except Exception:
@@ -60,7 +60,7 @@ def _record_tool_result(
60
60
 
61
61
 
62
62
  def _run_ffmpeg(args: list[str]) -> None:
63
- subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], check=True, timeout=120)
63
+ subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], stdin=subprocess.DEVNULL, check=True, timeout=120)
64
64
 
65
65
 
66
66
  def _make_synthetic_video(work_dir: Path) -> Path:
@@ -221,7 +221,7 @@ def measure_track_levels(
221
221
  if duration_seconds:
222
222
  cmd[4:4] = ["-t", str(duration_seconds)]
223
223
  try:
224
- proc = subprocess.run(cmd, capture_output=True, timeout=120)
224
+ proc = subprocess.run(cmd, stdin=subprocess.DEVNULL, capture_output=True, timeout=120)
225
225
  except (subprocess.SubprocessError, OSError) as exc:
226
226
  return {"success": False, "error": f"decode failed for {name}: {exc}"}
227
227
  if proc.returncode != 0 or not proc.stdout:
@@ -160,7 +160,7 @@ def sync_event_install_guidance(capabilities: Optional[Dict[str, Any]] = None) -
160
160
 
161
161
  def _run_json(args: List[str], timeout: int) -> Tuple[int, Dict[str, Any], str]:
162
162
  try:
163
- proc = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
163
+ proc = subprocess.run(args, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
164
164
  except subprocess.TimeoutExpired:
165
165
  return 124, {}, f"Command timed out after {timeout}s"
166
166
  except OSError as exc:
@@ -397,7 +397,7 @@ def _decode_audio_window(
397
397
  "-",
398
398
  ])
399
399
  try:
400
- proc = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
400
+ proc = subprocess.run(args, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
401
401
  except subprocess.TimeoutExpired:
402
402
  return None, f"ffmpeg audio decode timed out after {timeout}s"
403
403
  except OSError as exc:
@@ -54,7 +54,7 @@ def _record_tool_result(
54
54
 
55
55
 
56
56
  def _run_ffmpeg(args: list[str]) -> None:
57
- subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], check=True)
57
+ subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], stdin=subprocess.DEVNULL, check=True)
58
58
 
59
59
 
60
60
  def _make_synthetic_video(work_dir: Path, name: str, source: str, frequency: int) -> Path:
@@ -164,6 +164,7 @@ def _make_synthetic_media(work_dir: Path) -> Path:
164
164
  "-y",
165
165
  str(media_path),
166
166
  ],
167
+ stdin=subprocess.DEVNULL,
167
168
  check=True,
168
169
  )
169
170
  return media_path