494 lines
16 KiB
Rust
494 lines
16 KiB
Rust
use clap::Parser;
|
|
use image::{DynamicImage, ImageBuffer, Rgb};
|
|
use libraw_sys::*;
|
|
use std::ffi::CString;
|
|
use std::os::raw::{c_int, c_size_t};
|
|
use std::path::{Path, PathBuf};
|
|
use std::ptr::null_mut;
|
|
|
|
/// Resolve and sanitize a user-provided path.
|
|
/// - Resolves to absolute path via canonicalize (or parent canonicalization for output)
|
|
/// - Rejects paths containing `..` components after resolution
|
|
/// - Returns human-readable error on failure
|
|
fn sanitize_path(
|
|
path_str: &str,
|
|
must_exist: bool,
|
|
) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
|
let path = Path::new(path_str);
|
|
|
|
// Block explicit traversal sequences in user input
|
|
for component in path.components() {
|
|
if component.as_os_str() == ".." {
|
|
return Err(format!(
|
|
"Path traversal ('..') is not allowed in: {}",
|
|
path_str
|
|
)
|
|
.into());
|
|
}
|
|
}
|
|
|
|
if must_exist {
|
|
let resolved = path.canonicalize().map_err(|e| {
|
|
format!("Failed to resolve input path '{}': {}", path_str, e)
|
|
})?;
|
|
Ok(resolved)
|
|
} else {
|
|
// For output paths that may not exist yet, resolve the parent directory
|
|
if let Some(parent) = path.parent() {
|
|
let resolved_parent = parent.canonicalize().map_err(|e| {
|
|
format!(
|
|
"Output directory '{}' does not exist or is not accessible: {}",
|
|
parent.display(),
|
|
e
|
|
)
|
|
})?;
|
|
let filename = path
|
|
.file_name()
|
|
.unwrap_or(path.as_os_str())
|
|
.to_string_lossy()
|
|
.to_string();
|
|
Ok(resolved_parent.join(filename))
|
|
} else {
|
|
let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
|
Ok(resolved)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Map libraw error codes to human-readable strings.
|
|
fn libraw_error_string(code: c_int) -> &'static str {
|
|
match code {
|
|
0 => "Success",
|
|
-1 => "General failure (LIBRAW_ERROR_UNSUCCESSFUL)",
|
|
-2 => "Unsupported file format",
|
|
-3 => "File open failed (LIBRAW_ERROR_FILE_OPEN_FAIL)",
|
|
-4 => "File is not recognized as RAW",
|
|
-5 => "Out of memory",
|
|
-6 => "File read failed",
|
|
-7 => "Internal error",
|
|
-8 => "Unsupported operation",
|
|
_ => "Unknown error code",
|
|
}
|
|
}
|
|
|
|
/// RAII guard for libraw memory cleanup.
|
|
/// Automatically calls libraw_close() and libraw_free() on drop,
|
|
/// eliminating the risk of leaked handles or double-free.
|
|
struct LibRawGuard(*mut libraw_data_t);
|
|
|
|
impl Drop for LibRawGuard {
|
|
fn drop(&mut self) {
|
|
if !self.0.is_null() {
|
|
unsafe {
|
|
libraw_close(self.0);
|
|
libraw_free(self.0);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl LibRawGuard {
|
|
fn ptr(&self) -> *mut libraw_data_t {
|
|
self.0
|
|
}
|
|
}
|
|
|
|
/// Photo Editor - A Rust-based photo editor with RAW image processing capabilities using libraw
|
|
#[derive(Parser, Debug)]
|
|
#[command(author, version, about, long_about = None)]
|
|
struct Args {
|
|
/// Input raw image file to process
|
|
#[arg(short, long)]
|
|
input: String,
|
|
|
|
/// Output processed image file
|
|
#[arg(short, long)]
|
|
output: String,
|
|
|
|
/// Exposure adjustment (-2.0 to 2.0)
|
|
#[arg(long, default_value = "0.0")]
|
|
exposure: f32,
|
|
|
|
/// Contrast adjustment (-100 to 100)
|
|
#[arg(long, default_value = "0")]
|
|
contrast: i32,
|
|
|
|
/// Highlights adjustment (-100 to 100)
|
|
#[arg(long, default_value = "0")]
|
|
highlights: i32,
|
|
|
|
/// Shadows adjustment (-100 to 100)
|
|
#[arg(long, default_value = "0")]
|
|
shadows: i32,
|
|
|
|
/// Whites adjustment (-100 to 100)
|
|
#[arg(long, default_value = "0")]
|
|
whites: i32,
|
|
|
|
/// Blacks adjustment (-100 to 100)
|
|
#[arg(long, default_value = "0")]
|
|
blacks: i32,
|
|
|
|
/// Clarity adjustment (-100 to 100)
|
|
#[arg(long, default_value = "0")]
|
|
clarity: i32,
|
|
|
|
/// Vibrance adjustment (-100 to 100)
|
|
#[arg(long, default_value = "0")]
|
|
vibrance: i32,
|
|
|
|
/// Saturation adjustment (-100 to 100)
|
|
#[arg(long, default_value = "0")]
|
|
saturation: i32,
|
|
|
|
/// Temperature adjustment (-100 to 100)
|
|
#[arg(long, default_value = "0")]
|
|
temperature: i32,
|
|
|
|
/// Tint adjustment (-100 to 100)
|
|
#[arg(long, default_value = "0")]
|
|
tint: i32,
|
|
}
|
|
|
|
/// Validate that all adjustment parameters are within their documented ranges.
|
|
fn validate_params(args: &Args) -> Result<(), Box<dyn std::error::Error>> {
|
|
if args.exposure < -2.0 || args.exposure > 2.0 {
|
|
return Err("--exposure must be between -2.0 and 2.0".into());
|
|
}
|
|
let range = -100i32..=100i32;
|
|
if !range.contains(&args.contrast) {
|
|
return Err("--contrast must be between -100 and 100".into());
|
|
}
|
|
if !range.contains(&args.highlights) {
|
|
return Err("--highlights must be between -100 and 100".into());
|
|
}
|
|
if !range.contains(&args.shadows) {
|
|
return Err("--shadows must be between -100 and 100".into());
|
|
}
|
|
if !range.contains(&args.whites) {
|
|
return Err("--whites must be between -100 and 100".into());
|
|
}
|
|
if !range.contains(&args.blacks) {
|
|
return Err("--blacks must be between -100 and 100".into());
|
|
}
|
|
if !range.contains(&args.clarity) {
|
|
return Err("--clarity must be between -100 and 100".into());
|
|
}
|
|
if !range.contains(&args.vibrance) {
|
|
return Err("--vibrance must be between -100 and 100".into());
|
|
}
|
|
if !range.contains(&args.saturation) {
|
|
return Err("--saturation must be between -100 and 100".into());
|
|
}
|
|
if !range.contains(&args.temperature) {
|
|
return Err("--temperature must be between -100 and 100".into());
|
|
}
|
|
if !range.contains(&args.tint) {
|
|
return Err("--tint must be between -100 and 100".into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
let args = Args::parse();
|
|
|
|
// Validate parameter ranges before processing
|
|
validate_params(&args)?;
|
|
|
|
println!("Photo Editor v0.1");
|
|
println!("==================");
|
|
println!("Input file: {}", args.input);
|
|
println!("Output file: {}", args.output);
|
|
println!("Exposure: {}", args.exposure);
|
|
println!("Contrast: {}", args.contrast);
|
|
println!("Highlights: {}", args.highlights);
|
|
println!("Shadows: {}", args.shadows);
|
|
println!("Whites: {}", args.whites);
|
|
println!("Blacks: {}", args.blacks);
|
|
println!("Clarity: {}", args.clarity);
|
|
println!("Vibrance: {}", args.vibrance);
|
|
println!("Saturation: {}", args.saturation);
|
|
println!("Temperature: {}", args.temperature);
|
|
println!("Tint: {}", args.tint);
|
|
|
|
// Sanitize and resolve input path
|
|
let input_path = sanitize_path(&args.input, true).map_err(|e| {
|
|
eprintln!("Error: {}", e);
|
|
e
|
|
})?;
|
|
|
|
// Validate RAW file format by extension
|
|
let raw_exts = [
|
|
"arw", "cr2", "cr3", "crw", "dcr", "dng", "erf", "fff",
|
|
"iiq", "k25", "kdc", "mdc", "mef", "mos", "mrw", "nef",
|
|
"orf", "pef", "ptx", "pxn", "raf", "raw", "rdc", "sr2",
|
|
"srf", "srw", "x3f",
|
|
];
|
|
if let Some(ext) = input_path.extension().and_then(|s| s.to_str()) {
|
|
let ext_lower = ext.to_lowercase();
|
|
if !raw_exts.contains(&ext_lower.as_str()) {
|
|
eprintln!(
|
|
"Error: '{}' is not a supported RAW format (extension: .{})",
|
|
input_path.display(),
|
|
ext_lower
|
|
);
|
|
eprintln!("Supported formats: {}", raw_exts.join(", "));
|
|
return Err("Unsupported file format".into());
|
|
}
|
|
} else {
|
|
eprintln!("Error: Input file has no extension: {}", input_path.display());
|
|
return Err("File has no extension".into());
|
|
}
|
|
|
|
// Sanitize and resolve output path
|
|
let output_path = sanitize_path(&args.output, false).map_err(|e| {
|
|
eprintln!("Error: {}", e);
|
|
e
|
|
})?;
|
|
|
|
println!("\n[INFO] Attempting RAW image processing with libraw integration...");
|
|
|
|
// Initialize libraw with RAII guard for automatic cleanup
|
|
let lr_raw = unsafe { libraw_init(LIBRAW_OPTIONS_NONE) };
|
|
if lr_raw.is_null() {
|
|
eprintln!("Error: Failed to initialize libraw");
|
|
return Err("Failed to initialize libraw".into());
|
|
}
|
|
let lr_guard = LibRawGuard(lr_raw);
|
|
let lr = lr_guard.ptr();
|
|
|
|
// Open the RAW file
|
|
let input_cstr = CString::new(input_path.to_string_lossy().as_bytes())
|
|
.map_err(|_| "Input path contains null bytes, cannot process".into())?;
|
|
let ret = unsafe { libraw_open_file(lr, input_cstr.as_ptr()) };
|
|
|
|
if ret != LIBRAW_SUCCESS {
|
|
eprintln!("Error: Failed to open file: {}", libraw_error_string(ret));
|
|
return Err("Failed to open file".into());
|
|
}
|
|
|
|
println!("[INFO] File opened successfully");
|
|
|
|
// Process the RAW data
|
|
let ret = unsafe { libraw_unpack(lr) };
|
|
if ret != LIBRAW_SUCCESS {
|
|
eprintln!("Error: Failed to unpack RAW data: {}", libraw_error_string(ret));
|
|
return Err("Failed to unpack RAW data".into());
|
|
}
|
|
|
|
println!("[INFO] RAW data unpacked successfully");
|
|
|
|
// Adjust image parameters based on user input
|
|
apply_image_parameters(lr, &args);
|
|
|
|
println!("[INFO] Image parameters applied");
|
|
|
|
// Process the image
|
|
let ret = unsafe { libraw_dcraw_process(lr) };
|
|
if ret != LIBRAW_SUCCESS {
|
|
eprintln!("Error: Failed to process image: {}", libraw_error_string(ret));
|
|
return Err("Failed to process image".into());
|
|
}
|
|
|
|
println!("[INFO] Image processed successfully");
|
|
|
|
// Determine output format from extension
|
|
let ext = output_path
|
|
.extension()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("jpg")
|
|
.to_lowercase();
|
|
|
|
let ret = if ext == "jpg" || ext == "jpeg" {
|
|
// Use libraw_dcraw_make_mem_image + image crate for proper JPEG encoding
|
|
let mut imgdata: *mut std::os::raw::c_uchar = null_mut();
|
|
let mut imgsize: c_size_t = 0;
|
|
let ret = unsafe { libraw_dcraw_make_mem_image(lr, &mut imgdata, &mut imgsize) };
|
|
if ret != LIBRAW_SUCCESS {
|
|
ret
|
|
} else {
|
|
let width = unsafe { (*(*lr).internal_data).imgdata.idata.width } as u32;
|
|
let height = unsafe { (*(*lr).internal_data).imgdata.idata.height } as u32;
|
|
let bytes = unsafe { std::slice::from_raw_parts(imgdata, imgsize as usize) };
|
|
let img = DynamicImage::ImageRgb8(ImageBuffer::<Rgb<u8>, Vec<u8>>::from_raw(
|
|
width,
|
|
height,
|
|
bytes.to_vec(),
|
|
).ok_or("Failed to create image from libraw data")?);
|
|
unsafe {
|
|
let freedata = *(*lr).internal_data.imgdata.freedata;
|
|
let freesize = *(*lr).internal_data.imgdata.freesize;
|
|
if !freedata.is_null() {
|
|
libc::free(freedata as *mut std::os::raw::c_void);
|
|
}
|
|
}
|
|
img.save_with_format(&output_path, image::ImageOutputFormat::Jpeg(90))
|
|
.map_err(|e| {
|
|
eprintln!("Error: Failed to write JPEG: {}", e);
|
|
"Failed to write JPEG".to_string()
|
|
})?;
|
|
LIBRAW_SUCCESS
|
|
}
|
|
} else {
|
|
// For TIFF/PPM, use libraw_dcraw_ppm_tiff_writer
|
|
let output_cstr = CString::new(output_path.to_string_lossy().as_bytes())
|
|
.map_err(|_| "Output path contains null bytes, cannot process".into())?;
|
|
unsafe { libraw_dcraw_ppm_tiff_writer(lr, output_cstr.as_ptr()) }
|
|
};
|
|
|
|
if ret != LIBRAW_SUCCESS {
|
|
eprintln!("Error: Failed to write output file: {}", libraw_error_string(ret));
|
|
return Err("Failed to write output file".into());
|
|
}
|
|
|
|
println!("[INFO] Processed image written to disk");
|
|
|
|
println!("\n[SUCCESS] Photo editing completed successfully with libraw integration!");
|
|
println!(
|
|
"[NOTICE] The edited image has been saved as: {}",
|
|
output_path.display()
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn apply_image_parameters(lr: *mut libraw_data_t, args: &Args) {
|
|
if lr.is_null() {
|
|
eprintln!("Warning: libraw pointer is null, skipping parameter application");
|
|
return;
|
|
}
|
|
unsafe {
|
|
// Set various processing parameters based on user input
|
|
|
|
// Apply exposure adjustment
|
|
if args.exposure != 0.0 && !lr.is_null() {
|
|
// Convert EV stops to brightness multiplier: EV 1.0 → 2x, EV -1.0 → 0.5x
|
|
let multiplier = 2.0f32.powf(args.exposure);
|
|
libraw_set_bright(lr, multiplier);
|
|
}
|
|
|
|
// Apply contrast (map to brightness multiplier for tonal range)
|
|
if args.contrast != 0 && !lr.is_null() {
|
|
let value = args.contrast.min(100).max(-100) as f32 / 100.0 * 100.0;
|
|
libraw_set_bright(lr, value);
|
|
}
|
|
|
|
// Apply highlights adjustment
|
|
if args.highlights != 0 && !lr.is_null() {
|
|
let value = args.highlights.min(100).max(-100) as c_int;
|
|
libraw_set_highlight(lr, value);
|
|
}
|
|
|
|
// Apply clarity (using noise reduction parameters)
|
|
if args.clarity != 0 && !lr.is_null() {
|
|
let value = args.clarity.min(100).max(-100) as c_int;
|
|
libraw_set_fbdd_noiserd(lr, value);
|
|
}
|
|
|
|
// Apply shadows adjustment (map to brightness for dark areas)
|
|
if args.shadows != 0 && !lr.is_null() {
|
|
let value = args.shadows.min(100).max(-100) as f32 / 100.0 * 200.0;
|
|
libraw_set_bright(lr, value);
|
|
}
|
|
|
|
// Apply whites adjustment (map to highlight recovery)
|
|
if args.whites != 0 && !lr.is_null() {
|
|
let value = args.whites.min(100).max(-100) as c_int;
|
|
libraw_set_highlight(lr, value);
|
|
}
|
|
|
|
// Apply blacks adjustment (map to brightness offset for dark point)
|
|
if args.blacks != 0 && !lr.is_null() {
|
|
let value = args.blacks.min(100).max(-100) as f32 / 100.0 * -100.0;
|
|
libraw_set_bright(lr, value);
|
|
}
|
|
|
|
// Apply vibrance adjustment (selective saturation, map to saturation multiplier)
|
|
if args.vibrance != 0 && !lr.is_null() {
|
|
let value = 1.0 + args.vibrance.min(100).max(-100) as f32 / 200.0;
|
|
libraw_set_gamma(lr, 0, value);
|
|
}
|
|
|
|
// Apply temperature adjustment (white balance via user_mul)
|
|
if args.temperature != 0 && !lr.is_null() {
|
|
let temp_mul = [
|
|
256i16 as c_int,
|
|
256i16 as c_int + args.temperature.min(100).max(-100) as c_int,
|
|
256i16 as c_int,
|
|
256i16 as c_int,
|
|
];
|
|
libraw_set_user_mul(lr, temp_mul.as_ptr());
|
|
libraw_set_use_auto_wb(lr, 0);
|
|
}
|
|
|
|
// Apply tint adjustment (green-magenta white balance axis)
|
|
if args.tint != 0 && !lr.is_null() {
|
|
let tint_mul = [
|
|
256i16 as c_int + args.tint.min(100).max(-100) as c_int,
|
|
256i16 as c_int,
|
|
256i16 as c_int,
|
|
256i16 as c_int - args.tint.min(100).max(-100) as c_int,
|
|
];
|
|
libraw_set_user_mul(lr, tint_mul.as_ptr());
|
|
libraw_set_use_auto_wb(lr, 0);
|
|
}
|
|
|
|
// Apply saturation (use saturation multiplier, not gamma)
|
|
if args.saturation != 0 && !lr.is_null() {
|
|
let mul = (256i32 + args.saturation.min(100).max(-100) as i32 * 2) as c_int;
|
|
let sat_mul = [mul, mul, mul, mul];
|
|
libraw_set_saturation_mul(lr, sat_mul.as_ptr());
|
|
}
|
|
|
|
// Set output color space (sRGB in this case)
|
|
if !lr.is_null() {
|
|
libraw_set_output_color(lr, 1); // 1 = sRGB
|
|
}
|
|
|
|
// Set output bits per sample
|
|
if !lr.is_null() {
|
|
libraw_set_output_bps(lr, 8);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_args_parsing() {
|
|
let args = Args::parse_from([
|
|
"photo_editor",
|
|
"--input",
|
|
"test.raw",
|
|
"--output",
|
|
"output.jpg",
|
|
]);
|
|
assert_eq!(args.input, "test.raw");
|
|
assert_eq!(args.output, "output.jpg");
|
|
}
|
|
|
|
#[test]
|
|
fn test_args_with_positive_values() {
|
|
let args = Args::parse_from([
|
|
"photo_editor",
|
|
"--input",
|
|
"input.raw",
|
|
"--output",
|
|
"output.jpg",
|
|
"--exposure",
|
|
"0.5",
|
|
"--contrast",
|
|
"20",
|
|
"--highlights",
|
|
"10",
|
|
]);
|
|
assert_eq!(args.input, "input.raw");
|
|
assert_eq!(args.output, "output.jpg");
|
|
assert_eq!(args.exposure, 0.5);
|
|
assert_eq!(args.contrast, 20);
|
|
assert_eq!(args.highlights, 10);
|
|
}
|
|
}
|