davinci-resolve-mcp 2.68.2 → 2.69.1

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.
@@ -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
+ }