davinci-resolve-mcp 4.8.26 → 4.8.28

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,77 @@
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.28 — ffmpeg can no longer hang on the protocol stream; a cancelled call no longer kills the server
6
+
7
+ ### Fixed
8
+
9
+ - **ffmpeg could hang indefinitely while reading the server's stdin.** Every
10
+ ffmpeg/ffprobe analysis pass (`edit_engine plan_silence_ripple`, media
11
+ analysis, sync detection, sound density and the rest) ran through a
12
+ runner that let the child inherit the server's stdin. Over stdio that is
13
+ the JSON-RPC stream. ffmpeg polls stdin for keyboard commands; the `c` in
14
+ `"jsonrpc"` opens its interactive command prompt, which then waits for a
15
+ newline while consuming the protocol bytes it reads. Measured with ffmpeg
16
+ 9.0.2: a partial frame arriving mid-pass hung a silencedetect run until it
17
+ was killed, and the same run with `stdin=subprocess.DEVNULL` finished
18
+ normally. The runner, and all 29 other call sites under `src/` that spawned
19
+ a child without `stdin=`, now pass `stdin=subprocess.DEVNULL`. Reported on
20
+ Windows with the free 21.0.2 bridge (#272); the mechanism was reproduced on
21
+ macOS, and the Windows build was not available to test.
22
+ - **A tool call cancelled by the client could take the whole session down.**
23
+ Tool bodies run in a worker thread, and the wait for that thread is
24
+ shielded from cancellation. When the client cancelled (for example after
25
+ its own timeout) and the body finished later, its result came back as if
26
+ nothing had happened. The SDK then tried to answer a request it had already
27
+ answered "cancelled", and its `Request already responded to` assertion
28
+ closed the connection. The pending cancellation is now raised once the
29
+ body returns, which the SDK treats as the cancellation it already
30
+ acknowledged. The body still runs to completion, so Resolve is never left
31
+ half-mutated; only its result is discarded.
32
+
33
+ ### Tests
34
+
35
+ - `tests/test_subprocess_stdin_discipline.py` (new) fails the suite on any
36
+ `subprocess.run/Popen/call/check_call/check_output` under `src/` that does
37
+ not pass `stdin=` or `input=`, in any import spelling. With the fix
38
+ reverted it names all 30 sites (the runner plus the 29 others).
39
+ - `tests/test_threaded_tool_dispatch.py` gains an end-to-end case over the
40
+ real SDK's in-memory transport: it cancels a call mid-body, lets the body
41
+ finish, and requires the next call to succeed. With the fix reverted, it
42
+ fails with the reported `AssertionError`.
43
+
44
+ ### Validation
45
+
46
+ - No Resolve scripting call changed. Both mechanisms were reproduced before
47
+ the fix and confirmed fixed after it, on macOS with ffmpeg 9.0.2 and
48
+ mcp 1.30.0. Not tested on Windows.
49
+
50
+ ## What's New in v4.8.27 — Linux exports no longer redirected away from /tmp-named folders
51
+
52
+ ### Fixed
53
+
54
+ - **On Linux, an output folder whose name merely began with `/tmp` or
55
+ `/var/tmp` was silently replaced.** `_resolve_safe_dir` tested
56
+ `path.startswith("/tmp")`, which is also true of `/tmpdata`, `/tmpfiles`,
57
+ `/tmp-scratch` and `/var/tmpdata`. Each was treated as a temp directory and
58
+ swapped for `~/Documents/resolve-stills`, and
59
+ `gallery_stills(grab_and_export)` then reported that folder as if it were the
60
+ one asked for. The same helper picks the output folder for `encrypt_dctl` and
61
+ the granular `save_project` export fallback. The Linux branch now compares by
62
+ path segment, as the macOS branch already did; `/tmp`, `/tmp/…`, `/var/tmp`
63
+ and `/var/tmp/…` still redirect. Both copies of the helper (`src/server.py`
64
+ and `src/granular/common.py`) are fixed. Thanks to @Dev-next-gen (#271).
65
+
66
+ ### Tests
67
+
68
+ - `tests/test_granular_safe_dir.py` gains a Linux class covering both copies:
69
+ real temp paths still redirect, sibling names are left alone.
70
+
71
+ ### Validation
72
+
73
+ - Path classification only, decided before any Resolve call; no Resolve
74
+ behavior changed. Live test not required.
75
+
5
76
  ## What's New in v4.8.26 — two dead layout-preset helpers removed
6
77
 
7
78
  ### Removed
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.26-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.8.28-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.26-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.8.28-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.26 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v4.8.28 版 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.26"
40
+ VERSION = "4.8.28"
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.26",
3
+ "version": "4.8.28",
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.26"
94
+ VERSION = "4.8.28"
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()}")
@@ -377,7 +377,10 @@ def _resolve_safe_dir(path):
377
377
  # silently into both, same as /var/folders (matches src/server.py).
378
378
  _is_sandbox = path.startswith(("/var/", "/private/var/", "/tmp/", "/private/tmp/")) or path in ("/tmp", "/private/tmp")
379
379
  elif platform.system() == "Linux":
380
- _is_sandbox = path.startswith("/tmp") or path.startswith("/var/tmp")
380
+ # By segment, not by character prefix (matches src/server.py): a sibling
381
+ # of /tmp whose name merely begins with it — /tmpfiles, /tmp-scratch,
382
+ # /var/tmpdata — cleared `startswith("/tmp")` and was redirected.
383
+ _is_sandbox = path.startswith(("/tmp/", "/var/tmp/")) or path in ("/tmp", "/var/tmp")
381
384
  elif platform.system() == "Windows":
382
385
  try:
383
386
  _is_sandbox = os.path.commonpath([os.path.abspath(path), os.path.abspath(system_temp)]) == os.path.abspath(system_temp)
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.26"
14
+ VERSION = "4.8.28"
15
15
 
16
16
  import base64
17
17
  import os
@@ -1421,7 +1421,13 @@ def _resolve_safe_dir(path):
1421
1421
  # silently fails into both (live-verified 2026-07-03), same as /var/folders.
1422
1422
  _is_sandbox = path.startswith(("/var/", "/private/var/", "/tmp/", "/private/tmp/")) or path in ("/tmp", "/private/tmp")
1423
1423
  elif platform.system() == "Linux":
1424
- _is_sandbox = path.startswith("/tmp") or path.startswith("/var/tmp")
1424
+ # By segment, not by character prefix: `startswith("/tmp")` also matched a
1425
+ # sibling of /tmp whose name merely begins with it — /tmpfiles,
1426
+ # /tmp-scratch, /var/tmpdata — so an export the caller aimed at one of
1427
+ # those was redirected away from it. The Darwin branch already compares
1428
+ # this way, and `/tmpfiles/out` is one of the paths
1429
+ # tests/test_granular_safe_dir.py asserts is left alone.
1430
+ _is_sandbox = path.startswith(("/tmp/", "/var/tmp/")) or path in ("/tmp", "/var/tmp")
1425
1431
  elif platform.system() == "Windows":
1426
1432
  # Check if path is under the system temp directory (e.g. AppData\Local\Temp)
1427
1433
  try:
@@ -1913,7 +1919,8 @@ def _activate_resolve_window() -> Dict[str, Any]:
1913
1919
  proc = subprocess.run(
1914
1920
  ["osascript", "-e", 'tell application "DaVinci Resolve" to activate'],
1915
1921
  capture_output=True, text=True, encoding="utf-8", errors="replace",
1916
- timeout=5, stdin=subprocess.DEVNULL,
1922
+ timeout=5,
1923
+ stdin=subprocess.DEVNULL,
1917
1924
  )
1918
1925
  return {
1919
1926
  "activated": proc.returncode == 0,
@@ -1927,7 +1934,8 @@ def _activate_resolve_window() -> Dict[str, Any]:
1927
1934
  "$s = New-Object -ComObject WScript.Shell; "
1928
1935
  "$null = $s.AppActivate('DaVinci Resolve')"],
1929
1936
  capture_output=True, text=True, encoding="utf-8", errors="replace",
1930
- timeout=5, stdin=subprocess.DEVNULL,
1937
+ timeout=5,
1938
+ stdin=subprocess.DEVNULL,
1931
1939
  )
1932
1940
  return {
1933
1941
  "activated": proc.returncode == 0,
@@ -1940,7 +1948,8 @@ def _activate_resolve_window() -> Dict[str, Any]:
1940
1948
  proc = subprocess.run(
1941
1949
  ["wmctrl", "-a", "DaVinci Resolve"],
1942
1950
  capture_output=True, text=True, encoding="utf-8", errors="replace",
1943
- timeout=5, stdin=subprocess.DEVNULL,
1951
+ timeout=5,
1952
+ stdin=subprocess.DEVNULL,
1944
1953
  )
1945
1954
  return {
1946
1955
  "activated": proc.returncode == 0,
@@ -1951,7 +1960,8 @@ def _activate_resolve_window() -> Dict[str, Any]:
1951
1960
  proc = subprocess.run(
1952
1961
  ["xdotool", "search", "--name", "DaVinci Resolve", "windowactivate"],
1953
1962
  capture_output=True, text=True, encoding="utf-8", errors="replace",
1954
- timeout=5, stdin=subprocess.DEVNULL,
1963
+ timeout=5,
1964
+ stdin=subprocess.DEVNULL,
1955
1965
  )
1956
1966
  return {
1957
1967
  "activated": proc.returncode == 0,
@@ -1983,7 +1993,8 @@ def _send_resolve_keystroke_go_to_mark_in() -> Dict[str, Any]:
1983
1993
  proc = subprocess.run(
1984
1994
  ["osascript", "-e", script],
1985
1995
  capture_output=True, text=True, encoding="utf-8", errors="replace",
1986
- timeout=5, stdin=subprocess.DEVNULL,
1996
+ timeout=5,
1997
+ stdin=subprocess.DEVNULL,
1987
1998
  )
1988
1999
  return {
1989
2000
  "sent": proc.returncode == 0,
@@ -1999,7 +2010,8 @@ def _send_resolve_keystroke_go_to_mark_in() -> Dict[str, Any]:
1999
2010
  "Start-Sleep -Milliseconds 150; "
2000
2011
  "[System.Windows.Forms.SendKeys]::SendWait('+i')"],
2001
2012
  capture_output=True, text=True, encoding="utf-8", errors="replace",
2002
- timeout=5, stdin=subprocess.DEVNULL,
2013
+ timeout=5,
2014
+ stdin=subprocess.DEVNULL,
2003
2015
  )
2004
2016
  return {
2005
2017
  "sent": proc.returncode == 0,
@@ -2013,7 +2025,8 @@ def _send_resolve_keystroke_go_to_mark_in() -> Dict[str, Any]:
2013
2025
  proc = subprocess.run(
2014
2026
  ["xdotool", "search", "--name", "DaVinci Resolve", "key", "--window", "%@", "shift+i"],
2015
2027
  capture_output=True, text=True, encoding="utf-8", errors="replace",
2016
- timeout=5, stdin=subprocess.DEVNULL,
2028
+ timeout=5,
2029
+ stdin=subprocess.DEVNULL,
2017
2030
  )
2018
2031
  return {"sent": proc.returncode == 0, "platform": "linux", "tool": "xdotool", "shortcut": "Shift+I"}
2019
2032
  return {"sent": False, "platform": sys.platform, "note": "no key-send tool found"}
@@ -13881,7 +13894,8 @@ def _ffprobe_media_summary(path: str) -> Optional[Dict[str, Any]]:
13881
13894
  "format=duration,size:stream=codec_type,codec_name",
13882
13895
  "-of", "json", path],
13883
13896
  capture_output=True, encoding="utf-8", errors="replace",
13884
- timeout=20, stdin=subprocess.DEVNULL,
13897
+ timeout=20,
13898
+ stdin=subprocess.DEVNULL,
13885
13899
  )
13886
13900
  except Exception:
13887
13901
  return None
@@ -14842,7 +14856,7 @@ def _ffmpeg_scale_to_bytes(src_path: str, max_width: Optional[int], out_format:
14842
14856
  # -2 keeps the height even (required by some encoders) and preserves AR.
14843
14857
  args += ["-vf", f"scale='min({int(max_width)},iw)':-2:flags=lanczos"]
14844
14858
  args += ["-frames:v", "1", tmp_out]
14845
- proc = subprocess.run(args, capture_output=True, timeout=120)
14859
+ proc = subprocess.run(args, stdin=subprocess.DEVNULL, capture_output=True, timeout=120)
14846
14860
  if proc.returncode != 0:
14847
14861
  return None, (proc.stderr.decode("utf-8", "replace").strip() or "ffmpeg failed")[:400]
14848
14862
  with open(tmp_out, "rb") as handle:
@@ -33357,14 +33371,16 @@ def _install_threaded_tool_dispatch(fastmcp) -> int:
33357
33371
  to a worker thread keeps the event loop servicing the transport. Bodies are
33358
33372
  serialized on _bridge_lock so the single-threaded Resolve bridge is never
33359
33373
  entered concurrently. A body that outlives a client cancellation runs to
33360
- 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.
33361
33376
 
33362
33377
  Couples to mcp SDK private attrs (ToolManager._tools, Tool.fn /
33363
- Tool.is_async; verified on mcp 1.27). Best-effort: if that shape changes,
33364
- 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.
33365
33380
  """
33366
33381
  import functools
33367
33382
  import anyio
33383
+ from anyio.lowlevel import checkpoint_if_cancelled
33368
33384
 
33369
33385
  manager = getattr(fastmcp, "_tool_manager", None)
33370
33386
  tools = getattr(manager, "_tools", None)
@@ -33377,7 +33393,18 @@ def _install_threaded_tool_dispatch(fastmcp) -> int:
33377
33393
  def call():
33378
33394
  with _bridge_lock:
33379
33395
  return fn(**kwargs)
33380
- 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()
33381
33408
  return run_off_thread
33382
33409
 
33383
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