Skip to main content

alpha2_compose/
facts.rs

1//! What compiling needs to know about the files a spec names, and which
2//! files those are.
3//!
4//! The compiler reads no files. A binding lists what a spec needs
5//! ([`requirements`]), finds it out (a host's `conform` describes media
6//! without a GPU; a caption file is read as text), and passes it in as
7//! [`Facts`].
8
9use std::collections::BTreeMap;
10
11use alpha2_model::MediaFingerprint;
12use alpha2_time::FrameRate;
13use serde::{Deserialize, Serialize};
14
15use crate::spec::{FileKind, MediaItem, Node, VideoSpec};
16
17/// What the compiler knows of the files a spec names, by the path the spec
18/// gives them.
19#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
20#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
21#[serde(deny_unknown_fields)]
22pub struct Facts {
23    /// Media files' shapes and lengths.
24    #[serde(default)]
25    pub media: BTreeMap<String, MediaFacts>,
26    /// Caption files' text.
27    #[serde(default)]
28    pub texts: BTreeMap<String, String>,
29}
30
31/// One media file's shape and length.
32#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
33#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
34#[serde(deny_unknown_fields)]
35pub struct MediaFacts {
36    /// Displayed width of its pictures, in pixels.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub width: Option<u32>,
39    /// Displayed height of its pictures, in pixels.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub height: Option<u32>,
42    /// Its video's frame rate.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub frame_rate: Option<FrameRate>,
45    /// Where it ends on its own timeline, in ticks.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub duration_ticks: Option<i64>,
48    /// Whether it has an audio stream.
49    #[serde(default)]
50    pub has_audio: bool,
51}
52
53impl MediaFacts {
54    /// The facts a host's fingerprint of the file records, as `conform`
55    /// reports them.
56    #[must_use]
57    pub fn from_fingerprint(fingerprint: &MediaFingerprint) -> Self {
58        let streams = fingerprint.streams.as_ref();
59        let video = streams.and_then(|streams| streams.video.as_ref());
60        Self {
61            width: video.map(|video| video.width),
62            height: video.map(|video| video.height),
63            frame_rate: video.and_then(|video| video.frame_rate),
64            duration_ticks: fingerprint.duration_ticks,
65            has_audio: streams.is_some_and(|streams| streams.audio.is_some()),
66        }
67    }
68}
69
70/// The files a spec names.
71#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
72#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
73#[serde(deny_unknown_fields)]
74pub struct Requirements {
75    /// Media files, each once, in the order the spec first names them.
76    pub media: Vec<RequiredMedia>,
77    /// Caption files, each once, in that order.
78    pub texts: Vec<String>,
79}
80
81/// A media file a spec names.
82#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
83#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
84#[serde(deny_unknown_fields)]
85pub struct RequiredMedia {
86    /// The path as the spec gives it.
87    pub path: String,
88    /// What the compiler takes it to hold, and a host should read it as.
89    pub kind: FileKind,
90}
91
92/// What a file holds by its extension: a still for the image formats the
93/// host decodes itself, sound for the audio-only ones, video otherwise.
94/// Formats an extension provider reads (an animated GIF, SVG, OpenEXR) are
95/// not media nodes' to guess.
96#[must_use]
97pub fn kind_of_path(path: &str) -> FileKind {
98    let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
99    let extension = name
100        .rsplit_once('.')
101        .map(|(_, extension)| extension.to_ascii_lowercase())
102        .unwrap_or_default();
103    match extension.as_str() {
104        "png" | "jpg" | "jpeg" | "webp" | "bmp" | "tif" | "tiff" | "avif" => FileKind::Image,
105        "mp3" | "m4a" | "aac" | "wav" | "flac" | "ogg" | "oga" | "opus" | "aif" | "aiff"
106        | "caf" => FileKind::Audio,
107        _ => FileKind::Video,
108    }
109}
110
111/// The files `spec` names, for a binding to describe before compiling.
112#[must_use]
113pub fn requirements(spec: &VideoSpec) -> Requirements {
114    let mut found = Requirements::default();
115    let nodes = std::iter::once(&spec.body)
116        .chain(&spec.overlays)
117        .chain(&spec.audio);
118    for node in nodes {
119        walk(node, &mut found);
120    }
121    found
122}
123
124fn add_media(found: &mut Requirements, path: &str, kind: Option<FileKind>) {
125    if !found.media.iter().any(|media| media.path == path) {
126        found.media.push(RequiredMedia {
127            path: path.to_owned(),
128            kind: kind.unwrap_or_else(|| kind_of_path(path)),
129        });
130    }
131}
132
133fn add_item(found: &mut Requirements, item: &MediaItem) {
134    match item {
135        MediaItem::Path(path) => add_media(found, path, None),
136        MediaItem::Node(node) => walk(node, found),
137    }
138}
139
140fn walk(node: &Node, found: &mut Requirements) {
141    match node {
142        Node::Sequence(sequence) => sequence
143            .children
144            .iter()
145            .for_each(|child| walk(child, found)),
146        Node::Stack(stack) => stack.children.iter().for_each(|child| walk(child, found)),
147        Node::Grid(grid) => grid.children.iter().for_each(|child| walk(child, found)),
148        Node::Media(media) => add_media(found, &media.path, media.kind),
149        Node::Audio(audio) => add_media(found, &audio.path, Some(FileKind::Audio)),
150        Node::MusicBed(music) => add_media(found, &music.path, Some(FileKind::Audio)),
151        Node::Logo(logo) => add_media(found, &logo.path, None),
152        Node::Captions(captions) => {
153            if let Some(file) = &captions.file
154                && !found.texts.contains(file)
155            {
156                found.texts.push(file.clone());
157            }
158        }
159        Node::Slideshow(slideshow) => {
160            slideshow
161                .items
162                .iter()
163                .for_each(|item| add_item(found, item));
164            if let Some(path) = &slideshow.fit_to {
165                add_media(found, path, Some(FileKind::Audio));
166            }
167        }
168        Node::CaptionedVideo(captioned) => {
169            add_item(found, &captioned.video);
170            walk(&Node::Captions((*captioned.captions).clone()), found);
171        }
172        Node::ComparisonGrid(grid) => grid.items.iter().for_each(|item| add_item(found, item)),
173        Node::Reframe(reframe) => add_item(found, &reframe.video),
174        Node::EndCard(card) => {
175            if let Some(logo) = &card.logo {
176                add_media(found, logo, None);
177            }
178        }
179        Node::Text(_)
180        | Node::Color(_)
181        | Node::Layout(_)
182        | Node::Raw(_)
183        | Node::Title(_)
184        | Node::TitleCard(_)
185        | Node::LowerThird(_) => {}
186    }
187}