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.
Files changed (37) hide show
  1. package/CHANGELOG.md +213 -0
  2. package/README.md +46 -3
  3. package/docs/SKILL.md +181 -1
  4. package/docs/install.md +27 -1
  5. package/install.py +26 -1
  6. package/package.json +1 -1
  7. package/resolve-advanced/LICENSE +21 -0
  8. package/resolve-advanced/package.json +2 -1
  9. package/resolve-advanced/vendor/conform-qc/package.json +1 -0
  10. package/resolve-advanced/vendor/drp-format/package.json +1 -0
  11. package/resolve-advanced/vendor/drt-format/package.json +1 -0
  12. package/scripts/bridge_differential.py +22 -4
  13. package/scripts/doctor.py +129 -3
  14. package/scripts/install_resolve_bridge.py +13 -3
  15. package/src/granular/common.py +1 -1
  16. package/src/server.py +639 -18
  17. package/src/utils/beat_detection.py +242 -0
  18. package/src/utils/bridge_differential.py +27 -1
  19. package/src/utils/broll_placement.py +201 -0
  20. package/src/utils/conform_lint.py +437 -0
  21. package/src/utils/edit_engine.py +633 -1
  22. package/src/utils/edit_handles.py +206 -0
  23. package/src/utils/edit_report.py +360 -0
  24. package/src/utils/first_impression.py +212 -0
  25. package/src/utils/prebalance.py +471 -0
  26. package/src/utils/project_journal.py +360 -0
  27. package/src/utils/reference_match.py +203 -0
  28. package/src/utils/rhythm_audit.py +252 -0
  29. package/src/utils/rule_of_six.py +217 -0
  30. package/src/utils/setup_sheet.py +121 -0
  31. package/src/utils/shot_assembly.py +259 -0
  32. package/src/utils/silence_ripple.py +89 -2
  33. package/src/utils/sound_density.py +253 -0
  34. package/src/utils/split_edits.py +180 -0
  35. package/src/utils/take_ranking.py +206 -0
  36. package/src/utils/transcript_edit.py +101 -5
  37. package/src/utils/turnover.py +187 -0
@@ -0,0 +1,360 @@
1
+ """The project journal — the paperwork that makes a cutting room work.
2
+
3
+ Every craft role in post keeps written records as a primary deliverable, and
4
+ they are the first thing to be skipped and the first thing to be missed:
5
+
6
+ - the assistant editor's **ingest log** (what came in, what verified, what
7
+ failed) and **known-issues list**
8
+ - the assistant colorist's **session-prep summary**, including the hours saved,
9
+ because that is what justifies the prep next time
10
+ - the online editor's **technical handoff document**
11
+ - the post supervisor's **status report**
12
+
13
+ This tool changed timelines and said almost nothing about it. `edit_report`
14
+ fixed that for a single operation; this fixes it for the project. The
15
+ distinction matters: an operation report answers "what did that do", and a
16
+ journal answers "what has happened to this project, and what is still wrong
17
+ with it".
18
+
19
+ ## Append-only, on purpose
20
+
21
+ Records are appended and never rewritten. A known-issues list that gets tidied
22
+ loses the thing it was for — the issue nobody got round to, still sitting there
23
+ three weeks later. Resolving an issue appends a resolution; it does not delete
24
+ the issue. That is the difference between a log and a status board, and the log
25
+ is what you want when someone asks why a shot shipped soft.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import hashlib
31
+ import json
32
+ import os
33
+ from typing import Any, Dict, List, Mapping, Optional, Sequence
34
+
35
+ JOURNAL_DIRNAME = "project_journal"
36
+ JOURNAL_FILE = "journal.jsonl"
37
+
38
+ #: Record kinds. Open set — an unknown kind is stored and rendered generically
39
+ #: rather than rejected, because refusing to record something is worse than
40
+ #: recording it under a label we do not recognise.
41
+ KIND_INGEST = "ingest"
42
+ KIND_ISSUE = "issue"
43
+ KIND_RESOLUTION = "resolution"
44
+ KIND_MILESTONE = "milestone"
45
+
46
+
47
+ def _journal_path(project_root: str) -> str:
48
+ return os.path.join(project_root, JOURNAL_DIRNAME, JOURNAL_FILE)
49
+
50
+
51
+ def append(project_root: str, *, kind: str, summary: str,
52
+ detail: Optional[str] = None, ref: Optional[str] = None,
53
+ data: Optional[Mapping[str, Any]] = None,
54
+ timestamp: Optional[str] = None) -> Dict[str, Any]:
55
+ """Append one record. Never rewrites, never deletes.
56
+
57
+ `timestamp` is supplied by the caller rather than generated here so the
58
+ journal stays deterministic and testable; the server passes the real clock.
59
+ """
60
+ if not str(summary).strip():
61
+ return {"success": False, "error": "a record needs a summary"}
62
+ path = _journal_path(project_root)
63
+ os.makedirs(os.path.dirname(path), exist_ok=True)
64
+ record = {
65
+ "seq": _next_seq(path),
66
+ "kind": str(kind or "note"),
67
+ "summary": str(summary).strip(),
68
+ "detail": str(detail).strip() if detail else None,
69
+ "ref": str(ref) if ref else None,
70
+ "timestamp": timestamp,
71
+ "data": dict(data) if data else None,
72
+ }
73
+ with open(path, "a", encoding="utf-8") as handle:
74
+ handle.write(json.dumps({k: v for k, v in record.items() if v is not None}) + "\n")
75
+ return {"success": True, "record": record}
76
+
77
+
78
+ def read(project_root: str, *, kind: Optional[str] = None) -> List[Dict[str, Any]]:
79
+ path = _journal_path(project_root)
80
+ if not os.path.exists(path):
81
+ return []
82
+ out: List[Dict[str, Any]] = []
83
+ with open(path, "r", encoding="utf-8") as handle:
84
+ for line in handle:
85
+ line = line.strip()
86
+ if not line:
87
+ continue
88
+ try:
89
+ record = json.loads(line)
90
+ except ValueError:
91
+ # A corrupt line must not hide the rest of the journal.
92
+ out.append({"kind": "corrupt", "summary": "unreadable journal line"})
93
+ continue
94
+ if kind is None or record.get("kind") == kind:
95
+ out.append(record)
96
+ return out
97
+
98
+
99
+ def open_issues(project_root: str) -> Dict[str, Any]:
100
+ """Issues with no matching resolution.
101
+
102
+ Resolutions reference an issue by its `ref`. An issue whose resolution never
103
+ arrived stays open forever, which is the entire point — this is the list
104
+ that answers "why did that ship soft".
105
+ """
106
+ records = read(project_root)
107
+ resolved = {r.get("ref") for r in records if r.get("kind") == KIND_RESOLUTION and r.get("ref")}
108
+ issues = [r for r in records if r.get("kind") == KIND_ISSUE]
109
+ still_open = [i for i in issues if i.get("ref") not in resolved]
110
+ return {
111
+ "open": still_open,
112
+ "resolved_count": len(issues) - len(still_open),
113
+ "total_raised": len(issues),
114
+ "note": (
115
+ f"{len(still_open)} of {len(issues)} issues still open. Resolving an "
116
+ "issue appends a resolution; it never deletes the issue. A tidied "
117
+ "known-issues list loses the one nobody got round to."
118
+ ),
119
+ }
120
+
121
+
122
+ KIND_PICTURE_LOCK = "picture_lock"
123
+
124
+
125
+ def timeline_fingerprint(items: Sequence[Mapping[str, Any]]) -> Dict[str, Any]:
126
+ """Shape-of-the-cut signature: item count, total duration, and edit points.
127
+
128
+ It answers one question — "has this been re-cut since we locked" — and it
129
+ has to answer it correctly, so the edit points themselves are hashed rather
130
+ than only summarised. Counts and totals alone miss the case that matters
131
+ most: shots redistributed inside an unchanged runtime, which is exactly what
132
+ a trim pass produces and exactly the change that breaks a conform.
133
+
134
+ Still deliberately blind to everything that is *not* the cut. A grade, a
135
+ marker, a rename, a track change: none of these trip it, because none of
136
+ them cost anything downstream.
137
+ """
138
+ boundaries: List[tuple] = []
139
+ total = 0
140
+ for item in items:
141
+ start, end = item.get("timeline_start_frame"), item.get("timeline_end_frame")
142
+ if start is None or end is None:
143
+ continue
144
+ boundaries.append((int(start), int(end)))
145
+ total += max(0, int(end) - int(start))
146
+ boundaries.sort()
147
+ digest = hashlib.sha1(
148
+ json.dumps(boundaries, separators=(",", ":")).encode("utf-8")
149
+ ).hexdigest()[:16]
150
+ return {
151
+ "item_count": len(boundaries),
152
+ "total_frames": total,
153
+ "edit_points_hash": digest,
154
+ }
155
+
156
+
157
+ def set_picture_lock(project_root: str, *, timeline_name: str,
158
+ items: Sequence[Mapping[str, Any]],
159
+ timestamp: Optional[str] = None) -> Dict[str, Any]:
160
+ """Record picture lock for a timeline, with the shape of the cut at that moment."""
161
+ fingerprint = timeline_fingerprint(items)
162
+ append(project_root, kind=KIND_PICTURE_LOCK,
163
+ summary=f"picture lock: {timeline_name}",
164
+ detail=f"{fingerprint['item_count']} items, {fingerprint['total_frames']} frames",
165
+ ref=timeline_name, data=fingerprint, timestamp=timestamp)
166
+ return {"success": True, "timeline_name": timeline_name, "fingerprint": fingerprint,
167
+ "note": ("Locked. Editorial changes after this point are the expensive kind — "
168
+ "conform, colour and sound all key off this cut.")}
169
+
170
+
171
+ def check_picture_lock(project_root: str, *, timeline_name: str,
172
+ items: Sequence[Mapping[str, Any]]) -> Dict[str, Any]:
173
+ """Has a locked timeline been re-cut since?"""
174
+ locks = [r for r in read(project_root, kind=KIND_PICTURE_LOCK)
175
+ if r.get("ref") == timeline_name]
176
+ if not locks:
177
+ return {
178
+ "locked": False,
179
+ "note": (f"{timeline_name!r} has no picture lock recorded. Not an error — "
180
+ "but nothing downstream can tell an intended recut from an accident."),
181
+ }
182
+ locked = locks[-1]
183
+ was = locked.get("data") or {}
184
+ now = timeline_fingerprint(items)
185
+ drifted = any(
186
+ was.get(key) != now[key]
187
+ for key in ("item_count", "total_frames", "edit_points_hash")
188
+ )
189
+ return {
190
+ "locked": True,
191
+ "drifted": drifted,
192
+ "locked_fingerprint": was,
193
+ "current_fingerprint": now,
194
+ "locked_at": locked.get("timestamp"),
195
+ "note": (
196
+ f"{timeline_name!r} was locked at {was.get('item_count')} items / "
197
+ f"{was.get('total_frames')} frames and is now {now['item_count']} / "
198
+ f"{now['total_frames']}. **This has been re-cut since picture lock.** "
199
+ "Conform, colour and sound all key off the locked cut; changes after it "
200
+ "are the expensive kind. Confirm the recut is intended and tell "
201
+ "downstream."
202
+ if drifted else
203
+ f"{timeline_name!r} matches its picture lock — the cut has not moved."
204
+ ),
205
+ }
206
+
207
+
208
+ def _next_seq(path: str) -> int:
209
+ if not os.path.exists(path):
210
+ return 1
211
+ count = 0
212
+ with open(path, "r", encoding="utf-8") as handle:
213
+ for line in handle:
214
+ if line.strip():
215
+ count += 1
216
+ return count + 1
217
+
218
+
219
+ # ── rendered documents ───────────────────────────────────────────────────────
220
+
221
+
222
+ def render_ingest_log(project_root: str) -> str:
223
+ records = read(project_root, kind=KIND_INGEST)
224
+ lines = ["# Ingest log", ""]
225
+ if not records:
226
+ return "\n".join(lines + ["_No ingest recorded._", ""])
227
+ lines += ["| # | What | Detail |", "|---|---|---|"]
228
+ for record in records:
229
+ lines.append(
230
+ f"| {record.get('seq')} | {record.get('summary')} | "
231
+ f"{str(record.get('detail') or '').replace('|', '/')} |"
232
+ )
233
+ return "\n".join(lines) + "\n"
234
+
235
+
236
+ def render_known_issues(project_root: str) -> str:
237
+ state = open_issues(project_root)
238
+ lines = ["# Known issues", "", f"**{state['note']}**", ""]
239
+ if not state["open"]:
240
+ lines.append("_Nothing open._")
241
+ return "\n".join(lines) + "\n"
242
+ for issue in state["open"]:
243
+ ref = f" `{issue['ref']}`" if issue.get("ref") else ""
244
+ lines.append(f"- **{issue.get('summary')}**{ref}")
245
+ if issue.get("detail"):
246
+ lines.append(f" {issue['detail']}")
247
+ return "\n".join(lines) + "\n"
248
+
249
+
250
+ def render_session_prep(
251
+ project_root: str,
252
+ *,
253
+ session_kind: str = "colour",
254
+ prep_hours: Optional[float] = None,
255
+ estimated_hours_saved: Optional[float] = None,
256
+ hourly_rate: Optional[float] = None,
257
+ ready: Optional[Sequence[str]] = None,
258
+ outstanding: Optional[Sequence[str]] = None,
259
+ ) -> str:
260
+ """The assistant's session-prep summary, including what the prep was worth.
261
+
262
+ The value figures are here because the craft is explicit that they are the
263
+ argument for doing the prep at all — an assistant's pre-balance pass that
264
+ saves two hours of a colourist's time has a number attached, and stating it
265
+ is how the prep survives the next budget conversation.
266
+ """
267
+ lines = [f"# Session prep — {session_kind}", ""]
268
+ for label, entries in (("Ready", ready), ("Outstanding", outstanding)):
269
+ if entries:
270
+ lines += [f"### {label}", ""] + [f"- {e}" for e in entries] + [""]
271
+ state = open_issues(project_root)
272
+ if state["open"]:
273
+ lines += ["### Known issues carried into this session", ""]
274
+ lines += [f"- {i.get('summary')}" for i in state["open"]] + [""]
275
+ if prep_hours is not None or estimated_hours_saved is not None:
276
+ lines += ["### Value", ""]
277
+ if prep_hours is not None:
278
+ lines.append(f"- Prep time: {prep_hours:g}h")
279
+ if estimated_hours_saved is not None:
280
+ lines.append(f"- Estimated session time saved: {estimated_hours_saved:g}h")
281
+ if hourly_rate:
282
+ lines.append(
283
+ f"- At {hourly_rate:g}/hour that is ~"
284
+ f"{estimated_hours_saved * hourly_rate:,.0f} of session time"
285
+ )
286
+ lines += ["", "_Estimates, stated as such. They exist because the prep has "
287
+ "to justify itself in the next budget conversation._", ""]
288
+ return "\n".join(lines).rstrip() + "\n"
289
+
290
+
291
+ def render_handoff(
292
+ *,
293
+ project: str,
294
+ to: str,
295
+ timeline_name: Optional[str] = None,
296
+ technical: Optional[Mapping[str, Any]] = None,
297
+ included: Optional[Sequence[str]] = None,
298
+ known_issues: Optional[Sequence[str]] = None,
299
+ notes: Optional[str] = None,
300
+ ) -> str:
301
+ """The online editor's technical handoff document.
302
+
303
+ Every field left blank is printed as `NOT STATED` rather than omitted. A
304
+ handoff that silently drops the frame rate reads as complete, and the
305
+ receiving facility discovers the gap after they have started.
306
+ """
307
+ lines = [f"# Technical handoff — {project}", "", f"**To:** {to}"]
308
+ if timeline_name:
309
+ lines.append(f"**Timeline:** {timeline_name}")
310
+ lines.append("")
311
+
312
+ spec_keys = ("frame_rate", "resolution", "timeline_color_space",
313
+ "output_color_space", "audio_layout", "start_timecode", "handles")
314
+ lines += ["### Technical", "", "| Field | Value |", "|---|---|"]
315
+ supplied = dict(technical or {})
316
+ for key in spec_keys:
317
+ value = supplied.pop(key, None)
318
+ lines.append(f"| {key.replace('_', ' ')} | {value if value not in (None, '') else '**NOT STATED**'} |")
319
+ for key, value in sorted(supplied.items()):
320
+ lines.append(f"| {key.replace('_', ' ')} | {value} |")
321
+ lines.append("")
322
+
323
+ if included:
324
+ lines += ["### Included", ""] + [f"- {i}" for i in included] + [""]
325
+ if known_issues:
326
+ lines += ["### Known issues", ""] + [f"- {i}" for i in known_issues] + [""]
327
+ if notes:
328
+ lines += ["### Notes", "", notes, ""]
329
+ lines += ["---", "",
330
+ "_Fields marked NOT STATED were not supplied. A handoff that omits "
331
+ "them reads as complete and the gap surfaces after work has started._", ""]
332
+ return "\n".join(lines)
333
+
334
+
335
+ def render_status(
336
+ *,
337
+ project: str,
338
+ phase: str,
339
+ percent_complete: Optional[float] = None,
340
+ blockers: Optional[Sequence[str]] = None,
341
+ next_milestone: Optional[str] = None,
342
+ open_issue_count: Optional[int] = None,
343
+ ) -> str:
344
+ """The post supervisor's status summary. Short on purpose — they are busy."""
345
+ overall = "BLOCKED" if blockers else "ON TRACK"
346
+ lines = [
347
+ f"# {project} — status", "",
348
+ f"**{overall}** · phase: {phase}"
349
+ + (f" · {percent_complete:g}% complete" if percent_complete is not None else ""),
350
+ "",
351
+ ]
352
+ if blockers:
353
+ lines += ["### Blockers", ""] + [f"- {b}" for b in blockers] + [""]
354
+ else:
355
+ lines += ["_No blockers._", ""]
356
+ if open_issue_count:
357
+ lines += [f"{open_issue_count} known issues still open.", ""]
358
+ if next_milestone:
359
+ lines += [f"**Next milestone:** {next_milestone}", ""]
360
+ return "\n".join(lines).rstrip() + "\n"
@@ -0,0 +1,203 @@
1
+ """Matching a shot to a graded reference still.
2
+
3
+ A colorist's most common instruction to an assistant is not a set of numbers,
4
+ it is a picture: *make it look like this one*. The reference still is the
5
+ shared language — the craft is explicit that stills and printed references are
6
+ how a DP and a colorist agree what they are aiming at, because the words for
7
+ colour are unreliable and the picture is not.
8
+
9
+ One of the users who prompted this whole programme was already doing it by hand:
10
+ grading a hero shot, saving it, and applying it as a power grade to get close on
11
+ the rest. That is exactly the right instinct and it should not require doing it
12
+ manually on every shot.
13
+
14
+ ## What this is, and what it very much is not
15
+
16
+ It is **the same neutral technical correction as `prebalance`**, aimed at a
17
+ different target. Pre-balance moves a shot's black and white points to fixed
18
+ neutral targets; this moves them to *the reference's* black and white points.
19
+ Identical machinery, identical guardrails — `validate_plan` from `prebalance` is
20
+ reused rather than reimplemented, so it is impossible for this path to permit an
21
+ operation the pre-balance path forbids.
22
+
23
+ It is **not** a look transfer. Matching end points gets two shots into the same
24
+ tonal neighbourhood; it does not reproduce a grade. A reference with a heavy
25
+ creative curve, a vignette, or a qualifier-driven secondary will not be matched
26
+ by this and must not appear to be. The result says so, every time, because a
27
+ tool that quietly under-delivers on "make it look like this" wastes more of a
28
+ colorist's time than one that refuses.
29
+
30
+ ## The one genuinely hard case, stated rather than hidden
31
+
32
+ If the reference and the target are of **different subject matter**, matching
33
+ their black and white points is close to meaningless — the darkest thing in a
34
+ night exterior and the darkest thing in a white-cyc interior are not the same
35
+ kind of measurement. A confidence signal is derived from how similar the two
36
+ level distributions are, and a poor match is reported as such instead of being
37
+ delivered with a straight face.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ from typing import Any, Dict, List, Mapping, Optional
43
+
44
+ from src.utils import prebalance as _pb
45
+
46
+ #: Node label. Distinct from `ASST: Balance` so a colorist can see at a glance
47
+ #: which shots were matched to a reference and which were balanced to neutral.
48
+ MATCH_NODE_LABEL = "ASST: Match to ref"
49
+
50
+ #: Level distributions further apart than this suggest the two shots are not
51
+ #: comparable material, whatever the numbers say.
52
+ DISSIMILARITY_WARN = 0.25
53
+
54
+
55
+ def _spread(levels: Mapping[str, Any]) -> float:
56
+ return max(
57
+ float(levels[c]["white"]) - float(levels[c]["black"]) for c in ("r", "g", "b")
58
+ )
59
+
60
+
61
+ def match_confidence(reference: Mapping[str, Any], target: Mapping[str, Any]) -> Dict[str, Any]:
62
+ """How comparable these two images are, before any correction is proposed.
63
+
64
+ Compares the *shape* of the two level distributions rather than their
65
+ position: two shots of the same scene at different exposures are highly
66
+ comparable, while a night exterior and a white-cyc interior are not, even if
67
+ their end points happen to be close.
68
+ """
69
+ ref_spread, tgt_spread = _spread(reference), _spread(target)
70
+ if ref_spread <= 0 or tgt_spread <= 0:
71
+ return {"band": "low", "dissimilarity": None,
72
+ "reason": "one of the images has no usable tonal range"}
73
+ ratio = min(ref_spread, tgt_spread) / max(ref_spread, tgt_spread)
74
+ dissimilarity = round(1.0 - ratio, 3)
75
+ if dissimilarity <= 0.10:
76
+ band, reason = "high", "the two images have comparable tonal range"
77
+ elif dissimilarity <= DISSIMILARITY_WARN:
78
+ band, reason = "medium", "tonal ranges differ noticeably; check the match by eye"
79
+ else:
80
+ band, reason = "low", (
81
+ "the two images have very different tonal ranges — matching end points "
82
+ "across dissimilar subject matter is close to meaningless. Treat this "
83
+ "as a starting point only"
84
+ )
85
+ return {"band": band, "dissimilarity": dissimilarity, "reason": reason}
86
+
87
+
88
+ def propose_match(
89
+ reference: Mapping[str, Any],
90
+ target: Mapping[str, Any],
91
+ *,
92
+ reference_name: Optional[str] = None,
93
+ ) -> Dict[str, Any]:
94
+ """Lift and gain that bring the target's end points to the reference's.
95
+
96
+ Same order of operations as a pre-balance: black point first, then the gain
97
+ derived from the *corrected* range rather than the raw one.
98
+ """
99
+ lift: Dict[str, float] = {}
100
+ gain: Dict[str, float] = {}
101
+ for channel in ("r", "g", "b"):
102
+ ref_black = float(reference[channel]["black"])
103
+ tgt_black = float(target[channel]["black"])
104
+ lift[channel] = round(ref_black - tgt_black, 5)
105
+
106
+ for channel in ("r", "g", "b"):
107
+ ref_black = float(reference[channel]["black"])
108
+ ref_white = float(reference[channel]["white"])
109
+ corrected_white = float(target[channel]["white"]) + lift[channel]
110
+ span = corrected_white - ref_black
111
+ gain[channel] = round((ref_white - ref_black) / span, 5) if span > 1e-6 else 1.0
112
+
113
+ confidence = match_confidence(reference, target)
114
+ plan = {
115
+ "node_label": MATCH_NODE_LABEL,
116
+ "reference": reference_name,
117
+ "operations": {
118
+ "lift": lift,
119
+ "gain": gain,
120
+ "legal_limit": {"black": _pb.LEGAL_BLACK, "white": _pb.LEGAL_WHITE},
121
+ },
122
+ "confidence": confidence,
123
+ "reasons": [
124
+ f"Black points moved to the reference's ({reference_name or 'reference'}).",
125
+ "Gain derived from the corrected range so the white point lands with it.",
126
+ f"Legal limiter in the tree from the first node "
127
+ f"({_pb.LEGAL_BLACK:.3f}-{_pb.LEGAL_WHITE:.3f}).",
128
+ ],
129
+ "midtones": (
130
+ "untouched, as in a pre-balance — skin is warm and belongs near "
131
+ "11 o'clock on the vectorscope"
132
+ ),
133
+ "scope": (
134
+ "END POINTS ONLY. This puts the shot in the reference's tonal "
135
+ "neighbourhood; it does NOT reproduce the reference's grade. Curves, "
136
+ "vignettes and secondaries in the reference are not transferred and "
137
+ "cannot be — copy the grade itself for that."
138
+ ),
139
+ }
140
+ # Reuse the pre-balance guardrails rather than reimplementing them, so this
141
+ # path cannot permit an operation the neutral path forbids.
142
+ _pb.validate_plan(plan)
143
+ return plan
144
+
145
+
146
+ def plan_reference_match(
147
+ reference: Mapping[str, Any],
148
+ targets: List[Mapping[str, Any]],
149
+ *,
150
+ reference_name: Optional[str] = None,
151
+ ) -> Dict[str, Any]:
152
+ """Match a set of clips to one reference still.
153
+
154
+ Each target: `{"name": str, "levels": {...}}` from `prebalance.channel_levels`.
155
+ Targets without levels are reported as **not matched**, never as matched.
156
+ """
157
+ if not reference:
158
+ return {"success": False, "error": "No reference levels supplied"}
159
+ if not targets:
160
+ return {"success": False, "error": "No target clips supplied"}
161
+
162
+ plans: List[Dict[str, Any]] = []
163
+ unmatched: List[Dict[str, Any]] = []
164
+ poor: List[str] = []
165
+ for clip in targets:
166
+ if not clip.get("levels"):
167
+ unmatched.append({
168
+ "clip": clip.get("name"),
169
+ "reason": "no levels measured — NOT matched, and not certified matched",
170
+ })
171
+ continue
172
+ plan = propose_match(reference, clip["levels"], reference_name=reference_name)
173
+ plan["clip"] = clip.get("name")
174
+ if plan["confidence"]["band"] == "low":
175
+ poor.append(str(clip.get("name")))
176
+ plans.append(plan)
177
+
178
+ return {
179
+ "success": True,
180
+ "kind": "reference_match",
181
+ "reference": reference_name,
182
+ "matched_count": len(plans),
183
+ "plans": plans,
184
+ "unmatched": unmatched,
185
+ "low_confidence_clips": poor,
186
+ "guardrails": {
187
+ "allowed_operations": sorted(_pb.ALLOWED_OPERATIONS),
188
+ "forbidden_operations": _pb.FORBIDDEN_OPERATIONS,
189
+ "node_label": MATCH_NODE_LABEL,
190
+ },
191
+ "note": (
192
+ f"{len(plans)} clips matched to {reference_name or 'the reference'} on "
193
+ "END POINTS ONLY — same neutral machinery and the same guardrails as a "
194
+ "pre-balance, aimed at the reference instead of at neutral. It does NOT "
195
+ "transfer the reference's grade; curves, vignettes and secondaries stay "
196
+ "where they are."
197
+ + (f" {len(poor)} clips are LOW confidence — very different tonal range "
198
+ "from the reference, where matching end points means little: "
199
+ + ", ".join(poor) + "." if poor else "")
200
+ + (f" {len(unmatched)} clips had no measured levels and were not matched."
201
+ if unmatched else "")
202
+ ),
203
+ }