Skip to main content

alpha2_compose/
lib.rs

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