#![allow(
clippy::many_single_char_names,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
pub mod colour;
#[cfg(feature = "ffmpeg")]
mod decode;
pub mod motion;
pub mod ocr;
pub mod rhythm;
#[cfg(feature = "ffmpeg")]
mod run;
pub mod sheet;
pub mod shotlist;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub use colour::Colour;
pub use ffcine_triage::Transcript;
pub use motion::{Motion, MotionKind, MotionParams};
pub use rhythm::Music;
#[cfg(feature = "ffmpeg")]
pub use run::{Options, run};
pub const VERSION: u32 = 1;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error("decode: {0}")]
Decode(String),
#[error("{0}")]
Invalid(String),
}
impl From<ffcine_triage::Error> for Error {
fn from(e: ffcine_triage::Error) -> Self {
match e {
ffcine_triage::Error::Io(e) => Self::Io(e),
ffcine_triage::Error::Json(e) => Self::Json(e),
ffcine_triage::Error::Decode(m) => Self::Decode(m),
e => Self::Invalid(e.to_string()),
}
}
}
pub type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Keyframes {
pub key: String,
pub key_time: f64,
pub first: String,
pub last: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Shot {
pub n: usize,
pub start: f64,
pub end: f64,
pub duration: f64,
pub frames: [usize; 2],
pub keyframes: Keyframes,
pub motion: Motion,
pub colour: Colour,
#[serde(skip_serializing_if = "Option::is_none")]
pub cut_on_beat: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub beat_offset: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub beats: Option<f64>,
#[serde(skip_serializing_if = "String::is_empty", default)]
pub speech: String,
#[serde(skip_serializing_if = "String::is_empty", default)]
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Pacing {
pub shots: usize,
pub mean: f64,
pub median: f64,
pub shortest: f64,
pub longest: f64,
pub cuts_per_minute: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Breakdown {
pub version: u32,
pub source: PathBuf,
pub duration: f64,
pub fps: f64,
pub width: u32,
pub height: u32,
pub frames: usize,
pub decoder: String,
pub pacing: Pacing,
pub cuts: Vec<f64>,
pub shots: Vec<Shot>,
pub music: Option<Music>,
pub transcript: Option<Transcript>,
pub contact_sheets: Vec<String>,
pub shotlist: String,
}
impl Breakdown {
pub fn load(path: &Path) -> Result<Self> {
Ok(serde_json::from_str(&std::fs::read_to_string(path)?)?)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Span {
pub a: usize,
pub b: usize,
pub first: usize,
pub key: usize,
pub last: usize,
}
pub fn spans(n: usize, cuts: &[usize], sharpness: &[f32]) -> Vec<Span> {
let mut starts = vec![0];
starts.extend(cuts.iter().copied().filter(|&c| c > 0 && c < n));
starts.dedup();
let mut out = Vec::with_capacity(starts.len());
for (i, &a) in starts.iter().enumerate() {
let b = starts.get(i + 1).copied().unwrap_or(n);
if b <= a {
continue;
}
let len = b - a;
let inset = usize::from(len > 4);
let (lo, hi) = (a + len / 5, (a + len * 4 / 5).max(a + len / 5 + 1).min(b));
let key = (lo..hi)
.max_by(|&x, &y| {
let score = |f: usize| {
let rel = (f - a) as f32 / len as f32;
sharpness.get(f).copied().unwrap_or(0.0) * (1.0 - (rel - 0.5).abs())
};
score(x).total_cmp(&score(y))
})
.unwrap_or(a + len / 2);
out.push(Span {
a,
b,
first: a + inset,
key,
last: b - 1 - inset,
});
}
out
}
fn median(sorted: &[f64]) -> f64 {
let n = sorted.len();
match n {
0 => 0.0,
_ if n.is_multiple_of(2) => f64::midpoint(sorted[n / 2 - 1], sorted[n / 2]),
_ => sorted[n / 2],
}
}
pub fn pacing(shots: &[Shot], duration: f64) -> Pacing {
let mut d: Vec<f64> = shots.iter().map(|s| s.duration).collect();
d.sort_by(f64::total_cmp);
let r2 = |v: f64| (v * 100.0).round() / 100.0;
let n = d.len();
Pacing {
shots: n,
mean: r2(if n == 0 {
0.0
} else {
d.iter().sum::<f64>() / n as f64
}),
median: r2(median(&d)),
shortest: r2(d.first().copied().unwrap_or(0.0)),
longest: r2(d.last().copied().unwrap_or(0.0)),
cuts_per_minute: r2(if duration > 0.0 {
n.saturating_sub(1) as f64 * 60.0 / duration
} else {
0.0
}),
}
}
pub fn words_in(t: &Transcript, start: f64, end: f64) -> String {
let inside = |a: f64, b: f64| (start..end).contains(&f64::midpoint(a, b));
let mut parts: Vec<(f64, &str)> = Vec::new();
for seg in &t.segments {
if seg.words.is_empty() {
if inside(seg.start, seg.end) {
parts.push((seg.start, seg.text.trim()));
}
} else {
parts.extend(
seg.words
.iter()
.filter(|w| inside(w.start, w.end))
.map(|w| (w.start, w.text.trim())),
);
}
}
parts.sort_by(|a, b| a.0.total_cmp(&b.0));
parts
.iter()
.map(|p| p.1)
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" ")
}
pub fn clock(t: f64) -> String {
let cs = (t.max(0.0) * 100.0).round() as u64;
format!("{}:{:02}.{:02}", cs / 6000, cs / 100 % 60, cs % 100)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn splits_at_cuts() {
let mut sharp = vec![1.0; 30];
sharp[14] = 9.0;
let s = spans(30, &[0, 10, 20, 20], &sharp);
assert_eq!(s.len(), 3);
assert_eq!((s[0].a, s[0].b, s[0].first, s[0].last), (0, 10, 1, 8));
assert_eq!(s[1].key, 14);
assert_eq!(s[2].b, 30);
let one = spans(3, &[], &[]);
assert_eq!((one[0].first, one[0].key, one[0].last), (0, 1, 2));
let tiny = spans(1, &[], &[]);
assert_eq!((tiny[0].first, tiny[0].key, tiny[0].last), (0, 0, 0));
}
#[test]
fn clocks() {
assert_eq!(clock(0.0), "0:00.00");
assert_eq!(clock(83.456), "1:23.46");
}
#[test]
fn even_median() {
assert!((median(&[1.0, 3.0]) - 2.0).abs() < 1e-9);
assert!((median(&[1.0, 2.0, 9.0]) - 2.0).abs() < 1e-9);
assert!(median(&[]).abs() < 1e-9);
}
#[test]
fn speech_keeps_untimed_segments() {
use ffcine_triage::index::{Segment, Word};
let w = |s: f64, t: &str| Word {
start: s,
end: s + 0.2,
text: format!(" {t}"),
prob: 1.0,
};
let t = Transcript {
language: "en".into(),
model: "m".into(),
segments: vec![
Segment {
start: 0.0,
end: 0.5,
text: " a b".into(),
words: vec![w(0.0, "a"), w(0.3, "b")],
},
Segment {
start: 0.6,
end: 0.9,
text: " untimed".into(),
words: vec![],
},
],
};
assert_eq!(words_in(&t, 0.0, 1.0), "a b untimed");
assert_eq!(words_in(&t, 0.25, 1.0), "b untimed");
}
}