video-performance-analyzer 0.1.0__py3-none-any.whl

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,253 @@
1
+ Metadata-Version: 2.5
2
+ Name: video-performance-analyzer
3
+ Version: 0.1.0
4
+ Summary: Predict and compare how videos land, using Meta's TRIBE v2 brain-encoding model, with LLM recommendations that learn from real performance.
5
+ Project-URL: Homepage, https://github.com/christopher-inegbedion/video-performance-analyzer
6
+ Project-URL: Issues, https://github.com/christopher-inegbedion/video-performance-analyzer/issues
7
+ Author: video-performance-analyzer contributors
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: analysis,creative,ffmpeg,fmri,neuroscience,tribe,video
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Multimedia :: Video
16
+ Requires-Python: >=3.11
17
+ Requires-Dist: httpx>=0.27
18
+ Requires-Dist: numpy>=1.26
19
+ Requires-Dist: platformdirs>=4.2
20
+ Requires-Dist: rich>=13.7
21
+ Requires-Dist: tomli-w>=1.0
22
+ Requires-Dist: tqdm>=4.66
23
+ Requires-Dist: typer>=0.12
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8; extra == 'dev'
26
+ Requires-Dist: ruff>=0.5; extra == 'dev'
27
+ Provides-Extra: tribe
28
+ Requires-Dist: faster-whisper>=1.0; extra == 'tribe'
29
+ Requires-Dist: torch<2.7,>=2.5; extra == 'tribe'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # video-performance-analyzer
33
+
34
+ [![PyPI](https://img.shields.io/pypi/v/video-performance-analyzer)](https://pypi.org/project/video-performance-analyzer/)
35
+ [![Python](https://img.shields.io/pypi/pyversions/video-performance-analyzer)](https://pypi.org/project/video-performance-analyzer/)
36
+ [![test](https://github.com/christopher-inegbedion/video-performance-analyzer/actions/workflows/test.yml/badge.svg)](https://github.com/christopher-inegbedion/video-performance-analyzer/actions/workflows/test.yml)
37
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
38
+
39
+ Predict how a video lands before you publish it — then find out whether the
40
+ prediction was right, and get better advice because of it.
41
+
42
+ `vpa` runs Meta's **TRIBE v2** brain-encoding model over your video, reduces the
43
+ output to a response curve you can read, compares it against a reference video
44
+ and against your own back catalogue, and asks an LLM for concrete editing
45
+ recommendations. When you later record what the video actually did — views,
46
+ likes, watch-through — it learns, and goes back to revise advice it has already
47
+ given you.
48
+
49
+ ```
50
+ vpa analyse my-cut-v3.mp4 --reference competitor-ad.mp4 \
51
+ --segments "hook:0-3,montage:3-16,card:16-19,end:19-24"
52
+ ```
53
+
54
+ ```
55
+ ╭─ my-cut-v3 ──────────────────────────────────────────────────╮
56
+ │ 24.1s · 25 timesteps · Audio, Video │
57
+ ╰──────────────────────────────────────────────────────────────╯
58
+ ╭─ predicted response over time ───────────────────────────────╮
59
+ │ ▃▄▄▅▅▆▇▇▆▇▇▇▆▅▄▃▃▃▃▂▂▂▂█ │
60
+ │ 0s 24s │
61
+ ╰──────────────────────────────────────────────────────────────╯
62
+ opening_2s 0.882 Mean response over the first two seconds.
63
+ trough_value 0.777 Lowest normalised response.
64
+ decay +0.055 Opening minus closing.
65
+ ```
66
+
67
+ ---
68
+
69
+ ## What this actually measures — read this first
70
+
71
+ TRIBE v2 predicts **fMRI brain response**. It does not predict attention, watch
72
+ time, clicks, or sales. Treating "more predicted response" as "better advert" is
73
+ an inference the model does not make and its authors do not claim.
74
+
75
+ This matters enough that the tool is built around it:
76
+
77
+ - Every metric shown has a plain-English explanation attached (`vpa explain`).
78
+ - The same caveats are injected into the LLM's context, so recommendations
79
+ inherit them rather than overclaiming.
80
+ - Known artefacts are flagged automatically. A cut to black spikes the curve
81
+ every time; `vpa` detects that and excludes it from peak-finding rather than
82
+ reporting it as your ending landing.
83
+ - Position matters more than content. In testing, the *same images* scored
84
+ highest in one edit and lowest in another purely because of where they sat.
85
+
86
+ Use it to rank variants of the same idea. Don't use it as a verdict on one video.
87
+
88
+ ## Install
89
+
90
+ ```bash
91
+ pip install video-performance-analyzer # core
92
+ pip install 'video-performance-analyzer[tribe]' # + scoring dependencies (~3GB of models)
93
+ pip install git+https://github.com/facebookresearch/tribev2.git
94
+ ```
95
+
96
+ TRIBE itself is not on PyPI, so that last line is always needed for scoring.
97
+
98
+ <details>
99
+ <summary>From source instead</summary>
100
+
101
+ ```bash
102
+ git clone https://github.com/christopher-inegbedion/video-performance-analyzer.git
103
+ cd video-performance-analyzer
104
+ python3.12 -m venv .venv && source .venv/bin/activate
105
+ pip install -e '.[dev]'
106
+ ```
107
+ </details>
108
+
109
+ You also need **ffmpeg** (`brew install ffmpeg` / `apt install ffmpeg`) and an
110
+ API key for whichever LLM you point it at:
111
+
112
+ ```bash
113
+ export OPENROUTER_API_KEY=sk-or-...
114
+ vpa doctor # tells you exactly what is missing
115
+ ```
116
+
117
+ ## Use
118
+
119
+ ```bash
120
+ vpa analyse cut.mp4 # score one video
121
+ vpa analyse cut.mp4 -r reference.mp4 # compare against something
122
+ vpa show eval_a1b2c3 # revisit a past evaluation
123
+ vpa list evals # everything you have run
124
+ vpa ask eval_a1b2c3 # ask questions about it
125
+ vpa explain artefacts # what the numbers can't tell you
126
+ vpa tui # interactive session
127
+ ```
128
+
129
+ ### Describing your structure
130
+
131
+ Even segments are a poor guide. Tell it where your real beats are and the report
132
+ speaks your language:
133
+
134
+ ```bash
135
+ vpa analyse cut.mp4 --segments "hook:0-3,montage:3-16,card:16-19,logo:19-24"
136
+ ```
137
+
138
+ ### The learning loop
139
+
140
+ This is what makes the tool improve. Record what a published video did:
141
+
142
+ ```bash
143
+ vpa metrics add my-cut-v3 --views 12400 --likes 380 --platform instagram
144
+ vpa metrics template -o metrics.csv && vpa metrics import metrics.csv
145
+ vpa metrics show # what it has learned so far
146
+ ```
147
+
148
+ Once outcomes exist, two things change:
149
+
150
+ 1. New recommendations weight **real performance above predicted response**.
151
+ 2. Old evaluations become *stale* — their advice predates what you now know.
152
+ `vpa retrofit` revisits them and writes a new generation of recommendations
153
+ saying what changed. Nothing is overwritten; you can read every generation
154
+ with `vpa show <id> -g 1`.
155
+
156
+ The tool is deliberately honest about sample size. Below five labelled videos it
157
+ refuses to call correlations findings and says so in plain terms.
158
+
159
+ ## Configuration
160
+
161
+ ```bash
162
+ vpa config init # writes a commented config file
163
+ vpa config show # effective settings and where they came from
164
+ ```
165
+
166
+ Any OpenAI-compatible endpoint works — OpenRouter (default), OpenAI, Together,
167
+ Groq, or a local model:
168
+
169
+ ```toml
170
+ [llm]
171
+ model = "google/gemini-2.5-flash"
172
+ base_url = "https://openrouter.ai/api/v1"
173
+
174
+ [tribe]
175
+ target_fps = 24 # 60fps costs ~2.5x for identical footage
176
+ enable_language = false # true needs a gated Llama repo + HF token
177
+
178
+ [analysis]
179
+ ignore_tail_s = 1.5 # don't let the cut-to-black spike become a "finding"
180
+ ```
181
+
182
+ For a fully local setup, point `base_url` at Ollama (`http://localhost:11434/v1`)
183
+ and no API key is needed.
184
+
185
+ ## Running TRIBE on a laptop
186
+
187
+ TRIBE ships configured for Meta's GPU cluster. `vpa` patches the known problems
188
+ automatically and tells you what it changed:
189
+
190
+ | Problem | What happens without the fix |
191
+ |---|---|
192
+ | `device: cuda` baked into the checkpoint | `Torch not compiled with CUDA enabled` |
193
+ | `num_workers: 20` baked in | DataLoader workers die silently; the process sits at 3% CPU looking alive |
194
+ | `compute_type` hardcoded to `float16` | CPU speech extraction always fails |
195
+ | `uvx whisperx` resolves a broken torchaudio | `module 'torchaudio' has no attribute 'list_audio_backends'` |
196
+ | Language pathway needs gated `meta-llama/Llama-3.2-1B` | 401 on an otherwise working run |
197
+
198
+ The last two are why word timings are generated locally with faster-whisper and
199
+ written to the `.tsv` cache TRIBE reads — whisperx is never invoked. The language
200
+ pathway is **off by default**, so vision + audition work with no licence gate.
201
+
202
+ ### How long it takes
203
+
204
+ Scoring is dominated entirely by the video encoder. Setup — importing the
205
+ package, loading the checkpoint, building events — is about 8 seconds. Everything
206
+ after that scales with how much footage you feed it.
207
+
208
+ Measured on an Apple Silicon laptop (CPU only), roughly **96x realtime**:
209
+
210
+ | video length | time to score |
211
+ |---:|---:|
212
+ | 10s | ~16 min |
213
+ | 15s | ~24 min |
214
+ | 24s | ~38 min |
215
+ | 60s | ~96 min |
216
+
217
+ **This is not an interactive tool.** Start a run and come back to it.
218
+
219
+ Three ways to make it tractable:
220
+
221
+ - **Halve the frame rate.** `target_fps = 12` roughly halves the encode, because
222
+ cost is proportional to frames. V-JEPA samples frames rather than reading every
223
+ one, so the quality cost is plausibly small — but that is untested, so measure
224
+ it on your own material before trusting it.
225
+ - **Score an excerpt.** For a feed asset the first 6-10 seconds is where the
226
+ scroll decision happens. Scoring only the opening is a legitimate strategy.
227
+ - **Use a GPU.** This is what the model was built for and it is a different order
228
+ of magnitude. Set `device = "cuda"`.
229
+
230
+ There is no caching or warm-start trick that helps: the cost is the encoder, not
231
+ the setup.
232
+
233
+ ## Licences
234
+
235
+ This tool is MIT. The models are not:
236
+
237
+ - **TRIBE v2** is **CC BY-NC 4.0 — non-commercial**. Using it to optimise
238
+ commercial advertising is arguably outside that licence. That is your call to
239
+ make deliberately, and the tool says so rather than hiding it.
240
+ - The language pathway uses **Llama-3.2-1B**, which is gated and carries Meta's
241
+ own terms.
242
+
243
+ ## Development
244
+
245
+ ```bash
246
+ pip install -e '.[dev]'
247
+ pytest -q
248
+ ruff check vpa
249
+ ```
250
+
251
+ Contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Platform
252
+ connectors for metrics ingestion (`vpa/metrics.py` has a documented seam) and
253
+ additional LLM providers (`vpa/providers/`) are the most useful places to start.
@@ -0,0 +1,24 @@
1
+ vpa/__init__.py,sha256=2gNybQfaKtq13Cb9P2z4wj-UOxc0m0rxgl8Jl1r1P-0,126
2
+ vpa/cli.py,sha256=dWDoWq_6yPSMJj3s0ORVg9LUiEyh4YxhWYKszH1m2v8,25260
3
+ vpa/config.py,sha256=leBAmzZe5QH5sW9Gx2ETRMiDtmMkIyQXzGD61EUrPoQ,6200
4
+ vpa/db.py,sha256=_HSJ00MaXtB2IcF7hpfUWX5ZJHyeCSm8SispjdqXSdQ,13231
5
+ vpa/explain.py,sha256=BiR_zvGYtuNDKUHZntcsoD_9mfVUDn-UmKbidwsx54Q,6982
6
+ vpa/learn.py,sha256=4A1ImXZ1k7ZG0xVaH7ERxDryrvUjZPgAV32bR0kKT7Q,8689
7
+ vpa/metrics.py,sha256=lSCG7yo1-Qci9czkkpiD1jiS9XiozefqdUXNLQvWzuI,6047
8
+ vpa/pipeline.py,sha256=ay03uAxQRBMimx8udFgA_E5mvNsFX-IJ844g19ayFSg,8490
9
+ vpa/quiet.py,sha256=nn6H_JUYPl8k1799YgpqQgFaZVtnyN4ULwbjHgxbXoA,6473
10
+ vpa/recommend.py,sha256=HyQa6b4emlzQ_-GVlSl_Dyl1fEylLesWBnQ9UqG6ntM,9432
11
+ vpa/report.py,sha256=smwGAZybs3esJSjF3ssXlq0k6RHamgbyzkwcZ-zMWk0,9403
12
+ vpa/tribe.py,sha256=BOVBFCeJ-9KXThBYg8-NtZzipJjnXlQHvKZnRpk5kqU,13624
13
+ vpa/tui.py,sha256=uC8xTvkblgmCGOI62MjB6vkPgqjgQ5VT-HPme3uQes8,16259
14
+ vpa/analysis/__init__.py,sha256=Ji53bt316vP833J3NMyr74gAo-G4LPxObQ43L53Jp0Y,71
15
+ vpa/analysis/compare.py,sha256=UOo0PMri6V-0p7lYE24aC-Vj7-efET-V2eSUhQuT2mg,7356
16
+ vpa/analysis/features.py,sha256=wn_pKn6dYipF1AXWlm1GTcYx0YUDU8Ub-VNWOhc8jnc,6264
17
+ vpa/providers/__init__.py,sha256=rJF0Hq4hKh445VkBibELxQOzJGWeH4NA0pXJrwoM3zk,1067
18
+ vpa/providers/base.py,sha256=M5xFuDzFVR92Q3yYK_0rktzwhns6nJZMTqzWQ0zZ2cM,639
19
+ vpa/providers/openai_compatible.py,sha256=ljhydNxz1wpaE4VDGutOtayAjqmbyuSf5p3bfFDooxs,4633
20
+ video_performance_analyzer-0.1.0.dist-info/METADATA,sha256=hwf9bk_Azl5tnX_0KqwpK8QBS4aaVSZy0yu-9hKbmsg,10483
21
+ video_performance_analyzer-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
22
+ video_performance_analyzer-0.1.0.dist-info/entry_points.txt,sha256=6FN3aTDsUNXm2l-MF9csud-nKGo-741Xm5oGkF0NQsg,36
23
+ video_performance_analyzer-0.1.0.dist-info/licenses/LICENSE,sha256=CdgeYt4HxEjFH8ctMYwR8AcvTaq3hOC9sQFA1MY4rfs,1655
24
+ video_performance_analyzer-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ vpa = vpa.cli:app
@@ -0,0 +1,36 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 video-performance-analyzer contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ NOTE ON MODEL LICENCES
26
+
27
+ This tool orchestrates third-party models that carry their own terms:
28
+
29
+ * Meta TRIBE v2 (facebook/tribev2, facebook/tribev2-mini) is released under
30
+ CC BY-NC 4.0 — NON-COMMERCIAL use only. Using it to optimise commercial
31
+ advertising is arguably outside that licence. This is your decision to make,
32
+ not this tool's.
33
+ * TRIBE's language pathway uses meta-llama/Llama-3.2-1B, a gated repository
34
+ requiring licence acceptance and a HuggingFace token.
35
+
36
+ The MIT licence above covers this tool's own source code only.
vpa/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """video-performance-analyzer — predict how a video lands, and learn from what actually happened."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """Turning raw cortical predictions into things a human can act on."""
@@ -0,0 +1,192 @@
1
+ """Compare one video's response profile against a reference, and against history.
2
+
3
+ Two comparisons, deliberately different in kind:
4
+
5
+ * 1:1 — your cut against one reference you are trying to emulate or beat.
6
+ Curves are resampled to a common length so videos of different durations can
7
+ be laid over each other. Position matters more than duration here.
8
+
9
+ * history — your cut against your own past evaluations. This is where the
10
+ tool becomes more useful the longer you use it, because past evaluations
11
+ can carry real published performance.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import asdict, dataclass, field
17
+ from typing import Any
18
+
19
+ import numpy as np
20
+
21
+ from .features import Features, resample
22
+
23
+
24
+ @dataclass
25
+ class SectionDelta:
26
+ label: str
27
+ subject: float
28
+ reference: float
29
+ delta: float
30
+ verdict: str # ahead | behind | level
31
+
32
+
33
+ @dataclass
34
+ class Comparison:
35
+ reference_label: str
36
+ correlation: float # shape similarity, -1..1
37
+ same_shape: bool
38
+ subject_summary: dict[str, float]
39
+ reference_summary: dict[str, float]
40
+ deltas: dict[str, float] # headline metric differences
41
+ sections: list[SectionDelta] = field(default_factory=list)
42
+ aligned_subject: list[float] = field(default_factory=list)
43
+ aligned_reference: list[float] = field(default_factory=list)
44
+ notes: list[str] = field(default_factory=list)
45
+
46
+ def to_dict(self) -> dict[str, Any]:
47
+ d = asdict(self)
48
+ d["sections"] = [
49
+ asdict(s) if not isinstance(s, dict) else s for s in self.sections
50
+ ]
51
+ return d
52
+
53
+
54
+ _HEADLINE = ("opening_2s", "closing_2s", "peak_value", "trough_value", "swing",
55
+ "variability", "decay", "sustained_above")
56
+
57
+
58
+ def _summary(f: Features) -> dict[str, float]:
59
+ return {k: float(getattr(f, k)) for k in _HEADLINE}
60
+
61
+
62
+ def compare(subject: Features, reference: Features, ref_label: str,
63
+ same_shape_r: float = 0.85, points: int = 60) -> Comparison:
64
+ """Lay two response curves over each other and report the differences."""
65
+ a = resample(subject.trace, points)
66
+ b = resample(reference.trace, points)
67
+
68
+ r = 0.0 if a.std() == 0 or b.std() == 0 else float(np.corrcoef(a, b)[0, 1])
69
+
70
+ sub_s, ref_s = _summary(subject), _summary(reference)
71
+ deltas = {k: round(sub_s[k] - ref_s[k], 4) for k in _HEADLINE}
72
+
73
+ # Compare in fifths of each video, so structure lines up proportionally even
74
+ # when the two cuts are different lengths.
75
+ sections: list[SectionDelta] = []
76
+ names = ["first fifth", "second fifth", "middle fifth", "fourth fifth", "final fifth"]
77
+ chunk = points // 5
78
+ for i, name in enumerate(names):
79
+ lo, hi = i * chunk, (i + 1) * chunk if i < 4 else points
80
+ sa, sb = float(a[lo:hi].mean()), float(b[lo:hi].mean())
81
+ d = sa - sb
82
+ verdict = "ahead" if d > 0.05 else ("behind" if d < -0.05 else "level")
83
+ sections.append(
84
+ SectionDelta(name, round(sa, 3), round(sb, 3), round(d, 3), verdict)
85
+ )
86
+
87
+ notes: list[str] = []
88
+ if subject.tail_spike or reference.tail_spike:
89
+ notes.append(
90
+ "One or both videos spike on the final step. That is almost always a "
91
+ "hard cut (to black or to a card), not the ending landing — treat it "
92
+ "as an artefact."
93
+ )
94
+ if subject.modalities and reference.modalities and \
95
+ set(subject.modalities) != set(reference.modalities):
96
+ notes.append(
97
+ f"Modality mismatch: subject scored on {', '.join(subject.modalities)}; "
98
+ f"reference on {', '.join(reference.modalities)}. Compare shapes, not "
99
+ "absolute magnitudes."
100
+ )
101
+ if abs(subject.duration_s - reference.duration_s) > max(subject.duration_s, 1) * 0.4:
102
+ notes.append(
103
+ f"Durations differ a lot ({subject.duration_s:.1f}s vs "
104
+ f"{reference.duration_s:.1f}s). Curves were stretched to align; "
105
+ "position is comparable, pacing is not."
106
+ )
107
+
108
+ return Comparison(
109
+ reference_label=ref_label,
110
+ correlation=round(r, 3),
111
+ same_shape=bool(r >= same_shape_r),
112
+ subject_summary={k: round(v, 4) for k, v in sub_s.items()},
113
+ reference_summary={k: round(v, 4) for k, v in ref_s.items()},
114
+ deltas=deltas,
115
+ sections=sections,
116
+ aligned_subject=[round(float(x), 4) for x in a],
117
+ aligned_reference=[round(float(x), 4) for x in b],
118
+ notes=notes,
119
+ )
120
+
121
+
122
+ @dataclass
123
+ class HistoryPosition:
124
+ """Where this evaluation sits among the user's own past work."""
125
+
126
+ n_previous: int
127
+ percentiles: dict[str, float] # metric -> 0..100 within own history
128
+ best_performing: dict[str, Any] | None # features of highest-engagement past video
129
+ trend: dict[str, str] # metric -> improving | declining | flat
130
+ notes: list[str] = field(default_factory=list)
131
+
132
+ def to_dict(self) -> dict[str, Any]:
133
+ return asdict(self)
134
+
135
+
136
+ def position_in_history(subject: Features, history: list[dict[str, Any]]) -> HistoryPosition:
137
+ """Rank this video's profile against previous evaluations.
138
+
139
+ `history` entries are {"features": Features-dict, "engagement": float|None, ...}
140
+ """
141
+ notes: list[str] = []
142
+ if not history:
143
+ return HistoryPosition(0, {}, None, {}, ["No previous evaluations to compare against yet."])
144
+
145
+ pct: dict[str, float] = {}
146
+ for key in _HEADLINE:
147
+ past = [float(h["features"][key]) for h in history if key in h.get("features", {})]
148
+ if not past:
149
+ continue
150
+ cur = float(getattr(subject, key))
151
+ pct[key] = round(100.0 * sum(p < cur for p in past) / len(past), 1)
152
+
153
+ # Trend across the last few, oldest -> newest.
154
+ trend: dict[str, str] = {}
155
+ if len(history) >= 3:
156
+ for key in ("opening_2s", "sustained_above", "decay"):
157
+ series = [float(h["features"][key]) for h in history if key in h.get("features", {})]
158
+ if len(series) >= 3:
159
+ half = len(series) // 2
160
+ first = np.mean(series[:half])
161
+ last = np.mean(series[half:])
162
+ diff = last - first
163
+ if key == "decay": # lower decay is better
164
+ trend[key] = (
165
+ "improving" if diff < -0.02
166
+ else ("declining" if diff > 0.02 else "flat")
167
+ )
168
+ else:
169
+ trend[key] = (
170
+ "improving" if diff > 0.02
171
+ else ("declining" if diff < -0.02 else "flat")
172
+ )
173
+
174
+ scored = [h for h in history if h.get("engagement") is not None]
175
+ best = None
176
+ if scored:
177
+ best_entry = max(scored, key=lambda h: h["engagement"])
178
+ best = {
179
+ "label": best_entry.get("label"),
180
+ "engagement": best_entry["engagement"],
181
+ "features": {k: best_entry["features"].get(k) for k in _HEADLINE},
182
+ }
183
+ notes.append(
184
+ f"Your best-performing measured video is '{best_entry.get('label') or 'unlabelled'}'."
185
+ )
186
+ else:
187
+ notes.append(
188
+ "No published performance recorded yet. Add views/likes with "
189
+ "`vpa metrics add` and recommendations start learning from outcomes."
190
+ )
191
+
192
+ return HistoryPosition(len(history), pct, best, trend, notes)