1#![allow(
16 clippy::cast_precision_loss,
17 clippy::cast_possible_truncation,
18 clippy::cast_sign_loss,
19 clippy::many_single_char_names
20)]
21
22pub mod cache;
23#[cfg(feature = "nvof")]
25#[allow(clippy::missing_safety_doc, clippy::not_unsafe_ptr_arg_deref)]
26pub mod cuda;
27#[cfg(feature = "nvof")]
28mod detect;
29pub mod fx;
30pub mod gyro;
31pub mod hook;
32pub mod motion;
33#[cfg(feature = "nvof")]
34pub mod nvof;
35#[cfg(feature = "telemetry")]
36mod parser;
37pub mod path;
38#[cfg(feature = "nvof")]
39mod read;
40
41#[cfg(feature = "nvof")]
42pub use cache::detect_cached;
43pub use cache::motions_text;
44#[cfg(feature = "nvof")]
45pub use detect::{analysis_size, detect};
46pub use gyro::GyroParams;
47pub use motion::{EstimateParams, Motion};
48pub use path::Zoom;
49
50use std::fmt;
51use std::str::FromStr;
52
53use ffcine_core::Error;
54use ffcine_core::color::named_enum;
55use ffcine_core::geom::Mat3;
56use ffcine_core::probe::Rational;
57
58#[derive(Debug, Clone, Copy, PartialEq)]
61pub struct SourceRange {
62 pub start: f64,
64 pub frames: usize,
65 pub rate: Rational,
66}
67
68named_enum!(
69 #[derive(Default)]
71 FlowQuality {
72 #[default]
73 Slow = "slow",
74 Medium = "medium",
75 Fast = "fast",
76 }
77);
78
79#[derive(Debug, Clone, Copy, PartialEq)]
80pub struct DetectParams {
81 pub analysis_width: u32,
84 pub quality: FlowQuality,
85 pub grid: u32,
87 pub estimate: EstimateParams,
88}
89
90impl Default for DetectParams {
91 fn default() -> Self {
92 Self {
93 analysis_width: 1920,
94 quality: FlowQuality::Slow,
95 grid: 4,
96 estimate: EstimateParams::default(),
97 }
98 }
99}
100
101#[derive(Debug, Clone, PartialEq)]
104pub struct Detection {
105 pub width: u32,
106 pub height: u32,
107 pub analysis_width: u32,
108 pub analysis_height: u32,
109 pub motions: Vec<Motion>,
110}
111
112named_enum!(
113 #[derive(Default)]
115 MotionSource {
116 #[default]
118 Auto = "auto",
119 Gyro = "gyro",
121 Flow = "flow",
123 }
124);
125
126#[derive(Debug, Clone, Copy, PartialEq, Default)]
128pub struct MotionOptions {
129 pub source: MotionSource,
130 pub gyro: GyroParams,
131}
132
133#[derive(Debug, Clone, PartialEq)]
135pub struct MotionDetection {
136 pub detection: Detection,
137 pub source: MotionSource,
139 pub fallback: Option<String>,
141 pub cached: bool,
143}
144
145#[cfg(feature = "nvof")]
149pub fn detect_motion(
150 path: &std::path::Path,
151 range: &SourceRange,
152 params: &DetectParams,
153 options: &MotionOptions,
154 cache: bool,
155) -> ffcine_core::Result<MotionDetection> {
156 let flow = |fallback| -> ffcine_core::Result<MotionDetection> {
157 let (detection, cached) = if cache {
158 detect_cached(path, range, params)?
159 } else {
160 (detect(path, range, params)?, false)
161 };
162 Ok(MotionDetection {
163 detection,
164 source: MotionSource::Flow,
165 fallback,
166 cached,
167 })
168 };
169 if options.source == MotionSource::Flow {
170 return flow(None);
171 }
172 let strict = options.source == MotionSource::Gyro;
173 let gyro = || -> Result<Detection, String> {
174 let (telemetry, size) = read::read(path, range, strict)?;
175 if let Some(why) = telemetry.auto_refusal.filter(|_| !strict) {
176 return Err(why.into());
177 }
178 gyro::detection(&telemetry, range, &options.gyro, size)
179 };
180 let got = if cache {
181 cache::gyro_cached(path, range, options, gyro)?
182 } else {
183 gyro().map(|d| (d, false))
184 };
185 match got {
186 Ok((detection, cached)) => Ok(MotionDetection {
187 detection,
188 source: MotionSource::Gyro,
189 fallback: None,
190 cached,
191 }),
192 Err(why) if strict => Err(Error::Pipeline(format!("gyro: {why}"))),
193 Err(why) => flow(Some(why)),
194 }
195}
196
197#[derive(Debug, Clone, Copy, PartialEq)]
199pub struct StabilizeParams {
200 pub smoothing: u32,
202 pub zoom: Zoom,
203}
204
205impl Default for StabilizeParams {
206 fn default() -> Self {
207 Self {
208 smoothing: 20,
209 zoom: Zoom::default(),
210 }
211 }
212}
213
214#[derive(Debug, Clone, PartialEq)]
215pub struct Stabilization {
216 pub transforms: Vec<Mat3>,
218 pub zoom: f64,
220 pub min_zoom: f64,
222}
223
224pub fn stabilize(det: &Detection, params: &StabilizeParams) -> Stabilization {
226 let (w, h) = (f64::from(det.width), f64::from(det.height));
227 let centre = [w / 2.0, h / 2.0];
228 let actual = path::camera_path(&det.motions, centre);
229 let smooth = path::gaussian(&actual, params.smoothing);
230 let mut transforms = path::corrections(&actual, &smooth, centre);
231 let min_zoom = path::min_zoom(&transforms, w, h);
232 let zoom = path::choose_zoom(params.zoom, min_zoom);
233 path::apply_zoom(&mut transforms, centre, zoom);
234 Stabilization {
235 transforms,
236 zoom,
237 min_zoom,
238 }
239}
240
241pub fn fold_crop(transforms: &[Mat3], crop: [f64; 4], width: u32, height: u32) -> Vec<Mat3> {
246 let [x, y, cw, ch] = crop;
247 let a = [
248 [cw / f64::from(width), 0.0, x],
249 [0.0, ch / f64::from(height), y],
250 [0.0, 0.0, 1.0],
251 ];
252 transforms
253 .iter()
254 .map(|m| ffcine_core::geom::mul(m, &a))
255 .collect()
256}
257
258pub fn transforms_text(transforms: &[Mat3]) -> String {
260 use std::fmt::Write as _;
261 let mut s =
262 String::from("# frame m00 m01 m02 m10 m11 m12 m20 m21 m22; output px -> source px\n");
263 for (k, m) in transforms.iter().enumerate() {
264 let _ = write!(s, "{k}");
265 for v in m.iter().flatten() {
266 let _ = write!(s, " {v}");
267 }
268 s.push('\n');
269 }
270 s
271}
272
273#[derive(Debug, Clone, Copy, PartialEq)]
276pub struct Jitter {
277 pub tx: f64,
279 pub ty: f64,
280 pub angle: f64,
282 pub pairs: usize,
284 pub confidence: f64,
286}
287
288pub fn jitter(motions: &[Motion], sigma: f64, exclude: &[usize]) -> Jitter {
291 let keep: Vec<usize> = (1..motions.len())
292 .filter(|i| !exclude.contains(i))
293 .collect();
294 let series = |f: fn(&Motion) -> f64| -> f64 {
295 let reach = (3.0 * sigma).ceil() as usize;
296 let mut sum2 = 0.0;
297 for &i in &keep {
298 let (mut s, mut ws) = (0.0, 0.0);
299 for &j in &keep {
300 if j + reach >= i && j <= i + reach {
301 let d = j as f64 - i as f64;
302 let w = (-d * d / (2.0 * sigma * sigma)).exp();
303 s += w * f(&motions[j]);
304 ws += w;
305 }
306 }
307 sum2 += (f(&motions[i]) - s / ws).powi(2);
308 }
309 (sum2 / keep.len().max(1) as f64).sqrt()
310 };
311 Jitter {
312 tx: series(|m| m.tx),
313 ty: series(|m| m.ty),
314 angle: series(|m| m.angle.to_degrees()),
315 pairs: keep.len(),
316 confidence: keep.iter().map(|&i| motions[i].confidence).sum::<f64>()
317 / keep.len().max(1) as f64,
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use ffcine_core::geom;
325
326 fn shaky(n: usize) -> Vec<Motion> {
327 (0..n)
328 .map(|i| {
329 if i == 0 {
330 return Motion::IDENTITY;
331 }
332 let t = i as f64;
333 Motion {
334 tx: -3.0 + 2.5 * (t * 1.7).sin(),
336 ty: 1.5 * (t * 2.3).cos(),
337 angle: 0.002 * (t * 1.1).sin(),
338 scale: 1.0,
339 confidence: 1.0,
340 }
341 })
342 .collect()
343 }
344
345 #[test]
346 fn stabilizing_removes_jitter() {
347 let det = Detection {
348 width: 3840,
349 height: 2160,
350 analysis_width: 1920,
351 analysis_height: 1080,
352 motions: shaky(120),
353 };
354 let before = jitter(&det.motions, 5.0, &[]);
355 let st = stabilize(&det, &StabilizeParams::default());
356 assert_eq!(st.transforms.len(), 120);
357 assert!(st.zoom > 1.0 && st.zoom < 1.05, "{st:?}");
358 let c = [1920.0, 1080.0];
360 let after: Vec<Motion> = (0..120)
361 .map(|k| {
362 if k == 0 {
363 return Motion::IDENTITY;
364 }
365 let m = det.motions[k].to_mat3(c);
366 let prev = geom::inverse(&st.transforms[k - 1]).unwrap();
367 let rel = geom::mul(&prev, &geom::mul(&m, &st.transforms[k]));
368 let (tx, ty, angle, scale) = geom::decompose_similarity(&rel, c);
369 Motion {
370 tx,
371 ty,
372 angle,
373 scale,
374 confidence: 1.0,
375 }
376 })
377 .collect();
378 let after = jitter(&after, 5.0, &[]);
379 assert!(after.tx < before.tx * 0.1, "{before:?} -> {after:?}");
380 assert!(after.ty < before.ty * 0.1, "{before:?} -> {after:?}");
381 assert!(after.angle < before.angle * 0.1, "{before:?} -> {after:?}");
382 assert_eq!(jitter(&det.motions, 5.0, &[50]).pairs, 118);
383 }
384
385 #[cfg(feature = "nvof")]
386 #[test]
387 fn no_gyro_is_an_error_only_when_asked_for() {
388 let dir = std::env::temp_dir().join(format!("ffcine-motion-{}", std::process::id()));
389 std::fs::create_dir_all(&dir).unwrap();
390 let f = dir.join("not-a-video.mp4");
391 std::fs::write(&f, b"not a video").unwrap();
392 let range = SourceRange {
393 start: 0.0,
394 frames: 2,
395 rate: Rational { num: 25, den: 1 },
396 };
397 let run = |source| {
398 let options = MotionOptions {
399 source,
400 ..MotionOptions::default()
401 };
402 detect_motion(&f, &range, &DetectParams::default(), &options, false)
403 .unwrap_err()
404 .to_string()
405 };
406 assert!(run(MotionSource::Gyro).contains("gyro: "));
408 assert!(!run(MotionSource::Auto).contains("gyro: "));
409 assert!(!run(MotionSource::Flow).contains("gyro: "));
410 std::fs::remove_dir_all(&dir).unwrap();
411 }
412
413 #[test]
414 fn crop_folds_before_the_warp() {
415 let t = [geom::translation(10.0, -4.0)];
416 let f = fold_crop(&t, [100.0, 50.0, 1920.0, 1080.0], 3840, 2160);
417 let p = geom::apply(&f[0], [3840.0, 2160.0]);
419 assert!(
420 (p[0] - 2030.0).abs() < 1e-9 && (p[1] - 1126.0).abs() < 1e-9,
421 "{p:?}"
422 );
423 }
424}