photograph 0.4.1

Native desktop photo browser and non-destructive editor for RAW images and color grading
use std::fs;
use std::io::BufWriter;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;

use anyhow::{Context, Result};
use image::codecs::jpeg::JpegEncoder;
use rayon::prelude::*;

#[path = "../processing/mod.rs"]
mod processing;
#[path = "../state.rs"]
mod state;
#[path = "../thumbnail.rs"]
mod thumbnail;

const PREVIEW_MAX: u32 = 1920;

#[derive(Clone, Copy, Debug)]
enum ProbeBackend {
    Cpu,
    Auto,
    GpuPipeline,
}

impl ProbeBackend {
    fn from_arg(raw: Option<&str>) -> Self {
        match raw.unwrap_or("auto").trim().to_ascii_lowercase().as_str() {
            "cpu" => ProbeBackend::Cpu,
            "auto" => ProbeBackend::Auto,
            "gpu" | "gpu_pipeline" | "gpu_spike" | "wgpu" | "spike" => ProbeBackend::GpuPipeline,
            _ => ProbeBackend::Auto,
        }
    }

    fn label(self) -> &'static str {
        match self {
            ProbeBackend::Cpu => "cpu",
            ProbeBackend::Auto => "auto",
            ProbeBackend::GpuPipeline => "gpu_pipeline",
        }
    }
}

fn list_raw_files(dir: &Path, limit: usize) -> Result<Vec<PathBuf>> {
    let mut files: Vec<PathBuf> = fs::read_dir(dir)
        .with_context(|| format!("read_dir failed for {}", dir.display()))?
        .flatten()
        .map(|e| e.path())
        .filter(|p| p.is_file() && thumbnail::is_raw_image(p))
        .collect();
    files.sort();
    if files.len() > limit {
        files.truncate(limit);
    }
    Ok(files)
}

fn median_ms(samples: &[f64]) -> f64 {
    if samples.is_empty() {
        return 0.0;
    }
    let mut sorted = samples.to_vec();
    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
    let mid = sorted.len() / 2;
    if sorted.len() % 2 == 0 {
        (sorted[mid - 1] + sorted[mid]) * 0.5
    } else {
        sorted[mid]
    }
}

fn build_state() -> state::EditState {
    let mut s = state::EditState::default();
    s.exposure = 0.35;
    s.contrast = 0.2;
    s.highlights = -0.2;
    s.shadows = 0.2;
    s.temperature = 0.1;
    s.saturation = 0.15;
    s.hue_shift = 8.0;
    s
}

fn ensure_preview_size(img: image::DynamicImage) -> image::DynamicImage {
    if img.width() > PREVIEW_MAX || img.height() > PREVIEW_MAX {
        img.thumbnail(PREVIEW_MAX, PREVIEW_MAX)
    } else {
        img
    }
}

fn apply_preview_backend(
    preview: &image::DynamicImage,
    state: &state::EditState,
    backend: ProbeBackend,
) -> Result<image::DynamicImage> {
    let allow_cpu_fallback = processing::gpu_pipeline::allow_debug_cpu_fallback();

    match backend {
        ProbeBackend::Cpu if allow_cpu_fallback => Ok(processing::transform::apply(preview, state)),
        ProbeBackend::Cpu => anyhow::bail!(
            "cpu backend requires {}=1",
            processing::gpu_pipeline::DEBUG_ALLOW_CPU_FALLBACK_ENV
        ),
        ProbeBackend::Auto | ProbeBackend::GpuPipeline => {
            match processing::gpu_pipeline::try_apply(preview, state) {
                Some(img) => Ok(img),
                None if allow_cpu_fallback => Ok(processing::transform::apply(preview, state)),
                None => anyhow::bail!(
                    "gpu pipeline unavailable/failed and CPU fallback is disabled (set {}=1 for debug fallback)",
                    processing::gpu_pipeline::DEBUG_ALLOW_CPU_FALLBACK_ENV
                ),
            }
        }
    }
}

fn main() -> Result<()> {
    let mut args = std::env::args();
    let _bin = args.next();
    let dir = args
        .next()
        .map(PathBuf::from)
        .context("usage: perf_probe <raw-dir> [count] [auto|cpu|gpu_pipeline]")?;
    let count = args
        .next()
        .and_then(|v| v.parse::<usize>().ok())
        .unwrap_or(20);
    let backend = ProbeBackend::from_arg(args.next().as_deref());
    let gpu_status = processing::gpu_pipeline::runtime_status();
    let allow_cpu_fallback = processing::gpu_pipeline::allow_debug_cpu_fallback();

    if !gpu_status.available && !allow_cpu_fallback {
        anyhow::bail!(
            "no compatible Vulkan GPU detected (set {}=1 for debug CPU fallback)",
            processing::gpu_pipeline::DEBUG_ALLOW_CPU_FALLBACK_ENV
        );
    }

    let files = list_raw_files(&dir, count)?;
    if files.is_empty() {
        anyhow::bail!("No RAW files found in {}", dir.display());
    }
    eprintln!(
        "Using {} RAW files from {} (preview backend: {})",
        files.len(),
        dir.display(),
        backend.label()
    );
    if matches!(backend, ProbeBackend::Auto | ProbeBackend::GpuPipeline) {
        let adapter_desc = match (
            gpu_status.adapter_name.as_deref(),
            gpu_status.adapter_backend.as_deref(),
        ) {
            (Some(name), Some(api)) => format!("{} ({})", name, api),
            (Some(name), None) => name.to_string(),
            _ => "n/a".to_string(),
        };
        eprintln!(
            "gpu_pipeline availability: {}{}",
            if gpu_status.available {
                "available"
            } else {
                "unavailable (debug cpu fallback)"
            },
            if gpu_status.available {
                format!(", adapter={}", adapter_desc)
            } else {
                String::new()
            },
        );
    }

    let mut first_preview_samples = Vec::with_capacity(files.len());
    for path in &files {
        let t0 = Instant::now();
        let img = thumbnail::open_image_for_preview(path)
            .with_context(|| format!("preview open failed for {}", path.display()))?;
        let _preview = ensure_preview_size(img);
        first_preview_samples.push(t0.elapsed().as_secs_f64() * 1000.0);
    }

    let state = build_state();
    let mut slider_samples = Vec::with_capacity(files.len());
    for path in &files {
        let img = thumbnail::open_image_for_preview(path)
            .with_context(|| format!("preview open failed for {}", path.display()))?;
        let preview = ensure_preview_size(img);
        let t0 = Instant::now();
        let processed = apply_preview_backend(&preview, &state, backend)?;
        let _raw = processed.to_rgba8().into_raw();
        slider_samples.push(t0.elapsed().as_secs_f64() * 1000.0);
    }

    let out_dir = std::env::temp_dir().join(format!(
        "photograph-perf-probe-{}-{}",
        std::process::id(),
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs()
    ));
    fs::create_dir_all(&out_dir)
        .with_context(|| format!("create_dir_all {}", out_dir.display()))?;

    let decode_ns_sum = AtomicU64::new(0);
    let gpu_ns_sum = AtomicU64::new(0);
    let encode_ns_sum = AtomicU64::new(0);

    let export_start = Instant::now();
    files.par_iter().try_for_each(|path| -> Result<()> {
        let t0 = Instant::now();
        let input = thumbnail::open_image(path)
            .with_context(|| format!("full open failed for {}", path.display()))?;
        decode_ns_sum.fetch_add(t0.elapsed().as_nanos() as u64, Ordering::Relaxed);

        let t1 = Instant::now();
        let processed = apply_preview_backend(&input, &state, backend)?;
        gpu_ns_sum.fetch_add(t1.elapsed().as_nanos() as u64, Ordering::Relaxed);

        let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("image");
        let output = out_dir.join(format!("{}.jpg", stem));
        let file = fs::File::create(&output)
            .with_context(|| format!("create output failed {}", output.display()))?;
        let writer = BufWriter::new(file);
        let encoder = JpegEncoder::new_with_quality(writer, 90);
        let t2 = Instant::now();
        processed
            .write_with_encoder(encoder)
            .with_context(|| format!("jpeg encode failed {}", output.display()))?;
        encode_ns_sum.fetch_add(t2.elapsed().as_nanos() as u64, Ordering::Relaxed);
        Ok(())
    })?;
    let export_wall_s = export_start.elapsed().as_secs_f64();
    let images_per_sec = files.len() as f64 / export_wall_s.max(1e-9);
    let decode_sum_s = decode_ns_sum.load(Ordering::Relaxed) as f64 / 1e9;
    let gpu_sum_s = gpu_ns_sum.load(Ordering::Relaxed) as f64 / 1e9;
    let encode_sum_s = encode_ns_sum.load(Ordering::Relaxed) as f64 / 1e9;

    println!("METRIC file_count={}", files.len());
    println!("METRIC preview_backend={}", backend.label());
    println!(
        "METRIC preview_ms_median={:.2}",
        median_ms(&first_preview_samples)
    );
    println!("METRIC slider_ms_median={:.2}", median_ms(&slider_samples));
    println!("METRIC export_wall_s={:.2}", export_wall_s);
    println!("METRIC export_images_per_sec={:.3}", images_per_sec);
    println!("METRIC export_decode_sum_s={:.2}", decode_sum_s);
    println!("METRIC export_gpu_apply_sum_s={:.2}", gpu_sum_s);
    println!("METRIC export_encode_sum_s={:.2}", encode_sum_s);
    println!(
        "METRIC export_gpu_apply_overlap_factor={:.2}",
        gpu_sum_s / export_wall_s.max(1e-9)
    );
    println!("METRIC export_out_dir={}", out_dir.display());

    Ok(())
}