redguard-preservation 0.6.2

A CLI tool to preserve Redguard files
Documentation
use super::{rgba_to_image, save_rgba_png};
use crate::opts::{TexbsiArgs, TexbsiFormat};
use color_eyre::Result;
use image::Frame;
use image::codecs::gif::{GifEncoder, Repeat};
use log::info;
use rayon::prelude::*;
use rgpre::import::{bsi, bsi::BsiImage, palette::Palette};
use serde_json::json;
use std::path::Path;

fn save_animated_gif(image: &BsiImage, palette: Option<&Palette>, path: &Path) -> Result<()> {
    let delay_ms = image.anim_delay.unsigned_abs().max(1);
    let delay = image::Delay::from_saturating_duration(std::time::Duration::from_millis(
        u64::from(delay_ms),
    ));

    let mut frames = Vec::with_capacity(image.frame_count as usize);
    for f in 0..image.frame_count as usize {
        let rgba = image
            .decode_frame_rgba(f, palette)
            .unwrap_or_else(|| image.decode_rgba(palette));
        let img = rgba_to_image(image.width, image.height, &rgba);
        frames.push(Frame::from_parts(img, 0, 0, delay));
    }

    let file = std::fs::File::create(path)?;
    let mut encoder = GifEncoder::new_with_speed(file, 10);
    encoder.set_repeat(Repeat::Infinite)?;
    encoder.encode_frames(frames)?;
    Ok(())
}

fn export_image_png(
    image: &BsiImage,
    palette: Option<&Palette>,
    output_path: &Path,
    compress: bool,
    export_all_frames: bool,
) -> Result<(String, Vec<String>)> {
    let png_name = format!("{}.png", image.name);
    let png_path = output_path.join(&png_name);
    let rgba = image.decode_rgba(palette);
    save_rgba_png(
        &png_path,
        u32::from(image.width),
        u32::from(image.height),
        &rgba,
        compress,
    )?;

    let mut frame_files: Vec<String> = vec![png_name.clone()];

    if export_all_frames && image.frame_count > 1 {
        for f in 1..image.frame_count as usize {
            if let Some(frame_rgba) = image.decode_frame_rgba(f, palette) {
                let frame_name = format!("{}_frame{f:02}.png", image.name);
                let frame_path = output_path.join(&frame_name);
                save_rgba_png(
                    &frame_path,
                    u32::from(image.width),
                    u32::from(image.height),
                    &frame_rgba,
                    compress,
                )?;
                frame_files.push(frame_name);
            }
        }
    }

    Ok((png_name, frame_files))
}

fn export_image_gif(
    image: &BsiImage,
    palette: Option<&Palette>,
    output_path: &Path,
    compress: bool,
) -> Result<(String, Vec<String>)> {
    if image.frame_count > 1 {
        let gif_name = format!("{}.gif", image.name);
        let gif_path = output_path.join(&gif_name);
        save_animated_gif(image, palette, &gif_path)?;
        Ok((gif_name.clone(), vec![gif_name]))
    } else {
        let png_name = format!("{}.png", image.name);
        let png_path = output_path.join(&png_name);
        let rgba = image.decode_rgba(palette);
        save_rgba_png(
            &png_path,
            u32::from(image.width),
            u32::from(image.height),
            &rgba,
            compress,
        )?;
        Ok((png_name.clone(), vec![png_name]))
    }
}

pub(crate) fn handle_texbsi_convert(args: &TexbsiArgs, output_path: &Path) -> Result<()> {
    let file_content = std::fs::read(&args.io.file)?;
    let bsi_file =
        bsi::parse_bsi_file(&file_content).map_err(|e| color_eyre::eyre::eyre!("{e}"))?;

    let palette = match args.palette.as_ref() {
        Some(p) => {
            let data = std::fs::read(p)?;
            Some(Palette::parse(&data).map_err(|e| color_eyre::eyre::eyre!("{e}"))?)
        }
        None => None,
    };

    std::fs::create_dir_all(output_path)?;

    let palette_name = args
        .palette
        .as_ref()
        .and_then(|p| p.file_name())
        .map(|n| n.to_string_lossy().to_string());

    let source_name = args
        .io
        .file
        .file_name()
        .map(|n| n.to_string_lossy().to_string())
        .unwrap_or_default();

    let use_gif = !matches!(args.format, TexbsiFormat::Png | TexbsiFormat::Frames);
    let export_all_frames = args.format == TexbsiFormat::Frames;
    let compress = args.compress_textures;

    let image_metadata: Vec<serde_json::Value> = bsi_file
        .images
        .par_iter()
        .filter(|image| image.width > 0 && image.height > 0)
        .map(|image| {
            let (primary_file, frame_files) = if use_gif {
                export_image_gif(image, palette.as_ref(), output_path, compress)?
            } else {
                export_image_png(
                    image,
                    palette.as_ref(),
                    output_path,
                    compress,
                    export_all_frames,
                )?
            };

            if frame_files.len() > 1 {
                info!(
                    "  [{}] {}x{} frames={} -> {} (+{} frames)",
                    image.name,
                    image.width,
                    image.height,
                    image.frame_count,
                    output_path.join(&primary_file).display(),
                    frame_files.len() - 1,
                );
            } else if use_gif && image.frame_count > 1 {
                info!(
                    "  [{}] {}x{} frames={} -> {} (animated gif)",
                    image.name,
                    image.width,
                    image.height,
                    image.frame_count,
                    output_path.join(&primary_file).display(),
                );
            } else {
                info!(
                    "  [{}] {}x{} -> {}",
                    image.name,
                    image.width,
                    image.height,
                    output_path.join(&primary_file).display(),
                );
            }

            let mut entry = serde_json::Map::new();
            entry.insert("name".into(), json!(image.name));
            entry.insert("image_index".into(), json!(image.image_index));
            entry.insert("width".into(), json!(image.width));
            entry.insert("height".into(), json!(image.height));
            entry.insert("x_offset".into(), json!(image.x_offset));
            entry.insert("y_offset".into(), json!(image.y_offset));
            entry.insert("frame_count".into(), json!(image.frame_count));
            entry.insert("anim_delay_ms".into(), json!(image.anim_delay));
            entry.insert("tex_scale".into(), json!(image.tex_scale));
            entry.insert("is_animated".into(), json!(image.is_animated));
            entry.insert(
                "has_embedded_palette".into(),
                json!(image.palette.is_some()),
            );
            entry.insert("data_encoding".into(), json!(image.data_encoding));
            entry.insert("file".into(), json!(primary_file));
            if frame_files.len() > 1 {
                entry.insert("frames".into(), json!(frame_files));
            }

            Ok(json!(entry))
        })
        .collect::<Result<Vec<_>>>()?;

    let metadata = json!({
        "source": source_name,
        "palette": palette_name,
        "image_count": image_metadata.len(),
        "images": image_metadata,
    });

    let json_path = output_path.join("index.json");
    let json_text = serde_json::to_string_pretty(&metadata)?;
    std::fs::write(&json_path, json_text)?;

    let total_files: usize = image_metadata
        .iter()
        .map(|e| {
            e.get("frames")
                .and_then(|f| f.as_array())
                .map_or(1, |a| a.len())
        })
        .sum();
    info!(
        "Extracted {} images ({} files) to {}",
        image_metadata.len(),
        total_files,
        output_path.display()
    );
    info!("Metadata written to {}", json_path.display());

    Ok(())
}