davinci-resolve-mcp 2.100.0 → 2.101.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,67 @@
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.101.0
6
+
7
+ **A grade can now reject itself.** `assess_grade` has measured grade damage since
8
+ v2.68.0 — banding in a sky, highlight levels collapsing, shadow grain amplified into
9
+ noise — and every flag it raises carries a remedy. Nothing consumed that report. The
10
+ measurement existed; the loop did not, so the remedy "reduce the strength" was advice an
11
+ agent had no way to act on.
12
+
13
+ ### Added
14
+
15
+ - **`media_analysis grade_loop`** — the retry ladder. Applies a look LUT, measures the
16
+ real decoded frame, and on any flag retries with the same look attenuated toward
17
+ identity (strength x 0.8 per rung, floored at 0.5, three tries by default). The first
18
+ strength that clears every sampled frame wins.
19
+ - **A flagged result is never reported acceptable.** An exhausted ladder returns
20
+ `needs_human` with the best attempt and its remaining flags — never a quiet success
21
+ at a strength that still bands.
22
+ - **Every sampled frame must pass.** `times=[...]` samples several timestamps and the
23
+ report names the one that failed; a grade clean on the frame you happened to check
24
+ is not a grade that passed.
25
+ - **The best attempt is the gentlest.** When nothing converges, attempts rank by flag
26
+ count with ties broken by the smallest colour shift — equal damage means taking the
27
+ one a human has less to undo.
28
+ - **It does not touch the project.** The result is an apply manifest with
29
+ `safe_to_apply`, and a flagged result carries the reason it is blocked.
30
+ - `dry_run` defaults to true and reports the ffmpeg decode budget before anyone
31
+ commits to it. `cost_tier` defaults to `numeric`, because escalating every rung to
32
+ vision would spend host turns on attempts that exist to be rejected.
33
+ - **`media_analysis grade_loop_capabilities`** — dependency state, ladder constants, and
34
+ an explicit statement of which modes exist.
35
+ - **`src/utils/cube_lut.py`** — read, write, and attenuate 3D `.cube` LUTs. Attenuation
36
+ is a blend toward identity, the same operation a LUT mix control performs. Exact at
37
+ both endpoints: strength 1.0 returns the table unchanged and 0.0 returns true
38
+ identity. 1D LUTs are refused by name, and attenuation on a non-unit
39
+ `DOMAIN_MIN`/`DOMAIN_MAX` is refused because identity is only identity on 0..1.
40
+
41
+ ### Not built, and said so
42
+
43
+ The in-loop **live** mode — apply in Resolve, render a frame, assess, repeat — is not
44
+ implemented. It needs a single-frame render per rung, and shipping it unvalidated would
45
+ put a "verified live" claim behind something no runnable command has produced.
46
+ `grade_loop_capabilities()` says this in the response rather than only in the docs. The
47
+ offline LUT ladder is complete and validated.
48
+
49
+ ### Documentation
50
+
51
+ - `docs/guides/color-decision-guide.md` — a new "Rejecting Your Own Grade" section on
52
+ when measurement beats eyeballing a compressed preview.
53
+ - `docs/kernels/color-grade-kernel.md` — the numeric grade-QC actions and their
54
+ display-referred-only contract.
55
+
56
+ ### Validation
57
+
58
+ - Offline suite: 2889 passed, 1 skipped, 711 subtests, 0 failures.
59
+ - End-to-end through real ffmpeg on generated media: a look that converges only after
60
+ backing off, and one that never converges and says so.
61
+ - Two deliberate mutations — `acceptable` hard-coded true, and a rung passing on its
62
+ first clean frame — were each caught by the new tests.
63
+ - No Resolve behavior changed; live test not required. A test asserts no Resolve
64
+ connection is attempted.
65
+
5
66
  ## What's New in v2.100.0
6
67
 
7
68
  **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.101.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.101.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.101.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -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:
@@ -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.101.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.101.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.101.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.101.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -20076,6 +20076,58 @@ 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())
20079
20131
  if action == "image_qc_capabilities":
20080
20132
  from src.utils import image_qc as _image_qc_mod
20081
20133
 
@@ -21028,6 +21080,8 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
21028
21080
  "capabilities",
21029
21081
  "recheck_capabilities",
21030
21082
  "assess_grade",
21083
+ "grade_loop",
21084
+ "grade_loop_capabilities",
21031
21085
  "image_qc_capabilities",
21032
21086
  "install_guidance",
21033
21087
  "resolve_output_root",
@@ -0,0 +1,270 @@
1
+ """Read, write, and attenuate Iridas/Resolve `.cube` 3D LUTs.
2
+
3
+ Exists to serve one operation the grade loop needs and nothing else has: producing a
4
+ *weaker version of the same look*. When numeric image QC reports that a grade bands the
5
+ sky or crunches the highlights, the remedy it prints is "reduce the strength" — and
6
+ without a way to actually build the reduced LUT, that remedy is advice an agent cannot
7
+ take.
8
+
9
+ ## Attenuation is a blend toward identity
10
+
11
+ `blend_toward_identity(table, size, s)` returns `(1 - s)·identity + s·table`, which is
12
+ the same thing a mix/strength control on a LUT node does. The blend happens in the LUT's
13
+ own output encoding, not in a perceptual space: a look LUT's entries *are* output values,
14
+ and re-encoding them to blend would change the look at s = 1, where the caller asked for
15
+ no change at all. The identity endpoint is exact by construction, so s = 0 is a genuine
16
+ no-op rather than an almost-no-op.
17
+
18
+ ## What this refuses
19
+
20
+ 1D LUTs (`LUT_1D_SIZE`) parse as a valid `.cube` and are not interchangeable with a 3D
21
+ table; they are refused by name rather than silently reshaped. A `DOMAIN_MIN`/`DOMAIN_MAX`
22
+ other than 0..1 is carried through untouched, and blending is refused on mismatched
23
+ domains, because interpolating between two different input ranges produces a table that
24
+ is wrong everywhere without ever looking malformed.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ import re
31
+ from typing import Any, Dict, Optional, Tuple
32
+
33
+ try:
34
+ import numpy as _np
35
+ except ImportError: # pragma: no cover - guarded by capabilities()
36
+ _np = None # type: ignore
37
+
38
+ #: Every entry point that touches an array calls `_require_numpy()` first, so the
39
+ #: arithmetic below treats `_np` as present. Declared machine-readably rather than left
40
+ #: implied — see tests/test_optional_dependency_guards.py.
41
+ _OPTIONAL_DEPENDENCY_CONTRACT = (
42
+ "numpy: every array entry point calls _require_numpy() first; internals assume it is present"
43
+ )
44
+
45
+ MIN_SIZE = 2
46
+ MAX_SIZE = 256
47
+ DEFAULT_WRITE_SIZE = 33
48
+
49
+
50
+ class CubeLutError(Exception):
51
+ """A `.cube` file that cannot be read, or an operation that cannot be honest."""
52
+
53
+
54
+ def _require_numpy() -> None:
55
+ if _np is None:
56
+ raise CubeLutError("numpy is required for LUT arithmetic (pip install numpy)")
57
+
58
+
59
+ def read_cube(path: str) -> Dict[str, Any]:
60
+ """Parse a 3D `.cube` file into `{size, table, domain_min, domain_max, title}`.
61
+
62
+ `table` is an (N**3, 3) float array in the file's own order: red varies fastest,
63
+ then green, then blue. That ordering is the format's, and it is preserved rather
64
+ than normalised so a write-back round-trips byte-for-byte in value terms.
65
+ """
66
+ _require_numpy()
67
+ if not os.path.isfile(path):
68
+ raise CubeLutError(f"LUT not found: {path}")
69
+
70
+ size: Optional[int] = None
71
+ title = ""
72
+ domain_min = [0.0, 0.0, 0.0]
73
+ domain_max = [1.0, 1.0, 1.0]
74
+ rows = []
75
+
76
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
77
+ for line_number, raw in enumerate(handle, 1):
78
+ line = raw.strip()
79
+ if not line or line.startswith("#"):
80
+ continue
81
+ upper = line.upper()
82
+ if upper.startswith("LUT_1D_SIZE"):
83
+ raise CubeLutError(
84
+ f"{path} is a 1D LUT. A 1D curve and a 3D cube are not "
85
+ "interchangeable; supply a 3D .cube."
86
+ )
87
+ if upper.startswith("LUT_3D_SIZE"):
88
+ try:
89
+ size = int(line.split()[1])
90
+ except (IndexError, ValueError):
91
+ raise CubeLutError(f"{path}:{line_number}: malformed LUT_3D_SIZE")
92
+ if not MIN_SIZE <= size <= MAX_SIZE:
93
+ raise CubeLutError(
94
+ f"{path}: LUT_3D_SIZE {size} outside the supported range "
95
+ f"{MIN_SIZE}-{MAX_SIZE}"
96
+ )
97
+ continue
98
+ if upper.startswith("TITLE"):
99
+ match = re.match(r'TITLE\s+"?(.*?)"?\s*$', line, re.I)
100
+ title = match.group(1) if match else ""
101
+ continue
102
+ if upper.startswith("DOMAIN_MIN"):
103
+ domain_min = [float(value) for value in line.split()[1:4]]
104
+ continue
105
+ if upper.startswith("DOMAIN_MAX"):
106
+ domain_max = [float(value) for value in line.split()[1:4]]
107
+ continue
108
+ parts = line.split()
109
+ if len(parts) != 3:
110
+ raise CubeLutError(f"{path}:{line_number}: expected 3 values, got {len(parts)}")
111
+ try:
112
+ rows.append([float(value) for value in parts])
113
+ except ValueError:
114
+ raise CubeLutError(f"{path}:{line_number}: non-numeric entry '{line}'")
115
+
116
+ if size is None:
117
+ raise CubeLutError(f"{path}: no LUT_3D_SIZE — not a 3D .cube")
118
+ expected = size ** 3
119
+ if len(rows) != expected:
120
+ raise CubeLutError(
121
+ f"{path}: LUT_3D_SIZE {size} needs {expected} entries, found {len(rows)}"
122
+ )
123
+ return {
124
+ "size": size,
125
+ "table": _np.asarray(rows, dtype=_np.float64),
126
+ "domain_min": domain_min,
127
+ "domain_max": domain_max,
128
+ "title": title,
129
+ "path": path,
130
+ }
131
+
132
+
133
+ def write_cube(
134
+ path: str,
135
+ table: "Any",
136
+ size: int,
137
+ *,
138
+ title: str = "",
139
+ domain_min: Optional[list] = None,
140
+ domain_max: Optional[list] = None,
141
+ precision: int = 6,
142
+ ) -> str:
143
+ """Write a 3D `.cube`. Returns the path written."""
144
+ _require_numpy()
145
+ array = _np.asarray(table, dtype=_np.float64)
146
+ if array.shape != (size ** 3, 3):
147
+ raise CubeLutError(
148
+ f"table shape {array.shape} does not match LUT_3D_SIZE {size} "
149
+ f"(expected {(size ** 3, 3)})"
150
+ )
151
+ directory = os.path.dirname(os.path.abspath(path))
152
+ if directory:
153
+ os.makedirs(directory, exist_ok=True)
154
+ lines = []
155
+ if title:
156
+ lines.append(f'TITLE "{title}"')
157
+ lines.append(f"LUT_3D_SIZE {size}")
158
+ if domain_min and list(domain_min) != [0.0, 0.0, 0.0]:
159
+ lines.append("DOMAIN_MIN " + " ".join(f"{value:.6f}" for value in domain_min))
160
+ if domain_max and list(domain_max) != [1.0, 1.0, 1.0]:
161
+ lines.append("DOMAIN_MAX " + " ".join(f"{value:.6f}" for value in domain_max))
162
+ lines.append("")
163
+ for row in array:
164
+ lines.append(" ".join(f"{value:.{precision}f}" for value in row))
165
+ with open(path, "w", encoding="utf-8") as handle:
166
+ handle.write("\n".join(lines) + "\n")
167
+ return path
168
+
169
+
170
+ def identity_table(size: int) -> "Any":
171
+ """The pass-through table for a cube of this size, in red-fastest order."""
172
+ _require_numpy()
173
+ if not MIN_SIZE <= size <= MAX_SIZE:
174
+ raise CubeLutError(f"size {size} outside {MIN_SIZE}-{MAX_SIZE}")
175
+ axis = _np.linspace(0.0, 1.0, size)
176
+ # Red fastest, then green, then blue — index = r + g*N + b*N*N.
177
+ blue, green, red = _np.meshgrid(axis, axis, axis, indexing="ij")
178
+ return _np.stack([red.ravel(), green.ravel(), blue.ravel()], axis=1)
179
+
180
+
181
+ def blend_toward_identity(table: "Any", size: int, strength: float) -> "Any":
182
+ """`(1 - strength)·identity + strength·table`, clamped to [0, 1] strength.
183
+
184
+ strength 1.0 returns the table unchanged (not merely close to it), and 0.0 returns
185
+ exact identity. Values between are the same attenuation a LUT mix control applies.
186
+ """
187
+ _require_numpy()
188
+ amount = float(min(1.0, max(0.0, strength)))
189
+ array = _np.asarray(table, dtype=_np.float64)
190
+ if amount == 1.0:
191
+ return array.copy()
192
+ identity = identity_table(size)
193
+ if amount == 0.0:
194
+ return identity
195
+ return (1.0 - amount) * identity + amount * array
196
+
197
+
198
+ def attenuate_file(
199
+ source_lut: str,
200
+ strength: float,
201
+ out_path: str,
202
+ *,
203
+ title_suffix: Optional[str] = None,
204
+ ) -> Dict[str, Any]:
205
+ """Read a `.cube`, blend it toward identity, write the result. Returns a summary."""
206
+ parsed = read_cube(source_lut)
207
+ if parsed["domain_min"] != [0.0, 0.0, 0.0] or parsed["domain_max"] != [1.0, 1.0, 1.0]:
208
+ # Identity is only identity on a 0..1 domain. Blending against it on a scaled
209
+ # domain silently produces a table that is wrong across the whole range.
210
+ raise CubeLutError(
211
+ f"{source_lut} declares a non-unit domain "
212
+ f"({parsed['domain_min']}..{parsed['domain_max']}); attenuation toward "
213
+ "identity is only defined on 0..1"
214
+ )
215
+ blended = blend_toward_identity(parsed["table"], parsed["size"], strength)
216
+ suffix = title_suffix if title_suffix is not None else f" @ {round(float(strength), 3)}"
217
+ write_cube(
218
+ out_path,
219
+ blended,
220
+ parsed["size"],
221
+ title=(parsed["title"] or os.path.splitext(os.path.basename(source_lut))[0]) + suffix,
222
+ )
223
+ return {
224
+ "path": out_path,
225
+ "size": parsed["size"],
226
+ "strength": round(float(min(1.0, max(0.0, strength))), 4),
227
+ "source_lut": source_lut,
228
+ }
229
+
230
+
231
+ def sample(table: "Any", size: int, rgb: "Any") -> "Any":
232
+ """Trilinearly interpolate the table at RGB inputs in [0, 1].
233
+
234
+ Present so the blending maths can be unit-tested without shelling out to ffmpeg.
235
+ The real pixel path is ffmpeg's `lut3d`, and that stays the measurement of record —
236
+ LUT interpolation and encode rounding are where banding is actually introduced.
237
+ """
238
+ _require_numpy()
239
+ array = _np.asarray(table, dtype=_np.float64).reshape(size, size, size, 3)
240
+ points = _np.clip(_np.atleast_2d(_np.asarray(rgb, dtype=_np.float64)), 0.0, 1.0)
241
+ scaled = points * (size - 1)
242
+ low = _np.floor(scaled).astype(int)
243
+ high = _np.minimum(low + 1, size - 1)
244
+ frac = scaled - low
245
+
246
+ out = _np.zeros((points.shape[0], 3), dtype=_np.float64)
247
+ for corner in range(8):
248
+ red_hi, green_hi, blue_hi = (corner >> 0) & 1, (corner >> 1) & 1, (corner >> 2) & 1
249
+ weight = (
250
+ (frac[:, 0] if red_hi else 1 - frac[:, 0])
251
+ * (frac[:, 1] if green_hi else 1 - frac[:, 1])
252
+ * (frac[:, 2] if blue_hi else 1 - frac[:, 2])
253
+ )
254
+ # Indexed [blue][green][red] because red varies fastest in the file order.
255
+ corner_values = array[
256
+ high[:, 2] if blue_hi else low[:, 2],
257
+ high[:, 1] if green_hi else low[:, 1],
258
+ high[:, 0] if red_hi else low[:, 0],
259
+ ]
260
+ out += weight[:, None] * corner_values
261
+ return out
262
+
263
+
264
+ def capabilities() -> Dict[str, Any]:
265
+ return {
266
+ "numpy_available": _np is not None,
267
+ "supported": "3D .cube (Iridas/Resolve)",
268
+ "refused": "1D .cube; attenuation on a non-unit DOMAIN_MIN/MAX",
269
+ "size_range": [MIN_SIZE, MAX_SIZE],
270
+ }
@@ -0,0 +1,356 @@
1
+ """The retry ladder that lets a grade reject itself.
2
+
3
+ `image_qc.assess_grade` already measures whether a graded frame carries damage its
4
+ source did not — banding in the sky, crushed highlights, amplified shadow grain — and
5
+ every flag it raises carries a remedy. Until now nothing consumed that report. The
6
+ measurement existed; the loop did not, so the remedy "reduce the strength" was advice
7
+ an agent had no way to act on.
8
+
9
+ This closes it. Given a source and a look LUT, the ladder applies the look, measures the
10
+ real decoded result, and on any flag retries with the same look attenuated toward
11
+ identity. The first strength that clears every gate wins.
12
+
13
+ ## What it never does
14
+
15
+ Report a flagged result as acceptable. When the ladder is exhausted the return carries
16
+ `needs_human` with the best attempt and its remaining flags — not a quiet success at a
17
+ strength that still bands. `acceptable` is derived from the flag list at every rung and
18
+ is never assigned.
19
+
20
+ ## Every sampled frame must pass
21
+
22
+ A grade that is clean on the frame you happened to check and bands two hundred frames
23
+ later has not passed anything. `times` accepts several timestamps and a strength is
24
+ accepted only when *all* of them clear; the report names the frame that failed, because
25
+ "the grade bands" and "the grade bands on the sky at 00:41" are different amounts of
26
+ help.
27
+
28
+ ## The best attempt is the gentlest one
29
+
30
+ When nothing converges, attempts are ranked by flag count and ties broken by the
31
+ *smallest* colour shift. A tie on damage means choosing between two equally-flawed
32
+ grades, and the one that moved the image less is the one a human has less to undo.
33
+
34
+ ## Cost
35
+
36
+ The default `cost_tier` here is `numeric` rather than `image_qc`'s own default. The
37
+ ladder runs several assessments per clip; escalating each of them to vision would spend
38
+ host turns on rungs that exist only to be rejected. Pass a different tier deliberately.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import os
44
+ import tempfile
45
+ from typing import Any, Dict, List, Optional, Sequence
46
+
47
+ from . import cube_lut, image_qc
48
+
49
+ # Each rung multiplies the last. Three rungs from 1.0 reach 0.64 — far enough to clear
50
+ # most damage, short of the point where a look stops reading as itself.
51
+ STRENGTH_DECAY = 0.8
52
+ DEFAULT_MAX_TRIES = 3
53
+ DEFAULT_STRENGTH_FLOOR = 0.5
54
+
55
+ # Numeric assessment only. See the module docstring on cost.
56
+ DEFAULT_COST_TIER = "numeric"
57
+
58
+
59
+ class GradeLoopError(Exception):
60
+ """Bad inputs. Damage found in the image is a result, not an error."""
61
+
62
+
63
+ def strength_schedule(
64
+ strength: float = 1.0,
65
+ max_tries: int = DEFAULT_MAX_TRIES,
66
+ floor: float = DEFAULT_STRENGTH_FLOOR,
67
+ ) -> List[float]:
68
+ """Decaying strengths, stopped when the floor makes another rung identical.
69
+
70
+ Two consecutive rungs that round to the same value would run the same LUT twice and
71
+ read as two independent failures. One clamped rung is evidence; a repeat of it is
72
+ not, so the schedule ends there.
73
+ """
74
+ start = float(min(1.0, max(0.0, strength)))
75
+ limit = float(min(start, max(0.0, floor)))
76
+ schedule: List[float] = []
77
+ current = start
78
+ for _ in range(max(1, int(max_tries))):
79
+ rung = round(max(current, limit), 4)
80
+ if schedule and schedule[-1] == rung:
81
+ break
82
+ schedule.append(rung)
83
+ current *= STRENGTH_DECAY
84
+ return schedule
85
+
86
+
87
+ def _normalise_times(times: Any, time_seconds: Any) -> List[float]:
88
+ if times is None and time_seconds is None:
89
+ raise GradeLoopError("supply times=[...] or time_seconds=<float>")
90
+ raw = times if times is not None else time_seconds
91
+ if isinstance(raw, (int, float)):
92
+ raw = [raw]
93
+ if not isinstance(raw, (list, tuple)) or not raw:
94
+ raise GradeLoopError("times must be a non-empty list of seconds")
95
+ out: List[float] = []
96
+ for value in raw:
97
+ try:
98
+ seconds = float(value)
99
+ except (TypeError, ValueError):
100
+ raise GradeLoopError(f"'{value}' is not a timestamp in seconds")
101
+ if seconds < 0:
102
+ raise GradeLoopError("timestamps must be >= 0")
103
+ out.append(seconds)
104
+ return out
105
+
106
+
107
+ def _flag_ids(report: Dict[str, Any]) -> List[str]:
108
+ return [str(flag.get("id")) for flag in report.get("flags", [])]
109
+
110
+
111
+ def plan(
112
+ source_path: str,
113
+ lut_path: str,
114
+ *,
115
+ times: Optional[Sequence[float]] = None,
116
+ time_seconds: Optional[float] = None,
117
+ strength: float = 1.0,
118
+ max_tries: int = DEFAULT_MAX_TRIES,
119
+ strength_floor: float = DEFAULT_STRENGTH_FLOOR,
120
+ working_space: str = "rec709",
121
+ cost_tier: str = DEFAULT_COST_TIER,
122
+ ) -> Dict[str, Any]:
123
+ """What the ladder would do, and what it would cost. Touches nothing."""
124
+ sampled = _normalise_times(times, time_seconds)
125
+ schedule = strength_schedule(strength, max_tries, strength_floor)
126
+ for path in (source_path, lut_path):
127
+ if not os.path.isfile(path):
128
+ raise GradeLoopError(f"file not found: {path}")
129
+ return {
130
+ "dry_run": True,
131
+ "source_path": source_path,
132
+ "lut_path": lut_path,
133
+ "times": sampled,
134
+ "strength_schedule": schedule,
135
+ "strength_floor": round(float(min(strength, max(0.0, strength_floor))), 4),
136
+ "working_space": working_space,
137
+ "cost_tier": cost_tier,
138
+ "cost": {
139
+ "max_assessments": len(schedule) * len(sampled),
140
+ # assess_grade decodes the source frame and the LUT-applied frame per call.
141
+ "max_ffmpeg_decodes": 2 * len(schedule) * len(sampled),
142
+ "luts_written": max(0, len(schedule) - (1 if schedule and schedule[0] == 1.0 else 0)),
143
+ "note": (
144
+ "Worst case. The ladder stops at the first strength that clears every "
145
+ "sampled frame, and a frame that fails ends that rung early."
146
+ ),
147
+ },
148
+ }
149
+
150
+
151
+ def run(
152
+ source_path: str,
153
+ lut_path: str,
154
+ *,
155
+ times: Optional[Sequence[float]] = None,
156
+ time_seconds: Optional[float] = None,
157
+ strength: float = 1.0,
158
+ max_tries: int = DEFAULT_MAX_TRIES,
159
+ strength_floor: float = DEFAULT_STRENGTH_FLOOR,
160
+ working_space: str = "rec709",
161
+ cost_tier: str = DEFAULT_COST_TIER,
162
+ output_dir: Optional[str] = None,
163
+ ) -> Dict[str, Any]:
164
+ """Run the ladder. Returns the converged grade, or the best attempt flagged."""
165
+ sampled = _normalise_times(times, time_seconds)
166
+ schedule = strength_schedule(strength, max_tries, strength_floor)
167
+ for path in (source_path, lut_path):
168
+ if not os.path.isfile(path):
169
+ raise GradeLoopError(f"file not found: {path}")
170
+
171
+ # Attenuated LUTs are derived artifacts and go to scratch, never beside the source.
172
+ target_dir = output_dir or tempfile.mkdtemp(prefix="grade_loop_")
173
+ os.makedirs(target_dir, exist_ok=True)
174
+ base = os.path.splitext(os.path.basename(lut_path))[0]
175
+
176
+ attempts: List[Dict[str, Any]] = []
177
+ converged: Optional[Dict[str, Any]] = None
178
+
179
+ for rung in schedule:
180
+ if rung >= 1.0:
181
+ candidate = lut_path
182
+ else:
183
+ candidate = os.path.join(target_dir, f"{base}_{int(round(rung * 1000)):04d}.cube")
184
+ cube_lut.attenuate_file(lut_path, rung, candidate)
185
+
186
+ frames: List[Dict[str, Any]] = []
187
+ failing_frame: Optional[float] = None
188
+ for seconds in sampled:
189
+ report = image_qc.assess_grade(
190
+ source_path,
191
+ time_seconds=seconds,
192
+ lut_path=candidate,
193
+ working_space=working_space,
194
+ cost_tier=cost_tier,
195
+ )
196
+ frames.append(
197
+ {
198
+ "time_seconds": seconds,
199
+ "acceptable": bool(report.get("acceptable")),
200
+ "flags": _flag_ids(report),
201
+ "grade_shift_delta_e2000": report.get("grade_shift_delta_e2000"),
202
+ "report": report,
203
+ }
204
+ )
205
+ if not report.get("acceptable"):
206
+ failing_frame = seconds
207
+ # No point measuring the rest of the frames at a strength already known
208
+ # to fail; the next rung has to run regardless.
209
+ break
210
+
211
+ flags = sorted({flag for frame in frames for flag in frame["flags"]})
212
+ shifts = [
213
+ frame["grade_shift_delta_e2000"]
214
+ for frame in frames
215
+ if frame["grade_shift_delta_e2000"] is not None
216
+ ]
217
+ attempt = {
218
+ "strength": rung,
219
+ "lut_path": candidate,
220
+ "acceptable": failing_frame is None,
221
+ "flags": flags,
222
+ "failing_time_seconds": failing_frame,
223
+ "frames_measured": len(frames),
224
+ "frames_total": len(sampled),
225
+ "max_grade_shift_delta_e2000": round(max(shifts), 3) if shifts else None,
226
+ "frames": frames,
227
+ }
228
+ attempts.append(attempt)
229
+ if failing_frame is None:
230
+ converged = attempt
231
+ break
232
+
233
+ if converged is not None:
234
+ chosen = converged
235
+ else:
236
+ # Fewest flags first; ties to the gentlest grade. A shift of None sorts last so
237
+ # an unmeasurable attempt never wins on a missing number.
238
+ chosen = min(
239
+ attempts,
240
+ key=lambda item: (
241
+ len(item["flags"]),
242
+ item["max_grade_shift_delta_e2000"]
243
+ if item["max_grade_shift_delta_e2000"] is not None
244
+ else float("inf"),
245
+ ),
246
+ )
247
+
248
+ remedies: List[Dict[str, str]] = []
249
+ seen_ids = set()
250
+ for frame in chosen["frames"]:
251
+ for flag in frame["report"].get("flags", []):
252
+ if flag.get("id") not in seen_ids:
253
+ seen_ids.add(flag.get("id"))
254
+ remedies.append(flag)
255
+
256
+ return {
257
+ "converged": converged is not None,
258
+ "needs_human": converged is None,
259
+ "acceptable": converged is not None,
260
+ "chosen": {
261
+ "strength": chosen["strength"],
262
+ "lut_path": chosen["lut_path"],
263
+ "flags": chosen["flags"],
264
+ "remedies": remedies,
265
+ "max_grade_shift_delta_e2000": chosen["max_grade_shift_delta_e2000"],
266
+ },
267
+ "attempts": [
268
+ {key: value for key, value in attempt.items() if key != "frames"}
269
+ for attempt in attempts
270
+ ],
271
+ "reports": {
272
+ str(attempt["strength"]): [
273
+ {"time_seconds": frame["time_seconds"], "report": frame["report"]}
274
+ for frame in attempt["frames"]
275
+ ]
276
+ for attempt in attempts
277
+ },
278
+ "strength_schedule": schedule,
279
+ "times": sampled,
280
+ "source_path": source_path,
281
+ "lut_path": lut_path,
282
+ "output_dir": target_dir,
283
+ "working_space": working_space,
284
+ "cost_tier": cost_tier,
285
+ "apply_manifest": _apply_manifest(chosen, converged is not None),
286
+ "summary": _summary(chosen, converged is not None, schedule, attempts),
287
+ }
288
+
289
+
290
+ def _apply_manifest(chosen: Dict[str, Any], converged: bool) -> Dict[str, Any]:
291
+ """How to put the chosen LUT into Resolve, and whether it should be put there yet.
292
+
293
+ The ladder does not touch the project. Applying a grade is a separate, versioned,
294
+ confirm-gated operation, and a result carrying unresolved flags should reach a human
295
+ before it reaches a timeline.
296
+ """
297
+ return {
298
+ "lut_path": chosen["lut_path"],
299
+ "strength": chosen["strength"],
300
+ "safe_to_apply": converged,
301
+ "apply_with": (
302
+ "timeline_item_color(action='apply_lut', params={'lut_path': ..., "
303
+ "'clip_index': ...}) — create or switch to a recoverable grade version first"
304
+ ),
305
+ "blocked_reason": None if converged else (
306
+ "The grade still carries measured damage at every strength tried. Read the "
307
+ "remedies, adjust the look, or accept it deliberately — do not apply on the "
308
+ "strength of this report alone."
309
+ ),
310
+ }
311
+
312
+
313
+ def _summary(
314
+ chosen: Dict[str, Any],
315
+ converged: bool,
316
+ schedule: List[float],
317
+ attempts: List[Dict[str, Any]],
318
+ ) -> str:
319
+ if converged:
320
+ if chosen["strength"] >= 1.0:
321
+ return "Clean at full strength; no attenuation needed."
322
+ # What the FIRST rung carried is the interesting part — the chosen rung is
323
+ # clean by definition, so quoting its (empty) flag list says nothing.
324
+ first = ", ".join(attempts[0]["flags"]) or "damage"
325
+ return (
326
+ f"Converged at strength {chosen['strength']}; "
327
+ f"strength {attempts[0]['strength']} carried {first}."
328
+ )
329
+ return (
330
+ f"No strength cleared every gate across {len(schedule)} attempts "
331
+ f"({schedule[0]} down to {schedule[-1]}). Best attempt {chosen['strength']} "
332
+ f"still carries: {', '.join(chosen['flags'])}."
333
+ )
334
+
335
+
336
+ def capabilities() -> Dict[str, Any]:
337
+ report = dict(image_qc.capabilities())
338
+ report.update(
339
+ {
340
+ "cube_lut": cube_lut.capabilities(),
341
+ "default_cost_tier": DEFAULT_COST_TIER,
342
+ "cost_tiers": list(image_qc.COST_TIERS),
343
+ "strength_decay": STRENGTH_DECAY,
344
+ "default_max_tries": DEFAULT_MAX_TRIES,
345
+ "default_strength_floor": DEFAULT_STRENGTH_FLOOR,
346
+ "modes": {
347
+ "lut": "Built and validated offline: attenuate a .cube and measure the real decoded result.",
348
+ "live": (
349
+ "Not built. Applying inside the loop needs a per-rung single-frame "
350
+ "render from Resolve; until that is live-validated the loop returns "
351
+ "an apply manifest instead of driving the project."
352
+ ),
353
+ },
354
+ }
355
+ )
356
+ return report