464 lines
13 KiB
Rust
464 lines
13 KiB
Rust
use clap::{Parser, Subcommand};
|
|
use pixstrip_core::discovery;
|
|
use pixstrip_core::executor::PipelineExecutor;
|
|
use pixstrip_core::operations::*;
|
|
use pixstrip_core::pipeline::ProcessingJob;
|
|
use pixstrip_core::preset::Preset;
|
|
use pixstrip_core::storage::{HistoryStore, PresetStore};
|
|
use pixstrip_core::types::*;
|
|
use std::path::PathBuf;
|
|
|
|
#[derive(Parser)]
|
|
#[command(name = "pixstrip")]
|
|
#[command(about = "Batch image processor - resize, convert, compress, strip metadata, watermark, and rename")]
|
|
#[command(version)]
|
|
struct Cli {
|
|
#[command(subcommand)]
|
|
command: Commands,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum Commands {
|
|
/// Process images with a preset or custom operations
|
|
Process {
|
|
/// Input files or directories
|
|
#[arg(required = true)]
|
|
input: Vec<String>,
|
|
|
|
/// Preset name to use
|
|
#[arg(long)]
|
|
preset: Option<String>,
|
|
|
|
/// Output directory
|
|
#[arg(short, long)]
|
|
output: Option<String>,
|
|
|
|
/// Resize to width (e.g., "1200" or "1200x900")
|
|
#[arg(long)]
|
|
resize: Option<String>,
|
|
|
|
/// Output format (jpeg, png, webp, avif)
|
|
#[arg(long)]
|
|
format: Option<String>,
|
|
|
|
/// Quality preset (maximum, high, medium, low, web)
|
|
#[arg(long)]
|
|
quality: Option<String>,
|
|
|
|
/// Strip all metadata
|
|
#[arg(long)]
|
|
strip_metadata: bool,
|
|
|
|
/// Include subdirectories
|
|
#[arg(short, long)]
|
|
recursive: bool,
|
|
},
|
|
|
|
/// Manage presets
|
|
Preset {
|
|
#[command(subcommand)]
|
|
action: PresetAction,
|
|
},
|
|
|
|
/// View processing history
|
|
History,
|
|
|
|
/// Undo last batch operation
|
|
Undo,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum PresetAction {
|
|
/// List all presets
|
|
List,
|
|
/// Export a preset to a file
|
|
Export {
|
|
name: String,
|
|
#[arg(short, long)]
|
|
output: String,
|
|
},
|
|
/// Import a preset from a file
|
|
Import { path: String },
|
|
}
|
|
|
|
fn main() {
|
|
let cli = Cli::parse();
|
|
|
|
match cli.command {
|
|
Commands::Process {
|
|
input,
|
|
preset,
|
|
output,
|
|
resize,
|
|
format,
|
|
quality,
|
|
strip_metadata,
|
|
recursive,
|
|
} => {
|
|
cmd_process(
|
|
input,
|
|
preset,
|
|
output,
|
|
resize,
|
|
format,
|
|
quality,
|
|
strip_metadata,
|
|
recursive,
|
|
);
|
|
}
|
|
Commands::Preset { action } => match action {
|
|
PresetAction::List => cmd_preset_list(),
|
|
PresetAction::Export { name, output } => cmd_preset_export(&name, &output),
|
|
PresetAction::Import { path } => cmd_preset_import(&path),
|
|
},
|
|
Commands::History => cmd_history(),
|
|
Commands::Undo => cmd_undo(),
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn cmd_process(
|
|
input: Vec<String>,
|
|
preset_name: Option<String>,
|
|
output: Option<String>,
|
|
resize: Option<String>,
|
|
format: Option<String>,
|
|
quality: Option<String>,
|
|
strip_metadata: bool,
|
|
recursive: bool,
|
|
) {
|
|
// Collect input files
|
|
let mut source_files = Vec::new();
|
|
for path_str in &input {
|
|
let path = PathBuf::from(path_str);
|
|
if path.is_dir() {
|
|
let files = discovery::discover_images(&path, recursive);
|
|
source_files.extend(files);
|
|
} else if path.is_file() {
|
|
source_files.push(path);
|
|
} else {
|
|
eprintln!("Warning: '{}' not found, skipping", path_str);
|
|
}
|
|
}
|
|
|
|
if source_files.is_empty() {
|
|
eprintln!("No images found in the specified input(s)");
|
|
std::process::exit(1);
|
|
}
|
|
|
|
println!("Found {} image(s)", source_files.len());
|
|
|
|
// Determine input directory (use parent of first file)
|
|
let input_dir = source_files[0]
|
|
.parent()
|
|
.unwrap_or_else(|| std::path::Path::new("."))
|
|
.to_path_buf();
|
|
|
|
// Determine output directory
|
|
let output_dir = output
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| input_dir.join("processed"));
|
|
|
|
// Build job from preset or CLI args
|
|
let mut job = if let Some(ref name) = preset_name {
|
|
let preset = find_preset(name);
|
|
println!("Using preset: {}", preset.name);
|
|
preset.to_job(&input_dir, &output_dir)
|
|
} else {
|
|
ProcessingJob::new(&input_dir, &output_dir)
|
|
};
|
|
|
|
// Override with CLI flags
|
|
if let Some(ref resize_str) = resize {
|
|
job.resize = Some(parse_resize(resize_str));
|
|
}
|
|
if let Some(ref fmt_str) = format
|
|
&& let Some(fmt) = parse_format(fmt_str)
|
|
{
|
|
job.convert = Some(ConvertConfig::SingleFormat(fmt));
|
|
}
|
|
if let Some(ref q_str) = quality
|
|
&& let Some(preset) = parse_quality(q_str)
|
|
{
|
|
job.compress = Some(CompressConfig::Preset(preset));
|
|
}
|
|
if strip_metadata {
|
|
job.metadata = Some(MetadataConfig::StripAll);
|
|
}
|
|
|
|
// Add sources
|
|
for file in &source_files {
|
|
job.add_source(file);
|
|
}
|
|
|
|
// Execute
|
|
let executor = PipelineExecutor::new();
|
|
let result = executor
|
|
.execute(&job, |update| {
|
|
eprint!(
|
|
"\r[{}/{}] {}...",
|
|
update.current, update.total, update.current_file
|
|
);
|
|
})
|
|
.unwrap_or_else(|e| {
|
|
eprintln!("\nProcessing failed: {}", e);
|
|
std::process::exit(1);
|
|
});
|
|
|
|
eprintln!();
|
|
|
|
// Print results
|
|
println!();
|
|
println!("Processing complete:");
|
|
println!(" Succeeded: {}", result.succeeded);
|
|
if result.failed > 0 {
|
|
println!(" Failed: {}", result.failed);
|
|
}
|
|
println!(
|
|
" Size: {} -> {} ({:.0}% reduction)",
|
|
format_bytes(result.total_input_bytes),
|
|
format_bytes(result.total_output_bytes),
|
|
if result.total_input_bytes > 0 {
|
|
(1.0 - result.total_output_bytes as f64 / result.total_input_bytes as f64) * 100.0
|
|
} else {
|
|
0.0
|
|
}
|
|
);
|
|
println!(" Time: {}", format_duration(result.elapsed_ms));
|
|
println!(" Output: {}", output_dir.display());
|
|
|
|
if !result.errors.is_empty() {
|
|
println!();
|
|
println!("Errors:");
|
|
for (file, err) in &result.errors {
|
|
println!(" {} - {}", file, err);
|
|
}
|
|
}
|
|
|
|
// Save to history
|
|
let history = HistoryStore::new();
|
|
let output_files: Vec<String> = source_files
|
|
.iter()
|
|
.map(|f| {
|
|
output_dir
|
|
.join(f.file_name().unwrap_or_default())
|
|
.to_string_lossy()
|
|
.into()
|
|
})
|
|
.collect();
|
|
|
|
let _ = history.add(pixstrip_core::storage::HistoryEntry {
|
|
timestamp: chrono_timestamp(),
|
|
input_dir: input_dir.to_string_lossy().into(),
|
|
output_dir: output_dir.to_string_lossy().into(),
|
|
preset_name,
|
|
total: result.total,
|
|
succeeded: result.succeeded,
|
|
failed: result.failed,
|
|
total_input_bytes: result.total_input_bytes,
|
|
total_output_bytes: result.total_output_bytes,
|
|
elapsed_ms: result.elapsed_ms,
|
|
output_files,
|
|
});
|
|
}
|
|
|
|
fn cmd_preset_list() {
|
|
println!("Built-in presets:");
|
|
for preset in Preset::all_builtins() {
|
|
println!(" {} - {}", preset.name, preset.description);
|
|
}
|
|
|
|
let store = PresetStore::new();
|
|
if let Ok(user_presets) = store.list()
|
|
&& !user_presets.is_empty()
|
|
{
|
|
println!();
|
|
println!("User presets:");
|
|
for preset in user_presets {
|
|
println!(" {} - {}", preset.name, preset.description);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_preset_export(name: &str, output: &str) {
|
|
let preset = find_preset(name);
|
|
let store = PresetStore::new();
|
|
match store.export_to_file(&preset, &PathBuf::from(output)) {
|
|
Ok(()) => println!("Exported '{}' to '{}'", name, output),
|
|
Err(e) => {
|
|
eprintln!("Failed to export preset: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_preset_import(path: &str) {
|
|
let store = PresetStore::new();
|
|
match store.import_from_file(&PathBuf::from(path)) {
|
|
Ok(preset) => {
|
|
let name = preset.name.clone();
|
|
match store.save(&preset) {
|
|
Ok(()) => println!("Imported preset '{}'", name),
|
|
Err(e) => {
|
|
eprintln!("Failed to save imported preset: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to import preset: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_history() {
|
|
let history = HistoryStore::new();
|
|
match history.list() {
|
|
Ok(entries) => {
|
|
if entries.is_empty() {
|
|
println!("No processing history yet.");
|
|
return;
|
|
}
|
|
for (i, entry) in entries.iter().enumerate().rev() {
|
|
println!(
|
|
"{}. [{}] {} -> {} ({}/{} succeeded, {})",
|
|
i + 1,
|
|
entry.timestamp,
|
|
entry.input_dir,
|
|
entry.output_dir,
|
|
entry.succeeded,
|
|
entry.total,
|
|
format_duration(entry.elapsed_ms),
|
|
);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to read history: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_undo() {
|
|
let history = HistoryStore::new();
|
|
match history.list() {
|
|
Ok(entries) => {
|
|
if let Some(last) = entries.last() {
|
|
println!("Last operation: {} images from {}", last.total, last.input_dir);
|
|
println!("Output files would be moved to trash.");
|
|
println!("(Undo with file deletion not yet implemented - requires gio trash support)");
|
|
} else {
|
|
println!("No processing history to undo.");
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to read history: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
fn find_preset(name: &str) -> Preset {
|
|
// Check builtins first
|
|
let lower = name.to_lowercase();
|
|
for preset in Preset::all_builtins() {
|
|
if preset.name.to_lowercase() == lower {
|
|
return preset;
|
|
}
|
|
}
|
|
|
|
// Check user presets
|
|
let store = PresetStore::new();
|
|
if let Ok(preset) = store.load(name) {
|
|
return preset;
|
|
}
|
|
|
|
eprintln!("Preset '{}' not found. Use 'pixstrip preset list' to see available presets.", name);
|
|
std::process::exit(1);
|
|
}
|
|
|
|
fn parse_resize(s: &str) -> ResizeConfig {
|
|
if let Some((w, h)) = s.split_once('x') {
|
|
let width: u32 = w.parse().unwrap_or_else(|_| {
|
|
eprintln!("Invalid resize width: '{}'", w);
|
|
std::process::exit(1);
|
|
});
|
|
let height: u32 = h.parse().unwrap_or_else(|_| {
|
|
eprintln!("Invalid resize height: '{}'", h);
|
|
std::process::exit(1);
|
|
});
|
|
ResizeConfig::Exact(Dimensions { width, height })
|
|
} else {
|
|
let width: u32 = s.parse().unwrap_or_else(|_| {
|
|
eprintln!("Invalid resize value: '{}'. Use a width like '1200' or dimensions like '1200x900'", s);
|
|
std::process::exit(1);
|
|
});
|
|
ResizeConfig::ByWidth(width)
|
|
}
|
|
}
|
|
|
|
fn parse_format(s: &str) -> Option<ImageFormat> {
|
|
match s.to_lowercase().as_str() {
|
|
"jpeg" | "jpg" => Some(ImageFormat::Jpeg),
|
|
"png" => Some(ImageFormat::Png),
|
|
"webp" => Some(ImageFormat::WebP),
|
|
"avif" => Some(ImageFormat::Avif),
|
|
"gif" => Some(ImageFormat::Gif),
|
|
"tiff" | "tif" => Some(ImageFormat::Tiff),
|
|
_ => {
|
|
eprintln!("Unknown format: '{}'. Supported: jpeg, png, webp, avif, gif, tiff", s);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_quality(s: &str) -> Option<QualityPreset> {
|
|
match s.to_lowercase().as_str() {
|
|
"maximum" | "max" => Some(QualityPreset::Maximum),
|
|
"high" => Some(QualityPreset::High),
|
|
"medium" | "med" => Some(QualityPreset::Medium),
|
|
"low" => Some(QualityPreset::Low),
|
|
"web" => Some(QualityPreset::WebOptimized),
|
|
_ => {
|
|
eprintln!("Unknown quality: '{}'. Supported: maximum, high, medium, low, web", s);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
fn format_bytes(bytes: u64) -> String {
|
|
if bytes < 1024 {
|
|
format!("{} B", bytes)
|
|
} else if bytes < 1024 * 1024 {
|
|
format!("{:.1} KB", bytes as f64 / 1024.0)
|
|
} else if bytes < 1024 * 1024 * 1024 {
|
|
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
|
|
} else {
|
|
format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
|
|
}
|
|
}
|
|
|
|
fn format_duration(ms: u64) -> String {
|
|
if ms < 1000 {
|
|
format!("{}ms", ms)
|
|
} else if ms < 60_000 {
|
|
format!("{:.1}s", ms as f64 / 1000.0)
|
|
} else {
|
|
let mins = ms / 60_000;
|
|
let secs = (ms % 60_000) / 1000;
|
|
format!("{}m {}s", mins, secs)
|
|
}
|
|
}
|
|
|
|
fn chrono_timestamp() -> String {
|
|
// Simple timestamp without chrono dependency
|
|
let now = std::time::SystemTime::now();
|
|
let duration = now
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default();
|
|
format!("{}", duration.as_secs())
|
|
}
|