ffcine-assembly 0.4.0

Final assembly: concat with dissolves, captions and title cards, chapters and cover
Documentation
//! Content keys: a canonical JSON form of what a render unit is made of, hashed.
//!
//! A key covers everything that changes the pixels (media identity, source range, the resolved
//! grade and effects, the render profile, the ffcine version) and nothing about where the unit sits
//! on the timeline, so inserting or removing a clip leaves every other clip's key alone.

use std::fmt::{self, Write as _};
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;

use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};

/// Bump when the render path changes its output for the same recipe (new effect applied, encoder
/// tweak, ...): every cached unit re-renders.
pub const RENDER_REVISION: u32 = 8;

/// Bump when the audio mix changes its output for the same recipe: the mix re-renders, the
/// picture doesn't.
pub const AUDIO_REVISION: u32 = 2;

/// A content key: 128 bits of SHA-256, hex.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Key(pub String);

impl Key {
    /// The key of a JSON value, in canonical form (object keys sorted).
    pub fn of(v: &Value) -> Self {
        let digest = Sha256::digest(canonical(v).as_bytes());
        Self(digest[..16].iter().fold(String::new(), |mut s, b| {
            let _ = write!(s, "{b:02x}");
            s
        }))
    }

    /// The first 10 hex digits, for display.
    pub fn short(&self) -> &str {
        &self.0[..self.0.len().min(10)]
    }
}

impl fmt::Display for Key {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

/// `v` as compact JSON with every object's keys sorted, so key order in the timeline file doesn't
/// matter.
pub fn canonical(v: &Value) -> String {
    fn sorted(v: &Value) -> Value {
        match v {
            Value::Object(m) => {
                let mut keys: Vec<&String> = m.keys().collect();
                keys.sort();
                Value::Object(
                    keys.into_iter()
                        .map(|k| (k.clone(), sorted(&m[k])))
                        .collect::<Map<_, _>>(),
                )
            }
            Value::Array(a) => Value::Array(a.iter().map(sorted).collect()),
            v => v.clone(),
        }
    }
    sorted(v).to_string()
}

/// Identity of a media file: canonical path, size and modification time (no content read, so
/// fingerprinting a 15-minute 4K timeline is instant). A missing file fingerprints as missing,
/// so plans and diffs still work without the media.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Fingerprint {
    pub path: PathBuf,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub size: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub mtime_ns: Option<u128>,
}

/// Looks up file fingerprints; tests swap in a fake.
pub trait Fingerprinter {
    fn fingerprint(&self, path: &Path) -> Fingerprint;
    /// SHA-256 of a small file's bytes (LUTs, shaders), `None` if unreadable.
    fn content_hash(&self, path: &Path) -> Option<String>;
    /// The ffmpeg build that runs filter nodes (`ffmpeg -version`'s version line and its
    /// libavfilter version), in every filter node's key.
    fn ffmpeg_build(&self) -> String {
        String::from("unknown")
    }
}

/// Fingerprints from the filesystem.
#[derive(Debug, Clone, Copy, Default)]
pub struct FsFingerprinter;

impl Fingerprinter for FsFingerprinter {
    fn fingerprint(&self, path: &Path) -> Fingerprint {
        let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_owned());
        let meta = std::fs::metadata(&canon).ok();
        Fingerprint {
            path: canon,
            size: meta.as_ref().map(std::fs::Metadata::len),
            mtime_ns: meta
                .and_then(|m| m.modified().ok())
                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
                .map(|d| d.as_nanos()),
        }
    }

    fn content_hash(&self, path: &Path) -> Option<String> {
        let bytes = std::fs::read(path).ok()?;
        let hash = Sha256::digest(&bytes)
            .iter()
            .fold(String::new(), |mut s, b| {
                let _ = write!(s, "{b:02x}");
                s
            });
        Some(Key::of(&Value::String(hash)).0)
    }

    /// Of `$FFMPEG` (default `ffmpeg` on the `PATH`, as the renderer runs it), asked once.
    fn ffmpeg_build(&self) -> String {
        static BUILD: std::sync::OnceLock<String> = std::sync::OnceLock::new();
        BUILD
            .get_or_init(|| {
                let ffmpeg = std::env::var_os("FFMPEG").unwrap_or_else(|| "ffmpeg".into());
                std::process::Command::new(ffmpeg)
                    .args(["-hide_banner", "-version"])
                    .output()
                    .ok()
                    .filter(|o| o.status.success())
                    .map_or_else(
                        || String::from("unavailable"),
                        |o| {
                            let out = String::from_utf8_lossy(&o.stdout);
                            let mut lines = out.lines();
                            let first = lines.next().unwrap_or("").trim().to_owned();
                            let lavfi = out
                                .lines()
                                .find(|l| l.trim_start().starts_with("libavfilter"))
                                .unwrap_or("")
                                .split_whitespace()
                                .collect::<Vec<_>>()
                                .join(" ");
                            format!("{first}; {lavfi}")
                        },
                    )
            })
            .clone()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn key_ignores_object_key_order() {
        let a = json!({"a": 1, "b": {"x": [1, 2], "y": null}});
        let b = json!({"b": {"y": null, "x": [1, 2]}, "a": 1});
        assert_eq!(Key::of(&a), Key::of(&b));
        assert_ne!(
            Key::of(&a),
            Key::of(&json!({"a": 2, "b": {"x": [1, 2], "y": null}}))
        );
        assert_eq!(Key::of(&a).0.len(), 32);
    }
}