Init
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
1017
Cargo.lock
generated
Normal file
1017
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
8
Cargo.toml
Normal file
8
Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "nekosprite"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
gif = "0.14.1"
|
||||
image = "0.25.9"
|
||||
190
src/main.rs
Normal file
190
src/main.rs
Normal 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]
|
||||
}
|
||||
Reference in New Issue
Block a user