use std::path::{Path, PathBuf};
use std::process::Command;
use chrono::{DateTime, Utc};
use crate::error::{ImmichError, Result};
#[derive(Debug, Clone)]
pub struct TransformSpec {
pub base_image: String,
pub width: Option<u32>,
pub height: Option<u32>,
pub quality: u8,
pub strip_dimensions: bool,
}
impl TransformSpec {
pub fn new(base_image: impl Into<String>) -> Self {
Self {
base_image: base_image.into(),
width: None,
height: None,
quality: 85,
strip_dimensions: false,
}
}
pub fn with_size(mut self, width: u32, height: u32) -> Self {
self.width = Some(width);
self.height = Some(height);
self
}
pub fn with_scale(mut self, scale_percent: u32) -> Self {
self.width = Some(scale_percent);
self.height = None; self
}
pub fn with_quality(mut self, quality: u8) -> Self {
self.quality = quality;
self
}
pub fn without_dimensions(mut self) -> Self {
self.strip_dimensions = true;
self
}
}
impl Default for TransformSpec {
fn default() -> Self {
Self::new("base_landscape.jpg")
}
}
#[derive(Debug, Clone, Default)]
pub struct ExifSpec {
pub gps: Option<(f64, f64)>,
pub datetime: Option<DateTime<Utc>>,
pub timezone: Option<String>,
pub camera_make: Option<String>,
pub camera_model: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Clone)]
pub struct TestImage {
pub filename: String,
pub transform: TransformSpec,
pub exif: ExifSpec,
}
impl TestImage {
pub fn new(filename: impl Into<String>, transform: TransformSpec) -> Self {
Self {
filename: filename.into(),
transform,
exif: ExifSpec::default(),
}
}
pub fn with_exif(mut self, exif: ExifSpec) -> Self {
self.exif = exif;
self
}
}
pub fn generate_image(spec: &TestImage, base_dir: &Path, output_dir: &Path) -> Result<PathBuf> {
use image::imageops::FilterType;
use image::ImageFormat;
let ext = Path::new(&spec.filename)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let output_path = output_dir.join(&spec.filename);
match ext.as_str() {
"mp4" | "mov" | "avi" => {
return generate_video(&spec.filename, output_dir, spec.transform.width, spec.transform.height);
}
"heic" | "heif" => {
return Err(ImmichError::Io(std::io::Error::other(
"HEIC encoding not available - requires platform-specific encoder",
)));
}
"cr3" | "cr2" | "nef" | "arw" | "dng" | "raf" | "orf" => {
return Err(ImmichError::Io(std::io::Error::other(
format!("RAW format .{} encoding not available - requires proprietary encoder", ext),
)));
}
_ => {}
}
let base_path = base_dir.join(&spec.transform.base_image);
let img = image::open(&base_path).map_err(|e| {
ImmichError::Io(std::io::Error::other(format!(
"Failed to load base image {}: {}",
base_path.display(),
e
)))
})?;
let (target_width, target_height) = match (spec.transform.width, spec.transform.height) {
(Some(w), Some(h)) => (w, h),
(Some(scale), None) if scale <= 100 => {
let w = (img.width() * scale) / 100;
let h = (img.height() * scale) / 100;
(w.max(1), h.max(1))
}
(Some(w), None) => {
let h = (img.height() * w) / img.width();
(w, h.max(1))
}
(None, Some(h)) => {
let w = (img.width() * h) / img.height();
(w.max(1), h)
}
(None, None) => (img.width(), img.height()),
};
let resized = if target_width != img.width() || target_height != img.height() {
img.resize_exact(target_width, target_height, FilterType::Lanczos3)
} else {
img
};
match ext.as_str() {
"png" => {
resized
.save_with_format(&output_path, ImageFormat::Png)
.map_err(|e| {
ImmichError::Io(std::io::Error::other(format!("Failed to save PNG: {}", e)))
})?;
}
_ => {
let mut output_file = std::fs::File::create(&output_path).map_err(|e| {
ImmichError::Io(std::io::Error::other(format!(
"Failed to create output file: {}",
e
)))
})?;
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(
&mut output_file,
spec.transform.quality,
);
resized.write_with_encoder(encoder).map_err(|e| {
ImmichError::Io(std::io::Error::other(format!("Failed to encode JPEG: {}", e)))
})?;
}
}
apply_exif(&output_path, &spec.exif, spec.transform.strip_dimensions)?;
Ok(output_path)
}
fn generate_video(
filename: &str,
output_dir: &Path,
width: Option<u32>,
height: Option<u32>,
) -> Result<PathBuf> {
let output_path = output_dir.join(filename);
let w = width.unwrap_or(1920);
let h = height.unwrap_or(1080);
let size = format!("{}x{}", w, h);
let output = Command::new("ffmpeg")
.args([
"-y",
"-f",
"lavfi",
"-i",
&format!("color=c=blue:s={}:d=1", size),
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
output_path.to_string_lossy().as_ref(),
])
.output()
.map_err(|e| {
ImmichError::Io(std::io::Error::other(format!(
"Failed to run ffmpeg: {}. Is ffmpeg installed?",
e
)))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(ImmichError::Io(std::io::Error::other(format!(
"ffmpeg failed: {}",
stderr
))));
}
Ok(output_path)
}
fn apply_exif(path: &Path, exif: &ExifSpec, strip_dimensions: bool) -> Result<()> {
let mut args: Vec<String> = vec!["-overwrite_original".to_string()];
if let Some((lat, lon)) = exif.gps {
let lat_ref = if lat >= 0.0 { "N" } else { "S" };
let lon_ref = if lon >= 0.0 { "E" } else { "W" };
args.push(format!("-GPSLatitude={}", lat.abs()));
args.push(format!("-GPSLatitudeRef={}", lat_ref));
args.push(format!("-GPSLongitude={}", lon.abs()));
args.push(format!("-GPSLongitudeRef={}", lon_ref));
}
if let Some(dt) = &exif.datetime {
let formatted = dt.format("%Y:%m:%d %H:%M:%S").to_string();
args.push(format!("-DateTimeOriginal={}", formatted));
}
if let Some(tz) = &exif.timezone {
args.push(format!("-OffsetTimeOriginal={}", tz));
}
if let Some(make) = &exif.camera_make {
args.push(format!("-Make={}", make));
}
if let Some(model) = &exif.camera_model {
args.push(format!("-Model={}", model));
}
if let Some(desc) = &exif.description {
args.push(format!("-ImageDescription={}", desc));
}
if strip_dimensions {
args.push("-ImageWidth=".to_string());
args.push("-ExifImageWidth=".to_string());
args.push("-ImageHeight=".to_string());
args.push("-ExifImageHeight=".to_string());
}
if args.len() > 1 {
args.push(path.to_string_lossy().to_string());
let output = Command::new("exiftool")
.args(&args)
.output()
.map_err(|e| {
ImmichError::Io(std::io::Error::other(format!(
"Failed to run exiftool: {}. Is exiftool installed?",
e
)))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(ImmichError::Io(std::io::Error::other(format!(
"exiftool failed: {}",
stderr
))));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_transform_spec_builder() {
let spec = TransformSpec::new("base_landscape.jpg")
.with_size(1000, 750)
.with_quality(90);
assert_eq!(spec.base_image, "base_landscape.jpg");
assert_eq!(spec.width, Some(1000));
assert_eq!(spec.height, Some(750));
assert_eq!(spec.quality, 90);
}
#[test]
fn test_transform_spec_scale() {
let spec = TransformSpec::new("base_portrait.jpg").with_scale(50);
assert_eq!(spec.width, Some(50));
assert_eq!(spec.height, None);
}
}