Skip to main content

ffcine_delivery/
lib.rs

1//! Delivery extras generated from a timeline (`docs/delivery.md`): YouTube chapter text from
2//! markers ([`chapters`]), music credits from music clips' metadata ([`credits`]), and the
3//! source frame under a timeline time for stills ([`frame_at`]), and chapters and cover art
4//! written into a finished MP4 ([`mp4`]).
5
6#![allow(clippy::doc_markdown)] // "YouTube"
7
8pub 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
19/// `167`, `2:47`, `1:02:47.5` -> seconds.
20pub 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/// Seconds as YouTube writes them: `M:SS`, or `H:MM:SS` when `long`.
40#[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/// Chapters and what YouTube would reject about them.
57#[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    /// The description block: one `0:00 Title` line per chapter.
66    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
82/// Chapters from the timeline's markers: the ones flagged `metadata.ffcine.chapter` (all markers
83/// when none are). YouTube wants the first at 0:00, so a later first chapter gets an `intro`
84/// one before it.
85pub 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    /// `metadata.ffcine.music` (the track's name), or the clip's name.
144    pub music: String,
145    pub attribution: Option<String>,
146    /// Timeline seconds of its first use.
147    pub first: f64,
148}
149
150/// Music credits: every enabled clip with `metadata.ffcine.music` or `attribution`, once per
151/// track, in order of first use. `public` leaves out `private` clips (the public cut swaps or
152/// drops them) and uses a `public_alt`'s own credit when the alt is given as an object.
153pub 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
201/// The credits block: one line per track, the attribution as written (else the name).
202pub 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/// The source frame shown at a timeline time.
211#[derive(Debug, Clone)]
212pub struct Frame<'a> {
213    pub clip: &'a Clip,
214    pub path: PathBuf,
215    /// Seconds into the media file.
216    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
231/// The top-most enabled video clip with media at timeline second `at` (text and other
232/// generators are skipped: they are drawn as overlays).
233pub 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        // The clip marker at source frame 1440 is 50 s into the clip that starts at 0 (in 240).
357        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        // b starts at 100 s and plays at 2x.
378        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}