Minor optimizations to sprite re-coloring
This commit is contained in:
@@ -1,7 +1,54 @@
|
||||
use base64::{engine::GeneralPurpose, Engine};
|
||||
use image::{Rgba, RgbaImage};
|
||||
use std::io::Cursor;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const INPUT_GIF_BASE64: &str = include_str!("./neko.gif.txt");
|
||||
const INPUT_GIF_BYTES: &[u8] = include_bytes!("./neko.gif");
|
||||
const B64_ENGINE: GeneralPurpose = base64::engine::general_purpose::STANDARD;
|
||||
|
||||
struct GifFrame {
|
||||
pixels: Vec<u8>,
|
||||
delay: u16,
|
||||
dispose: gif::DisposalMethod,
|
||||
}
|
||||
|
||||
struct DecodedGif {
|
||||
width: u16,
|
||||
height: u16,
|
||||
frames: Vec<GifFrame>,
|
||||
}
|
||||
|
||||
static DECODED_GIF: OnceLock<DecodedGif> = OnceLock::new();
|
||||
|
||||
fn get_decoded_gif() -> &'static DecodedGif {
|
||||
DECODED_GIF
|
||||
.get_or_init(|| decode_gif_once(INPUT_GIF_BYTES).expect("Failed to decode embedded GIF"))
|
||||
}
|
||||
|
||||
fn decode_gif_once(input_bytes: &[u8]) -> Result<DecodedGif, Box<dyn std::error::Error>> {
|
||||
let reader = Cursor::new(input_bytes);
|
||||
let mut decoder = gif::DecodeOptions::new();
|
||||
decoder.set_color_output(gif::ColorOutput::RGBA);
|
||||
let mut decoder = decoder.read_info(reader)?;
|
||||
|
||||
let width = decoder.width();
|
||||
let height = decoder.height();
|
||||
let mut frames = Vec::new();
|
||||
|
||||
while let Some(frame) = decoder.read_next_frame()? {
|
||||
frames.push(GifFrame {
|
||||
pixels: frame.buffer.to_vec(),
|
||||
delay: frame.delay,
|
||||
dispose: frame.dispose,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(DecodedGif {
|
||||
width,
|
||||
height,
|
||||
frames,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn recolor_gif_base64(
|
||||
white_color_hex: &str,
|
||||
@@ -11,18 +58,19 @@ pub fn recolor_gif_base64(
|
||||
let white_color = parse_hex_color(white_color_hex)?;
|
||||
let black_color = parse_hex_color(black_color_hex)?;
|
||||
|
||||
// Decode base64 input
|
||||
let input_bytes = base64::decode(INPUT_GIF_BASE64.trim())?;
|
||||
// Get pre-decoded GIF data
|
||||
let decoded_gif = get_decoded_gif();
|
||||
|
||||
// Process GIF
|
||||
let output_bytes = recolor_gif_bytes(&input_bytes, white_color, black_color, apply_texture)?;
|
||||
// Process GIF with cached decoded frames
|
||||
let output_bytes = recolor_gif_frames(decoded_gif, white_color, black_color, apply_texture)?;
|
||||
|
||||
// Encode output to base64
|
||||
Ok(base64::encode(&output_bytes))
|
||||
Ok(B64_ENGINE.encode(&output_bytes))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn parse_hex_color(hex: &str) -> Result<Rgba<u8>, Box<dyn std::error::Error>> {
|
||||
let hex = hex.trim_start_matches('#');
|
||||
let hex = hex.strip_prefix('#').unwrap_or(hex);
|
||||
if hex.len() != 6 {
|
||||
return Err("Hex color must be 6 characters".into());
|
||||
}
|
||||
@@ -34,38 +82,45 @@ fn parse_hex_color(hex: &str) -> Result<Rgba<u8>, Box<dyn std::error::Error>> {
|
||||
Ok(Rgba([r, g, b, 255]))
|
||||
}
|
||||
|
||||
fn recolor_gif_bytes(
|
||||
input_bytes: &[u8],
|
||||
fn recolor_gif_frames(
|
||||
decoded_gif: &DecodedGif,
|
||||
white_color: Rgba<u8>,
|
||||
black_color: Rgba<u8>,
|
||||
apply_texture: bool,
|
||||
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
|
||||
// Decode input GIF
|
||||
let reader = Cursor::new(input_bytes);
|
||||
let mut decoder = gif::DecodeOptions::new();
|
||||
decoder.set_color_output(gif::ColorOutput::RGBA);
|
||||
let mut decoder = decoder.read_info(reader)?;
|
||||
let width = decoded_gif.width;
|
||||
let height = decoded_gif.height;
|
||||
|
||||
// Get GIF properties
|
||||
let width = decoder.width();
|
||||
let height = decoder.height();
|
||||
|
||||
// Prepare output encoder with in-memory buffer
|
||||
let output_buffer = Vec::new();
|
||||
// Pre-allocate output buffer
|
||||
let estimated_capacity = INPUT_GIF_BYTES.len() * 2;
|
||||
let output_buffer = Vec::with_capacity(estimated_capacity);
|
||||
let writer = Cursor::new(output_buffer);
|
||||
let mut encoder = gif::Encoder::new(writer, width, height, &[])?;
|
||||
encoder.set_repeat(gif::Repeat::Infinite)?;
|
||||
|
||||
// Process each frame
|
||||
while let Some(frame) = decoder.read_next_frame()? {
|
||||
// Convert frame buffer to RgbaImage
|
||||
let mut img = RgbaImage::from_raw(width as u32, height as u32, frame.buffer.to_vec())
|
||||
// Pre-generate palettes once
|
||||
let white_palette = if apply_texture {
|
||||
generate_color_palette(white_color, 7)
|
||||
} else {
|
||||
vec![white_color]
|
||||
};
|
||||
|
||||
let black_palette = if apply_texture {
|
||||
generate_color_palette(black_color, 7)
|
||||
} else {
|
||||
vec![black_color]
|
||||
};
|
||||
|
||||
// Process each pre-decoded frame
|
||||
for frame in &decoded_gif.frames {
|
||||
// Create image from cached pixel data
|
||||
let mut img = RgbaImage::from_raw(width as u32, height as u32, frame.pixels.clone())
|
||||
.ok_or("Failed to create image from frame")?;
|
||||
|
||||
// Recolor the image
|
||||
recolor_image(&mut img, white_color, black_color, apply_texture);
|
||||
recolor_image(&mut img, &white_palette, &black_palette, apply_texture);
|
||||
|
||||
// Create output frame with same timing
|
||||
// Create output frame
|
||||
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;
|
||||
@@ -81,22 +136,13 @@ fn recolor_gif_bytes(
|
||||
|
||||
fn recolor_image(
|
||||
img: &mut RgbaImage,
|
||||
white_color: Rgba<u8>,
|
||||
black_color: Rgba<u8>,
|
||||
white_palette: &[Rgba<u8>],
|
||||
black_palette: &[Rgba<u8>],
|
||||
apply_texture: bool,
|
||||
) {
|
||||
// Generate color palettes with variations if texture is enabled
|
||||
let white_palette = if apply_texture {
|
||||
generate_color_palette(white_color, 7)
|
||||
} else {
|
||||
vec![white_color]
|
||||
};
|
||||
|
||||
let black_palette = if apply_texture {
|
||||
generate_color_palette(black_color, 7)
|
||||
} else {
|
||||
vec![black_color]
|
||||
};
|
||||
// Pre-compute values for hot path
|
||||
let white_single = white_palette[0];
|
||||
let black_single = black_palette[0];
|
||||
|
||||
for (x, y, pixel) in img.enumerate_pixels_mut() {
|
||||
let Rgba([r, g, b, a]) = *pixel;
|
||||
@@ -106,44 +152,42 @@ fn recolor_image(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate brightness (0-255)
|
||||
let brightness = (r as u16 + g as u16 + b as u16) / 3;
|
||||
// Calculate brightness using faster bit shift approximation
|
||||
let brightness = ((r as u16 + g as u16 + b as u16) * 85) >> 8;
|
||||
|
||||
// Determine pixel type based on brightness
|
||||
if brightness < 64 {
|
||||
// Very dark pixels (outlines) - apply black target color
|
||||
let chosen_color = if apply_texture {
|
||||
pick_color_from_palette(&black_palette, x, y)
|
||||
pick_color_from_palette(black_palette, x, y)
|
||||
} else {
|
||||
black_color
|
||||
black_single
|
||||
};
|
||||
*pixel = chosen_color;
|
||||
} else if brightness > 200 {
|
||||
// White/light pixels (body) - apply white target color
|
||||
let chosen_color = if apply_texture {
|
||||
pick_color_from_palette(&white_palette, x, y)
|
||||
pick_color_from_palette(white_palette, x, y)
|
||||
} else {
|
||||
white_color
|
||||
white_single
|
||||
};
|
||||
*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(&white_palette, x, y)
|
||||
pick_color_from_palette(white_palette, x, y)
|
||||
} else {
|
||||
white_color
|
||||
white_single
|
||||
};
|
||||
|
||||
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;
|
||||
// Use integer arithmetic instead of floating point
|
||||
let blend_scaled = ((brightness as u32 - 64) * 256) / 136;
|
||||
let blend_scaled = blend_scaled.min(256) as u16;
|
||||
let inv_blend = 256 - blend_scaled;
|
||||
|
||||
let new_r = ((r as u16 * inv_blend + chosen_color[0] as u16 * blend_scaled) >> 8) as u8;
|
||||
let new_g = ((g as u16 * inv_blend + chosen_color[1] as u16 * blend_scaled) >> 8) as u8;
|
||||
let new_b = ((b as u16 * inv_blend + chosen_color[2] as u16 * blend_scaled) >> 8) as u8;
|
||||
|
||||
*pixel = Rgba([new_r, new_g, new_b, a]);
|
||||
}
|
||||
@@ -155,7 +199,7 @@ fn generate_color_palette(base_color: Rgba<u8>, count: usize) -> Vec<Rgba<u8>> {
|
||||
let Rgba([r, g, b, a]) = base_color;
|
||||
|
||||
// Generate darker and brighter variations
|
||||
let variation_range = 25; // How much to vary (+/- this value)
|
||||
let variation_range = 25;
|
||||
let step = (variation_range * 2) / (count - 1).max(1);
|
||||
|
||||
for i in 0..count {
|
||||
@@ -171,12 +215,14 @@ fn generate_color_palette(base_color: Rgba<u8>, count: usize) -> Vec<Rgba<u8>> {
|
||||
palette
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
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]
|
||||
let index = (hash as usize) % palette.len();
|
||||
|
||||
// SAFETY: index is guaranteed to be < palette.len() due to modulo operation
|
||||
unsafe { *palette.get_unchecked(index) }
|
||||
}
|
||||
|
||||
BIN
src-tauri/src/services/sprite_recolor/neko.gif
Normal file
BIN
src-tauri/src/services/sprite_recolor/neko.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -1 +0,0 @@
|
||||
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
|
||||
@@ -106,7 +106,7 @@
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
generatePreview();
|
||||
}, 300); // Adjust debounce delay as needed (300ms is a common starting point)
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function updateFrame() {
|
||||
|
||||
Reference in New Issue
Block a user