davinci-resolve-mcp 4.16.0 → 4.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,60 @@
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.17.1 — Media analysis decodes each file once
6
+
7
+ ### Changed
8
+
9
+ - **The readthrough pass of media analysis runs ffmpeg once instead of four
10
+ times** (#329, thanks @Dev-next-gen). Loudness, scene scoring, black frames
11
+ and silence each used to be a separate ffmpeg run, and each run decoded the
12
+ whole file: `-af` alone does not drop the video stream from a null output,
13
+ so the audio passes decoded every video frame too. The four filters now
14
+ share one run through `split`/`asplit`. On a 1080p H.264 file the
15
+ readthrough took 8.1 s instead of 22.1 s, and CPU time fell from 84.8 s to
16
+ 30.5 s; ProRes and HEVC dropped by about the same share. The results are
17
+ the same as before. The parsers are unchanged.
18
+ - The four measurements now share one exit code and one timeout. The single
19
+ run gets four times the old per-pass timeout, the budget of the four runs it
20
+ replaces. If that run times out, all four report `success: false`, where
21
+ before the other three could still succeed.
22
+
23
+ ### Validation
24
+
25
+ - Python suite with the PR merged on `main`: 4,291 tests. The only failures
26
+ are the 4 `source_media_guard` path checks that always fail in a scratch
27
+ worktree. The new test compares the single run against the four separate
28
+ runs with the real ffmpeg on an A/V file, a video-only file and an
29
+ audio-only file.
30
+ - No Resolve behaviour changed; this is ffmpeg only.
31
+
32
+ ## What's New in v4.17.0 — Power windows for Resolve 21
33
+
34
+ ### Added
35
+
36
+ - **`drx generate` takes `resolveVersion`** (#328, thanks @hpaul). A major
37
+ number (`21`) or a version string (`"21.0.4"`). From 21 on, circle and
38
+ linear power windows are written the way Resolve 21 exports them: corrector
39
+ type 0x10004 (65540) with the full parameter set, `REF_WIDTH` 288 and an
40
+ identity transform matrix, and 0x10004 added to the node's corrector-type
41
+ list. Resolve 21 loads the older corrector-type-4 form into the Window
42
+ palette but renders an empty mask, which also hides the node's grade.
43
+ The default is unchanged, so output for Resolve 19/20 is byte-for-byte
44
+ what it was. Gradient and polygon/curve windows are not rewritten. The
45
+ format follows justhung69/resolve-mcp (MIT), pixel-verified there on free
46
+ 21.0.4.5.
47
+ - The node's corrector-type list is now varint encoded, because 65540 does
48
+ not fit in one byte. Every type id below 128 encodes to the same byte as
49
+ before.
50
+
51
+ ### Validation
52
+
53
+ - Advanced Node suite on Node 20 with a fresh `npm ci`, PR merged on
54
+ `main`: 1,214 tests, 1,153 passed, 0 failed, 61 skipped (8 new for the
55
+ Resolve 21 window layout). PR CI green on Python 3.10/3.11 and Node 20/22.
56
+ - Not live-validated on Resolve 21: the maintainer's box runs Studio
57
+ 19.1.3.7. The window layout is checked against Resolve 21 fixtures only.
58
+
5
59
  ## What's New in v4.16.0 — Calls name their timeline, and failures say so
6
60
 
7
61
  Eight reports from a Studio 21.1 / Windows deployment (#320–#327) and the
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.16.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.17.1-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(390%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.16.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.17.1-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(390%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.16.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v4.17.1 版 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.16.0"
40
+ VERSION = "4.17.1"
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.16.0",
3
+ "version": "4.17.1",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -258,6 +258,15 @@ const generateSchema = z.object({
258
258
  "(<pTrackVer>), for graph.apply_grade_from_drx with source='timeline'; a clip-target file applied to " +
259
259
  'the timeline is refused (DRX_NO_TIMELINE_GRADE).'
260
260
  ),
261
+ resolveVersion: z
262
+ .union([z.number().int().min(1), z.string()])
263
+ .optional()
264
+ .describe(
265
+ "Resolve version the file is for, as a major number (21) or a version string ('21.0.4'). From 21 on, " +
266
+ 'circle and linear power windows are written in the Resolve 21 form (corrector type 0x10004, full parameter ' +
267
+ 'set), which renders on Resolve 21; the older form loads there but renders an empty mask. Default: the ' +
268
+ 'Resolve 19 form. Not live-verified here; the format comes from justhung69/resolve-mcp.'
269
+ ),
261
270
  outputPath: z.string().optional().describe('If set, write the .drx here'),
262
271
  });
263
272
 
@@ -692,7 +701,11 @@ export const drxTool = {
692
701
  if (action === 'generate') {
693
702
  const p = generateSchema.parse(args);
694
703
  const warnings = [];
695
- const metadata = { ...(p.metadata || {}), ...(p.target ? { target: p.target } : {}) };
704
+ const metadata = {
705
+ ...(p.metadata || {}),
706
+ ...(p.target ? { target: p.target } : {}),
707
+ ...(p.resolveVersion !== undefined ? { resolveVersion: p.resolveVersion } : {}),
708
+ };
696
709
  const out = await drxGenerator().generateDRX(normalizeGradeParams(p.gradeParams, warnings), metadata);
697
710
  const content = typeof out === 'string' ? out : out?.content || out?.drxContent;
698
711
  const warn = warnings.length ? { warnings } : {};
@@ -222,6 +222,7 @@ const PARAM_IDS = {
222
222
  * Corrector Type IDs from shared library
223
223
  */
224
224
  const CORRECTOR_TYPES = drxParams.CORRECTOR_TYPES;
225
+ const POWER_WINDOW_IDS = drxParams.POWER_WINDOWS;
225
226
 
226
227
  /**
227
228
  * Presence marker IDs from shared library
@@ -547,7 +548,7 @@ function buildHSLCurveParams(colorParams) {
547
548
  * @param {Object} options - Additional options (label, enabled, correctorTypes)
548
549
  */
549
550
  function createNode(nodeId, xPos, yPos, colorParams = null, options = {}) {
550
- const { label = '', enabled = true, nodeIndex = nodeId } = options;
551
+ const { label = '', enabled = true, nodeIndex = nodeId, resolveVersion } = options;
551
552
 
552
553
  // Build node header with fields in order: F1, F2, F4, F5, F6, F7?, F8
553
554
  // Native Resolve analysis shows: id(F1), index(F2), pos(F4,F5), label?(F6), F8=44
@@ -749,9 +750,11 @@ function createNode(nodeId, xPos, yPos, colorParams = null, options = {}) {
749
750
  }
750
751
  }
751
752
  if (colorParams) {
752
- const win = buildWindowParams(colorParams.window);
753
+ const win = buildWindowParams(colorParams.window, { resolveVersion });
753
754
  if (win.transform.length > 0) {
754
- correctorBlocks.push(buildCorrectorBlock(CORRECTOR_TYPES.POWER_WINDOW, win.transform));
755
+ correctorBlocks.push(buildCorrectorBlock(win.transformType, win.transform));
756
+ // Resolve 21 also lists its window type in the node's corrector-type list.
757
+ if (win.transformType !== CORRECTOR_TYPES.POWER_WINDOW && presenceTypes.length > 0) presenceTypes.push(win.transformType);
755
758
  }
756
759
  if (win.softMask.length > 0) {
757
760
  // Linear-window softness mask (0x0870xxxx) lives under corrector type 3 in live data.
@@ -881,7 +884,7 @@ function createNode(nodeId, xPos, yPos, colorParams = null, options = {}) {
881
884
 
882
885
  // Add compact presence list as F2 (raw byte array of type IDs)
883
886
  if (presenceTypes.length > 0) {
884
- const presenceBytes = Buffer.from(presenceTypes);
887
+ const presenceBytes = Buffer.concat(presenceTypes.map(encodeVarint)); // varints: the R21 window type is > 127
885
888
  f9Parts.push(protoBytes(2, presenceBytes));
886
889
  debugLog('[DRX] Added F2 presence list:', presenceTypes);
887
890
  }
@@ -1636,6 +1639,7 @@ function generateProtobuf(gradeParams, options = {}) {
1636
1639
  width = 1920,
1637
1640
  height = 1080,
1638
1641
  label = '',
1642
+ resolveVersion,
1639
1643
  } = options;
1640
1644
 
1641
1645
  const timestamp = generateTimestamp();
@@ -1667,6 +1671,7 @@ function generateProtobuf(gradeParams, options = {}) {
1667
1671
  const node = createNode(nodeId, xPos, yPos, nodeConfig.params, {
1668
1672
  label: nodeConfig.label || `Node ${nodeId}`,
1669
1673
  enabled: nodeConfig.enabled !== false,
1674
+ resolveVersion,
1670
1675
  });
1671
1676
  containerParts.push(node);
1672
1677
  });
@@ -1686,6 +1691,7 @@ function generateProtobuf(gradeParams, options = {}) {
1686
1691
  }
1687
1692
  const node = createNode(1, 500, 300, gradeParams, {
1688
1693
  label: label,
1694
+ resolveVersion,
1689
1695
  });
1690
1696
  containerParts.push(node);
1691
1697
  // firstNodeId and lastNodeId both stay 1
@@ -1752,6 +1758,7 @@ async function generateMultiNodeDRX(nodes, connections, metadata = {}) {
1752
1758
  baseNodeId: passedBaseNodeId,
1753
1759
  firstNodeId: passedFirstNodeId,
1754
1760
  lastNodeId: passedLastNodeId,
1761
+ resolveVersion,
1755
1762
  } = metadata;
1756
1763
 
1757
1764
  const timestamp = generateTimestamp();
@@ -1806,6 +1813,7 @@ async function generateMultiNodeDRX(nodes, connections, metadata = {}) {
1806
1813
  enabled: nodeConfig.enabled !== false,
1807
1814
  nodeIndex: nodeIndex, // Pass separate index for F2 field
1808
1815
  clipVersionId,
1816
+ resolveVersion,
1809
1817
  }
1810
1818
  );
1811
1819
  containerParts.push(node);
@@ -1975,13 +1983,14 @@ async function generateDRX(gradeParams, metadata = {}) {
1975
1983
  sourceTC = '00:00:00:00',
1976
1984
  recordTC = '01:00:00:00',
1977
1985
  target = 'clip',
1986
+ resolveVersion,
1978
1987
  } = metadata;
1979
1988
  if (target !== 'clip' && target !== 'timeline') {
1980
1989
  throw new Error(`target must be 'clip' or 'timeline', got ${JSON.stringify(target)}`);
1981
1990
  }
1982
1991
 
1983
1992
  // Generate the body (pass label for single-node mode)
1984
- const body = await generateDRXBody(gradeParams, { width, height, label });
1993
+ const body = await generateDRXBody(gradeParams, { width, height, label, resolveVersion });
1985
1994
  const bodyHex = body.toString('hex');
1986
1995
 
1987
1996
  // Generate UUIDs
@@ -2803,8 +2812,8 @@ function buildLumaQualifierParams(lumaParams) {
2803
2812
  * @param {number} [windowParams.soft4=0] - Softness 4 (linear only)
2804
2813
  * @returns {Array} - Parameter entries for window corrector
2805
2814
  */
2806
- function buildWindowParams(windowParams) {
2807
- const groups = { transform: [], softMask: [], gradient: [], shape: [] };
2815
+ function buildWindowParams(windowParams, { resolveVersion } = {}) {
2816
+ const groups = { transform: [], transformType: CORRECTOR_TYPES.POWER_WINDOW, softMask: [], gradient: [], shape: [] };
2808
2817
  const paramEntries = groups.transform;
2809
2818
  const { POWER_WINDOWS } = drxParams;
2810
2819
 
@@ -2940,12 +2949,78 @@ function buildWindowParams(windowParams) {
2940
2949
  paramEntries.push(createParameterEntry(POWER_WINDOWS.SOFT_REF, w.soft1 * 16));
2941
2950
  }
2942
2951
 
2952
+ if (isResolve21(resolveVersion) && !isVertexShape && paramEntries.length > 0) {
2953
+ groups.transform = toResolve21WindowTransform(paramEntries);
2954
+ groups.transformType = CORRECTOR_TYPE_WINDOW_R21;
2955
+ }
2956
+
2943
2957
  if (paramEntries.length > 0 || groups.softMask.length > 0) {
2944
2958
  debugLog('[DRX] Window params built:', paramEntries.length, '+', groups.softMask.length, isLinear ? '(linear: ct4 + ct3 mask)' : '(circle: ct4)');
2945
2959
  }
2946
2960
  return groups;
2947
2961
  }
2948
2962
 
2963
+ // Resolve 21 stores a circle/linear window transform as corrector type 0x10004 with the full
2964
+ // parameter set, not as corrector type 4 holding only the non-neutral parameters. The type 4
2965
+ // form loads into the Window palette on Resolve 21 but renders an empty mask, which also hides
2966
+ // the grade of the node. Order and neutral values follow what Resolve 21.0.4 exports for a
2967
+ // fresh circle window, as measured and pixel-verified by justhung69 in
2968
+ // https://github.com/justhung69/resolve-mcp (server/drx_r21.py, MIT, (c) 2026 Bùi Xuân Hưng).
2969
+ const CORRECTOR_TYPE_WINDOW_R21 = 0x10004;
2970
+ const R21_WINDOW_ORDER = [
2971
+ [POWER_WINDOW_IDS.ROTATE, 0],
2972
+ [POWER_WINDOW_IDS.ASPECT, 0],
2973
+ [POWER_WINDOW_IDS.SOFT_REF, 0],
2974
+ [POWER_WINDOW_IDS.SIZE, 1],
2975
+ [POWER_WINDOW_IDS.PAN, 0],
2976
+ [POWER_WINDOW_IDS.TILT, 0],
2977
+ ];
2978
+
2979
+ /** True when `resolveVersion` (a major number such as 21, or a string such as "21.0.4") is Resolve 21 or later. */
2980
+ function isResolve21(resolveVersion) {
2981
+ return Number.parseInt(resolveVersion, 10) >= 21;
2982
+ }
2983
+
2984
+ /** Read the parameter id out of an F3 parameter entry built by createParameterEntry. */
2985
+ function parameterEntryId(entry) {
2986
+ let i = 1; // skip the F3 tag
2987
+ while (entry[i++] & 0x80); // skip the entry length
2988
+ i++; // skip the F1 tag
2989
+ let id = 0;
2990
+ let shift = 0;
2991
+ let byte;
2992
+ do {
2993
+ byte = entry[i++];
2994
+ id += (byte & 0x7f) * 2 ** shift;
2995
+ shift += 7;
2996
+ } while (byte & 0x80);
2997
+ return id;
2998
+ }
2999
+
3000
+ function toResolve21WindowTransform(entries) {
3001
+ const byId = new Map(entries.map((entry) => [parameterEntryId(entry), entry]));
3002
+ const take = (id) => {
3003
+ const entry = byId.get(id);
3004
+ byId.delete(id);
3005
+ return entry;
3006
+ };
3007
+ const out = R21_WINDOW_ORDER.map(([id, neutral]) => take(id) || createParameterEntry(id, neutral));
3008
+ take(POWER_WINDOW_IDS.WINDOW_TYPE);
3009
+ const identity = [1, 0, 0, 0, 1, 0, 0, 0, 1];
3010
+ const matrix = protoBytes(3, Buffer.concat([
3011
+ protoVarint(1, POWER_WINDOW_IDS.TRACKING_BLOB),
3012
+ protoBytes(2, protoBytes(10, Buffer.concat(identity.map((v, i) => protoFloat32(i + 1, v))))),
3013
+ ]));
3014
+ out.push(
3015
+ createVarintParameterEntry(POWER_WINDOW_IDS.WINDOW_TYPE, 2),
3016
+ createParameterEntry(POWER_WINDOW_IDS.REF_WIDTH, 288),
3017
+ createParameterEntry(POWER_WINDOW_IDS.UNK_0E, 0),
3018
+ createParameterEntry(POWER_WINDOW_IDS.UNK_0F, 0),
3019
+ take(POWER_WINDOW_IDS.TRACKING_BLOB) || matrix,
3020
+ );
3021
+ return out.concat([...byId.values()]); // e.g. opacity, kept after the measured set
3022
+ }
3023
+
2949
3024
  /**
2950
3025
  * Check if any secondary correction tools are used (qualifier, window, HDR wheels)
2951
3026
  * Used to determine if we need to add those corrector blocks
@@ -92,7 +92,7 @@ if not logging.getLogger().handlers:
92
92
  handlers=[logging.StreamHandler()],
93
93
  )
94
94
 
95
- VERSION = "4.16.0"
95
+ VERSION = "4.17.1"
96
96
  logger = logging.getLogger("davinci-resolve-mcp")
97
97
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
98
98
  logger.info(f"Detected platform: {get_platform()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 377-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "4.16.0"
14
+ VERSION = "4.17.1"
15
15
 
16
16
  import base64
17
17
  import os
@@ -2781,7 +2781,13 @@ def _media_duration_seconds(record: Dict[str, Any], technical: Dict[str, Any]) -
2781
2781
  return None
2782
2782
 
2783
2783
 
2784
- def _ffmpeg_stderr_filter(path: str, video_filter: Optional[str] = None, audio_filter: Optional[str] = None, frames: Optional[int] = None) -> Tuple[int, str]:
2784
+ def _ffmpeg_stderr_filter(
2785
+ path: str,
2786
+ video_filter: Optional[str] = None,
2787
+ audio_filter: Optional[str] = None,
2788
+ frames: Optional[int] = None,
2789
+ timeout: int = COMMAND_TIMEOUT_SECONDS,
2790
+ ) -> Tuple[int, str]:
2785
2791
  args = ["ffmpeg", "-hide_banner", "-nostats", "-i", path]
2786
2792
  if video_filter:
2787
2793
  args.extend(["-vf", video_filter])
@@ -2790,7 +2796,7 @@ def _ffmpeg_stderr_filter(path: str, video_filter: Optional[str] = None, audio_f
2790
2796
  if frames is not None:
2791
2797
  args.extend(["-frames:v", str(frames)])
2792
2798
  args.extend(["-f", "null", "-"])
2793
- code, _, stderr = _run_command(args)
2799
+ code, _, stderr = _run_command(args, timeout=timeout)
2794
2800
  return code, stderr
2795
2801
 
2796
2802
 
@@ -2934,25 +2940,45 @@ def _parse_idet(stderr: str) -> Dict[str, Any]:
2934
2940
  }
2935
2941
 
2936
2942
 
2943
+ # The four whole-file measurements `_readthrough_analysis` makes. They used to be four
2944
+ # ffmpeg runs, and every run decoded the whole file: `-af` alone does not drop the video
2945
+ # stream from a null output, so the loudness and silence passes decoded every frame just
2946
+ # as the scene and black passes did, and each video pass decoded the audio too. One run
2947
+ # with `split`/`asplit` decodes each stream once and feeds all four filters. Each parser
2948
+ # below matches only its own filter's lines, so sharing one stderr changes no result.
2949
+ # A missing stream behaves as it did per pass: ffmpeg skips the -vf or -af it cannot
2950
+ # use, exits 0, and that filter's parser finds nothing.
2951
+ _SCENE_SCORE_FILTER = "select='gt(scene,0)',metadata=print:key=lavfi.scene_score,showinfo"
2952
+ _BLACKDETECT_FILTER = "blackdetect=d=0.5:pix_th=0.10"
2953
+ _LOUDNESS_FILTER = "ebur128=peak=true"
2954
+ _SILENCEDETECT_FILTER = "silencedetect=noise=-50dB:d=1"
2955
+ _READTHROUGH_VIDEO_FILTER = f"split[scene][black];[scene]{_SCENE_SCORE_FILTER},nullsink;[black]{_BLACKDETECT_FILTER}"
2956
+ _READTHROUGH_AUDIO_FILTER = f"asplit[loud][silence];[loud]{_LOUDNESS_FILTER},anullsink;[silence]{_SILENCEDETECT_FILTER}"
2957
+
2958
+
2937
2959
  def _readthrough_analysis(path: str) -> Dict[str, Any]:
2938
2960
  result: Dict[str, Any] = {"success": True}
2939
2961
 
2940
- loud_code, loud_stderr = _ffmpeg_stderr_filter(path, audio_filter="ebur128=peak=true")
2962
+ # The one run carries the timeout budget of the four it replaces, so a file whose
2963
+ # slowest pass (scene scoring) finished in time before still finishes in time.
2964
+ code, stderr = _ffmpeg_stderr_filter(
2965
+ path,
2966
+ video_filter=_READTHROUGH_VIDEO_FILTER,
2967
+ audio_filter=_READTHROUGH_AUDIO_FILTER,
2968
+ timeout=COMMAND_TIMEOUT_SECONDS * 4,
2969
+ )
2970
+
2941
2971
  result["loudness"] = {
2942
- "success": loud_code == 0,
2943
- "metrics": _parse_loudness(loud_stderr),
2972
+ "success": code == 0,
2973
+ "metrics": _parse_loudness(stderr),
2944
2974
  }
2945
2975
 
2946
- # Adaptive scene detection. One ffmpeg pass dumps a (pts_time, scene_score) pair
2976
+ # Adaptive scene detection. The scene branch dumps a (pts_time, scene_score) pair
2947
2977
  # for every frame whose scene score is > 0 (i.e. every non-first frame); we then
2948
2978
  # compute a content-aware threshold from that distribution and keep peaks above it.
2949
2979
  # Replaces the legacy hardcoded ``gt(scene,0.3)``, which was too coarse on
2950
2980
  # high-motion content (missed real cuts) and too sensitive on locked-off content.
2951
- scene_code, scene_stderr = _ffmpeg_stderr_filter(
2952
- path,
2953
- video_filter="select='gt(scene,0)',metadata=print:key=lavfi.scene_score,showinfo",
2954
- )
2955
- scene_score_pairs = _parse_scene_score_pairs(scene_stderr)
2981
+ scene_score_pairs = _parse_scene_score_pairs(stderr)
2956
2982
  scene_threshold, scene_threshold_stats = _adaptive_scene_threshold(scene_score_pairs)
2957
2983
  adaptive_scene_items = [
2958
2984
  {"time_seconds": pts, "score": score}
@@ -2960,22 +2986,20 @@ def _readthrough_analysis(path: str) -> Dict[str, Any]:
2960
2986
  if score is not None and score > scene_threshold
2961
2987
  ]
2962
2988
  result["scenes"] = {
2963
- "success": scene_code == 0,
2989
+ "success": code == 0,
2964
2990
  "items": adaptive_scene_items,
2965
2991
  "threshold": scene_threshold,
2966
2992
  "threshold_stats": scene_threshold_stats,
2967
2993
  }
2968
2994
 
2969
- black_code, black_stderr = _ffmpeg_stderr_filter(path, video_filter="blackdetect=d=0.5:pix_th=0.10")
2970
2995
  result["black_frames"] = {
2971
- "success": black_code == 0,
2972
- "items": _parse_blackdetect(black_stderr),
2996
+ "success": code == 0,
2997
+ "items": _parse_blackdetect(stderr),
2973
2998
  }
2974
2999
 
2975
- silence_code, silence_stderr = _ffmpeg_stderr_filter(path, audio_filter="silencedetect=noise=-50dB:d=1")
2976
3000
  result["silence"] = {
2977
- "success": silence_code == 0,
2978
- "items": _parse_silencedetect(silence_stderr),
3001
+ "success": code == 0,
3002
+ "items": _parse_silencedetect(stderr),
2979
3003
  }
2980
3004
 
2981
3005
  idet_code, idet_stderr = _ffmpeg_stderr_filter(path, video_filter="idet", frames=500)