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,252 @@
|
|
|
1
|
+
"""Rhythm — criterion #3 of the Rule of Six, and the largest measurable slice at 10%.
|
|
2
|
+
|
|
3
|
+
The naive reading of "rhythm" is *consistency*, and a tool built on that reading
|
|
4
|
+
would flag every variation and reward metronomic cutting. That is backwards.
|
|
5
|
+
An editor of long standing states the actual principle:
|
|
6
|
+
|
|
7
|
+
"You set up a rhythm... and then you need to break that rhythm, because
|
|
8
|
+
that's what gets people's attention."
|
|
9
|
+
|
|
10
|
+
**The break is where meaning lives.**
|
|
11
|
+
|
|
12
|
+
So there are two findings here, and they are opposites:
|
|
13
|
+
|
|
14
|
+
1. **Monotony** — a long metronomic stretch. The pattern was established and
|
|
15
|
+
never broken, so nothing is emphasised and attention drifts.
|
|
16
|
+
2. **An unmotivated break** — the pattern broke somewhere nothing was happening.
|
|
17
|
+
A break is an emphasis; emphasising nothing spends the audience's attention
|
|
18
|
+
on noise.
|
|
19
|
+
|
|
20
|
+
A break that lands on a story beat is neither. It is the craft working, and this
|
|
21
|
+
module reports it as a **PASS with evidence**, not as an absence of findings.
|
|
22
|
+
Silence about the thing that went right is how a tool teaches people that it
|
|
23
|
+
only ever complains.
|
|
24
|
+
|
|
25
|
+
## On cuts per minute
|
|
26
|
+
|
|
27
|
+
Dialogue scenes are often observed to land around 14–16 cuts per minute, and the
|
|
28
|
+
observation was always **descriptive, not prescriptive**. It is reported here as
|
|
29
|
+
a comparison and never as a target. Celebrated features have shipped at well
|
|
30
|
+
under a thousand cuts in their entirety — extraordinarily sparse — and are not
|
|
31
|
+
wrong. Any reading of this number as something to hit would make the tool
|
|
32
|
+
actively harmful.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
|
38
|
+
|
|
39
|
+
from src.utils import rule_of_six as _six
|
|
40
|
+
|
|
41
|
+
#: Commonly observed density for dialogue scenes. Descriptive, NOT a
|
|
42
|
+
#: target — see the module docstring.
|
|
43
|
+
DIALOGUE_CPM_RANGE = (14.0, 16.0)
|
|
44
|
+
|
|
45
|
+
#: Coefficient of variation below which a run of shots reads as metronomic.
|
|
46
|
+
#: Deliberately generous: real editing is uneven, and flagging mild regularity
|
|
47
|
+
#: would bury the genuinely mechanical stretches.
|
|
48
|
+
MONOTONY_CV_THRESHOLD = 0.15
|
|
49
|
+
#: Shortest run that can be called monotonous. Four similar shots is a pattern;
|
|
50
|
+
#: three is a coincidence.
|
|
51
|
+
MONOTONY_MIN_RUN = 6
|
|
52
|
+
|
|
53
|
+
#: How far a shot length must depart from the local norm to count as a break.
|
|
54
|
+
BREAK_RATIO = 1.8
|
|
55
|
+
#: How close a break must sit to a story beat to count as motivated, in seconds.
|
|
56
|
+
BREAK_BEAT_TOLERANCE_S = 2.0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def shot_lengths(items: Sequence[Mapping[str, Any]], fps: float) -> List[Dict[str, Any]]:
|
|
60
|
+
"""Ordered shot durations in seconds, with their timeline positions."""
|
|
61
|
+
rows: List[Dict[str, Any]] = []
|
|
62
|
+
ordered = sorted(
|
|
63
|
+
(i for i in items
|
|
64
|
+
if i.get("timeline_start_frame") is not None
|
|
65
|
+
and i.get("timeline_end_frame") is not None),
|
|
66
|
+
key=lambda i: i["timeline_start_frame"],
|
|
67
|
+
)
|
|
68
|
+
for item in ordered:
|
|
69
|
+
frames = int(item["timeline_end_frame"]) - int(item["timeline_start_frame"])
|
|
70
|
+
if frames <= 0:
|
|
71
|
+
continue
|
|
72
|
+
rows.append({
|
|
73
|
+
"name": item.get("item_name"),
|
|
74
|
+
"start_frame": int(item["timeline_start_frame"]),
|
|
75
|
+
"seconds": round(frames / fps, 3),
|
|
76
|
+
})
|
|
77
|
+
return rows
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _cv(values: Sequence[float]) -> float:
|
|
81
|
+
"""Coefficient of variation — spread relative to length, so it is scale-free."""
|
|
82
|
+
if len(values) < 2:
|
|
83
|
+
return 0.0
|
|
84
|
+
mean = sum(values) / len(values)
|
|
85
|
+
if mean <= 0:
|
|
86
|
+
return 0.0
|
|
87
|
+
variance = sum((v - mean) ** 2 for v in values) / len(values)
|
|
88
|
+
return (variance ** 0.5) / mean
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def find_monotony(shots: Sequence[Mapping[str, Any]]) -> List[Dict[str, Any]]:
|
|
92
|
+
"""Runs of near-identical shot lengths — a pattern that never breaks."""
|
|
93
|
+
findings: List[Dict[str, Any]] = []
|
|
94
|
+
start = 0
|
|
95
|
+
while start < len(shots):
|
|
96
|
+
end = start + 1
|
|
97
|
+
while end < len(shots):
|
|
98
|
+
window = [float(s["seconds"]) for s in shots[start:end + 1]]
|
|
99
|
+
if _cv(window) > MONOTONY_CV_THRESHOLD:
|
|
100
|
+
break
|
|
101
|
+
end += 1
|
|
102
|
+
run = shots[start:end]
|
|
103
|
+
if len(run) >= MONOTONY_MIN_RUN:
|
|
104
|
+
lengths = [float(s["seconds"]) for s in run]
|
|
105
|
+
findings.append({
|
|
106
|
+
"criterion": "rhythm",
|
|
107
|
+
"scope": "sequence",
|
|
108
|
+
"at": run[0]["start_frame"],
|
|
109
|
+
"summary": f"{len(run)} consecutive shots of near-identical length",
|
|
110
|
+
"detail": (
|
|
111
|
+
f"{len(run)} shots averaging {sum(lengths) / len(lengths):.2f}s with "
|
|
112
|
+
f"almost no variation. A pattern was established and never broken, so "
|
|
113
|
+
f"nothing in this stretch is emphasised. The break is where meaning "
|
|
114
|
+
f"lives; there is no break here."
|
|
115
|
+
),
|
|
116
|
+
"evidence": {"shot_count": len(run), "lengths": [round(x, 2) for x in lengths]},
|
|
117
|
+
})
|
|
118
|
+
start = end
|
|
119
|
+
else:
|
|
120
|
+
start += 1
|
|
121
|
+
return findings
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def find_breaks(
|
|
125
|
+
shots: Sequence[Mapping[str, Any]],
|
|
126
|
+
beats: Sequence[float],
|
|
127
|
+
fps: float,
|
|
128
|
+
) -> Dict[str, List[Dict[str, Any]]]:
|
|
129
|
+
"""Pattern breaks, split by whether anything motivated them.
|
|
130
|
+
|
|
131
|
+
`beats` are story-beat times in seconds — sentence boundaries, markers,
|
|
132
|
+
whatever the caller has. A break landing near one is craft.
|
|
133
|
+
"""
|
|
134
|
+
motivated: List[Dict[str, Any]] = []
|
|
135
|
+
unmotivated: List[Dict[str, Any]] = []
|
|
136
|
+
for index in range(2, len(shots)):
|
|
137
|
+
prior = [float(s["seconds"]) for s in shots[max(0, index - 4):index]]
|
|
138
|
+
if len(prior) < 2:
|
|
139
|
+
continue
|
|
140
|
+
local_norm = sum(prior) / len(prior)
|
|
141
|
+
length = float(shots[index]["seconds"])
|
|
142
|
+
if local_norm <= 0:
|
|
143
|
+
continue
|
|
144
|
+
ratio = length / local_norm
|
|
145
|
+
if ratio < BREAK_RATIO and ratio > 1.0 / BREAK_RATIO:
|
|
146
|
+
continue
|
|
147
|
+
at_seconds = float(shots[index]["start_frame"]) / fps
|
|
148
|
+
near = [b for b in beats if abs(float(b) - at_seconds) <= BREAK_BEAT_TOLERANCE_S]
|
|
149
|
+
record = {
|
|
150
|
+
"criterion": "rhythm",
|
|
151
|
+
"scope": "scene",
|
|
152
|
+
"at": shots[index]["start_frame"],
|
|
153
|
+
"shot": shots[index]["name"],
|
|
154
|
+
"length_seconds": round(length, 2),
|
|
155
|
+
"local_norm_seconds": round(local_norm, 2),
|
|
156
|
+
"ratio": round(ratio, 2),
|
|
157
|
+
"direction": "longer" if ratio > 1 else "shorter",
|
|
158
|
+
}
|
|
159
|
+
if near:
|
|
160
|
+
record["summary"] = "rhythm break lands on a story beat"
|
|
161
|
+
record["detail"] = (
|
|
162
|
+
f"Shot runs {round(ratio, 1)}x the local norm and coincides with a beat "
|
|
163
|
+
f"at {near[0]:.1f}s. This is the pattern being broken deliberately — "
|
|
164
|
+
"emphasis where something is happening."
|
|
165
|
+
)
|
|
166
|
+
motivated.append(record)
|
|
167
|
+
else:
|
|
168
|
+
record["summary"] = "rhythm break with nothing to emphasise"
|
|
169
|
+
record["detail"] = (
|
|
170
|
+
f"Shot runs {round(ratio, 1)}x the local norm but no story beat sits "
|
|
171
|
+
f"within {BREAK_BEAT_TOLERANCE_S}s. A break is emphasis; emphasising "
|
|
172
|
+
"nothing spends the audience's attention on noise. Check whether this "
|
|
173
|
+
"is a beat we cannot see, or a shot that simply ran long."
|
|
174
|
+
)
|
|
175
|
+
unmotivated.append(record)
|
|
176
|
+
return {"motivated": motivated, "unmotivated": unmotivated}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def cuts_per_minute(shots: Sequence[Mapping[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
180
|
+
"""Cut density, reported only ever as a comparison."""
|
|
181
|
+
total = sum(float(s["seconds"]) for s in shots)
|
|
182
|
+
if total <= 0 or len(shots) < 2:
|
|
183
|
+
return None
|
|
184
|
+
cpm = len(shots) / (total / 60.0)
|
|
185
|
+
low, high = DIALOGUE_CPM_RANGE
|
|
186
|
+
if cpm < low:
|
|
187
|
+
relation = "sparser than"
|
|
188
|
+
elif cpm > high:
|
|
189
|
+
relation = "denser than"
|
|
190
|
+
else:
|
|
191
|
+
relation = "within"
|
|
192
|
+
return {
|
|
193
|
+
"cuts_per_minute": round(cpm, 1),
|
|
194
|
+
"observed_dialogue_range": list(DIALOGUE_CPM_RANGE),
|
|
195
|
+
"relation": relation,
|
|
196
|
+
"note": (
|
|
197
|
+
f"{round(cpm, 1)} cuts/min, {relation} the 14-16 commonly observed in "
|
|
198
|
+
"dialogue scenes. That range is DESCRIPTIVE, NOT PRESCRIPTIVE — a comparison, "
|
|
199
|
+
"not a target. Celebrated features have shipped at a small fraction of that "
|
|
200
|
+
"density and are not wrong."
|
|
201
|
+
),
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def assess(
|
|
206
|
+
items: Sequence[Mapping[str, Any]],
|
|
207
|
+
*,
|
|
208
|
+
fps: float,
|
|
209
|
+
story_beats: Optional[Sequence[float]] = None,
|
|
210
|
+
) -> Dict[str, Any]:
|
|
211
|
+
"""Evaluate the rhythm criterion. Returns a `rule_of_six` criterion result."""
|
|
212
|
+
shots = shot_lengths(items, fps)
|
|
213
|
+
if len(shots) < 3:
|
|
214
|
+
return {
|
|
215
|
+
"state": _six.STATE_PASS,
|
|
216
|
+
"detail": "too few shots to have a rhythm — nothing to assess",
|
|
217
|
+
"findings": [],
|
|
218
|
+
"shots": shots,
|
|
219
|
+
"insufficient_data": True,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
beats = list(story_beats or [])
|
|
223
|
+
monotony = find_monotony(shots)
|
|
224
|
+
breaks = find_breaks(shots, beats, fps)
|
|
225
|
+
findings = monotony + breaks["unmotivated"]
|
|
226
|
+
|
|
227
|
+
lengths = [float(s["seconds"]) for s in shots]
|
|
228
|
+
detail_bits = [
|
|
229
|
+
f"{len(shots)} shots, {min(lengths):.1f}-{max(lengths):.1f}s, "
|
|
230
|
+
f"variation {_cv(lengths):.2f}"
|
|
231
|
+
]
|
|
232
|
+
if breaks["motivated"]:
|
|
233
|
+
# Say what went right. A tool that only ever complains teaches its
|
|
234
|
+
# reader that a clean report means it did not look.
|
|
235
|
+
detail_bits.append(
|
|
236
|
+
f"{len(breaks['motivated'])} rhythm breaks land on story beats — "
|
|
237
|
+
"the pattern is being broken deliberately"
|
|
238
|
+
)
|
|
239
|
+
if not beats:
|
|
240
|
+
detail_bits.append(
|
|
241
|
+
"no story beats supplied, so breaks could NOT be judged motivated or not"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
"state": _six.STATE_FLAG if findings else _six.STATE_PASS,
|
|
246
|
+
"detail": "; ".join(detail_bits),
|
|
247
|
+
"findings": findings,
|
|
248
|
+
"motivated_breaks": breaks["motivated"],
|
|
249
|
+
"shots": shots,
|
|
250
|
+
"cut_density": cuts_per_minute(shots),
|
|
251
|
+
"beats_supplied": len(beats),
|
|
252
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""The Rule of Six, as an audit that knows what it cannot see.
|
|
2
|
+
|
|
3
|
+
The classical formulation weights its own criteria, which makes them unusually
|
|
4
|
+
codifiable and sets
|
|
5
|
+
exactly one trap:
|
|
6
|
+
|
|
7
|
+
emotion 51% · story 23% · rhythm 10% · eye-trace 7% · 2D plane 5% · 3D space 4%
|
|
8
|
+
|
|
9
|
+
**The weighting is almost perfectly inverted against measurability.** Everything
|
|
10
|
+
a machine can compute is the bottom four, and it sums to 26%.
|
|
11
|
+
|
|
12
|
+
Two conclusions are available and both are wrong. "Don't build it" throws away
|
|
13
|
+
real value — a cut that is technically clean is precisely the cut where a human
|
|
14
|
+
should spend attention on emotion instead of hunting for a stage-line violation.
|
|
15
|
+
"Approximate the top two" is far worse: face-based affect inference scoring the
|
|
16
|
+
51% would be confidently wrong exactly where being wrong costs most, and would
|
|
17
|
+
train its reader to stop looking.
|
|
18
|
+
|
|
19
|
+
So this computes the bottom four and **abstains loudly** on the top two.
|
|
20
|
+
Emotion and story appear in every single audit, carrying their weights, marked
|
|
21
|
+
`NOT_ASSESSED`, and cannot be suppressed.
|
|
22
|
+
|
|
23
|
+
## Three states that must never collapse
|
|
24
|
+
|
|
25
|
+
- `NOT_ASSESSED` — permanent. Emotion and story. No future release changes this.
|
|
26
|
+
- `NOT_IMPLEMENTED` — a criterion this build does not compute yet.
|
|
27
|
+
- `PASS` / `FLAG` — actually evaluated.
|
|
28
|
+
|
|
29
|
+
Collapsing the first two would let the tool grow *quieter* as it grew more
|
|
30
|
+
capable, which is backwards. Collapsing either into `PASS` is the
|
|
31
|
+
`unverified ≠ clean` failure this codebase has spent a lot of effort avoiding.
|
|
32
|
+
|
|
33
|
+
## Two ordering axes
|
|
34
|
+
|
|
35
|
+
The iron rule of the formulation: **always sacrifice lower priorities to preserve higher
|
|
36
|
+
ones.** A cut that breaks 3D continuity to serve rhythm is *correct*, and a tool
|
|
37
|
+
that nags about the 4% above the 10% has inverted the craft. So `CRITERION_WEIGHTS`
|
|
38
|
+
is the ordering key — not detectability, which is the order most editing tools
|
|
39
|
+
accidentally use because continuity errors are the easiest thing to find.
|
|
40
|
+
|
|
41
|
+
A second, orthogonal rule from the same tradition is just as real: **"movie first, scene
|
|
42
|
+
second, moment third"** — sacrifice a moment to save a scene, a scene to save
|
|
43
|
+
the film. That is a *scope* ranking, not a criterion ranking, and a finding that
|
|
44
|
+
threatens the sequence outranks one that threatens a single cut regardless of
|
|
45
|
+
which criterion it belongs to. Both axes are in the sort.
|
|
46
|
+
|
|
47
|
+
## No composite score
|
|
48
|
+
|
|
49
|
+
There is deliberately no field any caller could mistake for an overall cut
|
|
50
|
+
quality number. Averaging four criteria worth 26% into one number implies the
|
|
51
|
+
number describes the cut. It describes a quarter of it, and the least important
|
|
52
|
+
quarter. Per-criterion or nothing.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
from __future__ import annotations
|
|
56
|
+
|
|
57
|
+
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
|
58
|
+
|
|
59
|
+
#: The Rule of Six weights, verbatim. The ordering key for every
|
|
60
|
+
#: finding in this program.
|
|
61
|
+
CRITERION_WEIGHTS = {
|
|
62
|
+
"emotion": 0.51,
|
|
63
|
+
"story": 0.23,
|
|
64
|
+
"rhythm": 0.10,
|
|
65
|
+
"eye_trace": 0.07,
|
|
66
|
+
"two_d_plane": 0.05,
|
|
67
|
+
"three_d_space": 0.04,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#: Human-readable, in weighted order.
|
|
71
|
+
CRITERION_LABELS = {
|
|
72
|
+
"emotion": "Emotion — what should the audience feel at this moment?",
|
|
73
|
+
"story": "Story — does the cut advance the narrative?",
|
|
74
|
+
"rhythm": "Rhythm — is this the rhythmically right moment?",
|
|
75
|
+
"eye_trace": "Eye-trace — does the cut respect where the eye is focused?",
|
|
76
|
+
"two_d_plane": "2D plane — does it honour screen geography?",
|
|
77
|
+
"three_d_space": "3D space — does it maintain physical continuity?",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#: Permanently unmeasurable. Not a TODO. No release changes this.
|
|
81
|
+
UNMEASURABLE = ("emotion", "story")
|
|
82
|
+
|
|
83
|
+
#: Scope axis: sacrifice a moment to save a scene, a scene to save the
|
|
84
|
+
#: film. Lower sorts first.
|
|
85
|
+
SCOPE_RANK = {"movie": 0, "sequence": 1, "scene": 2, "moment": 3}
|
|
86
|
+
|
|
87
|
+
STATE_PASS = "PASS"
|
|
88
|
+
STATE_FLAG = "FLAG"
|
|
89
|
+
STATE_NOT_ASSESSED = "NOT_ASSESSED"
|
|
90
|
+
STATE_NOT_IMPLEMENTED = "NOT_IMPLEMENTED"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _criterion_row(name: str, state: str, *, detail: str) -> Dict[str, Any]:
|
|
94
|
+
return {
|
|
95
|
+
"criterion": name,
|
|
96
|
+
"label": CRITERION_LABELS[name],
|
|
97
|
+
"weight": CRITERION_WEIGHTS[name],
|
|
98
|
+
"state": state,
|
|
99
|
+
"detail": detail,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def abstention_rows() -> List[Dict[str, Any]]:
|
|
104
|
+
"""The 74% this tool does not and will not measure.
|
|
105
|
+
|
|
106
|
+
Present in every audit. Not suppressible — the whole design rests on the
|
|
107
|
+
reader seeing them.
|
|
108
|
+
"""
|
|
109
|
+
return [
|
|
110
|
+
_criterion_row(
|
|
111
|
+
"emotion", STATE_NOT_ASSESSED,
|
|
112
|
+
detail=(
|
|
113
|
+
"51% of this decision, and not measurable. No affect inference is "
|
|
114
|
+
"performed and none is planned: a number here would be believed and "
|
|
115
|
+
"would be wrong in the cases that matter most."
|
|
116
|
+
),
|
|
117
|
+
),
|
|
118
|
+
_criterion_row(
|
|
119
|
+
"story", STATE_NOT_ASSESSED,
|
|
120
|
+
detail="23% of this decision, and not measurable.",
|
|
121
|
+
),
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def coverage(rows: Sequence[Mapping[str, Any]]) -> Dict[str, Any]:
|
|
126
|
+
"""How much of the decision this audit actually looked at."""
|
|
127
|
+
assessed = [r for r in rows if r["state"] in (STATE_PASS, STATE_FLAG)]
|
|
128
|
+
weight = sum(float(r["weight"]) for r in assessed)
|
|
129
|
+
return {
|
|
130
|
+
"assessed_criteria": len(assessed),
|
|
131
|
+
"total_criteria": len(CRITERION_WEIGHTS),
|
|
132
|
+
"weight_assessed": round(weight, 2),
|
|
133
|
+
"weight_unmeasurable": round(sum(CRITERION_WEIGHTS[c] for c in UNMEASURABLE), 2),
|
|
134
|
+
"statement": (
|
|
135
|
+
f"{len(assessed)} of {len(CRITERION_WEIGHTS)} criteria assessed, covering "
|
|
136
|
+
f"{round(weight * 100)}% of the decision. The remaining "
|
|
137
|
+
f"{round((1 - weight) * 100)}% includes emotion (51%) and story (23%), "
|
|
138
|
+
"which are not measurable and are your call."
|
|
139
|
+
),
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def sort_findings(findings: Sequence[Mapping[str, Any]]) -> List[Dict[str, Any]]:
|
|
144
|
+
"""Order by scope then by criterion weight, never by count.
|
|
145
|
+
|
|
146
|
+
Sorting by how many of each kind were found — the intuitive default — would
|
|
147
|
+
bury a single rhythm problem under fifty continuity nits, which is precisely
|
|
148
|
+
the inversion the iron rule forbids.
|
|
149
|
+
"""
|
|
150
|
+
return sorted(
|
|
151
|
+
findings,
|
|
152
|
+
key=lambda f: (
|
|
153
|
+
SCOPE_RANK.get(str(f.get("scope") or "moment"), 9),
|
|
154
|
+
-CRITERION_WEIGHTS.get(str(f.get("criterion") or ""), 0.0),
|
|
155
|
+
str(f.get("at") or ""),
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def audit(
|
|
161
|
+
*,
|
|
162
|
+
timeline_name: Optional[str] = None,
|
|
163
|
+
criterion_results: Optional[Mapping[str, Mapping[str, Any]]] = None,
|
|
164
|
+
findings: Optional[Sequence[Mapping[str, Any]]] = None,
|
|
165
|
+
) -> Dict[str, Any]:
|
|
166
|
+
"""Assemble a Rule of Six audit from whichever criteria have been computed.
|
|
167
|
+
|
|
168
|
+
`criterion_results` maps a measurable criterion name to
|
|
169
|
+
`{"state": PASS|FLAG, "detail": str}`. Anything absent is reported as
|
|
170
|
+
`NOT_IMPLEMENTED` — never as a pass.
|
|
171
|
+
"""
|
|
172
|
+
computed = dict(criterion_results or {})
|
|
173
|
+
rows: List[Dict[str, Any]] = []
|
|
174
|
+
|
|
175
|
+
for name in CRITERION_WEIGHTS:
|
|
176
|
+
if name in UNMEASURABLE:
|
|
177
|
+
continue
|
|
178
|
+
result = computed.get(name)
|
|
179
|
+
if result is None:
|
|
180
|
+
rows.append(_criterion_row(
|
|
181
|
+
name, STATE_NOT_IMPLEMENTED,
|
|
182
|
+
detail="not computed by this build — absence of a finding is not a pass",
|
|
183
|
+
))
|
|
184
|
+
continue
|
|
185
|
+
state = str(result.get("state") or STATE_FLAG).upper()
|
|
186
|
+
if state not in (STATE_PASS, STATE_FLAG):
|
|
187
|
+
raise ValueError(
|
|
188
|
+
f"criterion {name!r} returned state {state!r}; a computed criterion "
|
|
189
|
+
f"must be {STATE_PASS} or {STATE_FLAG}"
|
|
190
|
+
)
|
|
191
|
+
rows.append(_criterion_row(name, state, detail=str(result.get("detail") or "")))
|
|
192
|
+
|
|
193
|
+
all_rows = abstention_rows() + rows
|
|
194
|
+
all_rows.sort(key=lambda r: -CRITERION_WEIGHTS[r["criterion"]])
|
|
195
|
+
|
|
196
|
+
ordered = sort_findings(findings or [])
|
|
197
|
+
return {
|
|
198
|
+
"success": True,
|
|
199
|
+
"kind": "rule_of_six_audit",
|
|
200
|
+
"timeline_name": timeline_name,
|
|
201
|
+
"criteria": all_rows,
|
|
202
|
+
"coverage": coverage(all_rows),
|
|
203
|
+
"findings": ordered,
|
|
204
|
+
"finding_count": len(ordered),
|
|
205
|
+
"ordering": (
|
|
206
|
+
"Findings are ordered by scope (movie > sequence > scene > moment) then "
|
|
207
|
+
"by criterion weight — never by how many of each kind were found. The "
|
|
208
|
+
"established rule is to sacrifice lower priorities to preserve higher ones, "
|
|
209
|
+
"so a rhythm problem (10%) always outranks a screen-geography one (5%)."
|
|
210
|
+
),
|
|
211
|
+
"note": (
|
|
212
|
+
"Emotion (51%) and story (23%) are NOT ASSESSED and never will be. A "
|
|
213
|
+
"clean report here means the measurable quarter is clean — which is "
|
|
214
|
+
"exactly the point at which the 74% that matters becomes worth your "
|
|
215
|
+
"attention. There is deliberately no overall score."
|
|
216
|
+
),
|
|
217
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""One frame per setup — the cutting-room wall of stills.
|
|
2
|
+
|
|
3
|
+
The practice is to pin a representative frame from every setup around the
|
|
4
|
+
cutting room, so
|
|
5
|
+
the whole film is visible at a glance rather than only through the keyhole of a
|
|
6
|
+
timeline. It is a different way of seeing the same material: scale and shape
|
|
7
|
+
become obvious, repetition becomes obvious, and a sequence that is all one size
|
|
8
|
+
becomes obvious in a way scrubbing never makes it.
|
|
9
|
+
|
|
10
|
+
`timeline.thumbnail_contact_sheet` already renders frames — but per **shot**,
|
|
11
|
+
which on a 200-shot timeline produces 200 thumbnails and reproduces the keyhole
|
|
12
|
+
at higher resolution. The practice is per **setup**: one frame for each
|
|
13
|
+
distinct camera position, so twenty images stand for the whole film.
|
|
14
|
+
|
|
15
|
+
## Two decisions worth stating
|
|
16
|
+
|
|
17
|
+
**Which frame.** From the middle of the *longest* usage of that setup. Heads and
|
|
18
|
+
tails catch fades, slates and handles; the longest usage is the one the audience
|
|
19
|
+
spends most time in, so it is the one that should represent it.
|
|
20
|
+
|
|
21
|
+
**What order.** By first appearance, so the sheet reads in the same direction
|
|
22
|
+
the film does. Sorting alphabetically or by duration would produce a tidier grid
|
|
23
|
+
that tells you nothing about shape.
|
|
24
|
+
|
|
25
|
+
## The grouping is a proxy and says so
|
|
26
|
+
|
|
27
|
+
Resolve does not expose "lighting setup". Reel name is used where present and
|
|
28
|
+
the containing folder otherwise — both usually track a camera roll, which
|
|
29
|
+
usually tracks a setup. Usually. An editor regrouping by eye will beat this, and
|
|
30
|
+
the output says so rather than presenting the grouping as fact.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import os
|
|
36
|
+
from typing import Any, Dict, List, Mapping, Sequence
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def setup_key(item: Mapping[str, Any]) -> str:
|
|
40
|
+
"""Best available proxy for "which camera setup is this"."""
|
|
41
|
+
reel = item.get("reel_name")
|
|
42
|
+
if reel:
|
|
43
|
+
return str(reel)
|
|
44
|
+
path = item.get("media_path")
|
|
45
|
+
if path:
|
|
46
|
+
folder = os.path.basename(os.path.dirname(str(path)))
|
|
47
|
+
if folder:
|
|
48
|
+
return folder
|
|
49
|
+
return "ungrouped"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def select_representatives(
|
|
53
|
+
items: Sequence[Mapping[str, Any]],
|
|
54
|
+
*,
|
|
55
|
+
fps: float = 24.0,
|
|
56
|
+
) -> Dict[str, Any]:
|
|
57
|
+
"""One representative frame per setup, ordered by first appearance."""
|
|
58
|
+
if not items:
|
|
59
|
+
return {"success": False, "error": "No timeline items supplied"}
|
|
60
|
+
|
|
61
|
+
groups: Dict[str, List[Mapping[str, Any]]] = {}
|
|
62
|
+
unusable: List[Dict[str, Any]] = []
|
|
63
|
+
for item in items:
|
|
64
|
+
start = item.get("timeline_start_frame")
|
|
65
|
+
end = item.get("timeline_end_frame")
|
|
66
|
+
if start is None or end is None or int(end) <= int(start):
|
|
67
|
+
unusable.append({
|
|
68
|
+
"item": item.get("item_name"),
|
|
69
|
+
"reason": "no usable timeline range — NOT represented on the sheet",
|
|
70
|
+
})
|
|
71
|
+
continue
|
|
72
|
+
groups.setdefault(setup_key(item), []).append(item)
|
|
73
|
+
|
|
74
|
+
representatives: List[Dict[str, Any]] = []
|
|
75
|
+
for setup, members in groups.items():
|
|
76
|
+
longest = max(
|
|
77
|
+
members,
|
|
78
|
+
key=lambda i: int(i["timeline_end_frame"]) - int(i["timeline_start_frame"]),
|
|
79
|
+
)
|
|
80
|
+
start = int(longest["timeline_start_frame"])
|
|
81
|
+
end = int(longest["timeline_end_frame"])
|
|
82
|
+
# Middle of the longest usage: heads and tails catch fades, slates and
|
|
83
|
+
# handles, none of which represent the shot.
|
|
84
|
+
middle = start + (end - start) // 2
|
|
85
|
+
source_start = int(longest.get("source_start_frame") or 0)
|
|
86
|
+
first_appearance = min(int(m["timeline_start_frame"]) for m in members)
|
|
87
|
+
representatives.append({
|
|
88
|
+
"setup": setup,
|
|
89
|
+
"item_name": longest.get("item_name"),
|
|
90
|
+
"media_path": longest.get("media_path"),
|
|
91
|
+
"timeline_frame": middle,
|
|
92
|
+
"source_frame": source_start + (middle - start),
|
|
93
|
+
"source_seconds": round((source_start + (middle - start)) / fps, 3) if fps else None,
|
|
94
|
+
"shot_count": len(members),
|
|
95
|
+
"first_appearance_frame": first_appearance,
|
|
96
|
+
"longest_usage_frames": end - start,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
representatives.sort(key=lambda r: r["first_appearance_frame"])
|
|
100
|
+
return {
|
|
101
|
+
"success": True,
|
|
102
|
+
"kind": "setup_sheet",
|
|
103
|
+
"setup_count": len(representatives),
|
|
104
|
+
"shot_count": sum(r["shot_count"] for r in representatives),
|
|
105
|
+
"representatives": representatives,
|
|
106
|
+
"unusable": unusable,
|
|
107
|
+
"grouping_basis": (
|
|
108
|
+
"reel name where present, else containing folder — a PROXY for lighting "
|
|
109
|
+
"setup. Resolve does not expose setup directly. An editor regrouping by "
|
|
110
|
+
"eye will beat this."
|
|
111
|
+
),
|
|
112
|
+
"note": (
|
|
113
|
+
f"{len(representatives)} setups standing for "
|
|
114
|
+
f"{sum(r['shot_count'] for r in representatives)} shots, ordered by first "
|
|
115
|
+
"appearance so the sheet reads in the direction the film does. Each frame "
|
|
116
|
+
"comes from the middle of that setup's longest usage — heads and tails "
|
|
117
|
+
"catch fades, slates and handles."
|
|
118
|
+
+ (f" {len(unusable)} items had no usable range and are NOT on the sheet."
|
|
119
|
+
if unusable else "")
|
|
120
|
+
),
|
|
121
|
+
}
|