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}