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 +61 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/docs/guides/color-decision-guide.md +30 -0
- package/docs/kernels/color-grade-kernel.md +22 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +55 -1
- package/src/utils/cube_lut.py +270 -0
- package/src/utils/grade_loop.py +356 -0
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
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#server-modes)
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#服务器模式)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
[](https://www.python.org/downloads/)
|
|
13
13
|
[](https://opensource.org/licenses/MIT)
|
|
14
14
|
|
|
15
|
-
> 本翻译对应 v2.
|
|
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.
|
|
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
package/src/granular/common.py
CHANGED
|
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
|
|
|
87
87
|
handlers=[logging.StreamHandler()],
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
-
VERSION = "2.
|
|
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.
|
|
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
|