This commit is contained in:
2025-12-19 11:14:58 +08:00
commit d48c04d2b2
4 changed files with 1216 additions and 0 deletions

190
src/main.rs Normal file
View File

@@ -0,0 +1,190 @@
use image::{Rgba, RgbaImage};
use std::fs::File;
use std::io::{self, BufReader, BufWriter, Write};
fn main() {
println!("=== Neko Sprite Recolor Tool ===\n");
// Get input path
println!("Enter input GIF path:");
let input_path = read_line();
// Get output path
println!("Enter output GIF path:");
let output_path = read_line();
// Get target color
println!("Enter target color (hex, e.g., FF5733):");
let color_input = read_line();
let target_color = parse_hex_color(&color_input).expect("Invalid hex color");
// Ask about texture
println!("Apply texture/noise? (y/n):");
let texture_input = read_line();
let apply_texture = texture_input.to_lowercase().starts_with('y');
println!("\nProcessing...");
match recolor_gif(&input_path, &output_path, target_color, apply_texture) {
Ok(_) => println!("✓ Successfully created: {}", output_path),
Err(e) => eprintln!("✗ Error: {}", e),
}
}
fn read_line() -> String {
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.expect("Failed to read line");
input.trim().to_string()
}
fn parse_hex_color(hex: &str) -> Result<Rgba<u8>, String> {
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return Err("Hex color must be 6 characters".to_string());
}
let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| "Invalid hex")?;
let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| "Invalid hex")?;
let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| "Invalid hex")?;
Ok(Rgba([r, g, b, 255]))
}
fn recolor_gif(
input_path: &str,
output_path: &str,
target_color: Rgba<u8>,
apply_texture: bool,
) -> Result<(), Box<dyn std::error::Error>> {
// Open and decode input GIF
let file = File::open(input_path)?;
let reader = BufReader::new(file);
let mut decoder = gif::DecodeOptions::new();
decoder.set_color_output(gif::ColorOutput::RGBA);
let mut decoder = decoder.read_info(reader)?;
// Get GIF properties
let width = decoder.width();
let height = decoder.height();
// Prepare output encoder
let output_file = File::create(output_path)?;
let writer = BufWriter::new(output_file);
let mut encoder = gif::Encoder::new(writer, width, height, &[])?;
encoder.set_repeat(gif::Repeat::Infinite)?;
let mut frame_count = 0;
// Process each frame
while let Some(frame) = decoder.read_next_frame()? {
frame_count += 1;
print!("\rProcessing frame {}...", frame_count);
io::stdout().flush()?;
// Convert frame buffer to RgbaImage
let mut img = RgbaImage::from_raw(width as u32, height as u32, frame.buffer.to_vec())
.ok_or("Failed to create image from frame")?;
// Recolor the image
recolor_image(&mut img, target_color, apply_texture);
// Create output frame with same timing
let mut pixel_data = img.into_raw();
let mut output_frame = gif::Frame::from_rgba_speed(width, height, &mut pixel_data, 10);
output_frame.delay = frame.delay;
output_frame.dispose = frame.dispose;
encoder.write_frame(&output_frame)?;
}
println!("\n✓ Processed {} frames", frame_count);
Ok(())
}
fn recolor_image(img: &mut RgbaImage, target_color: Rgba<u8>, apply_texture: bool) {
// Generate color palette with variations if texture is enabled
let color_palette = if apply_texture {
generate_color_palette(target_color, 7)
} else {
vec![target_color]
};
for (x, y, pixel) in img.enumerate_pixels_mut() {
let Rgba([r, g, b, a]) = *pixel;
// Skip fully transparent pixels
if a == 0 {
continue;
}
// Calculate brightness (0-255)
let brightness = (r as u16 + g as u16 + b as u16) / 3;
// Determine pixel type based on brightness
if brightness < 64 {
// Very dark pixels (outlines) - keep original
continue;
} else if brightness > 200 {
// White/light pixels (body) - apply target color
let chosen_color = if apply_texture {
pick_color_from_palette(&color_palette, x, y)
} else {
target_color
};
*pixel = chosen_color;
} else {
// Gray pixels (anti-aliasing/shadows) - blend with target color
// Blend factor based on how gray it is (0.0 = keep original, 1.0 = full target)
let blend_factor = (brightness as f32 - 64.0) / (200.0 - 64.0);
let blend_factor = blend_factor.clamp(0.0, 1.0);
let chosen_color = if apply_texture {
pick_color_from_palette(&color_palette, x, y)
} else {
target_color
};
let new_r =
(r as f32 * (1.0 - blend_factor) + chosen_color[0] as f32 * blend_factor) as u8;
let new_g =
(g as f32 * (1.0 - blend_factor) + chosen_color[1] as f32 * blend_factor) as u8;
let new_b =
(b as f32 * (1.0 - blend_factor) + chosen_color[2] as f32 * blend_factor) as u8;
*pixel = Rgba([new_r, new_g, new_b, a]);
}
}
}
fn generate_color_palette(base_color: Rgba<u8>, count: usize) -> Vec<Rgba<u8>> {
let mut palette = Vec::with_capacity(count);
let Rgba([r, g, b, a]) = base_color;
// Generate darker and brighter variations
let variation_range = 25; // How much to vary (+/- this value)
let step = (variation_range * 2) / (count - 1).max(1);
for i in 0..count {
let offset = -(variation_range as i32) + (i as i32 * step as i32);
let new_r = (r as i32 + offset).clamp(0, 255) as u8;
let new_g = (g as i32 + offset).clamp(0, 255) as u8;
let new_b = (b as i32 + offset).clamp(0, 255) as u8;
palette.push(Rgba([new_r, new_g, new_b, a]));
}
palette
}
fn pick_color_from_palette(palette: &[Rgba<u8>], x: u32, y: u32) -> Rgba<u8> {
// Use a deterministic "random" selection based on pixel position
// This creates consistent noise pattern across frames
let hash = x
.wrapping_mul(374761393)
.wrapping_add(y.wrapping_mul(668265263));
let index = (hash % palette.len() as u32) as usize;
palette[index]
}