shotdrift 0.2.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Syamjith NK
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.
@@ -0,0 +1,283 @@
1
+ Metadata-Version: 2.4
2
+ Name: shotdrift
3
+ Version: 0.2.0
4
+ Summary: Measure whether a video holds the camera move it was given.
5
+ Author-email: Syamjith NK <hello@syamjithnk.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Syamjith-NK/shotdrift
8
+ Project-URL: Source, https://github.com/Syamjith-NK/shotdrift
9
+ Project-URL: Issues, https://github.com/Syamjith-NK/shotdrift/issues
10
+ Keywords: video,camera,cinematography,ai-video,quality-assurance,motion-estimation,generative-video,vfx,measurement
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Multimedia :: Video
18
+ Classifier: Topic :: Scientific/Engineering :: Image Processing
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: numpy>=1.22
23
+ Requires-Dist: pillow>=9
24
+ Provides-Extra: test
25
+ Requires-Dist: pytest>=7; extra == "test"
26
+ Dynamic: license-file
27
+
28
+ # shotdrift
29
+
30
+ **You asked for a slow push in. Measure whether you got one.**
31
+
32
+ Generative video is given a camera move and is under no obligation to deliver it.
33
+ The usual check is a person watching sixty clips and forming an impression.
34
+ `shotdrift` measures the camera path out of the pixels — pan, zoom, roll, frame by
35
+ frame — and reports whether one physical camera could have produced it.
36
+
37
+ ```console
38
+ $ pip install git+https://github.com/Syamjith-NK/shotdrift
39
+ $ shotdrift pan_demo.mp4 --expect push-in
40
+ ```
41
+
42
+ ```
43
+ pan_demo.mp4
44
+ 1280x720 -> measured at 512x288, 97 frames @ 24 fps
45
+
46
+ camera path
47
+ dominant move pan
48
+ pan 0.7159 of frame width (x -0.7159, y -0.0000)
49
+ zoom 1.000x
50
+ roll -0.00 deg
51
+ reversals 0 (measured, not judged)
52
+ jerk 0.35
53
+ incoherence 0.0000
54
+ morph 0.041 (used to find cuts, not judged)
55
+ closure 0.0000
56
+ confidence 0.98
57
+
58
+ asked for: push-in
59
+ NOT HELD - asked for push in / dolly in; zoom moved +0.0001, under the 0.02
60
+ floor - that move did not happen
61
+
62
+ no findings: the motion is consistent with one physical camera.
63
+ ```
64
+
65
+ That clip is a perfectly good shot — smooth, coherent, one physical camera. It is
66
+ also not the shot that was ordered: it pans, and the push-in never happened. Both facts matter and they are reported
67
+ separately, because "is this a real camera move?" and "is it the move I asked
68
+ for?" are different questions.
69
+
70
+ Exit codes make it usable in a loop: `0` clean, `1` findings at or above
71
+ `--min-severity`, `2` could not run, `3` the clip could not be measured.
72
+
73
+ ## Why pixels and not a solve
74
+
75
+ Nothing else is available. You are handed an mp4 by a model that will not tell you
76
+ what it did. No camera metadata, no depth, no solve — so the measurement has to
77
+ come from the frames themselves, which means it also works on footage from a
78
+ camera, a render, or a competitor's demo reel.
79
+
80
+ Everything is reported in **fractions of the frame width**, never pixels, so a
81
+ bound means the same thing on a 720p proxy and a 4K master.
82
+
83
+ ## What it measures
84
+
85
+ A grid of tiles is tracked by phase correlation, then one similarity transform is
86
+ fitted to all of them:
87
+
88
+ ```
89
+ dx = tx + s*x - r*y
90
+ dy = ty + s*y + r*x
91
+ ```
92
+
93
+ That yields pan, scale and roll — and the part the fit **cannot** explain is the
94
+ other half of the product. If tiles refuse to agree with any single camera,
95
+ something in the frame is moving independently of one.
96
+
97
+ | | what it means |
98
+ |---|---|
99
+ | `incoherence` | tile disagreement the camera model cannot explain |
100
+ | `jerk` | third derivative of the path: a carried camera changes speed smoothly |
101
+ | `wander` | travelled a long way, arrived nowhere |
102
+ | `breathing` | scale oscillates without going anywhere |
103
+ | `closure` | adding up every step vs. measuring first-to-last directly |
104
+
105
+ `closure` is worth a sentence. The per-frame chain says where frame *k* ended up
106
+ by summing every step; measuring frame 0 against frame *k* directly asks the same
107
+ question using none of those steps. Where both are trustworthy they must agree.
108
+ It is an independent route to the same number rather than a second opinion from
109
+ the same code.
110
+
111
+ Severities are `clean`, `soft`, `broken`. `soft` exists so that a real camera with
112
+ a person walking through it can be *reported* without being *blocked* — the first
113
+ thing anyone does with a noisy alarm is stop reading it. `broken` is the default
114
+ exit gate; `--min-severity soft` makes it strict.
115
+
116
+ ## Declared moves
117
+
118
+ ```console
119
+ $ shotdrift *.mp4 --expect push-in --min-severity broken
120
+ $ shotdrift --list-moves
121
+ ```
122
+
123
+ `static` `locked` `push-in` `dolly-in` `zoom-in` `pull-out` `dolly-out`
124
+ `zoom-out` `pan-left` `pan-right` `tilt-up` `tilt-down` `roll-cw` `roll-ccw`
125
+
126
+ Each is checked three ways, because the three failures need different fixes:
127
+ **did it happen** at all, **was it the right way round**, and **was it held** or
128
+ did a quarter of the travel run backwards.
129
+
130
+ The signs are **camera-relative**. A camera panning right makes the picture move
131
+ left, so `pan-right` expects a negative picture-x. Writing those the intuitive way
132
+ round made a correctly measured clean pan report *"x went the other way"* — the
133
+ measurement was right and the vocabulary was wrong.
134
+
135
+ ## Edited sequences
136
+
137
+ A clip containing cuts is segmented and each shot measured on its own. Run as one
138
+ take, an edited reel reported 13 reversals and a broken closure: all true, and
139
+ useless, because there were six shots and no single camera move was ever there to
140
+ hold.
141
+
142
+ Cuts come free from the morph residual already being computed. There are two
143
+ signatures, and the second nearly got missed: where structure survives the join —
144
+ a whip transition, a dissolve — morph spikes to 1.7–8.6 against the 0.36 real
145
+ within-shot footage reaches. Across a **clean** cut between unrelated shots,
146
+ nothing correlates at all, so no camera can be fitted and morph is `NaN`. The
147
+ first version only tested for the spike, so it caught smeared transitions and
148
+ walked straight past an ordinary hard cut.
149
+
150
+ Use `--no-segment` to force one measurement over everything.
151
+
152
+ ## What it cannot do
153
+
154
+ This section is the useful one.
155
+
156
+ **It does not detect invented geometry.** A tool like this ought to catch the
157
+ melting-background tell, and I could not make it work. Measured over a fixed warp
158
+ budget, real footage reached a structural residual of **1.06** while a literal
159
+ cross-dissolve between two entirely different worlds read **0.358**. Real scenes
160
+ contain people, LED walls and vision mixes, so they change structure *more* than
161
+ melting geometry does. No threshold separates them in either direction, so that
162
+ check is not shipped. A dissolve under a locked-off camera comes back **clean**
163
+ here, and that is pinned as a test so it cannot quietly change.
164
+
165
+ **Direction changes are not a fault signal.** Counting reversals seemed obvious.
166
+ Real operated footage showed **27** direction changes on a slow zoom — an operator
167
+ riding a rocker — against **3** in a deliberately broken control. The real
168
+ material scores worse than the fault, so the count carries no signal alone. The
169
+ question it was trying to answer is answered properly by `--expect`, which
170
+ measures backtracked travel against the move actually requested.
171
+
172
+ **Subject motion is a confound, by construction.** A person crossing a locked-off
173
+ frame raises `incoherence`. That is reported as `soft`, with advice saying so,
174
+ rather than pretended away. Trimmed fitting rejects the worst-fitting quarter of
175
+ tiles, which handles a subject occupying part of the frame and will not save you
176
+ from one that fills it.
177
+
178
+ **Cut detection can miss a cut between two visually similar shots**, and
179
+ `closure` degrades to `NaN` once the first and last frames no longer overlap. Both
180
+ report "not measurable" rather than a number — an anchor that cannot be trusted is
181
+ skipped, because *cannot measure* must never be reported as *measured bad*.
182
+
183
+ ## Calibration
184
+
185
+ Thresholds are not taste. Every bound was set by measuring real single-camera
186
+ footage and synthetic controls with known faults; `calibration.md` records the
187
+ distributions and `validate_real.py` re-derives them.
188
+
189
+ The requirement that shaped the tool is **silence on real footage**: across 24
190
+ shots from two events, nothing `broken` fires, and the three `soft` findings are
191
+ all genuine operator behaviour — two stage cameras that reframed and came back,
192
+ one of which also zoomed in and back out.
193
+
194
+ | measured over 24 real shots | real max | gate | soft at |
195
+ |---|---|---|---|
196
+ | `incoherence` | 0.00068 | — | 0.004 |
197
+ | `closure` | 0.0092 | — | 0.02 |
198
+ | `jerk` | 2.35 | `speed` ≥ 0.005 | 1.9 |
199
+ | `breathing` | 110 | `scale_amp` ≥ 0.04 | 4.0 |
200
+ | `wander` | 52 | travel ≥ 0.05 | 4.0 |
201
+
202
+ **Three of those five are gated rather than thresholded, and that is the most
203
+ useful thing in this README.** They are ratios, and on a locked-off camera each
204
+ denominator is legitimately near zero, so noise divided by nothing produces an
205
+ enormous number on the most ordinary footage there is. Real maxima of 2.3, 110 and
206
+ 52 against bounds of 1.9 and 4.0 are not near-misses — the ratios have come apart,
207
+ and no bound can be raised to cover that without covering every real fault too.
208
+ What works is refusing to judge the quality of a move until there demonstrably
209
+ *was* one. Every gate above has a measured gap behind it: real footage reaches a
210
+ speed of 0.003 where the slowest control with a genuine move sits at 0.014.
211
+
212
+ ### The harness passed while the tool was wrong
213
+
214
+ Worth the paragraph because it is the failure mode of every calibrated tool.
215
+
216
+ `shotdrift <clip>` reported **`BROKEN` on four of seven real clips** while
217
+ `validate_real.py` printed `VALIDATION PASSED` — same code, same files, same
218
+ afternoon. The harness measured the first **8 seconds**; the tool measures **600
219
+ frames**. And `breathing` was gated on *accumulated* scale change against a fixed
220
+ bound, which on a locked-off camera walks 0.0025 at 100 frames to 0.0263 at 1200
221
+ while the per-frame rate stays flat at 2e-5. The gate was crossed somewhere past
222
+ 500 frames by nothing but clip length, and 8 seconds at 50p is 400 frames — just
223
+ underneath it.
224
+
225
+ So: **a fixed bound on a cumulative quantity is a time bomb**, and **a control
226
+ that does not run the shipped configuration validates a configuration nobody
227
+ uses**. The gate is now peak-to-peak amplitude, which does not grow with length
228
+ and is what a viewer can actually see, and the harness defaults to the whole clip.
229
+
230
+ `breathing` also lost its `broken` bound in the same round, for the third
231
+ instance of the pattern this tool keeps running into: real operated footage
232
+ reached a breathing ratio of 169 against the deliberate fault's 46. An operator
233
+ zooming in and back out *is* scale oscillating without going anywhere.
234
+
235
+ ## Python
236
+
237
+ ```python
238
+ from shotdrift import measure
239
+
240
+ r = measure("take_07.mp4", expect="push-in")
241
+ print(r.verdict, r.ok)
242
+ for s in r.shots: # cuts are detected; one entry per shot
243
+ print(s.start, s.end, s.verdict, [f.code for f in s.findings])
244
+ ```
245
+
246
+ ### Frames you never wrote to disk
247
+
248
+ ```python
249
+ from shotdrift import measure_frames, report
250
+
251
+ r = measure_frames(batch, expect="push-in") # (n, h, w, c), numpy or torch
252
+ print(report(r))
253
+ ```
254
+
255
+ No ffmpeg, no temp file. Takes uint8 0..255 or float 0..1, greyscale or RGB(A),
256
+ and a channels-first batch is refused by name rather than measured sideways.
257
+ `report()` renders exactly what the command line prints — the CLI calls it, so
258
+ the two surfaces cannot drift apart.
259
+
260
+ ## In ComfyUI
261
+
262
+ [**shotdrift-comfyui**](https://github.com/Syamjith-NK/shotdrift-comfyui) measures
263
+ the batch inside the graph that produced it, and can stop the queue when a take
264
+ does not hold the move it was given. That is the point of measuring here rather
265
+ than afterwards: an unattended run of sixty takes is only worth doing if the bad
266
+ ones announce themselves.
267
+
268
+ ## Requirements
269
+
270
+ Python ≥ 3.9, `numpy`, `pillow`, and **ffmpeg on PATH**. No OpenCV, no torch, no
271
+ network, no GPU. A tool that needs a 2 GB wheel to measure a camera move does not
272
+ get run.
273
+
274
+ ## Development
275
+
276
+ ```console
277
+ pip install -e ".[test]"
278
+ pytest -q # 83 tests
279
+ PYTHONPATH=src python validate_real.py # real footage + controls, as shipped
280
+ ```
281
+
282
+ MIT. Built by [Syamjith NK](https://syamjithnk.com) — cinematographer and AI
283
+ creative technologist, Abu Dhabi.
@@ -0,0 +1,256 @@
1
+ # shotdrift
2
+
3
+ **You asked for a slow push in. Measure whether you got one.**
4
+
5
+ Generative video is given a camera move and is under no obligation to deliver it.
6
+ The usual check is a person watching sixty clips and forming an impression.
7
+ `shotdrift` measures the camera path out of the pixels — pan, zoom, roll, frame by
8
+ frame — and reports whether one physical camera could have produced it.
9
+
10
+ ```console
11
+ $ pip install git+https://github.com/Syamjith-NK/shotdrift
12
+ $ shotdrift pan_demo.mp4 --expect push-in
13
+ ```
14
+
15
+ ```
16
+ pan_demo.mp4
17
+ 1280x720 -> measured at 512x288, 97 frames @ 24 fps
18
+
19
+ camera path
20
+ dominant move pan
21
+ pan 0.7159 of frame width (x -0.7159, y -0.0000)
22
+ zoom 1.000x
23
+ roll -0.00 deg
24
+ reversals 0 (measured, not judged)
25
+ jerk 0.35
26
+ incoherence 0.0000
27
+ morph 0.041 (used to find cuts, not judged)
28
+ closure 0.0000
29
+ confidence 0.98
30
+
31
+ asked for: push-in
32
+ NOT HELD - asked for push in / dolly in; zoom moved +0.0001, under the 0.02
33
+ floor - that move did not happen
34
+
35
+ no findings: the motion is consistent with one physical camera.
36
+ ```
37
+
38
+ That clip is a perfectly good shot — smooth, coherent, one physical camera. It is
39
+ also not the shot that was ordered: it pans, and the push-in never happened. Both facts matter and they are reported
40
+ separately, because "is this a real camera move?" and "is it the move I asked
41
+ for?" are different questions.
42
+
43
+ Exit codes make it usable in a loop: `0` clean, `1` findings at or above
44
+ `--min-severity`, `2` could not run, `3` the clip could not be measured.
45
+
46
+ ## Why pixels and not a solve
47
+
48
+ Nothing else is available. You are handed an mp4 by a model that will not tell you
49
+ what it did. No camera metadata, no depth, no solve — so the measurement has to
50
+ come from the frames themselves, which means it also works on footage from a
51
+ camera, a render, or a competitor's demo reel.
52
+
53
+ Everything is reported in **fractions of the frame width**, never pixels, so a
54
+ bound means the same thing on a 720p proxy and a 4K master.
55
+
56
+ ## What it measures
57
+
58
+ A grid of tiles is tracked by phase correlation, then one similarity transform is
59
+ fitted to all of them:
60
+
61
+ ```
62
+ dx = tx + s*x - r*y
63
+ dy = ty + s*y + r*x
64
+ ```
65
+
66
+ That yields pan, scale and roll — and the part the fit **cannot** explain is the
67
+ other half of the product. If tiles refuse to agree with any single camera,
68
+ something in the frame is moving independently of one.
69
+
70
+ | | what it means |
71
+ |---|---|
72
+ | `incoherence` | tile disagreement the camera model cannot explain |
73
+ | `jerk` | third derivative of the path: a carried camera changes speed smoothly |
74
+ | `wander` | travelled a long way, arrived nowhere |
75
+ | `breathing` | scale oscillates without going anywhere |
76
+ | `closure` | adding up every step vs. measuring first-to-last directly |
77
+
78
+ `closure` is worth a sentence. The per-frame chain says where frame *k* ended up
79
+ by summing every step; measuring frame 0 against frame *k* directly asks the same
80
+ question using none of those steps. Where both are trustworthy they must agree.
81
+ It is an independent route to the same number rather than a second opinion from
82
+ the same code.
83
+
84
+ Severities are `clean`, `soft`, `broken`. `soft` exists so that a real camera with
85
+ a person walking through it can be *reported* without being *blocked* — the first
86
+ thing anyone does with a noisy alarm is stop reading it. `broken` is the default
87
+ exit gate; `--min-severity soft` makes it strict.
88
+
89
+ ## Declared moves
90
+
91
+ ```console
92
+ $ shotdrift *.mp4 --expect push-in --min-severity broken
93
+ $ shotdrift --list-moves
94
+ ```
95
+
96
+ `static` `locked` `push-in` `dolly-in` `zoom-in` `pull-out` `dolly-out`
97
+ `zoom-out` `pan-left` `pan-right` `tilt-up` `tilt-down` `roll-cw` `roll-ccw`
98
+
99
+ Each is checked three ways, because the three failures need different fixes:
100
+ **did it happen** at all, **was it the right way round**, and **was it held** or
101
+ did a quarter of the travel run backwards.
102
+
103
+ The signs are **camera-relative**. A camera panning right makes the picture move
104
+ left, so `pan-right` expects a negative picture-x. Writing those the intuitive way
105
+ round made a correctly measured clean pan report *"x went the other way"* — the
106
+ measurement was right and the vocabulary was wrong.
107
+
108
+ ## Edited sequences
109
+
110
+ A clip containing cuts is segmented and each shot measured on its own. Run as one
111
+ take, an edited reel reported 13 reversals and a broken closure: all true, and
112
+ useless, because there were six shots and no single camera move was ever there to
113
+ hold.
114
+
115
+ Cuts come free from the morph residual already being computed. There are two
116
+ signatures, and the second nearly got missed: where structure survives the join —
117
+ a whip transition, a dissolve — morph spikes to 1.7–8.6 against the 0.36 real
118
+ within-shot footage reaches. Across a **clean** cut between unrelated shots,
119
+ nothing correlates at all, so no camera can be fitted and morph is `NaN`. The
120
+ first version only tested for the spike, so it caught smeared transitions and
121
+ walked straight past an ordinary hard cut.
122
+
123
+ Use `--no-segment` to force one measurement over everything.
124
+
125
+ ## What it cannot do
126
+
127
+ This section is the useful one.
128
+
129
+ **It does not detect invented geometry.** A tool like this ought to catch the
130
+ melting-background tell, and I could not make it work. Measured over a fixed warp
131
+ budget, real footage reached a structural residual of **1.06** while a literal
132
+ cross-dissolve between two entirely different worlds read **0.358**. Real scenes
133
+ contain people, LED walls and vision mixes, so they change structure *more* than
134
+ melting geometry does. No threshold separates them in either direction, so that
135
+ check is not shipped. A dissolve under a locked-off camera comes back **clean**
136
+ here, and that is pinned as a test so it cannot quietly change.
137
+
138
+ **Direction changes are not a fault signal.** Counting reversals seemed obvious.
139
+ Real operated footage showed **27** direction changes on a slow zoom — an operator
140
+ riding a rocker — against **3** in a deliberately broken control. The real
141
+ material scores worse than the fault, so the count carries no signal alone. The
142
+ question it was trying to answer is answered properly by `--expect`, which
143
+ measures backtracked travel against the move actually requested.
144
+
145
+ **Subject motion is a confound, by construction.** A person crossing a locked-off
146
+ frame raises `incoherence`. That is reported as `soft`, with advice saying so,
147
+ rather than pretended away. Trimmed fitting rejects the worst-fitting quarter of
148
+ tiles, which handles a subject occupying part of the frame and will not save you
149
+ from one that fills it.
150
+
151
+ **Cut detection can miss a cut between two visually similar shots**, and
152
+ `closure` degrades to `NaN` once the first and last frames no longer overlap. Both
153
+ report "not measurable" rather than a number — an anchor that cannot be trusted is
154
+ skipped, because *cannot measure* must never be reported as *measured bad*.
155
+
156
+ ## Calibration
157
+
158
+ Thresholds are not taste. Every bound was set by measuring real single-camera
159
+ footage and synthetic controls with known faults; `calibration.md` records the
160
+ distributions and `validate_real.py` re-derives them.
161
+
162
+ The requirement that shaped the tool is **silence on real footage**: across 24
163
+ shots from two events, nothing `broken` fires, and the three `soft` findings are
164
+ all genuine operator behaviour — two stage cameras that reframed and came back,
165
+ one of which also zoomed in and back out.
166
+
167
+ | measured over 24 real shots | real max | gate | soft at |
168
+ |---|---|---|---|
169
+ | `incoherence` | 0.00068 | — | 0.004 |
170
+ | `closure` | 0.0092 | — | 0.02 |
171
+ | `jerk` | 2.35 | `speed` ≥ 0.005 | 1.9 |
172
+ | `breathing` | 110 | `scale_amp` ≥ 0.04 | 4.0 |
173
+ | `wander` | 52 | travel ≥ 0.05 | 4.0 |
174
+
175
+ **Three of those five are gated rather than thresholded, and that is the most
176
+ useful thing in this README.** They are ratios, and on a locked-off camera each
177
+ denominator is legitimately near zero, so noise divided by nothing produces an
178
+ enormous number on the most ordinary footage there is. Real maxima of 2.3, 110 and
179
+ 52 against bounds of 1.9 and 4.0 are not near-misses — the ratios have come apart,
180
+ and no bound can be raised to cover that without covering every real fault too.
181
+ What works is refusing to judge the quality of a move until there demonstrably
182
+ *was* one. Every gate above has a measured gap behind it: real footage reaches a
183
+ speed of 0.003 where the slowest control with a genuine move sits at 0.014.
184
+
185
+ ### The harness passed while the tool was wrong
186
+
187
+ Worth the paragraph because it is the failure mode of every calibrated tool.
188
+
189
+ `shotdrift <clip>` reported **`BROKEN` on four of seven real clips** while
190
+ `validate_real.py` printed `VALIDATION PASSED` — same code, same files, same
191
+ afternoon. The harness measured the first **8 seconds**; the tool measures **600
192
+ frames**. And `breathing` was gated on *accumulated* scale change against a fixed
193
+ bound, which on a locked-off camera walks 0.0025 at 100 frames to 0.0263 at 1200
194
+ while the per-frame rate stays flat at 2e-5. The gate was crossed somewhere past
195
+ 500 frames by nothing but clip length, and 8 seconds at 50p is 400 frames — just
196
+ underneath it.
197
+
198
+ So: **a fixed bound on a cumulative quantity is a time bomb**, and **a control
199
+ that does not run the shipped configuration validates a configuration nobody
200
+ uses**. The gate is now peak-to-peak amplitude, which does not grow with length
201
+ and is what a viewer can actually see, and the harness defaults to the whole clip.
202
+
203
+ `breathing` also lost its `broken` bound in the same round, for the third
204
+ instance of the pattern this tool keeps running into: real operated footage
205
+ reached a breathing ratio of 169 against the deliberate fault's 46. An operator
206
+ zooming in and back out *is* scale oscillating without going anywhere.
207
+
208
+ ## Python
209
+
210
+ ```python
211
+ from shotdrift import measure
212
+
213
+ r = measure("take_07.mp4", expect="push-in")
214
+ print(r.verdict, r.ok)
215
+ for s in r.shots: # cuts are detected; one entry per shot
216
+ print(s.start, s.end, s.verdict, [f.code for f in s.findings])
217
+ ```
218
+
219
+ ### Frames you never wrote to disk
220
+
221
+ ```python
222
+ from shotdrift import measure_frames, report
223
+
224
+ r = measure_frames(batch, expect="push-in") # (n, h, w, c), numpy or torch
225
+ print(report(r))
226
+ ```
227
+
228
+ No ffmpeg, no temp file. Takes uint8 0..255 or float 0..1, greyscale or RGB(A),
229
+ and a channels-first batch is refused by name rather than measured sideways.
230
+ `report()` renders exactly what the command line prints — the CLI calls it, so
231
+ the two surfaces cannot drift apart.
232
+
233
+ ## In ComfyUI
234
+
235
+ [**shotdrift-comfyui**](https://github.com/Syamjith-NK/shotdrift-comfyui) measures
236
+ the batch inside the graph that produced it, and can stop the queue when a take
237
+ does not hold the move it was given. That is the point of measuring here rather
238
+ than afterwards: an unattended run of sixty takes is only worth doing if the bad
239
+ ones announce themselves.
240
+
241
+ ## Requirements
242
+
243
+ Python ≥ 3.9, `numpy`, `pillow`, and **ffmpeg on PATH**. No OpenCV, no torch, no
244
+ network, no GPU. A tool that needs a 2 GB wheel to measure a camera move does not
245
+ get run.
246
+
247
+ ## Development
248
+
249
+ ```console
250
+ pip install -e ".[test]"
251
+ pytest -q # 83 tests
252
+ PYTHONPATH=src python validate_real.py # real footage + controls, as shipped
253
+ ```
254
+
255
+ MIT. Built by [Syamjith NK](https://syamjithnk.com) — cinematographer and AI
256
+ creative technologist, Abu Dhabi.
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "shotdrift"
7
+ version = "0.2.0"
8
+ description = "Measure whether a video holds the camera move it was given."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Syamjith NK", email = "hello@syamjithnk.com" }]
13
+ keywords = [
14
+ "video", "camera", "cinematography", "ai-video", "quality-assurance",
15
+ "motion-estimation", "generative-video", "vfx", "measurement",
16
+ ]
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Environment :: Console",
20
+ "Intended Audience :: Developers",
21
+ "Intended Audience :: End Users/Desktop",
22
+ "License :: OSI Approved :: MIT License",
23
+ "Programming Language :: Python :: 3",
24
+ "Topic :: Multimedia :: Video",
25
+ "Topic :: Scientific/Engineering :: Image Processing",
26
+ ]
27
+ dependencies = ["numpy>=1.22", "pillow>=9"]
28
+
29
+ [project.optional-dependencies]
30
+ test = ["pytest>=7"]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/Syamjith-NK/shotdrift"
34
+ Source = "https://github.com/Syamjith-NK/shotdrift"
35
+ Issues = "https://github.com/Syamjith-NK/shotdrift/issues"
36
+
37
+ [project.scripts]
38
+ shotdrift = "shotdrift.cli:main"
39
+
40
+ [tool.setuptools.packages.find]
41
+ where = ["src"]
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,25 @@
1
+ """shotdrift - measure whether a video holds the camera move it was given.
2
+
3
+ AI video is asked for a camera move and is not obliged to deliver one. The usual
4
+ check is a person watching sixty clips. This measures the camera path out of the
5
+ pixels - pan, zoom, roll, frame by frame - and reports whether one physical camera
6
+ could have produced it.
7
+
8
+ from shotdrift import measure
9
+ r = measure("take_07.mp4", expect="push-in")
10
+ print(r.verdict, [f.code for f in r.findings])
11
+
12
+ Frames already in memory - inside a generation graph, say, where the clip was
13
+ never written to disk - skip ffmpeg entirely:
14
+
15
+ from shotdrift import measure_frames, report
16
+ r = measure_frames(image_batch, expect="push-in") # (n, h, w, c), 0..1
17
+ print(report(r))
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from .core import Result, Shot, measure, measure_frames, report
23
+
24
+ __version__ = "0.2.0"
25
+ __all__ = ["measure", "measure_frames", "report", "Result", "Shot", "__version__"]