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.
- video_performance_analyzer-0.1.0.dist-info/METADATA +253 -0
- video_performance_analyzer-0.1.0.dist-info/RECORD +24 -0
- video_performance_analyzer-0.1.0.dist-info/WHEEL +4 -0
- video_performance_analyzer-0.1.0.dist-info/entry_points.txt +2 -0
- video_performance_analyzer-0.1.0.dist-info/licenses/LICENSE +36 -0
- vpa/__init__.py +3 -0
- vpa/analysis/__init__.py +1 -0
- vpa/analysis/compare.py +192 -0
- vpa/analysis/features.py +170 -0
- vpa/cli.py +699 -0
- vpa/config.py +164 -0
- vpa/db.py +382 -0
- vpa/explain.py +152 -0
- vpa/learn.py +231 -0
- vpa/metrics.py +171 -0
- vpa/pipeline.py +242 -0
- vpa/providers/__init__.py +26 -0
- vpa/providers/base.py +29 -0
- vpa/providers/openai_compatible.py +111 -0
- vpa/quiet.py +175 -0
- vpa/recommend.py +241 -0
- vpa/report.py +278 -0
- vpa/tribe.py +370 -0
- vpa/tui.py +380 -0
|
@@ -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
|
+
[](https://pypi.org/project/video-performance-analyzer/)
|
|
35
|
+
[](https://pypi.org/project/video-performance-analyzer/)
|
|
36
|
+
[](https://github.com/christopher-inegbedion/video-performance-analyzer/actions/workflows/test.yml)
|
|
37
|
+
[](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,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
vpa/analysis/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Turning raw cortical predictions into things a human can act on."""
|
vpa/analysis/compare.py
ADDED
|
@@ -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)
|