Skip to main content

alpha2_compose/
lib.rs

1//! Compose specs: a video described by intent, compiled into a plain project document.
2//!
3//! [`compile`] turns a [`VideoSpec`] into an [`alpha2_model::Project`] that
4//! [`alpha2_model::Project::validate`] accepts, plus the export options the project does not
5//! hold.
6//!
7//! Compiling is pure and reads no files. Facts about the files a spec names come in as
8//! [`Facts`], gathered by a binding from [`requirements`]. The same spec, facts, fonts and
9//! compiler version give a byte-identical project.
10//!
11//! The spec is data without logic (no loops, conditions or expressions); components that take
12//! any number of inputs are Rust code here.
13
14mod compile;
15mod de;
16mod facts;
17mod lower;
18#[cfg(feature = "schema")]
19mod schema;
20pub mod spec;
21#[cfg(test)]
22mod tests;
23mod theme;
24
25use std::collections::BTreeMap;
26use std::fmt;
27use std::sync::Arc;
28
29use alpha2_model::Project;
30use serde::{Deserialize, Serialize};
31
32pub use facts::{Facts, MediaFacts, RequiredMedia, Requirements, kind_of_path, requirements};
33#[cfg(feature = "schema")]
34pub use schema::compose_json_schema;
35pub use spec::{COMPOSE_VERSION, VideoSpec};
36pub use theme::THEMES;
37
38/// Why a spec does not compile, and where.
39#[derive(Clone, Debug, Eq, PartialEq)]
40pub struct ComposeError {
41    /// Where in the spec, as `spec.body.children[2].path`.
42    pub path: String,
43    /// What is wrong there.
44    pub message: String,
45}
46
47impl ComposeError {
48    pub(crate) fn new(path: &str, message: impl Into<String>) -> Self {
49        Self {
50            path: path.to_owned(),
51            message: message.into(),
52        }
53    }
54}
55
56impl fmt::Display for ComposeError {
57    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
58        write!(formatter, "{}: {}", self.path, self.message)
59    }
60}
61
62impl std::error::Error for ComposeError {}
63
64/// The loudness an export normalizes to.
65#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
66#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
67#[serde(deny_unknown_fields)]
68pub struct LoudnessTarget {
69    /// Integrated loudness, -60..=0 LUFS.
70    pub target_lufs: f64,
71    /// True-peak ceiling, -20..=0 dBTP. Default -1.
72    #[serde(default = "default_ceiling")]
73    pub true_peak_ceiling_dbtp: f64,
74}
75
76const fn default_ceiling() -> f64 {
77    -1.0
78}
79
80/// A compiled spec: the project, and what an export of it needs besides.
81#[derive(Clone, Debug, PartialEq, Serialize)]
82pub struct Compiled {
83    /// The project document.
84    pub project: Project,
85    /// Each media source's file, by source id: an export's assets.
86    pub assets: BTreeMap<String, String>,
87    /// The loudness the spec asks an export to normalize to.
88    pub loudness: Option<LoudnessTarget>,
89    /// What the compiler did not do as asked, and why.
90    pub warnings: Vec<String>,
91}
92
93/// Compiles `spec` with `facts` about its files.
94///
95/// `fonts` are the font files an export registers, in fallback order; with them, text that
96/// must fit is measured and shrunk to fit.
97///
98/// # Errors
99/// Returns where in the spec, and why, it cannot be compiled.
100pub fn compile(
101    spec: &VideoSpec,
102    facts: &Facts,
103    fonts: &[Arc<[u8]>],
104) -> Result<Compiled, ComposeError> {
105    if let Some(loudness) = spec.loudness {
106        let (target, ceiling) = match loudness {
107            spec::Loudness::Lufs(target) => (target, default_ceiling()),
108            spec::Loudness::Target(target) => (target.target_lufs, target.true_peak_ceiling_dbtp),
109        };
110        if !(-60.0..=0.0).contains(&target) || !(-20.0..=0.0).contains(&ceiling) {
111            return Err(ComposeError::new(
112                "spec.loudness",
113                format!(
114                    "{target} LUFS under {ceiling} dBTP: the target is -60..=0 LUFS and the ceiling -20..=0 dBTP"
115                ),
116            ));
117        }
118    }
119    compile::compile(spec, facts, fonts)
120}
121
122/// Parses a spec from JSON, naming where an unparsable value lies.
123///
124/// # Errors
125/// Returns the place and the parser's reason.
126pub fn parse_spec(json: &str) -> Result<VideoSpec, ComposeError> {
127    parse(json, "spec")
128}
129
130/// Parses facts from JSON.
131///
132/// # Errors
133/// Returns the place and the parser's reason.
134pub fn parse_facts(json: &str) -> Result<Facts, ComposeError> {
135    parse(json, "facts")
136}
137
138fn parse<T: serde::de::DeserializeOwned>(json: &str, root: &str) -> Result<T, ComposeError> {
139    let value =
140        serde_json::from_str(json).map_err(|error| ComposeError::new(root, error.to_string()))?;
141    de::from_value(value)
142        .map_err(|located| ComposeError::new(&de::join(root, &located.path), located.message))
143}
144
145/// [`compile`] across a JSON boundary. The result is `{project, assets, loudness, warnings}`.
146///
147/// # Errors
148/// Returns where in the spec or facts, and why, they do not parse or compile.
149///
150/// # Panics
151/// Never; a compiled project always serializes.
152pub fn compile_json(spec: &str, facts: &str, fonts: &[Arc<[u8]>]) -> Result<String, ComposeError> {
153    let spec = parse_spec(spec)?;
154    let facts = parse_facts(facts)?;
155    let compiled = compile(&spec, &facts, fonts)?;
156    Ok(serde_json::to_string(&compiled).expect("a compiled project serializes"))
157}
158
159/// [`requirements`] across a JSON boundary.
160///
161/// # Errors
162/// Returns where in the spec, and why, it does not parse.
163///
164/// # Panics
165/// Never; requirements always serialize.
166pub fn requirements_json(spec: &str) -> Result<String, ComposeError> {
167    let spec = parse_spec(spec)?;
168    Ok(serde_json::to_string(&requirements(&spec)).expect("requirements serialize"))
169}