davinci-resolve-mcp 2.100.0 → 2.102.0

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,126 @@
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.102.0
6
+
7
+ **A rough mix that reports what it achieved, not what it intended.** The pieces were
8
+ already here — `media_analysis` measures EBU R128 loudness and detects silence,
9
+ `delivery_targets` holds the standards, `loudness_qc` grades a finished file. What was
10
+ missing is the step between measuring and grading: deciding the gains.
11
+
12
+ ### Added
13
+
14
+ - **`media_analysis mix_plan`** — dialogue-normalisation gain, a music-bed level relative
15
+ to it, and ducking windows derived from silence detection **on the dialogue stem**, so
16
+ the bed follows the words rather than a hand-placed envelope. `dry_run` defaults to
17
+ true and renders nothing.
18
+ - **The achieved loudness is measured, not derived.** The premix is rendered, then
19
+ re-measured; `achieved` carries integrated LUFS, true peak, loudness range, and the
20
+ delta from target. A plan that hits its target on paper and clips on true peak is a
21
+ failed plan, and only the measurement tells you which one you have.
22
+ - **Dialogue-anchored, then programme-trimmed.** Anchoring dialogue at target is right
23
+ for a dialogue-gated standard and wrong for a full-programme one the moment a bed is
24
+ added. For non-dialogue-gated standards one measured trim is applied to everything
25
+ equally — preserving the dialogue-to-bed relationship — and reported as
26
+ `program_normalize.trim_db`. It never runs on a dialogue-gated standard, where
27
+ dialogue is the figure being graded.
28
+ - **Nothing else is corrected.** `loudness_off_target`, `true_peak_over`, and `clipped`
29
+ come back as flags with remedies, never as a quietly normalised file.
30
+ - Standards come from `delivery_targets` (`web`, `podcast`, `ebu_r128`, `atsc_a85`,
31
+ `ott_dialogue_gated`) — the table the delivery tools already grade against, not a
32
+ second copy.
33
+ - **`media_analysis measure_loudness`** — integrated LUFS, loudness range, and true peak
34
+ per file.
35
+ - **`media_analysis mix_plan_capabilities`** — dependency state, known standards, and the
36
+ defaults, including the music-bed offset, which is the number most likely to be argued
37
+ with and so is named rather than buried.
38
+
39
+ ### Fixed while building it
40
+
41
+ The new loudness parser reads the `Summary:` block **and** drops ebur128's per-frame
42
+ progress lines, which carry their own `I:` and `LRA:` fields. A plain last-match-wins
43
+ parse is correct only because ffmpeg happens to print the summary last, and scoping to
44
+ the summary alone still swallows a progress line printed after it. Both steps are needed;
45
+ a test with a trailing progress line pins it.
46
+
47
+ ### Scope
48
+
49
+ A rough mix: gain staging, a bed, and ducking. No EQ, compression, de-essing, or
50
+ limiting, and the module says so in its capabilities rather than leaving it implied.
51
+
52
+ ### Validation
53
+
54
+ - Offline suite: 2924 passed, 1 skipped, 711 subtests, 0 failures.
55
+ - End-to-end through real ffmpeg on generated tones: target hit from measurement, the
56
+ programme trim landing a hot bed on R128, a dialogue-gated standard refusing the trim,
57
+ and clipping reported rather than normalised away.
58
+ - Three deliberate mutations (silent peak normalisation, trimming a dialogue-gated
59
+ standard, and dropping the parser scoping) were each caught. The parser mutation was
60
+ caught only after the test was strengthened — the first version of it passed against
61
+ both the fix and its absence.
62
+ - No Resolve behavior changed; live test not required.
63
+
64
+ ## What's New in v2.101.0
65
+
66
+ **A grade can now reject itself.** `assess_grade` has measured grade damage since
67
+ v2.68.0 — banding in a sky, highlight levels collapsing, shadow grain amplified into
68
+ noise — and every flag it raises carries a remedy. Nothing consumed that report. The
69
+ measurement existed; the loop did not, so the remedy "reduce the strength" was advice an
70
+ agent had no way to act on.
71
+
72
+ ### Added
73
+
74
+ - **`media_analysis grade_loop`** — the retry ladder. Applies a look LUT, measures the
75
+ real decoded frame, and on any flag retries with the same look attenuated toward
76
+ identity (strength x 0.8 per rung, floored at 0.5, three tries by default). The first
77
+ strength that clears every sampled frame wins.
78
+ - **A flagged result is never reported acceptable.** An exhausted ladder returns
79
+ `needs_human` with the best attempt and its remaining flags — never a quiet success
80
+ at a strength that still bands.
81
+ - **Every sampled frame must pass.** `times=[...]` samples several timestamps and the
82
+ report names the one that failed; a grade clean on the frame you happened to check
83
+ is not a grade that passed.
84
+ - **The best attempt is the gentlest.** When nothing converges, attempts rank by flag
85
+ count with ties broken by the smallest colour shift — equal damage means taking the
86
+ one a human has less to undo.
87
+ - **It does not touch the project.** The result is an apply manifest with
88
+ `safe_to_apply`, and a flagged result carries the reason it is blocked.
89
+ - `dry_run` defaults to true and reports the ffmpeg decode budget before anyone
90
+ commits to it. `cost_tier` defaults to `numeric`, because escalating every rung to
91
+ vision would spend host turns on attempts that exist to be rejected.
92
+ - **`media_analysis grade_loop_capabilities`** — dependency state, ladder constants, and
93
+ an explicit statement of which modes exist.
94
+ - **`src/utils/cube_lut.py`** — read, write, and attenuate 3D `.cube` LUTs. Attenuation
95
+ is a blend toward identity, the same operation a LUT mix control performs. Exact at
96
+ both endpoints: strength 1.0 returns the table unchanged and 0.0 returns true
97
+ identity. 1D LUTs are refused by name, and attenuation on a non-unit
98
+ `DOMAIN_MIN`/`DOMAIN_MAX` is refused because identity is only identity on 0..1.
99
+
100
+ ### Not built, and said so
101
+
102
+ The in-loop **live** mode — apply in Resolve, render a frame, assess, repeat — is not
103
+ implemented. It needs a single-frame render per rung, and shipping it unvalidated would
104
+ put a "verified live" claim behind something no runnable command has produced.
105
+ `grade_loop_capabilities()` says this in the response rather than only in the docs. The
106
+ offline LUT ladder is complete and validated.
107
+
108
+ ### Documentation
109
+
110
+ - `docs/guides/color-decision-guide.md` — a new "Rejecting Your Own Grade" section on
111
+ when measurement beats eyeballing a compressed preview.
112
+ - `docs/kernels/color-grade-kernel.md` — the numeric grade-QC actions and their
113
+ display-referred-only contract.
114
+
115
+ ### Validation
116
+
117
+ - Offline suite: 2889 passed, 1 skipped, 711 subtests, 0 failures.
118
+ - End-to-end through real ffmpeg on generated media: a look that converges only after
119
+ backing off, and one that never converges and says so.
120
+ - Two deliberate mutations — `acceptable` hard-coded true, and a rung passing on its
121
+ first clean frame — were each caught by the new tests.
122
+ - No Resolve behavior changed; live test not required. A test asserts no Resolve
123
+ connection is attempted.
124
+
5
125
  ## What's New in v2.100.0
6
126
 
7
127
  **The craft guidance is now readable by any MCP client.** This repository carries a
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.100.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.102.0-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-36%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.100.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.102.0-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-36%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.100.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.102.0 版 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
@@ -552,6 +552,13 @@ Key actions:
552
552
  `load_user_preferences_preset`
553
553
  - `quit` — terminates Resolve (destructive; confirm with user first)
554
554
 
555
+ **Offline audio and image QC on `media_analysis`** — no Resolve connection required:
556
+ `measure_loudness`, `mix_plan` / `mix_plan_capabilities` (dialogue-anchored rough mix
557
+ with dialogue-following ducking, rendered and re-measured), and `assess_grade` /
558
+ `grade_loop` / `grade_loop_capabilities` (numeric grade-damage QC and the retry ladder
559
+ that backs a look off until it stops damaging the picture). See
560
+ `docs/kernels/audio-fairlight-kernel.md` and `docs/kernels/color-grade-kernel.md`.
561
+
555
562
  **`layout_presets`** — Save, load, export, import, delete UI layout presets.
556
563
  `list` (Resolve 21.0.4+) enumerates the saved preset names the other actions
557
564
  take.
@@ -211,6 +211,36 @@ If untreated/current/after comparison is not available through the API in the
211
211
  moment, say which part is unavailable and whether the user wants a blind/global
212
212
  pass. Do not imply that a grade was reviewed if no rendered frames were checked.
213
213
 
214
+ ## Rejecting Your Own Grade
215
+
216
+ Looking at frames tells you whether a grade reads right. It does not reliably tell you
217
+ whether it *damaged* the picture — banding in a sky, highlight levels collapsing, shadow
218
+ grain amplified into visible noise. Those are measurable, and measuring them is cheaper
219
+ and steadier than eyeballing a compressed preview.
220
+
221
+ `media_analysis assess_grade` measures one graded frame against its source and returns
222
+ flags with remedies. `media_analysis grade_loop` closes the loop: it applies a look LUT,
223
+ measures the real decoded result, and on any flag retries with the same look attenuated
224
+ toward identity (strength × 0.8 per rung, floored at 0.5, three tries by default). The
225
+ first strength that clears every sampled frame wins.
226
+
227
+ Three properties matter more than the convenience:
228
+
229
+ - **A flagged result is never reported acceptable.** When the ladder is exhausted the
230
+ answer is `needs_human`, carrying the best attempt and what it still fails on. There
231
+ is no strength at which the loop shrugs and ships.
232
+ - **Every sampled frame must pass.** Pass several timestamps — a grade that is clean on
233
+ the frame you happened to check and bands two hundred frames later has not passed
234
+ anything. The report names the frame that failed.
235
+ - **It does not touch the project.** The loop returns an apply manifest and stops.
236
+ Applying a grade is still a deliberate, version-protected step, and a result carrying
237
+ unresolved flags should reach a human before it reaches a timeline.
238
+
239
+ Reach for it when applying an unfamiliar look LUT to unfamiliar footage, which is
240
+ exactly where "it looked fine on the first shot" goes wrong. The flags are advisory
241
+ thresholds, not standards — the raw measurements come back too, so a colorist can
242
+ disagree with the flag rather than only with the verdict.
243
+
214
244
  ## Safe Color Workflow
215
245
 
216
246
  Before changing color:
@@ -53,6 +53,39 @@ All kernel actions are exposed through `timeline`.
53
53
  - Subtitle generation from the generated timeline returned true.
54
54
  - Fairlight preset listing and the full boundary report worked.
55
55
 
56
+ ## Rough mix (`media_analysis`)
57
+
58
+ Offline and file-based — no Resolve connection — sitting between measuring loudness and
59
+ grading it against a spec. The step nothing else covered was deciding the gains.
60
+
61
+ - `measure_loudness(path|paths)` — integrated LUFS, loudness range, and true peak per
62
+ file via ffmpeg `ebur128`.
63
+ - `mix_plan(dialogue[], music?, sfx?, standard?, target_lufs?, bed_offset_lu?, duck_db?,
64
+ attack_s?, release_s?, hold_s?, dry_run?, output_path?, program_normalize?)` —
65
+ dialogue-normalisation gain, a bed level relative to it, and ducking windows derived
66
+ from silence detection **on the dialogue stem**, so the bed follows the words rather
67
+ than a hand-placed envelope. `dry_run` defaults to true and renders nothing.
68
+ - `mix_plan_capabilities()` — dependency state, the standards it knows, and the defaults.
69
+
70
+ Three things worth knowing before using it:
71
+
72
+ - **The achieved loudness is measured, not derived.** The premix is rendered, then
73
+ re-measured, and `achieved` carries what came back. A plan that hits its target on
74
+ paper and clips on true peak is a failed plan; only the measurement distinguishes them.
75
+ - **Dialogue-anchored, then programme-trimmed.** Anchoring dialogue at target is right
76
+ for a dialogue-gated standard and wrong for a full-programme one the moment a bed is
77
+ added. For non-dialogue-gated standards a single measured trim is applied to everything
78
+ equally — preserving the dialogue-to-bed relationship — and reported as
79
+ `program_normalize.trim_db`. It never runs on a dialogue-gated standard.
80
+ - **Nothing else is corrected.** Over true peak or clipped comes back as a flag with a
81
+ remedy, never as a quietly normalised file. Pulling the mix down to fix a peak would
82
+ move the loudness off the target it just hit.
83
+
84
+ Standards come from `src/utils/delivery_targets.py` (`web`, `podcast`, `ebu_r128`,
85
+ `atsc_a85`, `ott_dialogue_gated`) — the same table the delivery tools grade against, not
86
+ a second copy. It is a rough mix: gain staging, a bed, and ducking. No EQ, compression,
87
+ de-essing, or limiting.
88
+
56
89
  ## Boundaries
57
90
 
58
91
  - Timeline item audio properties may be readable as `None` and can reject writes
@@ -67,6 +67,28 @@ All actions are exposed through `timeline_item_color`.
67
67
  and delete worked.
68
68
  - Gallery capability and album list/create calls worked.
69
69
 
70
+ ## Numeric grade QC (`media_analysis`)
71
+
72
+ Not part of the live kernel — no Resolve connection is involved — but it belongs to the
73
+ same decision. Both actions measure a decoded frame of the real result, never a
74
+ simulated transform, because LUT interpolation and encode rounding are where banding is
75
+ actually introduced.
76
+
77
+ - `assess_grade(source_path, time_seconds, graded_path|lut_path, working_space)` —
78
+ flags (`flat`, `washed_out`, `milky`, `noisy`, `clipped`, `posterized`, `banding`),
79
+ each with a remedy, plus the raw tonal/noise/damage measurements.
80
+ - `grade_loop(source_path, lut_path, times[], strength?, max_tries?, strength_floor?,
81
+ dry_run?)` — the retry ladder over `assess_grade`. Attenuates the look toward identity
82
+ until every sampled frame clears, or returns `needs_human` with the best attempt.
83
+ `dry_run` defaults to **true** and reports the ffmpeg decode budget first.
84
+ - `grade_loop_capabilities()` — dependency state, ladder constants, and which modes
85
+ exist. The in-loop **live** mode (apply in Resolve, render, assess) is **not built**;
86
+ the loop returns an apply manifest instead of driving the project.
87
+
88
+ Both are display-referred only. Log and scene-referred encodings run through the same
89
+ arithmetic happily and produce meaningless numbers, so `working_space` must be declared
90
+ and non-display-referred values are refused rather than guessed at.
91
+
70
92
  ## Boundaries
71
93
 
72
94
  - Node graph internals are intentionally limited by Resolve's public API. The
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.100.0"
40
+ VERSION = "2.102.0"
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.100.0",
3
+ "version": "2.102.0",
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.100.0"
90
+ VERSION = "2.102.0"
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.100.0"
14
+ VERSION = "2.102.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -20076,6 +20076,104 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
20076
20076
  "metrics are undefined on them and will not be guessed at."
20077
20077
  ),
20078
20078
  )
20079
+ if action == "grade_loop":
20080
+ # The retry ladder that consumes assess_grade's own verdict: apply the look,
20081
+ # measure the real decoded frame, and on any flag retry with the look
20082
+ # attenuated toward identity. Exhausting the ladder returns needs_human with
20083
+ # the best attempt — never a quiet success at a strength that still bands.
20084
+ from src.utils import grade_loop as _grade_loop_mod
20085
+
20086
+ source = str(p.get("source_path") or p.get("sourcePath") or "")
20087
+ lut = str(p.get("lut_path") or p.get("lutPath") or "")
20088
+ err, _clean = _validate_params(
20089
+ {"source_path": source, "lut_path": lut},
20090
+ {
20091
+ "source_path": {"type": str, "required": True, "non_empty": True},
20092
+ "lut_path": {"type": str, "required": True, "non_empty": True},
20093
+ },
20094
+ )
20095
+ if err:
20096
+ return _err(err)
20097
+ kwargs = dict(
20098
+ times=p.get("times"),
20099
+ time_seconds=p.get("time_seconds", p.get("timeSeconds")),
20100
+ strength=float(p.get("strength", 1.0) or 1.0),
20101
+ max_tries=int(p.get("max_tries", p.get("maxTries", _grade_loop_mod.DEFAULT_MAX_TRIES))),
20102
+ strength_floor=float(
20103
+ p.get("strength_floor", p.get("strengthFloor", _grade_loop_mod.DEFAULT_STRENGTH_FLOOR))
20104
+ ),
20105
+ working_space=str(p.get("working_space") or p.get("workingSpace") or "rec709"),
20106
+ cost_tier=str(p.get("cost_tier") or p.get("costTier") or _grade_loop_mod.DEFAULT_COST_TIER),
20107
+ )
20108
+ try:
20109
+ # Dry run by default: the ladder can spend a dozen ffmpeg decodes per clip,
20110
+ # and the plan names that budget before anyone commits to it.
20111
+ if p.get("dry_run", True):
20112
+ return _ok(**_grade_loop_mod.plan(source, lut, **kwargs))
20113
+ return _ok(**_grade_loop_mod.run(
20114
+ source, lut,
20115
+ output_dir=(p.get("output_dir") or p.get("outputDir")) or None,
20116
+ **kwargs,
20117
+ ))
20118
+ except (_grade_loop_mod.GradeLoopError, _grade_loop_mod.cube_lut.CubeLutError) as exc:
20119
+ return _err(str(exc), code="GRADE_LOOP_REFUSED", category="invalid_input",
20120
+ remediation=(
20121
+ "Supply an existing source_path and a 3D .cube lut_path, plus "
20122
+ "times=[seconds,...] to sample. A grade clean on one frame is "
20123
+ "not a grade that passed."
20124
+ ))
20125
+ except _grade_loop_mod.image_qc.ImageQcError as exc:
20126
+ return _err(str(exc), code="IMAGE_QC_REFUSED", category="invalid_input")
20127
+ if action == "grade_loop_capabilities":
20128
+ from src.utils import grade_loop as _grade_loop_mod
20129
+
20130
+ return _ok(**_grade_loop_mod.capabilities())
20131
+ if action in {"mix_plan", "measure_loudness", "mix_plan_capabilities"}:
20132
+ # Gain staging between measuring loudness and grading it: dialogue-norm gain,
20133
+ # a bed level relative to it, and ducking windows the dialogue itself implies.
20134
+ # The render is measured afterwards, so what comes back is the loudness
20135
+ # achieved rather than the arithmetic meant to produce it.
20136
+ from src.utils import mix_plan as _mix_plan_mod
20137
+
20138
+ if action == "mix_plan_capabilities":
20139
+ return _ok(**_mix_plan_mod.capabilities())
20140
+ try:
20141
+ if action == "measure_loudness":
20142
+ paths = p.get("paths") or ([p["path"]] if p.get("path") else [])
20143
+ if not paths:
20144
+ return _err("measure_loudness requires path or paths")
20145
+ return _ok(measurements=[_mix_plan_mod.measure(str(item)) for item in paths])
20146
+
20147
+ dialogue = p.get("dialogue") or ([p["path"]] if p.get("path") else [])
20148
+ if isinstance(dialogue, str):
20149
+ dialogue = [dialogue]
20150
+ kwargs = dict(
20151
+ music=p.get("music") or [],
20152
+ sfx=p.get("sfx") or [],
20153
+ standard=str(p.get("standard") or _mix_plan_mod.DEFAULT_STANDARD),
20154
+ target_lufs=(
20155
+ float(p["target_lufs"]) if p.get("target_lufs") is not None else None
20156
+ ),
20157
+ bed_offset_lu=float(p.get("bed_offset_lu", _mix_plan_mod.DEFAULT_BED_OFFSET_LU)),
20158
+ duck_db=float(p.get("duck_db", _mix_plan_mod.DEFAULT_DUCK_DB)),
20159
+ attack_s=float(p.get("attack_s", _mix_plan_mod.DEFAULT_ATTACK_S)),
20160
+ release_s=float(p.get("release_s", _mix_plan_mod.DEFAULT_RELEASE_S)),
20161
+ hold_s=float(p.get("hold_s", _mix_plan_mod.DEFAULT_HOLD_S)),
20162
+ )
20163
+ if p.get("dry_run", True):
20164
+ return _ok(**_mix_plan_mod.plan(dialogue, **kwargs))
20165
+ return _ok(**_mix_plan_mod.render(
20166
+ dialogue,
20167
+ output_path=(p.get("output_path") or p.get("outputPath")) or None,
20168
+ program_normalize=p.get("program_normalize"),
20169
+ **kwargs,
20170
+ ))
20171
+ except _mix_plan_mod.MixPlanError as exc:
20172
+ return _err(str(exc), code="MIX_PLAN_REFUSED", category="invalid_input",
20173
+ remediation=(
20174
+ "Supply dialogue=[...] stems that carry audio. The mix is "
20175
+ "anchored to dialogue, so it cannot be planned without one."
20176
+ ))
20079
20177
  if action == "image_qc_capabilities":
20080
20178
  from src.utils import image_qc as _image_qc_mod
20081
20179
 
@@ -21028,6 +21126,11 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
21028
21126
  "capabilities",
21029
21127
  "recheck_capabilities",
21030
21128
  "assess_grade",
21129
+ "grade_loop",
21130
+ "grade_loop_capabilities",
21131
+ "mix_plan",
21132
+ "mix_plan_capabilities",
21133
+ "measure_loudness",
21031
21134
  "image_qc_capabilities",
21032
21135
  "install_guidance",
21033
21136
  "resolve_output_root",