1#![allow(clippy::doc_markdown)] pub mod mp4;
9
10use std::fmt::Write as _;
11use std::path::PathBuf;
12
13use ffcine_core::{Error, Result};
14use ffcine_timeline::{
15 Clip, Effect, HasMetadata, Item, MediaReference, Placed, Timeline, track_kind,
16};
17use serde_json::Value;
18
19pub fn parse_time(s: &str) -> Result<f64> {
21 let invalid = || Error::Invalid {
22 what: "time (S, M:SS or H:MM:SS)",
23 value: s.to_owned(),
24 };
25 let mut t = 0.0;
26 for (i, part) in s.trim().split(':').enumerate() {
27 if i > 2 {
28 return Err(invalid());
29 }
30 let v: f64 = part.parse().map_err(|_| invalid())?;
31 if !(v.is_finite() && v >= 0.0) {
32 return Err(invalid());
33 }
34 t = t * 60.0 + v;
35 }
36 Ok(t)
37}
38
39#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
41pub fn timestamp(seconds: f64, long: bool) -> String {
42 let s = seconds.max(0.0).floor() as u64;
43 if long {
44 format!("{}:{:02}:{:02}", s / 3600, s / 60 % 60, s % 60)
45 } else {
46 format!("{}:{:02}", s / 60, s % 60)
47 }
48}
49
50#[derive(Debug, Clone, PartialEq)]
51pub struct Chapter {
52 pub start: f64,
53 pub title: String,
54}
55
56#[derive(Debug, Clone, PartialEq)]
58pub struct Chapters {
59 pub chapters: Vec<Chapter>,
60 pub duration: f64,
61 pub warnings: Vec<String>,
62}
63
64impl Chapters {
65 pub fn youtube(&self) -> String {
67 let long = self.duration >= 3600.0;
68 let mut s = String::new();
69 for c in &self.chapters {
70 let _ = writeln!(s, "{} {}", timestamp(c.start, long), c.title);
71 }
72 s
73 }
74}
75
76fn flagged(m: &serde_json::Map<String, Value>) -> bool {
77 m.get("ffcine")
78 .and_then(|f| f.get("chapter"))
79 .is_some_and(|v| v.as_bool().unwrap_or(true))
80}
81
82pub fn chapters(t: &Timeline, intro: &str) -> Chapters {
86 let markers = t.placed_markers();
87 let any_flagged = markers.iter().any(|m| flagged(&m.marker.metadata));
88 let mut list: Vec<Chapter> = Vec::new();
89 for m in markers
90 .iter()
91 .filter(|m| !any_flagged || flagged(&m.marker.metadata))
92 {
93 if list.last().is_some_and(|c| (m.start - c.start).abs() < 1.0) {
94 continue;
95 }
96 let title = m.marker.name.trim();
97 list.push(Chapter {
98 start: m.start.max(0.0),
99 title: if title.is_empty() {
100 format!("Chapter {}", list.len() + 1)
101 } else {
102 title.to_owned()
103 },
104 });
105 }
106 let duration = t.duration();
107 let mut warnings = Vec::new();
108 match list.first_mut() {
109 Some(c) if c.start < 1.0 => c.start = 0.0,
110 Some(_) => list.insert(
111 0,
112 Chapter {
113 start: 0.0,
114 title: intro.to_owned(),
115 },
116 ),
117 None => warnings.push("no markers on the timeline".to_owned()),
118 }
119 if !list.is_empty() && list.len() < 3 {
120 warnings.push(format!("{} chapters: YouTube needs at least 3", list.len()));
121 }
122 let long = duration >= 3600.0;
123 for (i, c) in list.iter().enumerate() {
124 let end = list.get(i + 1).map_or(duration, |n| n.start);
125 if end - c.start < 10.0 {
126 warnings.push(format!(
127 "{} {}: {:.1} s long, YouTube needs 10 s",
128 timestamp(c.start, long),
129 c.title,
130 end - c.start
131 ));
132 }
133 }
134 Chapters {
135 chapters: list,
136 duration,
137 warnings,
138 }
139}
140
141#[derive(Debug, Clone, PartialEq)]
142pub struct Credit {
143 pub music: String,
145 pub attribution: Option<String>,
146 pub first: f64,
148}
149
150pub fn credits(t: &Timeline, public: bool) -> (Vec<Credit>, Vec<String>) {
154 let mut out: Vec<Credit> = Vec::new();
155 let mut warnings = Vec::new();
156 let mut placed = t.placed_clips();
157 placed.sort_by(|a, b| a.start.total_cmp(&b.start));
158 for p in placed.iter().filter(|p| p.clip.enabled) {
159 let Some(ns) = p.clip.ffcine() else { continue };
160 if !ns.contains_key("music") && !ns.contains_key("attribution") {
161 continue;
162 }
163 let mut ns = ns.clone();
164 if public && ns.get("private").and_then(Value::as_bool) == Some(true) {
165 match ns.get("public_alt") {
166 Some(Value::Object(alt)) => ns = alt.clone(),
167 Some(Value::String(path)) => {
168 warnings.push(format!(
169 "{}: public cut uses {path}, which has no credit here",
170 p.clip.name
171 ));
172 continue;
173 }
174 _ => continue,
175 }
176 }
177 let music = ns.get("music").and_then(Value::as_str);
178 let attribution = ns
179 .get("attribution")
180 .and_then(Value::as_str)
181 .map(str::to_owned);
182 if music.is_none() && attribution.is_none() {
183 continue;
184 }
185 let music = music.map_or_else(|| p.clip.name.clone(), str::to_owned);
186 if out.iter().any(|c| c.music == music) {
187 continue;
188 }
189 if attribution.is_none() {
190 warnings.push(format!("{music}: no attribution (CC BY tracks need one)"));
191 }
192 out.push(Credit {
193 music,
194 attribution,
195 first: p.start,
196 });
197 }
198 (out, warnings)
199}
200
201pub fn credits_text(credits: &[Credit]) -> String {
203 let mut s = String::new();
204 for c in credits {
205 let _ = writeln!(s, "{}", c.attribution.as_deref().unwrap_or(&c.music));
206 }
207 s
208}
209
210#[derive(Debug, Clone)]
212pub struct Frame<'a> {
213 pub clip: &'a Clip,
214 pub path: PathBuf,
215 pub source_time: f64,
217 pub placed: Placed<'a>,
218}
219
220fn time_scalar(c: &Clip) -> f64 {
221 c.effects
222 .iter()
223 .find_map(|e| match e {
224 Effect::LinearTimeWarp(w) => Some(w.time_scalar),
225 Effect::FreezeFrame(_) => Some(0.0),
226 _ => None,
227 })
228 .unwrap_or(1.0)
229}
230
231pub fn frame_at(t: &Timeline, at: f64) -> Option<Frame<'_>> {
234 let enabled = |i: usize| match t.tracks.children.get(i) {
235 Some(Item::Track(tr)) => tr.enabled,
236 _ => true,
237 };
238 t.placed_clips()
239 .into_iter()
240 .rev()
241 .filter(|p| p.kind == track_kind::VIDEO && p.clip.enabled && enabled(p.track))
242 .filter(|p| p.start <= at && at < p.end())
243 .find_map(|p| {
244 let MediaReference::External(r) = p.clip.media_reference()? else {
245 return None;
246 };
247 let in_point = p
248 .clip
249 .source_range
250 .as_ref()
251 .map_or(0.0, |r| r.start_time.to_seconds());
252 Some(Frame {
253 clip: p.clip,
254 path: r.path()?,
255 source_time: in_point + (at - p.start) * time_scalar(p.clip),
256 placed: p,
257 })
258 })
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use ffcine_timeline::{
265 ExternalReference, Gap, LinearTimeWarp, Marker, RationalTime, TimeRange, Track, track_kind,
266 };
267 use serde_json::json;
268
269 #[allow(clippy::many_single_char_names)]
270 fn timeline() -> Timeline {
271 let mut v = Track::new("V1", track_kind::VIDEO);
272 let media =
273 |p: &str| MediaReference::External(ExternalReference::from_path(p.as_ref(), None));
274 let mut a = Clip::new(
275 "a",
276 media("/m/a.mov"),
277 Some(TimeRange::from_frames(240.0, 2640.0, 24.0)),
278 );
279 let mut m = Marker::new(
280 "Old Town",
281 TimeRange::from_frames(1440.0, 1440.0, 24.0),
282 "RED",
283 );
284 m.metadata.insert("ffcine".into(), json!({"chapter": true}));
285 a.markers.push(m);
286 let mut b = Clip::new(
287 "b",
288 media("/m/b.mov"),
289 Some(TimeRange::from_frames(0.0, 960.0, 24.0)),
290 );
291 b.effects
292 .push(Effect::LinearTimeWarp(LinearTimeWarp::new(2.0)));
293 v.children.push(Item::Clip(a));
294 v.children.push(Item::Clip(b));
295 let mut music = Track::new("Music", track_kind::AUDIO);
296 for (name, attr, private) in [
297 (
298 "Carefree",
299 Some("\"Carefree\" Kevin MacLeod, CC BY 4.0"),
300 false,
301 ),
302 ("Meme", None, true),
303 (
304 "Carefree",
305 Some("\"Carefree\" Kevin MacLeod, CC BY 4.0"),
306 false,
307 ),
308 ] {
309 let mut c = Clip::new(
310 name,
311 media("/m/x.wav"),
312 Some(TimeRange::from_frames(0.0, 240.0, 24.0)),
313 );
314 let ns = c.ffcine_mut();
315 ns.insert("music".into(), name.into());
316 if let Some(a) = attr {
317 ns.insert("attribution".into(), a.into());
318 }
319 if private {
320 ns.insert("private".into(), true.into());
321 }
322 music.children.push(Item::Clip(c));
323 music
324 .children
325 .push(Item::Gap(Gap::new(RationalTime::new(240.0, 24.0))));
326 }
327 let mut t = Timeline::default();
328 t.tracks.children.push(Item::Track(v));
329 t.tracks.children.push(Item::Track(music));
330 let mut start = Marker::new("Arrival", TimeRange::from_frames(0.0, 0.0, 24.0), "RED");
331 start
332 .metadata
333 .insert("ffcine".into(), json!({"chapter": true}));
334 t.tracks.markers.push(start);
335 t.tracks.markers.push(Marker::new(
336 "not a chapter",
337 TimeRange::from_frames(24.0, 24.0, 24.0),
338 "RED",
339 ));
340 t
341 }
342
343 #[test]
344 fn times() {
345 assert!((parse_time("2:47").expect("t") - 167.0).abs() < 1e-9);
346 assert!((parse_time("1:02:47.5").expect("t") - 3767.5).abs() < 1e-9);
347 assert!((parse_time("12.5").expect("t") - 12.5).abs() < 1e-9);
348 assert!(parse_time("1:x").is_err());
349 assert_eq!(timestamp(167.9, false), "2:47");
350 assert_eq!(timestamp(3767.0, true), "1:02:47");
351 }
352
353 #[test]
354 fn chapters_from_flagged_markers() {
355 let c = chapters(&timeline(), "Intro");
356 assert_eq!(c.youtube(), "0:00 Arrival\n0:50 Old Town\n");
358 assert_eq!(c.warnings.len(), 1, "{:?}", c.warnings);
359 }
360
361 #[test]
362 fn credits_dedupe_and_public() {
363 let t = timeline();
364 let (c, w) = credits(&t, false);
365 assert_eq!(c.len(), 2);
366 assert_eq!(w.len(), 1);
367 let (c, _) = credits(&t, true);
368 assert_eq!(credits_text(&c), "\"Carefree\" Kevin MacLeod, CC BY 4.0\n");
369 }
370
371 #[test]
372 fn frame_under_a_time() {
373 let t = timeline();
374 let f = frame_at(&t, 10.0).expect("frame");
375 assert_eq!(f.path, PathBuf::from("/m/a.mov"));
376 assert!((f.source_time - 20.0).abs() < 1e-9);
377 let f = frame_at(&t, 101.0).expect("frame");
379 assert!((f.source_time - 2.0).abs() < 1e-9);
380 assert!(frame_at(&t, 500.0).is_none());
381 }
382}