Skip to main content

alpha2_compose/
facts.rs

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