1#![allow(
14 clippy::many_single_char_names,
15 clippy::cast_precision_loss,
16 clippy::cast_possible_truncation,
17 clippy::cast_sign_loss
18)]
19
20pub mod colour;
21#[cfg(feature = "ffmpeg")]
22mod decode;
23pub mod motion;
24pub mod ocr;
25pub mod rhythm;
26#[cfg(feature = "ffmpeg")]
27mod run;
28pub mod sheet;
29pub mod shotlist;
30
31use std::path::{Path, PathBuf};
32
33use serde::{Deserialize, Serialize};
34
35pub use colour::Colour;
36pub use ffcine_triage::Transcript;
37pub use motion::{Motion, MotionKind, MotionParams};
38pub use rhythm::Music;
39#[cfg(feature = "ffmpeg")]
40pub use run::{Options, run};
41
42pub const VERSION: u32 = 1;
44
45#[derive(Debug, thiserror::Error)]
46pub enum Error {
47 #[error(transparent)]
48 Io(#[from] std::io::Error),
49 #[error(transparent)]
50 Json(#[from] serde_json::Error),
51 #[error("decode: {0}")]
52 Decode(String),
53 #[error("{0}")]
54 Invalid(String),
55}
56
57impl From<ffcine_triage::Error> for Error {
58 fn from(e: ffcine_triage::Error) -> Self {
59 match e {
60 ffcine_triage::Error::Io(e) => Self::Io(e),
61 ffcine_triage::Error::Json(e) => Self::Json(e),
62 ffcine_triage::Error::Decode(m) => Self::Decode(m),
63 e => Self::Invalid(e.to_string()),
64 }
65 }
66}
67
68pub type Result<T, E = Error> = std::result::Result<T, E>;
69
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct Keyframes {
73 pub key: String,
75 pub key_time: f64,
77 pub first: String,
78 pub last: String,
79}
80
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub struct Shot {
84 pub n: usize,
86 pub start: f64,
88 pub end: f64,
89 pub duration: f64,
90 pub frames: [usize; 2],
92 pub keyframes: Keyframes,
93 pub motion: Motion,
94 pub colour: Colour,
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub cut_on_beat: Option<bool>,
98 #[serde(skip_serializing_if = "Option::is_none")]
100 pub beat_offset: Option<f64>,
101 #[serde(skip_serializing_if = "Option::is_none")]
103 pub beats: Option<f64>,
104 #[serde(skip_serializing_if = "String::is_empty", default)]
106 pub speech: String,
107 #[serde(skip_serializing_if = "String::is_empty", default)]
109 pub text: String,
110}
111
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114pub struct Pacing {
115 pub shots: usize,
116 pub mean: f64,
118 pub median: f64,
119 pub shortest: f64,
120 pub longest: f64,
121 pub cuts_per_minute: f64,
122}
123
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
126pub struct Breakdown {
127 pub version: u32,
128 pub source: PathBuf,
129 pub duration: f64,
131 pub fps: f64,
132 pub width: u32,
133 pub height: u32,
134 pub frames: usize,
135 pub decoder: String,
137 pub pacing: Pacing,
138 pub cuts: Vec<f64>,
140 pub shots: Vec<Shot>,
141 pub music: Option<Music>,
143 pub transcript: Option<Transcript>,
145 pub contact_sheets: Vec<String>,
147 pub shotlist: String,
148}
149
150impl Breakdown {
151 pub fn load(path: &Path) -> Result<Self> {
152 Ok(serde_json::from_str(&std::fs::read_to_string(path)?)?)
153 }
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub struct Span {
159 pub a: usize,
161 pub b: usize,
162 pub first: usize,
163 pub key: usize,
164 pub last: usize,
165}
166
167pub fn spans(n: usize, cuts: &[usize], sharpness: &[f32]) -> Vec<Span> {
171 let mut starts = vec![0];
172 starts.extend(cuts.iter().copied().filter(|&c| c > 0 && c < n));
173 starts.dedup();
174 let mut out = Vec::with_capacity(starts.len());
175 for (i, &a) in starts.iter().enumerate() {
176 let b = starts.get(i + 1).copied().unwrap_or(n);
177 if b <= a {
178 continue;
179 }
180 let len = b - a;
181 let inset = usize::from(len > 4);
182 let (lo, hi) = (a + len / 5, (a + len * 4 / 5).max(a + len / 5 + 1).min(b));
183 let key = (lo..hi)
184 .max_by(|&x, &y| {
185 let score = |f: usize| {
186 let rel = (f - a) as f32 / len as f32;
187 sharpness.get(f).copied().unwrap_or(0.0) * (1.0 - (rel - 0.5).abs())
188 };
189 score(x).total_cmp(&score(y))
190 })
191 .unwrap_or(a + len / 2);
192 out.push(Span {
193 a,
194 b,
195 first: a + inset,
196 key,
197 last: b - 1 - inset,
198 });
199 }
200 out
201}
202
203fn median(sorted: &[f64]) -> f64 {
205 let n = sorted.len();
206 match n {
207 0 => 0.0,
208 _ if n.is_multiple_of(2) => f64::midpoint(sorted[n / 2 - 1], sorted[n / 2]),
209 _ => sorted[n / 2],
210 }
211}
212
213pub fn pacing(shots: &[Shot], duration: f64) -> Pacing {
215 let mut d: Vec<f64> = shots.iter().map(|s| s.duration).collect();
216 d.sort_by(f64::total_cmp);
217 let r2 = |v: f64| (v * 100.0).round() / 100.0;
218 let n = d.len();
219 Pacing {
220 shots: n,
221 mean: r2(if n == 0 {
222 0.0
223 } else {
224 d.iter().sum::<f64>() / n as f64
225 }),
226 median: r2(median(&d)),
227 shortest: r2(d.first().copied().unwrap_or(0.0)),
228 longest: r2(d.last().copied().unwrap_or(0.0)),
229 cuts_per_minute: r2(if duration > 0.0 {
230 n.saturating_sub(1) as f64 * 60.0 / duration
231 } else {
232 0.0
233 }),
234 }
235}
236
237pub fn words_in(t: &Transcript, start: f64, end: f64) -> String {
240 let inside = |a: f64, b: f64| (start..end).contains(&f64::midpoint(a, b));
241 let mut parts: Vec<(f64, &str)> = Vec::new();
242 for seg in &t.segments {
243 if seg.words.is_empty() {
244 if inside(seg.start, seg.end) {
245 parts.push((seg.start, seg.text.trim()));
246 }
247 } else {
248 parts.extend(
249 seg.words
250 .iter()
251 .filter(|w| inside(w.start, w.end))
252 .map(|w| (w.start, w.text.trim())),
253 );
254 }
255 }
256 parts.sort_by(|a, b| a.0.total_cmp(&b.0));
257 parts
258 .iter()
259 .map(|p| p.1)
260 .filter(|s| !s.is_empty())
261 .collect::<Vec<_>>()
262 .join(" ")
263}
264
265pub fn clock(t: f64) -> String {
267 let cs = (t.max(0.0) * 100.0).round() as u64;
268 format!("{}:{:02}.{:02}", cs / 6000, cs / 100 % 60, cs % 100)
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 #[test]
276 fn splits_at_cuts() {
277 let mut sharp = vec![1.0; 30];
278 sharp[14] = 9.0;
279 let s = spans(30, &[0, 10, 20, 20], &sharp);
280 assert_eq!(s.len(), 3);
281 assert_eq!((s[0].a, s[0].b, s[0].first, s[0].last), (0, 10, 1, 8));
282 assert_eq!(s[1].key, 14);
283 assert_eq!(s[2].b, 30);
284 let one = spans(3, &[], &[]);
285 assert_eq!((one[0].first, one[0].key, one[0].last), (0, 1, 2));
286 let tiny = spans(1, &[], &[]);
287 assert_eq!((tiny[0].first, tiny[0].key, tiny[0].last), (0, 0, 0));
288 }
289
290 #[test]
291 fn clocks() {
292 assert_eq!(clock(0.0), "0:00.00");
293 assert_eq!(clock(83.456), "1:23.46");
294 }
295
296 #[test]
297 fn even_median() {
298 assert!((median(&[1.0, 3.0]) - 2.0).abs() < 1e-9);
299 assert!((median(&[1.0, 2.0, 9.0]) - 2.0).abs() < 1e-9);
300 assert!(median(&[]).abs() < 1e-9);
301 }
302
303 #[test]
304 fn speech_keeps_untimed_segments() {
305 use ffcine_triage::index::{Segment, Word};
306 let w = |s: f64, t: &str| Word {
307 start: s,
308 end: s + 0.2,
309 text: format!(" {t}"),
310 prob: 1.0,
311 };
312 let t = Transcript {
313 language: "en".into(),
314 model: "m".into(),
315 segments: vec![
316 Segment {
317 start: 0.0,
318 end: 0.5,
319 text: " a b".into(),
320 words: vec![w(0.0, "a"), w(0.3, "b")],
321 },
322 Segment {
323 start: 0.6,
324 end: 0.9,
325 text: " untimed".into(),
326 words: vec![],
327 },
328 ],
329 };
330 assert_eq!(words_in(&t, 0.0, 1.0), "a b untimed");
331 assert_eq!(words_in(&t, 0.25, 1.0), "b untimed");
332 }
333}