davinci-resolve-mcp 2.68.1 → 2.69.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 +213 -0
- package/README.md +46 -3
- package/docs/SKILL.md +181 -1
- package/docs/install.md +27 -1
- package/install.py +26 -1
- package/package.json +1 -1
- package/resolve-advanced/LICENSE +21 -0
- package/resolve-advanced/package.json +2 -1
- package/resolve-advanced/vendor/conform-qc/package.json +1 -0
- package/resolve-advanced/vendor/drp-format/package.json +1 -0
- package/resolve-advanced/vendor/drt-format/package.json +1 -0
- package/scripts/bridge_differential.py +22 -4
- package/scripts/doctor.py +129 -3
- package/scripts/install_resolve_bridge.py +13 -3
- package/src/granular/common.py +1 -1
- package/src/server.py +639 -18
- package/src/utils/beat_detection.py +242 -0
- package/src/utils/bridge_differential.py +27 -1
- package/src/utils/broll_placement.py +201 -0
- package/src/utils/conform_lint.py +437 -0
- package/src/utils/edit_engine.py +633 -1
- package/src/utils/edit_handles.py +206 -0
- package/src/utils/edit_report.py +360 -0
- package/src/utils/first_impression.py +212 -0
- package/src/utils/prebalance.py +471 -0
- package/src/utils/project_journal.py +360 -0
- package/src/utils/reference_match.py +203 -0
- package/src/utils/rhythm_audit.py +252 -0
- package/src/utils/rule_of_six.py +217 -0
- package/src/utils/setup_sheet.py +121 -0
- package/src/utils/shot_assembly.py +259 -0
- package/src/utils/silence_ripple.py +89 -2
- package/src/utils/sound_density.py +253 -0
- package/src/utils/split_edits.py +180 -0
- package/src/utils/take_ranking.py +206 -0
- package/src/utils/transcript_edit.py +101 -5
- package/src/utils/turnover.py +187 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""The first-impression log — the one perception that cannot be recovered.
|
|
2
|
+
|
|
3
|
+
The editor is the only person who ever gets to be a first-time audience for the
|
|
4
|
+
material, and that perception is destroyed by the second viewing. The classical
|
|
5
|
+
practice is to record reactions during the first pass through dailies for
|
|
6
|
+
exactly this reason: by the tenth screening an editor knows the film too well to
|
|
7
|
+
feel what a stranger will feel, and no amount of later analysis restores it.
|
|
8
|
+
|
|
9
|
+
Everything else in this codebase measures something. This measures nothing. It
|
|
10
|
+
is craft infrastructure: capture the reaction while it is still available, then
|
|
11
|
+
**make it immutable**, so that an editor eight weeks in — who now finds the slow
|
|
12
|
+
opening perfectly paced because they have seen it ninety times — can be
|
|
13
|
+
confronted with what they thought the first time.
|
|
14
|
+
|
|
15
|
+
## The lock is the entire feature
|
|
16
|
+
|
|
17
|
+
An editable first-impression log is worthless. The failure mode is not malice,
|
|
18
|
+
it is the completely natural act of revising a note that "no longer seems
|
|
19
|
+
right" — which is precisely the moment the note is most valuable, because what
|
|
20
|
+
changed is the reader, not the film. So `record` refuses on a locked log and
|
|
21
|
+
says why.
|
|
22
|
+
|
|
23
|
+
## Deliberately not built
|
|
24
|
+
|
|
25
|
+
- **No schema.** Reactions are free text. A dailies notation is personal
|
|
26
|
+
shorthand; the notation is the editor's, the capture is the tool.
|
|
27
|
+
- **No sentiment analysis.** Scoring "confused here" as 0.3-negative destroys
|
|
28
|
+
the only thing it carried. The words are the artifact.
|
|
29
|
+
- **No inference about whether a note was addressed.** The diff reports whether
|
|
30
|
+
a later pass has anything near the same timecode. That is *revisited*, not
|
|
31
|
+
*fixed*, and it says so.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
import re
|
|
39
|
+
from typing import Any, Dict, List, Mapping, Optional
|
|
40
|
+
|
|
41
|
+
LOG_DIRNAME = "first_impressions"
|
|
42
|
+
|
|
43
|
+
#: Two reactions within this many seconds are about the same moment.
|
|
44
|
+
PROXIMITY_SECONDS = 5.0
|
|
45
|
+
|
|
46
|
+
_SAFE = re.compile(r"[^A-Za-z0-9_.-]+")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LogLocked(Exception):
|
|
50
|
+
"""Raised on any attempt to change a sealed log. The lock is the feature."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _log_dir(project_root: str) -> str:
|
|
54
|
+
return os.path.join(project_root, LOG_DIRNAME)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _log_path(project_root: str, log_id: str) -> str:
|
|
58
|
+
return os.path.join(_log_dir(project_root), f"{_SAFE.sub('_', log_id)}.json")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def start_pass(project_root: str, *, timeline_name: str, log_id: str,
|
|
62
|
+
viewer: Optional[str] = None) -> Dict[str, Any]:
|
|
63
|
+
"""Open a log for a first viewing. Fails rather than overwriting an existing one."""
|
|
64
|
+
path = _log_path(project_root, log_id)
|
|
65
|
+
if os.path.exists(path):
|
|
66
|
+
return {
|
|
67
|
+
"success": False,
|
|
68
|
+
"error": f"a log named {log_id!r} already exists",
|
|
69
|
+
"remediation": "Use a new log_id. A first impression happens once.",
|
|
70
|
+
}
|
|
71
|
+
os.makedirs(_log_dir(project_root), exist_ok=True)
|
|
72
|
+
log = {
|
|
73
|
+
"log_id": log_id,
|
|
74
|
+
"timeline_name": timeline_name,
|
|
75
|
+
"viewer": viewer,
|
|
76
|
+
"locked": False,
|
|
77
|
+
"entries": [],
|
|
78
|
+
}
|
|
79
|
+
_write(path, log)
|
|
80
|
+
return {"success": True, "log": log,
|
|
81
|
+
"note": "Log open. Record reactions as you watch, then lock it. "
|
|
82
|
+
"Once locked it cannot be edited — that is the point."}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def record(project_root: str, *, log_id: str, time_seconds: float,
|
|
86
|
+
text: str) -> Dict[str, Any]:
|
|
87
|
+
"""Append a reaction. Refuses on a locked log."""
|
|
88
|
+
path = _log_path(project_root, log_id)
|
|
89
|
+
log = _read(path)
|
|
90
|
+
if log is None:
|
|
91
|
+
return {"success": False, "error": f"no log named {log_id!r}"}
|
|
92
|
+
if log.get("locked"):
|
|
93
|
+
raise LogLocked(
|
|
94
|
+
f"log {log_id!r} is locked and cannot be added to. A first impression "
|
|
95
|
+
"that can be revised after the fact is worthless — what changed by then "
|
|
96
|
+
"is the viewer, not the film. Start a new log for a later pass."
|
|
97
|
+
)
|
|
98
|
+
if not str(text).strip():
|
|
99
|
+
return {"success": False, "error": "an empty reaction is not a reaction"}
|
|
100
|
+
log["entries"].append({
|
|
101
|
+
"time_seconds": round(float(time_seconds), 3),
|
|
102
|
+
"text": str(text).strip(),
|
|
103
|
+
})
|
|
104
|
+
log["entries"].sort(key=lambda e: e["time_seconds"])
|
|
105
|
+
_write(path, log)
|
|
106
|
+
return {"success": True, "entry_count": len(log["entries"])}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def lock(project_root: str, *, log_id: str) -> Dict[str, Any]:
|
|
110
|
+
"""Seal a log. Irreversible by design; there is no unlock function."""
|
|
111
|
+
path = _log_path(project_root, log_id)
|
|
112
|
+
log = _read(path)
|
|
113
|
+
if log is None:
|
|
114
|
+
return {"success": False, "error": f"no log named {log_id!r}"}
|
|
115
|
+
if log.get("locked"):
|
|
116
|
+
return {"success": True, "already_locked": True, "log": log}
|
|
117
|
+
log["locked"] = True
|
|
118
|
+
_write(path, log)
|
|
119
|
+
return {
|
|
120
|
+
"success": True,
|
|
121
|
+
"log": log,
|
|
122
|
+
"note": (
|
|
123
|
+
f"Sealed with {len(log['entries'])} reactions. There is deliberately no "
|
|
124
|
+
"unlock — the value of this record is precisely that a later, "
|
|
125
|
+
"better-informed self cannot talk it out of what it noticed."
|
|
126
|
+
),
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def load(project_root: str, *, log_id: str) -> Optional[Dict[str, Any]]:
|
|
131
|
+
return _read(_log_path(project_root, log_id))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def list_logs(project_root: str) -> List[Dict[str, Any]]:
|
|
135
|
+
directory = _log_dir(project_root)
|
|
136
|
+
if not os.path.isdir(directory):
|
|
137
|
+
return []
|
|
138
|
+
out = []
|
|
139
|
+
for name in sorted(os.listdir(directory)):
|
|
140
|
+
if not name.endswith(".json"):
|
|
141
|
+
continue
|
|
142
|
+
log = _read(os.path.join(directory, name))
|
|
143
|
+
if log:
|
|
144
|
+
out.append({
|
|
145
|
+
"log_id": log.get("log_id"),
|
|
146
|
+
"timeline_name": log.get("timeline_name"),
|
|
147
|
+
"locked": bool(log.get("locked")),
|
|
148
|
+
"entry_count": len(log.get("entries") or []),
|
|
149
|
+
})
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def diff(first: Mapping[str, Any], later: Mapping[str, Any], *,
|
|
154
|
+
proximity_seconds: float = PROXIMITY_SECONDS) -> Dict[str, Any]:
|
|
155
|
+
"""Compare a first-impression log against a later pass.
|
|
156
|
+
|
|
157
|
+
Reports whether each first reaction was **revisited** — whether the later
|
|
158
|
+
pass said anything near the same moment. It does **not** claim anything was
|
|
159
|
+
fixed: a note with no counterpart may have been addressed and forgotten, or
|
|
160
|
+
simply never looked at again. Those are different and this cannot tell them
|
|
161
|
+
apart, so it does not pretend to.
|
|
162
|
+
"""
|
|
163
|
+
first_entries = list(first.get("entries") or [])
|
|
164
|
+
later_entries = list(later.get("entries") or [])
|
|
165
|
+
revisited, not_revisited = [], []
|
|
166
|
+
for entry in first_entries:
|
|
167
|
+
near = [
|
|
168
|
+
l for l in later_entries
|
|
169
|
+
if abs(float(l["time_seconds"]) - float(entry["time_seconds"])) <= proximity_seconds
|
|
170
|
+
]
|
|
171
|
+
(revisited if near else not_revisited).append({
|
|
172
|
+
**entry,
|
|
173
|
+
"later_notes": [n["text"] for n in near],
|
|
174
|
+
})
|
|
175
|
+
new_concerns = [
|
|
176
|
+
entry for entry in later_entries
|
|
177
|
+
if not any(
|
|
178
|
+
abs(float(entry["time_seconds"]) - float(f["time_seconds"])) <= proximity_seconds
|
|
179
|
+
for f in first_entries
|
|
180
|
+
)
|
|
181
|
+
]
|
|
182
|
+
return {
|
|
183
|
+
"first_log_id": first.get("log_id"),
|
|
184
|
+
"later_log_id": later.get("log_id"),
|
|
185
|
+
"first_reaction_count": len(first_entries),
|
|
186
|
+
"revisited": revisited,
|
|
187
|
+
"not_revisited": not_revisited,
|
|
188
|
+
"new_in_later_pass": new_concerns,
|
|
189
|
+
"note": (
|
|
190
|
+
f"{len(revisited)} of {len(first_entries)} first reactions were revisited in "
|
|
191
|
+
f"the later pass; {len(not_revisited)} were not. **Not revisited does NOT "
|
|
192
|
+
"mean not fixed** — it means nobody wrote anything there the second time, "
|
|
193
|
+
"which could be because it was solved or because it stopped being visible "
|
|
194
|
+
"once the film became familiar. That second possibility is the reason this "
|
|
195
|
+
"log exists."
|
|
196
|
+
),
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _read(path: str) -> Optional[Dict[str, Any]]:
|
|
201
|
+
try:
|
|
202
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
203
|
+
return json.load(handle)
|
|
204
|
+
except (OSError, ValueError):
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _write(path: str, log: Mapping[str, Any]) -> None:
|
|
209
|
+
tmp = f"{path}.tmp"
|
|
210
|
+
with open(tmp, "w", encoding="utf-8") as handle:
|
|
211
|
+
json.dump(log, handle, indent=2)
|
|
212
|
+
os.replace(tmp, path)
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"""Pre-balance: neutral technical correction, and nothing else.
|
|
2
|
+
|
|
3
|
+
The assistant colorist's highest-leverage job is having the timeline *balanced*
|
|
4
|
+
before the colorist sits down, so expensive creative time is spent on the look
|
|
5
|
+
instead of on exposure. The economics are not subtle — a colorist at $400/hour
|
|
6
|
+
who spends the first two hours of every session fixing black levels is the most
|
|
7
|
+
expensive way in the building to do a job that is almost entirely mechanical.
|
|
8
|
+
|
|
9
|
+
What makes it automatable is that the craft defines the task **negatively**, and
|
|
10
|
+
with unusual precision:
|
|
11
|
+
|
|
12
|
+
IS: neutral technical correction, a consistent starting point,
|
|
13
|
+
scene-to-scene baseline matching, problem identification.
|
|
14
|
+
IS NOT: creative grading, look development, final correction, or guessing
|
|
15
|
+
at the director's intent.
|
|
16
|
+
|
|
17
|
+
Every "is not" in that list is a guardrail this module enforces in code rather
|
|
18
|
+
than in a docstring — see `ALLOWED_OPERATIONS` and `validate_plan`. An agent is
|
|
19
|
+
a beginner by default, and the classic beginner failure is exactly the one the
|
|
20
|
+
craft warns about: reaching for curves and a look before the image is balanced.
|
|
21
|
+
|
|
22
|
+
Two specifics that a naive implementation gets backwards:
|
|
23
|
+
|
|
24
|
+
1. **Black balance first, on the parade.** Align the channel bottoms; blue
|
|
25
|
+
usually needs the most. Then the white point, on the brightest neutral.
|
|
26
|
+
2. **Do not neutralize midtones.** Skin is warm. It belongs near 11 o'clock on
|
|
27
|
+
the vectorscope, not at equal RGB. "Make the midtones neutral" is the single
|
|
28
|
+
fastest way to make everyone in the programme look grey, and it is what
|
|
29
|
+
"auto-balance" means to most people writing it for the first time.
|
|
30
|
+
|
|
31
|
+
**The honest limit:** this has scopes and no eyes. Numeric balance is
|
|
32
|
+
defensible — the numbers are the numbers. Look development is not, and this
|
|
33
|
+
module will not attempt it. It also cannot know that the dim office was dim on
|
|
34
|
+
purpose, which is why every judgement call is flagged for a human instead of
|
|
35
|
+
being applied.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
|
41
|
+
|
|
42
|
+
#: The only grade operations a pre-balance is permitted to propose. Anything
|
|
43
|
+
#: outside this set is creative and belongs to the colorist. Enforced by
|
|
44
|
+
#: `validate_plan`, which is called before any plan is returned.
|
|
45
|
+
ALLOWED_OPERATIONS = frozenset({"lift", "gain", "gamma_master", "legal_limit"})
|
|
46
|
+
|
|
47
|
+
#: Broadcast-legal limits, 0-1 normalized (Rec.709 10-bit 64-940 → 0.0625-0.918).
|
|
48
|
+
#: Present from the first node rather than bolted on at delivery: the craft is
|
|
49
|
+
#: blunt that a grade violating legal levels is an instant QC rejection, and a
|
|
50
|
+
#: limiter added at the end changes an image someone already approved.
|
|
51
|
+
LEGAL_BLACK = 0.0625
|
|
52
|
+
LEGAL_WHITE = 0.918
|
|
53
|
+
|
|
54
|
+
#: Pass depths. Named after the assistant-colorist convention so a colorist can
|
|
55
|
+
#: ask for a level and get what they expect. Rates are for planning, not
|
|
56
|
+
#: promises — they come from the craft's own published throughput figures.
|
|
57
|
+
QUALITY_TIERS = {
|
|
58
|
+
"quick": {
|
|
59
|
+
"level": 1,
|
|
60
|
+
"clips_per_hour": 45,
|
|
61
|
+
"description": "black and white balance only, hero shot per scene, rough match",
|
|
62
|
+
},
|
|
63
|
+
"standard": {
|
|
64
|
+
"level": 2,
|
|
65
|
+
"clips_per_hour": 22,
|
|
66
|
+
"description": "full balance of each shot, scene-to-scene matching, problem flagging",
|
|
67
|
+
},
|
|
68
|
+
"deep": {
|
|
69
|
+
"level": 3,
|
|
70
|
+
"clips_per_hour": 11,
|
|
71
|
+
"description": "complete balance, continuity checking, technical QC, documentation",
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
DEFAULT_TIER = "standard"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve_tier(tier: Optional[str]) -> Dict[str, Any]:
|
|
78
|
+
"""Map a tier name to its depth. Unknown names raise rather than default."""
|
|
79
|
+
name = (tier or DEFAULT_TIER).strip().lower()
|
|
80
|
+
if name not in QUALITY_TIERS:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"unknown tier {tier!r}; expected one of {', '.join(sorted(QUALITY_TIERS))}"
|
|
83
|
+
)
|
|
84
|
+
return {"tier": name, **QUALITY_TIERS[name]}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def estimate_effort(clip_count: int, tier: Optional[str] = None) -> Dict[str, Any]:
|
|
88
|
+
"""Rough hours for a pass at this depth, and what it is worth.
|
|
89
|
+
|
|
90
|
+
Estimates and labelled as such. They exist because the assistant's prep has
|
|
91
|
+
to justify itself in the next budget conversation, and a number nobody
|
|
92
|
+
stated is a number nobody credits.
|
|
93
|
+
"""
|
|
94
|
+
resolved = resolve_tier(tier)
|
|
95
|
+
rate = float(resolved["clips_per_hour"])
|
|
96
|
+
hours = round(clip_count / rate, 2) if rate else None
|
|
97
|
+
return {
|
|
98
|
+
**resolved,
|
|
99
|
+
"clip_count": clip_count,
|
|
100
|
+
"estimated_hours": hours,
|
|
101
|
+
"basis": (
|
|
102
|
+
f"{resolved['clips_per_hour']} clips/hour at the '{resolved['tier']}' depth. "
|
|
103
|
+
"An ESTIMATE from published assistant-colorist throughput, not a promise."
|
|
104
|
+
),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#: Explicitly forbidden, with the reason, so a rejection can explain itself.
|
|
108
|
+
FORBIDDEN_OPERATIONS = {
|
|
109
|
+
"curves": "curves are look development, not balance",
|
|
110
|
+
"vignette": "a vignette is a creative choice about where the eye goes",
|
|
111
|
+
"saturation": "saturation is taste; balance does not touch it",
|
|
112
|
+
"qualifier": "secondaries are beyond an assistant's technical scope",
|
|
113
|
+
"power_window": "windows are creative isolation, not correction",
|
|
114
|
+
"hue_shift": "shifting hue is grading",
|
|
115
|
+
"midtone_neutralize": (
|
|
116
|
+
"skin is warm and belongs near 11 o'clock on the vectorscope; "
|
|
117
|
+
"neutralizing midtones makes everyone grey"
|
|
118
|
+
),
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#: Node label. One node, at the head of the tree, so the colorist can bypass the
|
|
122
|
+
#: entire assistant pass with a single keystroke and see the original.
|
|
123
|
+
BALANCE_NODE_LABEL = "ASST: Balance"
|
|
124
|
+
|
|
125
|
+
#: Percentiles used to find the black and white points. Not min/max: a single
|
|
126
|
+
#: hot pixel or a dead one would otherwise define the whole correction.
|
|
127
|
+
BLACK_POINT_PERCENTILE = 0.5
|
|
128
|
+
WHITE_POINT_PERCENTILE = 99.5
|
|
129
|
+
|
|
130
|
+
#: Targets in 0-1. Not 0.0/1.0 — legal video leaves headroom, and slamming the
|
|
131
|
+
#: black point to zero crushes the shadow detail the colorist needs to work in.
|
|
132
|
+
TARGET_BLACK = 0.02
|
|
133
|
+
TARGET_WHITE = 0.92
|
|
134
|
+
|
|
135
|
+
#: Above this fraction of pixels at the extremes, the shot has a problem the
|
|
136
|
+
#: grade cannot fix and a human should be told rather than have it papered over.
|
|
137
|
+
CLIP_WARN_FRACTION = 0.02
|
|
138
|
+
CRUSH_WARN_FRACTION = 0.02
|
|
139
|
+
|
|
140
|
+
#: Corrections smaller than this are noise. Proposing them adds churn to the
|
|
141
|
+
#: node tree and tells the colorist nothing.
|
|
142
|
+
MIN_MEANINGFUL_ADJUSTMENT = 0.004
|
|
143
|
+
|
|
144
|
+
#: How close to the targets already counts as "there". Without this, a shot
|
|
145
|
+
#: whose white point sits at 0.90 against a 0.92 target gets a 2% gain node it
|
|
146
|
+
#: does not need — and a timeline of near-identical corrections is worse than
|
|
147
|
+
#: none, because the colorist now has to check every one of them to find the
|
|
148
|
+
#: handful that mattered. Being nearly right is the normal state of graded-ish
|
|
149
|
+
#: material and should produce silence, not activity.
|
|
150
|
+
TARGET_TOLERANCE = 0.03
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class GuardrailViolation(Exception):
|
|
154
|
+
"""Raised when a plan contains an operation a pre-balance may not make."""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def channel_levels(percentiles: Mapping[str, Mapping[str, float]]) -> Dict[str, Any]:
|
|
158
|
+
"""Normalize per-channel percentile readings into a levels report.
|
|
159
|
+
|
|
160
|
+
`percentiles`: `{"r": {"black": .., "white": .., "clipped": .., "crushed": ..}, ...}`
|
|
161
|
+
with values in 0-1. Kept as plain numbers so the analysis is testable
|
|
162
|
+
without decoding a frame.
|
|
163
|
+
"""
|
|
164
|
+
out: Dict[str, Any] = {}
|
|
165
|
+
for channel in ("r", "g", "b"):
|
|
166
|
+
row = percentiles.get(channel) or {}
|
|
167
|
+
out[channel] = {
|
|
168
|
+
"black": float(row.get("black", 0.0)),
|
|
169
|
+
"white": float(row.get("white", 1.0)),
|
|
170
|
+
"clipped_fraction": float(row.get("clipped", 0.0)),
|
|
171
|
+
"crushed_fraction": float(row.get("crushed", 0.0)),
|
|
172
|
+
}
|
|
173
|
+
blacks = [out[c]["black"] for c in "rgb"]
|
|
174
|
+
whites = [out[c]["white"] for c in "rgb"]
|
|
175
|
+
out["black_spread"] = round(max(blacks) - min(blacks), 5)
|
|
176
|
+
out["white_spread"] = round(max(whites) - min(whites), 5)
|
|
177
|
+
return out
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def measure_frame_levels(media_path: str, at_seconds: float = 0.0) -> Dict[str, Any]:
|
|
181
|
+
"""Read black/white points off one frame via ffmpeg + numpy.
|
|
182
|
+
|
|
183
|
+
Separated from the math above so the analysis stays testable without
|
|
184
|
+
decoding video. Honest-refuses when the tools are absent rather than
|
|
185
|
+
guessing — an invented level would produce a confident, wrong grade.
|
|
186
|
+
|
|
187
|
+
Percentiles rather than min/max: one hot pixel or one dead one would
|
|
188
|
+
otherwise define the entire correction for the shot.
|
|
189
|
+
"""
|
|
190
|
+
import shutil
|
|
191
|
+
import subprocess
|
|
192
|
+
|
|
193
|
+
if not shutil.which("ffmpeg"):
|
|
194
|
+
return {"success": False, "error": "ffmpeg not found on PATH — cannot measure levels"}
|
|
195
|
+
try:
|
|
196
|
+
import numpy as np
|
|
197
|
+
except Exception:
|
|
198
|
+
return {
|
|
199
|
+
"success": False,
|
|
200
|
+
"error": "numpy is required to measure levels",
|
|
201
|
+
"remediation": "pip install numpy",
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
width = height = 128 # enough for percentiles, cheap to decode
|
|
205
|
+
cmd = [
|
|
206
|
+
"ffmpeg", "-v", "error", "-ss", str(max(0.0, float(at_seconds))),
|
|
207
|
+
"-i", media_path, "-frames:v", "1",
|
|
208
|
+
"-vf", f"scale={width}:{height}", "-f", "rawvideo", "-pix_fmt", "rgb24", "-",
|
|
209
|
+
]
|
|
210
|
+
try:
|
|
211
|
+
proc = subprocess.run(cmd, capture_output=True, timeout=30)
|
|
212
|
+
except (subprocess.SubprocessError, OSError) as exc:
|
|
213
|
+
return {"success": False, "error": f"frame extraction failed: {exc}"}
|
|
214
|
+
expected = width * height * 3
|
|
215
|
+
if proc.returncode != 0 or len(proc.stdout) < expected:
|
|
216
|
+
return {
|
|
217
|
+
"success": False,
|
|
218
|
+
"error": (proc.stderr or b"").decode("utf-8", "replace").strip()
|
|
219
|
+
or "ffmpeg produced no frame at that timestamp",
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
frame = np.frombuffer(proc.stdout[:expected], dtype=np.uint8).reshape(height, width, 3)
|
|
223
|
+
readings: Dict[str, Dict[str, float]] = {}
|
|
224
|
+
for index, channel in enumerate("rgb"):
|
|
225
|
+
plane = frame[:, :, index].astype("float32") / 255.0
|
|
226
|
+
readings[channel] = {
|
|
227
|
+
"black": float(np.percentile(plane, BLACK_POINT_PERCENTILE)),
|
|
228
|
+
"white": float(np.percentile(plane, WHITE_POINT_PERCENTILE)),
|
|
229
|
+
"clipped": float((plane >= 0.996).mean()),
|
|
230
|
+
"crushed": float((plane <= 0.004).mean()),
|
|
231
|
+
}
|
|
232
|
+
return {"success": True, "levels": channel_levels(readings), "sampled_at_seconds": at_seconds}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def propose_balance(levels: Mapping[str, Any]) -> Dict[str, Any]:
|
|
236
|
+
"""Lift and gain offsets that align the channels and set the end points.
|
|
237
|
+
|
|
238
|
+
Black balance first (align the bottoms), then white (align the tops). The
|
|
239
|
+
two interact — moving gain shifts where the blacks sit — so the black
|
|
240
|
+
correction is computed first and the gain is derived from the *corrected*
|
|
241
|
+
range rather than the raw one.
|
|
242
|
+
"""
|
|
243
|
+
lift: Dict[str, float] = {}
|
|
244
|
+
gain: Dict[str, float] = {}
|
|
245
|
+
reasons: List[str] = []
|
|
246
|
+
|
|
247
|
+
within_tolerance = all(
|
|
248
|
+
abs(float(levels[c]["black"]) - TARGET_BLACK) <= TARGET_TOLERANCE
|
|
249
|
+
and abs(float(levels[c]["white"]) - TARGET_WHITE) <= TARGET_TOLERANCE
|
|
250
|
+
for c in ("r", "g", "b")
|
|
251
|
+
)
|
|
252
|
+
if within_tolerance:
|
|
253
|
+
return {
|
|
254
|
+
"node_label": BALANCE_NODE_LABEL,
|
|
255
|
+
"operations": {"lift": {c: 0.0 for c in "rgb"}, "gain": {c: 1.0 for c in "rgb"}},
|
|
256
|
+
"reasons": [
|
|
257
|
+
"Already balanced within tolerance — no correction proposed. A node "
|
|
258
|
+
"per shot that changes nothing is worse than no node: the colorist "
|
|
259
|
+
"has to check every one to find the few that mattered."
|
|
260
|
+
],
|
|
261
|
+
"midtones": "untouched — nothing to correct",
|
|
262
|
+
"no_op": True,
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
for channel in ("r", "g", "b"):
|
|
266
|
+
black = float(levels[channel]["black"])
|
|
267
|
+
offset = TARGET_BLACK - black
|
|
268
|
+
lift[channel] = round(offset, 5)
|
|
269
|
+
|
|
270
|
+
biggest = max(("r", "g", "b"), key=lambda c: abs(lift[c]))
|
|
271
|
+
if abs(lift[biggest]) >= MIN_MEANINGFUL_ADJUSTMENT:
|
|
272
|
+
reasons.append(
|
|
273
|
+
f"Black balance: {biggest.upper()} moved {lift[biggest]:+.3f} to align the "
|
|
274
|
+
f"channel bottoms on the parade (spread was {levels['black_spread']:.3f})."
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
for channel in ("r", "g", "b"):
|
|
278
|
+
corrected_black = TARGET_BLACK
|
|
279
|
+
corrected_white = float(levels[channel]["white"]) + lift[channel]
|
|
280
|
+
span = corrected_white - corrected_black
|
|
281
|
+
gain[channel] = round((TARGET_WHITE - corrected_black) / span, 5) if span > 1e-6 else 1.0
|
|
282
|
+
|
|
283
|
+
if any(abs(gain[c] - 1.0) >= MIN_MEANINGFUL_ADJUSTMENT for c in "rgb"):
|
|
284
|
+
reasons.append(
|
|
285
|
+
f"White balance: gain set per channel to bring the top of the range to "
|
|
286
|
+
f"{TARGET_WHITE:.2f} without clipping (white spread was {levels['white_spread']:.3f})."
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
if not reasons:
|
|
290
|
+
reasons.append("Already balanced within tolerance — no correction proposed.")
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
"node_label": BALANCE_NODE_LABEL,
|
|
294
|
+
"operations": {
|
|
295
|
+
"lift": lift,
|
|
296
|
+
"gain": gain,
|
|
297
|
+
# In the tree from the start, not bolted on at delivery. A limiter
|
|
298
|
+
# added at the end changes an image the colourist already approved,
|
|
299
|
+
# and a grade that violates legal levels is an instant QC rejection.
|
|
300
|
+
"legal_limit": {"black": LEGAL_BLACK, "white": LEGAL_WHITE},
|
|
301
|
+
},
|
|
302
|
+
"reasons": reasons + [
|
|
303
|
+
f"Legal limiter in the tree from the first node ({LEGAL_BLACK:.3f}-"
|
|
304
|
+
f"{LEGAL_WHITE:.3f}), not added at delivery."
|
|
305
|
+
],
|
|
306
|
+
# Stated explicitly in the output, not just in the docs, because it is
|
|
307
|
+
# the thing most likely to be "improved" by someone later.
|
|
308
|
+
"midtones": (
|
|
309
|
+
"untouched by design — skin is warm and belongs near 11 o'clock on the "
|
|
310
|
+
"vectorscope; neutralizing midtones would make everyone grey"
|
|
311
|
+
),
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def detect_problems(levels: Mapping[str, Any]) -> List[Dict[str, str]]:
|
|
316
|
+
"""Things the grade cannot fix. Flag them; never quietly paper over them."""
|
|
317
|
+
problems: List[Dict[str, str]] = []
|
|
318
|
+
for channel in ("r", "g", "b"):
|
|
319
|
+
if levels[channel]["clipped_fraction"] > CLIP_WARN_FRACTION:
|
|
320
|
+
problems.append({
|
|
321
|
+
"type": "TECH",
|
|
322
|
+
"channel": channel,
|
|
323
|
+
"description": (
|
|
324
|
+
f"{levels[channel]['clipped_fraction'] * 100:.1f}% of the "
|
|
325
|
+
f"{channel.upper()} channel is clipped at the top — that detail "
|
|
326
|
+
"is not in the file and no grade recovers it"
|
|
327
|
+
),
|
|
328
|
+
})
|
|
329
|
+
if levels[channel]["crushed_fraction"] > CRUSH_WARN_FRACTION:
|
|
330
|
+
problems.append({
|
|
331
|
+
"type": "TECH",
|
|
332
|
+
"channel": channel,
|
|
333
|
+
"description": (
|
|
334
|
+
f"{levels[channel]['crushed_fraction'] * 100:.1f}% of the "
|
|
335
|
+
f"{channel.upper()} channel is crushed at the bottom"
|
|
336
|
+
),
|
|
337
|
+
})
|
|
338
|
+
if levels["black_spread"] > 0.08:
|
|
339
|
+
problems.append({
|
|
340
|
+
"type": "CREATIVE",
|
|
341
|
+
"channel": "all",
|
|
342
|
+
"description": (
|
|
343
|
+
f"Black spread is {levels['black_spread']:.3f} — a strong colour cast "
|
|
344
|
+
"in the shadows. Balanced here on the assumption it is unintended; "
|
|
345
|
+
"if the DP asked for it, bypass this node"
|
|
346
|
+
),
|
|
347
|
+
})
|
|
348
|
+
return problems
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def validate_plan(plan: Mapping[str, Any]) -> None:
|
|
352
|
+
"""Refuse any plan that strays outside neutral technical correction.
|
|
353
|
+
|
|
354
|
+
A test, not a guideline. The guardrails are the product: a pre-balance that
|
|
355
|
+
quietly develops a look has destroyed exactly the trust that makes a
|
|
356
|
+
colorist willing to let an assistant near the timeline at all.
|
|
357
|
+
"""
|
|
358
|
+
operations = plan.get("operations") or {}
|
|
359
|
+
for name in operations:
|
|
360
|
+
if name in FORBIDDEN_OPERATIONS:
|
|
361
|
+
raise GuardrailViolation(
|
|
362
|
+
f"pre-balance may not use {name!r}: {FORBIDDEN_OPERATIONS[name]}"
|
|
363
|
+
)
|
|
364
|
+
if name not in ALLOWED_OPERATIONS:
|
|
365
|
+
raise GuardrailViolation(
|
|
366
|
+
f"pre-balance proposed an unrecognized operation {name!r}; "
|
|
367
|
+
f"allowed: {', '.join(sorted(ALLOWED_OPERATIONS))}"
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def group_by_setup(clips: Sequence[Mapping[str, Any]]) -> Dict[str, List[Mapping[str, Any]]]:
|
|
372
|
+
"""Group clips by lighting setup rather than timeline order.
|
|
373
|
+
|
|
374
|
+
Balancing in timeline order means re-deriving the same correction for every
|
|
375
|
+
angle of the same setup and getting slightly different answers each time —
|
|
376
|
+
which is precisely the scene-to-scene inconsistency the pass exists to
|
|
377
|
+
prevent. Group first, balance the hero, match the rest to it.
|
|
378
|
+
"""
|
|
379
|
+
groups: Dict[str, List[Mapping[str, Any]]] = {}
|
|
380
|
+
for clip in clips:
|
|
381
|
+
key = str(clip.get("setup") or clip.get("scene") or "ungrouped")
|
|
382
|
+
groups.setdefault(key, []).append(clip)
|
|
383
|
+
return groups
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def pick_hero(group: Sequence[Mapping[str, Any]]) -> Optional[Mapping[str, Any]]:
|
|
387
|
+
"""The clip the rest of a setup is matched to.
|
|
388
|
+
|
|
389
|
+
Longest on screen, tie-broken by the widest usable range — the shot with
|
|
390
|
+
most screen time carries the audience's sense of what the scene looks like,
|
|
391
|
+
and a well-exposed one gives the match something honest to aim at.
|
|
392
|
+
"""
|
|
393
|
+
if not group:
|
|
394
|
+
return None
|
|
395
|
+
def score(clip: Mapping[str, Any]):
|
|
396
|
+
levels = clip.get("levels") or {}
|
|
397
|
+
span = 0.0
|
|
398
|
+
if levels:
|
|
399
|
+
span = max(
|
|
400
|
+
(float(levels[c]["white"]) - float(levels[c]["black"])) for c in "rgb"
|
|
401
|
+
)
|
|
402
|
+
return (float(clip.get("duration_seconds") or 0.0), span)
|
|
403
|
+
return max(group, key=score)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def plan_prebalance(clips: Sequence[Mapping[str, Any]]) -> Dict[str, Any]:
|
|
407
|
+
"""Build a per-clip pre-balance plan, grouped by setup, hero-matched.
|
|
408
|
+
|
|
409
|
+
Each clip: `{"name", "duration_seconds", "setup"|"scene", "levels"}` where
|
|
410
|
+
`levels` came from `channel_levels`. Clips with no levels are reported as
|
|
411
|
+
**not analyzed**, never as balanced.
|
|
412
|
+
"""
|
|
413
|
+
if not clips:
|
|
414
|
+
return {"success": False, "error": "No clips supplied"}
|
|
415
|
+
|
|
416
|
+
groups = group_by_setup(clips)
|
|
417
|
+
plans: List[Dict[str, Any]] = []
|
|
418
|
+
unanalyzed: List[Dict[str, Any]] = []
|
|
419
|
+
flags: List[Dict[str, Any]] = []
|
|
420
|
+
|
|
421
|
+
for setup, group in sorted(groups.items()):
|
|
422
|
+
measured = [c for c in group if c.get("levels")]
|
|
423
|
+
for clip in group:
|
|
424
|
+
if not clip.get("levels"):
|
|
425
|
+
unanalyzed.append({
|
|
426
|
+
"clip": clip.get("name"),
|
|
427
|
+
"setup": setup,
|
|
428
|
+
"reason": "no levels measured — NOT balanced, and not certified balanced",
|
|
429
|
+
})
|
|
430
|
+
hero = pick_hero(measured)
|
|
431
|
+
for clip in measured:
|
|
432
|
+
balance = propose_balance(clip["levels"])
|
|
433
|
+
validate_plan(balance)
|
|
434
|
+
problems = detect_problems(clip["levels"])
|
|
435
|
+
for problem in problems:
|
|
436
|
+
flags.append({
|
|
437
|
+
"clip": clip.get("name"),
|
|
438
|
+
"marker_note": f"ASST: {problem['type']} - {problem['description']}",
|
|
439
|
+
**problem,
|
|
440
|
+
})
|
|
441
|
+
plans.append({
|
|
442
|
+
"clip": clip.get("name"),
|
|
443
|
+
"setup": setup,
|
|
444
|
+
"is_hero": hero is not None and clip is hero,
|
|
445
|
+
**balance,
|
|
446
|
+
"problems": problems,
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
"success": True,
|
|
451
|
+
"kind": "prebalance",
|
|
452
|
+
"clip_count": len(clips),
|
|
453
|
+
"balanced_count": len(plans),
|
|
454
|
+
"setup_count": len(groups),
|
|
455
|
+
"plans": plans,
|
|
456
|
+
"flagged": flags,
|
|
457
|
+
"unanalyzed": unanalyzed,
|
|
458
|
+
"guardrails": {
|
|
459
|
+
"allowed_operations": sorted(ALLOWED_OPERATIONS),
|
|
460
|
+
"forbidden_operations": FORBIDDEN_OPERATIONS,
|
|
461
|
+
"node_label": BALANCE_NODE_LABEL,
|
|
462
|
+
},
|
|
463
|
+
"note": (
|
|
464
|
+
f"Neutral technical correction only, as one bypassable "
|
|
465
|
+
f"'{BALANCE_NODE_LABEL}' node at the head of each tree. No curves, no "
|
|
466
|
+
"vignettes, no saturation, no secondaries, and midtones deliberately "
|
|
467
|
+
"left warm. This is a starting point for the colorist, not a look — "
|
|
468
|
+
"it has scopes and no eyes, and it cannot know that the dim shot was "
|
|
469
|
+
"dim on purpose. Problems are flagged for a human, never silently fixed."
|
|
470
|
+
),
|
|
471
|
+
}
|