src/main.rs
// # License (AGPL Version 3.0)
//
// photo-what-what - tool to automate tagging of image files
// Copyright (C) 2025 Trevor Bentley <pww@x.mrmekon.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
use regex::Regex;
use std::collections::HashSet;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use image2::Image;
use tempfile::NamedTempFile;
use clap::{Parser, ValueEnum};
use serde::{Serialize, Deserialize};
#[derive(Debug)]
enum PwwError {
Unknown(String),
MissingSidecar(String),
InvalidImage,
}
impl std::fmt::Display for PwwError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
PwwError::Unknown(s) => write!(f, "PWW error: {}", s),
PwwError::MissingSidecar(s) => write!(f, "missing XMP sidecar: {}", s),
PwwError::InvalidImage => write!(f, "input image invalid"),
}
}
}
#[derive(Parser, Debug)]
#[clap(trailing_var_arg=true, term_width=100)]
struct PwwArgs {
/// Path to program that classifies images
#[arg(short = 'b', long)]
identifier_bin: Option<PathBuf>,
/// Directory to store temporary files
#[arg(long)]
temp_dir: Option<PathBuf>,
/// Do not actually write metadata to file, but print which files
/// would have changed.
#[arg(short = 'n', long)]
dry_run: bool,
/// Skip files that have already been processed by pww
#[arg(short = 's', long)]
skip_processed: bool,
/// Policy for which metadata should be updated
#[arg(long)]
file_update_policy: Option<FileUpdatePolicy>,
/// Policy for how changes should be made to the specified tags.
#[arg(long)]
tag_update_policy: Option<TagUpdatePolicy>,
/// Print information about which tags are written
#[arg(short = 'v', long)]
verbose: bool,
/// Print extra trace information about program flow, for debugging.
#[arg(short = 'd', long)]
debug: bool,
/// Whether to stop processing after first error, or continue.
#[arg(short = 'e', long)]
halt_on_error: bool,
/// Keep converted image files in temp directory instead of
/// removing them.
#[arg(long)]
keep_converted: bool,
/// Disables setting of the pww processed metadata field.
#[arg(long)]
disable_pww_tag: bool,
/// Image files to analyze and tag
#[arg(trailing_var_arg = true, allow_hyphen_values = true, value_name = "IMAGE", required = true)]
image_paths: Vec<PathBuf>,
}
#[derive(Default, Debug, Clone, Serialize, Deserialize, ValueEnum)]
enum TagUpdatePolicy {
/// Replace all existing contents (if any) with new items
Replace,
/// Append new items to existing tag contents
Append,
/// Remove existing items with the same prefix and then append new ones.
#[default]
ReplacePrefixed,
}
#[derive(Default, Debug, Clone, Serialize, Deserialize, ValueEnum, PartialEq)]
enum FileUpdatePolicy {
/// Just print the tags, do not update any files
DisplayOnly,
/// Only update XMP sidecar (error if XMP file missing). Only
/// sidecar tags are used.
#[default]
SidecarOnly,
/// Only update image itself. Only image tags are used.
ImageOnly,
/// Update both XMP sidecar and image itself. Both sidecar and
/// image tags are used.
SidecarAndImage,
/// Only update XMP sidecar if present, otherwise update image
/// itself. Either sidecar or image tags are used.
SidecarIfPresent,
/// Only update XMP sidecar unless image is one of a few common
/// known types with good EXIF support (JPG, PNG, TIFF, WebP).
/// Either sidecar or image tags are used. Error if XMP file
/// missing.
SidecarUnlessCommonImage,
}
#[derive(Default, Clone, Serialize, Deserialize, ValueEnum)]
enum DownscalePolicy {
/// Images that require conversion due to unsupported input format
/// or images that exceed the maximum dimension are downscaled.
#[default]
LargeOrConverted,
/// Only images that require conversion due to unsupported input
/// format are downscaled.
ConvertedOnly,
/// Images that exceed the maximum dimensions are downscaled.
Large,
/// Images are never downscaled. They are processed at the original size.
Never,
/// Images are always downscaled. This means that all images
/// larger than the minimum dimension will be converted.
Always,
}
#[derive(Clone, Debug, Serialize, Deserialize, ValueEnum, PartialEq)]
enum ValidImageFormat {
Png,
Jpg,
Tiff,
Unknown,
}
impl From<image::ImageFormat> for ValidImageFormat {
fn from(fmt: image::ImageFormat) -> Self {
match fmt {
image::ImageFormat::Jpeg => ValidImageFormat::Jpg,
image::ImageFormat::Png => ValidImageFormat::Png,
image::ImageFormat::Tiff => ValidImageFormat::Tiff,
_ => ValidImageFormat::Unknown,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ValueEnum, PartialEq)]
enum ValidImageColor {
Rgb8,
Rgba8,
Rgb16,
Rgba16,
Unknown,
}
impl From<image::ColorType> for ValidImageColor {
fn from(fmt: image::ColorType) -> Self {
match fmt {
image::ColorType::Rgb8 => ValidImageColor::Rgb8,
image::ColorType::Rgba8 => ValidImageColor::Rgba8,
image::ColorType::Rgb16 => ValidImageColor::Rgb16,
image::ColorType::Rgba16 => ValidImageColor::Rgba16,
_ => ValidImageColor::Unknown,
}
}
}
#[derive(Serialize, Deserialize)]
struct PwwConfig {
/// Path to program that classifies images
identifier_bin: Option<PathBuf>,
/// Extra arguments passed to identifier binary before the image
/// filename.
identifier_bin_args: Vec<String>,
/// Directory to store temporary files
temp_dir: Option<PathBuf>,
/// Skip files that have already been processed by pww
skip_processed: bool,
/// Policy for which metadata should be updated
file_update_policy: FileUpdatePolicy,
/// Policy for how changes should be made to the specified tags.
tag_update_policy: TagUpdatePolicy,
/// Prefix that should be appended to all tags
tag_prefix: Option<String>,
/// Full hierarchical XMP, EXIF, or IPTC tags to write to sidecar files.
///
/// examples:
/// - Xmp.dc.subject
/// - Xmp.lr.hierarchicalSubject
/// - Xmp.digiKam.TagsList
sidecar_tags: Vec<String>,
/// Full hierarchical XMP, EXIF, or IPTC tags to write to image files.
///
/// examples:
/// - Iptc.Application2.Keywords
/// - Xmp.dc.subject
/// - Xmp.lr.hierarchicalSubject
/// - Xmp.digiKam.TagsList
image_tags: Vec<String>,
/// Maximum input image dimension (in pixels). If height or width
/// exceeds this, image will be downscaled before analysis.
max_dimension: u32,
/// Minimum image dimension when converting or downscaling.
/// Neither height nor width will shrink below this dimension (in
/// pixels).
min_dimension: u32,
/// Policy for when images should be downscaled
downscale_policy: DownscalePolicy,
/// Image formats that can be analyzed without conversion
valid_image_formats: Vec<ValidImageFormat>,
/// Image color formats that can be analyzed without conversion.
valid_image_colors: Vec<ValidImageColor>,
/// Whether to stop processing after first error, or continue.
halt_on_error: bool,
/// List of tags that are allowed to be added to image metadata.
/// Any other tags in output will be ignored. If not provided,
/// all tags permitted.
permitted_tags: Vec<String>,
/// List of tags that are forbidden from being added to image
/// metadata.
forbidden_tags: Vec<String>,
/// Disables setting of the pww processed metadata field.
///
/// pww sets an Xmp tag indicating it has processed a file. This
/// can be used to skip reprocessing of files that have already
/// been handled.
disable_pww_tag: bool,
#[serde(skip_serializing, skip_deserializing)]
cli: Option<PwwArgs>,
}
impl PwwConfig {
fn identifier_bin(&self) -> Option<PathBuf> {
match self.cli.as_ref().map(|c| c.identifier_bin.clone()) {
Some(ref b) => b.clone(),
_ => self.identifier_bin.clone()
}
}
fn temp_dir(&self) -> Option<PathBuf> {
match &self.cli {
Some(c) => c.temp_dir.clone(),
_ => self.temp_dir.clone(),
}
}
fn max_dimension(&self) -> u32 {
match self.max_dimension {
0 => std::u32::MAX,
_ => self.max_dimension
}
}
fn min_dimension(&self) -> u32 {
match self.max_dimension {
0 => std::u32::MAX,
_ => self.max_dimension
}
}
fn image_paths(&self) -> Vec<PathBuf> {
match &self.cli {
Some(c) => c.image_paths.clone(),
_ => Default::default(),
}
}
fn dry_run(&self) -> bool {
self.cli.as_ref().map(|c| c.dry_run).unwrap_or_default()
}
fn skip_processed(&self) -> bool {
self.cli.as_ref().map(|c| c.skip_processed).unwrap_or(self.skip_processed)
}
fn tag_update_policy(&self) -> TagUpdatePolicy {
match &self.cli {
Some(c) => match &c.tag_update_policy {
Some(p) => p.clone(),
_ => self.tag_update_policy.clone(),
},
_ => self.tag_update_policy.clone(),
}
}
fn file_update_policy(&self) -> FileUpdatePolicy {
match &self.cli {
Some(c) => match &c.file_update_policy {
Some(p) => p.clone(),
_ => self.file_update_policy.clone(),
},
_ => self.file_update_policy.clone(),
}
}
fn verbose(&self) -> bool {
self.cli.as_ref().map(|c| c.verbose).unwrap_or_default()
}
#[allow(dead_code)]
fn debug(&self) -> bool {
self.cli.as_ref().map(|c| c.debug).unwrap_or_default()
}
fn halt_on_error(&self) -> bool {
self.cli.as_ref().map(|c| c.halt_on_error).unwrap_or(self.halt_on_error)
}
fn keep_converted(&self) -> bool {
self.cli.as_ref().map(|c| c.keep_converted).unwrap_or_default()
}
fn disable_pww_tag(&self) -> bool {
self.cli.as_ref().map(|c| c.disable_pww_tag).unwrap_or(self.disable_pww_tag)
}
}
impl ::std::default::Default for PwwConfig {
fn default() -> Self {
Self {
identifier_bin: None,
identifier_bin_args: vec!(),
temp_dir: None,
skip_processed: false,
file_update_policy: FileUpdatePolicy::SidecarOnly,
tag_update_policy: TagUpdatePolicy::ReplacePrefixed,
tag_prefix: Some("[ML] ".to_owned()),
sidecar_tags: vec!(
// note: capitalization is critical: "subject" makes
// an unordered list read by standard tools, "Subject"
// makes a single string read by nothing.
"Xmp.dc.subject".into(),
"Xmp.lr.hierarchicalSubject".into(),
),
image_tags: vec!(
"Iptc.Application2.Keywords".into(),
),
max_dimension: 8000,
min_dimension: 768,
downscale_policy: DownscalePolicy::LargeOrConverted,
valid_image_formats: vec!(ValidImageFormat::Jpg,
ValidImageFormat::Png
),
valid_image_colors: vec!(ValidImageColor::Rgb8,
ValidImageColor::Rgba8,
ValidImageColor::Rgb16,
ValidImageColor::Rgba16
),
halt_on_error: false,
disable_pww_tag: false,
permitted_tags: vec!(),
forbidden_tags: vec!(),
cli: None,
}
}
}
fn exec_identifier<P: AsRef<Path>>(config: &PwwConfig, file_path: P) -> Result<String, PwwError> {
let os_path = file_path.as_ref().as_os_str();
let mut args: Vec<std::ffi::OsString> = config.identifier_bin_args.iter().map(|x| x.into()).collect();
args.push(os_path.to_os_string());
let mut p = std::process::Command::new(&config.identifier_bin().expect("No identifier binary provided."))
.args(args)
.stdout(Stdio::piped())
.spawn()
.map_err(|e| PwwError::Unknown(format!("Failed to launch identifier binary: {}", e)))?;
let res = p.wait()
.map_err(|e| PwwError::Unknown(format!("Failed to wait for identifier binary: {}", e)))?;
if !res.success() {
return Err(PwwError::Unknown("identifier binary exited with failure.".into()));
}
let mut s: String = String::new();
let mut stdout = p.stdout.ok_or_else(|| PwwError::Unknown(format!("Failed to get output from identifier binary")))?;
stdout.read_to_string(&mut s).map_err(|x| PwwError::Unknown(x.to_string()))?;
Ok(s)
}
fn normalized_tags(config: &PwwConfig, output: String) -> Vec<String> {
let re_num: Regex = Regex::new(r"^(\s*\d+[.]\s+)")
.expect("Couldn't compile regex.");
let re_bul: Regex = Regex::new(r"^(\s*[*]\s+)")
.expect("Couldn't compile regex.");
let re_lst: Regex = Regex::new(r"^(\s*[-]\s+)")
.expect("Couldn't compile regex.");
// trim line spacing
let lines: Vec<String> = output.lines().map(|l| {
// remove quotes
let l = l.chars().filter(|c| !['"', '\'', '`'].contains(c)).collect::<String>()
// make lowercase
.to_lowercase()
// trim line spacing
.trim().to_owned();
// remove numbering
let l = re_num.replace_all(&l, "").trim().to_owned();
// remove star bullets
let l = re_bul.replace_all(&l, "").trim().to_owned();
// remove dash bullets
let l = re_lst.replace_all(&l, "").trim().to_owned();
// change underscores and dashes to spaces
l.replace("_", " ")
.replace("-", " ")
// remove space after commas
.replace(", ", ",")
.chars()
// remove brackets
.filter(|c| !"[]<>".chars().any(|x| c == &x)).collect()
}).collect();
let set: HashSet<String> = lines.join(",")
.split(",")
.map(|l| l.to_owned())
.filter(|l| l.len() > 0)
.collect();
let mut tags = set.iter()
.filter(|t| config.permitted_tags.is_empty() || config.permitted_tags.contains(t))
.filter(|t| !config.forbidden_tags.contains(t))
.map(|l| {
config.tag_prefix.as_ref().map(|p| p.to_owned()).unwrap_or_default() + &l
})
.collect::<Vec<String>>();
tags.sort();
tags
}
// Assume that scripts can handle 8- or 16-bit JPG or PNG files. All
// others require conversion. Resize policy may also trigger
// conversion.
fn image_needs_conversion<P: AsRef<Path>>(config: &PwwConfig, filepath: P) -> Result<bool, PwwError> {
let img = image::ImageReader::open(filepath)
.map_err(|e| PwwError::Unknown(e.to_string()))?;
let valid_format = match img.format() {
Some(fmt) => {
let local_fmt: ValidImageFormat = fmt.into();
local_fmt != ValidImageFormat::Unknown &&
config.valid_image_formats.contains(&local_fmt)
},
_ => false,
};
let (valid_color, valid_dimension) = match valid_format {
true => {
let img = img.decode().map_err(|e| PwwError::Unknown(e.to_string()))?;
let local_color: ValidImageColor = img.color().into();
match local_color != ValidImageColor::Unknown &&
config.valid_image_colors.contains(&local_color) {
true => {
let max_dimension = std::cmp::max(img.width(), img.height());
match config.downscale_policy {
DownscalePolicy::Never |
DownscalePolicy::ConvertedOnly => {
(true, true)
},
DownscalePolicy::Always |
DownscalePolicy::Large |
DownscalePolicy::LargeOrConverted => {
(true, max_dimension <= config.max_dimension())
},
}
},
_ => (false, false),
}
},
_ => (false, false),
};
let conversion_required = !valid_format || !valid_color || !valid_dimension;
if config.debug() && conversion_required {
if !valid_format {
println!(" - conversion required: {}",
match (valid_format, valid_color, valid_dimension) {
(false, _, _) => "invalid format",
(true, false, _) => "invalid color space",
(true, true, false) => "invalid dimensions",
_ => "not required",
})
}
}
Ok(conversion_required)
}
fn xmp_file_path<P: AsRef<Path>>(filepath: P) -> Result<Option<PathBuf>, PwwError> {
// Look for XMP files, which must have the same filename plus
// either .xmp or .XMP extension.
let xmp_path: PathBuf = filepath.as_ref().to_path_buf();
let ext = xmp_path.extension().unwrap_or_default().to_string_lossy().to_string();
let xmp_ext_lower: String = ext.clone() + ".xmp";
let xmp_ext_upper: String = ext + ".XMP";
let xmp_path_lower = xmp_path.with_extension(xmp_ext_lower);
let xmp_path_upper = xmp_path.with_extension(xmp_ext_upper);
let lower_exists = std::path::Path::exists(&xmp_path_lower);
let upper_exists = std::path::Path::exists(&xmp_path_upper);
let xmp_path: Option<PathBuf> = match (lower_exists, upper_exists) {
(false, false) => None,
(false, true) => Some(xmp_path_upper),
_ => Some(xmp_path_lower),
};
Ok(xmp_path)
}
fn should_update_sidecar<P: AsRef<Path>>(config: &PwwConfig, filepath: P) -> Result<Option<PathBuf>, PwwError> {
let xmp_path: Option<PathBuf> = xmp_file_path(&filepath).unwrap_or_default();
// Return path to XMP file if it exists and we're in a mode that
// updates it, None if we shouldn't update it, or error if it's
// required but missing.
match config.file_update_policy() {
FileUpdatePolicy::SidecarOnly | FileUpdatePolicy::SidecarAndImage => {
match xmp_path {
Some(_) => Ok(xmp_path),
_ => Err(PwwError::MissingSidecar("XMP sidecar file not found.".into())),
}
},
FileUpdatePolicy::SidecarUnlessCommonImage => {
let img = image::ImageReader::open(filepath)
.map_err(|e| PwwError::Unknown(e.to_string()))?;
match img.format() {
Some(image::ImageFormat::Jpeg) |
Some(image::ImageFormat::Png) |
Some(image::ImageFormat::Tiff) |
Some(image::ImageFormat::WebP) => {
Ok(None)
},
_ => {
match xmp_path {
Some(_) => Ok(xmp_path),
_ => Err(PwwError::MissingSidecar("XMP sidecar file not found.".into())),
}
}
}
}
FileUpdatePolicy::SidecarIfPresent => {
Ok(xmp_path)
},
FileUpdatePolicy::DisplayOnly | FileUpdatePolicy::ImageOnly => {
Ok(None)
},
}
}
fn should_update_image<P: AsRef<Path>>(config: &PwwConfig, filepath: P) -> Result<bool, PwwError> {
match config.file_update_policy() {
FileUpdatePolicy::ImageOnly | FileUpdatePolicy::SidecarAndImage => Ok(true),
FileUpdatePolicy::SidecarIfPresent => {
match xmp_file_path(&filepath) {
Ok(Some(_)) => Ok(false),
_ => Ok(true),
}
}
FileUpdatePolicy::SidecarUnlessCommonImage => {
let img = image::ImageReader::open(filepath)
.map_err(|e| PwwError::Unknown(e.to_string()))?;
match img.format() {
Some(image::ImageFormat::Jpeg) |
Some(image::ImageFormat::Png) |
Some(image::ImageFormat::Tiff) |
Some(image::ImageFormat::WebP) => {
Ok(true)
},
_ => Ok(false),
}
}
_ => Ok(false),
}
}
fn write_tags_to_file<P: AsRef<std::ffi::OsStr>>(config: &PwwConfig, metatags: &[String], tags: &[String], filepath: P) -> Result<(), PwwError> {
let meta = rexiv2::Metadata::new_from_path(&filepath)
.map_err(|e| PwwError::Unknown(format!("Unable to read metadata from image file: {}", e)))?;
if config.debug() {
println!(" - updating file: {}", filepath.as_ref().to_string_lossy());
}
for metatag in metatags {
let new_values: Vec<String> = match config.tag_update_policy() {
TagUpdatePolicy::Append => {
meta.get_tag_multiple_strings(&metatag).unwrap_or_default().into_iter().chain(tags.iter().cloned()).collect()
},
TagUpdatePolicy::Replace => {
tags.to_vec()
},
TagUpdatePolicy::ReplacePrefixed => {
let new_tags: Vec<String> = meta.get_tag_multiple_strings(&metatag)
.unwrap_or_default()
.iter()
.map(|t| t.trim().to_string())
.filter(|t| t.len() > 0 && !t.starts_with(&config.tag_prefix.clone().unwrap_or_default()))
.collect();
new_tags.into_iter().chain(tags.iter().cloned()).collect()
},
};
let ref_values: Vec<&str> = new_values.iter().map(|s| s.as_ref()).collect();
meta.clear_tag(&metatag);
meta.set_tag_multiple_strings(&metatag, &ref_values)
.map_err(|e| PwwError::Unknown(format!("Failed to update metadata tag in file: {}", e)))?;
if config.debug() {
println!(" - updated tag: {}", metatag);
}
}
if !config.disable_pww_tag() {
let _ = rexiv2::register_xmp_namespace("http://trevor.town/pww/1.0/", "photo-what-what");
let pww_tag = "Xmp.photo-what-what.processed";
meta.clear_tag(pww_tag);
meta.set_tag_numeric(pww_tag, 1)
.map_err(|_e| PwwError::Unknown("Couldn't set pww processed tag".into()))?;
if config.verbose() {
println!(" - set pww processed flag");
}
}
match config.dry_run() {
false => {
meta.save_to_file(&filepath)
.map_err(|e| PwwError::Unknown(format!("Failed to write metadata to image file: {}", e)))?;
if config.verbose() {
println!(" - saved: {}\n", filepath.as_ref().to_string_lossy());
}
},
true => {
println!("would update: {} (dry run)\n", filepath.as_ref().to_string_lossy());
},
}
Ok(())
}
fn process_image(config: &PwwConfig, input_path: &Path) -> Result<(), PwwError> {
// If a tempfile is generated, it must live longer than all path
// references. It is deleted automatically when dropped.
let tmpfile: Option<NamedTempFile>;
// Path of file to analyze: either the input path if not
// converted, or the tempfile path if converted.
let mut analyze_path: PathBuf = input_path.to_path_buf();
if config.verbose() {
println!("Analyzing: {}", analyze_path.to_string_lossy());
}
// Check sidecar file before analysis because it's a common error
// and we might as well not waste time analyzing when the tags
// won't be writeable.
let metafile = match should_update_sidecar(&config, &input_path)? {
Some(p) => p,
None => input_path.to_owned(),
};
// Check the metadata to see if this file has already been
// processed, and skip it if configured to do so.
let _ = rexiv2::register_xmp_namespace("http://trevor.town/pww/1.0/", "photo-what-what");
let meta = rexiv2::Metadata::new_from_path(&metafile)
.map_err(|e| PwwError::Unknown(format!("Unable to read metadata from image file: {}", e)))?;
let pww_tag = "Xmp.photo-what-what.processed";
if config.skip_processed() && meta.get_tag_numeric(pww_tag) == 1 {
if config.verbose() {
println!(" - already processed, skipped!")
}
return Ok(());
}
// Transcode to a temporary file if necessary
match image_needs_conversion(&config, &input_path) {
Ok(true) => {
if config.debug() {
println!(" - converting to rgb8 jpg");
}
let i = Image::<u8, image2::Rgb>::open(&input_path)
.map_err(|_e| PwwError::InvalidImage)?;
let temp_dir = config.temp_dir().clone()
.unwrap_or(PathBuf::from(std::env::temp_dir()));
tmpfile = Some(tempfile::Builder::new()
.prefix("pww-")
.suffix(".jpg")
.rand_bytes(8)
.keep(config.keep_converted())
.tempfile_in(temp_dir)
.map_err(|e| PwwError::Unknown(format!("Failed to create temp file for image conversion: {}", e)))?);
let outpath = tmpfile.as_ref().map(|x| x.path().to_path_buf())
.ok_or_else(|| PwwError::Unknown(format!("Unable to open temp file for image conversion.")))?;
// convert to Rgb8
let conv = image2::filter::convert();
let i: Image<u8, image2::Rgb> = i.run(conv, None);
let max_dimension = std::cmp::max(i.width(), i.height());
let min_dimension = std::cmp::min(i.width(), i.height());
let should_scale = match config.downscale_policy {
DownscalePolicy::Always => {
true
},
DownscalePolicy::ConvertedOnly => {
true
},
DownscalePolicy::LargeOrConverted => {
true
}
DownscalePolicy::Large => {
max_dimension as u32 > config.max_dimension()
}
DownscalePolicy::Never => {
false
}
};
if should_scale && min_dimension as u32 > config.min_dimension() {
let scale = config.min_dimension() as f64 / min_dimension as f64;
if config.debug() {
println!(" - scaling by {:.2} ({} to {})", scale, min_dimension, config.min_dimension());
}
i.scale(scale, scale);
}
i.save(&outpath)
.map_err(|e| PwwError::Unknown(format!("Unable to save converted image file: {}", e)))?;
analyze_path = outpath;
},
Err(e) => {
eprintln!("Error parsing input image: {}", e.to_string());
}
_ => {},
}
// run ML engine on file
if config.debug() {
if analyze_path != input_path {
println!(" - converted to: {}", analyze_path.to_string_lossy())
}
}
let raw_output = exec_identifier(&config, &analyze_path)
.map_err(|e| PwwError::Unknown(format!("Identifier binary failed to execute: {}", e)))?;
// convert string output to list of tags
let tags = normalized_tags(&config, raw_output);
if config.verbose() {
println!(" - tags: {}", tags.join(", "));
}
match should_update_sidecar(&config, &input_path) {
Ok(Some(sidecar_path)) => {
write_tags_to_file(&config, &config.sidecar_tags, &tags, &sidecar_path)?;
},
Err(e) => {
return Err(PwwError::Unknown(format!("Error: XMP file required but not found: {}", e)));
},
_ => {
}
}
match should_update_image(&config, &input_path) {
Ok(true) => {
write_tags_to_file(&config, &config.image_tags, &tags, &input_path)?;
},
Err(e) => {
return Err(PwwError::Unknown(format!("Error: Unable to determine if EXIF should be written: {}", e)));
},
_ => {
}
}
if config.file_update_policy() == FileUpdatePolicy::DisplayOnly {
println!("{}", tags.join(", "));
}
Ok(())
}
fn main() -> Result<(), PwwError> {
let mut config: PwwConfig = confy::load("photo-what-what", "pww_config")
.map_err(|e| PwwError::Unknown(format!("Error loading config file: {}", e)))?;
config.cli = Some(PwwArgs::parse());
let mut count = 0;
for input_path in config.image_paths() {
match process_image(&config, &input_path) {
Err(e) => {
println!("Error processing image ({}): {}", input_path.to_string_lossy(), e);
if config.halt_on_error() {
std::process::exit(1);
}
if config.verbose() || config.debug() {
println!();
}
},
_ => {
count += 1;
}
}
}
if count == 0 {
println!("Error: no images processed.");
std::process::exit(1);
}
Ok(())
}