mini-film 7.6.2

Apply Lightroom-style film emulation profiles to RAW files with RawTherapee and HALD workflows.
Documentation
use std::{
    fmt::Write as _,
    path::{Path, PathBuf},
};

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha1::{Digest, Sha1};

use mini_film::ProfileAdjustments;

#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq)]
pub(crate) struct BasicRetouchAdjustments {
    #[serde(default)]
    pub(crate) exposure: f32,
    #[serde(default)]
    pub(crate) highlights: f32,
    #[serde(default)]
    pub(crate) shadows: f32,
    #[serde(default)]
    pub(crate) whites: f32,
    #[serde(default)]
    pub(crate) blacks: f32,
    #[serde(default)]
    pub(crate) temperature: f32,
    #[serde(default)]
    pub(crate) clarity: f32,
}

impl BasicRetouchAdjustments {
    pub(crate) fn from_profile_adjustments(adjustments: &ProfileAdjustments) -> Self {
        Self {
            exposure: adjustments.exposure,
            highlights: adjustments.highlights,
            shadows: adjustments.shadows,
            whites: adjustments.whites,
            blacks: adjustments.blacks,
            temperature: 0.0,
            clarity: adjustments.clarity,
        }
    }

    pub(crate) fn add(self, other: Self) -> Self {
        Self {
            exposure: self.exposure + other.exposure,
            highlights: self.highlights + other.highlights,
            shadows: self.shadows + other.shadows,
            whites: self.whites + other.whites,
            blacks: self.blacks + other.blacks,
            temperature: self.temperature + other.temperature,
            clarity: self.clarity + other.clarity,
        }
    }

    pub(crate) fn is_default(self) -> bool {
        self.exposure == 0.0
            && self.highlights == 0.0
            && self.shadows == 0.0
            && self.whites == 0.0
            && self.blacks == 0.0
            && self.temperature == 0.0
            && self.clarity == 0.0
    }
}

#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
pub(crate) struct RetouchCrop {
    #[serde(default)]
    pub(crate) x: f32,
    #[serde(default)]
    pub(crate) y: f32,
    #[serde(default = "full_extent")]
    pub(crate) width: f32,
    #[serde(default = "full_extent")]
    pub(crate) height: f32,
}

fn full_extent() -> f32 {
    1.0
}

impl RetouchCrop {
    pub(crate) fn normalized(self) -> Option<Self> {
        let width = self.width.clamp(0.01, 1.0);
        let height = self.height.clamp(0.01, 1.0);
        let x = self.x.clamp(0.0, 1.0 - width);
        let y = self.y.clamp(0.0, 1.0 - height);
        let crop = Self {
            x,
            y,
            width,
            height,
        };
        (!crop.is_full_frame()).then_some(crop)
    }

    pub(crate) fn is_full_frame(self) -> bool {
        self.x.abs() <= 0.0001
            && self.y.abs() <= 0.0001
            && (self.width - 1.0).abs() <= 0.0001
            && (self.height - 1.0).abs() <= 0.0001
    }
}

#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub(crate) struct RetouchSettings {
    #[serde(default)]
    pub(crate) adjustments: BasicRetouchAdjustments,
    #[serde(default)]
    pub(crate) crop: Option<RetouchCrop>,
    #[serde(default)]
    pub(crate) rotation_degrees: f32,
}

impl RetouchSettings {
    pub(crate) fn normalized(mut self) -> Self {
        self.crop = self.crop.and_then(RetouchCrop::normalized);
        self.rotation_degrees = normalize_rotation(self.rotation_degrees);
        self
    }

    pub(crate) fn render_key(&self) -> String {
        let normalized = self.clone().normalized();
        let mut hasher = Sha1::new();
        hasher.update(format!("{:.4}", normalized.adjustments.exposure));
        hasher.update(format!("{:.3}", normalized.adjustments.highlights));
        hasher.update(format!("{:.3}", normalized.adjustments.shadows));
        hasher.update(format!("{:.3}", normalized.adjustments.whites));
        hasher.update(format!("{:.3}", normalized.adjustments.blacks));
        hasher.update(format!("{:.3}", normalized.adjustments.temperature));
        hasher.update(format!("{:.3}", normalized.adjustments.clarity));
        hasher.update(format!("{:.3}", normalized.rotation_degrees));
        if let Some(crop) = normalized.crop {
            hasher.update(format!(
                "{:.5}:{:.5}:{:.5}:{:.5}",
                crop.x, crop.y, crop.width, crop.height
            ));
        }
        let digest = hasher.finalize();
        digest
            .iter()
            .take(8)
            .map(|byte| format!("{byte:02x}"))
            .collect()
    }

    pub(crate) fn summary(&self) -> String {
        let normalized = self.clone().normalized();
        let crop = normalized
            .crop
            .map(|crop| {
                format!(
                    " crop={:.1}%,{:.1}%,{:.1}%,{:.1}%",
                    crop.x * 100.0,
                    crop.y * 100.0,
                    crop.width * 100.0,
                    crop.height * 100.0
                )
            })
            .unwrap_or_default();
        format!(
            "retouch exposure={:.2} highlights={:.1} shadows={:.1} whites={:.1} blacks={:.1} temperature={:.0} clarity={:.1} rotation={:.1}{}",
            normalized.adjustments.exposure,
            normalized.adjustments.highlights,
            normalized.adjustments.shadows,
            normalized.adjustments.whites,
            normalized.adjustments.blacks,
            normalized.adjustments.temperature,
            normalized.adjustments.clarity,
            normalized.rotation_degrees,
            crop
        )
    }
}

pub(crate) fn normalize_rotation(rotation: f32) -> f32 {
    if !rotation.is_finite() {
        return 0.0;
    }
    let mut rotation = rotation % 360.0;
    if rotation > 180.0 {
        rotation -= 360.0;
    } else if rotation < -180.0 {
        rotation += 360.0;
    }
    if rotation.abs() < 0.0001 {
        0.0
    } else {
        rotation
    }
}

pub(crate) fn write_rawtherapee_retouch_profile(
    path: &Path,
    base: BasicRetouchAdjustments,
    retouch: &RetouchSettings,
) -> Result<Option<PathBuf>> {
    if retouch.adjustments.is_default() {
        return Ok(None);
    }
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)
            .with_context(|| format!("creating {}", parent.display()))?;
    }
    let effective = base.add(retouch.adjustments);
    let mut out = String::new();
    let _ = writeln!(out, "[Exposure]");
    let _ = writeln!(out, "Auto=false");
    let _ = writeln!(out, "Compensation={}", fmt_f32(effective.exposure));
    let _ = writeln!(out, "Brightness={}", fmt_slider(effective.whites * 0.5));
    let _ = writeln!(out, "Black={}", fmt_slider(-effective.blacks));
    let _ = writeln!(
        out,
        "HighlightCompr={}",
        fmt_slider((-effective.highlights).clamp(0.0, 100.0))
    );
    let _ = writeln!(
        out,
        "ShadowCompr={}",
        fmt_slider(effective.shadows.clamp(0.0, 100.0))
    );
    let _ = writeln!(out);
    let _ = writeln!(out, "[Luminance Curve]");
    let _ = writeln!(out, "Enabled=true");
    let _ = writeln!(out, "Contrast={}", fmt_slider(effective.clarity));
    let _ = writeln!(out);
    if effective.temperature != 0.0 {
        let _ = writeln!(out, "[White Balance]");
        let _ = writeln!(out, "Enabled=true");
        let _ = writeln!(out, "Setting=Camera");
        let _ = writeln!(out, "TemperatureBias={}", fmt_f32(effective.temperature));
        let _ = writeln!(out);
    }
    std::fs::write(path, out).with_context(|| format!("writing {}", path.display()))?;
    Ok(Some(path.to_path_buf()))
}

fn fmt_f32(value: f32) -> String {
    let rounded = (value * 1000.0).round() / 1000.0;
    if rounded == 0.0 {
        "0".to_string()
    } else {
        format!("{rounded:.3}")
            .trim_end_matches('0')
            .trim_end_matches('.')
            .to_string()
    }
}

fn fmt_slider(value: f32) -> String {
    fmt_f32(value.clamp(-100.0, 100.0))
}

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

    #[test]
    fn crop_normalization_rejects_full_frame() {
        assert!(
            RetouchCrop {
                x: 0.0,
                y: 0.0,
                width: 1.0,
                height: 1.0,
            }
            .normalized()
            .is_none()
        );
        assert_eq!(
            RetouchCrop {
                x: 0.9,
                y: -1.0,
                width: 0.3,
                height: 1.2,
            }
            .normalized(),
            Some(RetouchCrop {
                x: 0.7,
                y: 0.0,
                width: 0.3,
                height: 1.0,
            })
        );
    }

    #[test]
    fn rotation_is_normalized_to_half_turn_range() {
        assert_eq!(normalize_rotation(0.0), 0.0);
        assert_eq!(normalize_rotation(450.0), 90.0);
        assert_eq!(normalize_rotation(-450.0), -90.0);
        assert_eq!(normalize_rotation(f32::NAN), 0.0);
    }

    #[test]
    fn retouch_key_changes_when_adjustments_change() {
        let mut left = RetouchSettings::default();
        let mut right = RetouchSettings::default();
        assert_eq!(left.render_key(), right.render_key());
        right.adjustments.exposure = 0.25;
        assert_ne!(left.render_key(), right.render_key());
        left.adjustments.exposure = 0.25;
        assert_eq!(left.render_key(), right.render_key());
    }

    #[test]
    fn rawtherapee_retouch_profile_writes_tone_and_temperature_overrides() {
        let dir = tempfile::tempdir().unwrap();
        let output = dir.path().join("retouch.pp3");
        let retouch = RetouchSettings {
            adjustments: BasicRetouchAdjustments {
                exposure: 0.5,
                highlights: -20.0,
                shadows: 30.0,
                whites: 10.0,
                blacks: -5.0,
                temperature: 450.0,
                clarity: 12.0,
            },
            crop: None,
            rotation_degrees: 0.0,
        };

        let written = write_rawtherapee_retouch_profile(
            &output,
            BasicRetouchAdjustments::default(),
            &retouch,
        )
        .unwrap();

        assert_eq!(written, Some(output.clone()));
        let text = std::fs::read_to_string(output).unwrap();
        assert!(text.contains("[Exposure]"));
        assert!(text.contains("Compensation=0.5"));
        assert!(text.contains("HighlightCompr=20"));
        assert!(text.contains("ShadowCompr=30"));
        assert!(text.contains("[White Balance]"));
        assert!(text.contains("TemperatureBias=450"));
        assert!(text.contains("Contrast=12"));
    }
}