diff --git a/src-tauri/src/services/sprite_recolor/mod.rs b/src-tauri/src/services/sprite_recolor/mod.rs index a359ff3..4258a0c 100644 --- a/src-tauri/src/services/sprite_recolor/mod.rs +++ b/src-tauri/src/services/sprite_recolor/mod.rs @@ -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, + delay: u16, + dispose: gif::DisposalMethod, +} + +struct DecodedGif { + width: u16, + height: u16, + frames: Vec, +} + +static DECODED_GIF: OnceLock = 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> { + 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, Box> { - 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, Box> { Ok(Rgba([r, g, b, 255])) } -fn recolor_gif_bytes( - input_bytes: &[u8], +fn recolor_gif_frames( + decoded_gif: &DecodedGif, white_color: Rgba, black_color: Rgba, apply_texture: bool, ) -> Result, Box> { - // 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, - black_color: Rgba, + white_palette: &[Rgba], + black_palette: &[Rgba], 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, count: usize) -> Vec> { 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, count: usize) -> Vec> { palette } +#[inline(always)] fn pick_color_from_palette(palette: &[Rgba], x: u32, y: u32) -> Rgba { // 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) } } diff --git a/src-tauri/src/services/sprite_recolor/neko.gif b/src-tauri/src/services/sprite_recolor/neko.gif new file mode 100644 index 0000000..f55a425 Binary files /dev/null and b/src-tauri/src/services/sprite_recolor/neko.gif differ diff --git a/src-tauri/src/services/sprite_recolor/neko.gif.txt b/src-tauri/src/services/sprite_recolor/neko.gif.txt deleted file mode 100644 index 5f507b9..0000000 --- a/src-tauri/src/services/sprite_recolor/neko.gif.txt +++ /dev/null @@ -1 +0,0 @@ -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 diff --git a/src/routes/app-menu/tabs/DollPreview.svelte b/src/routes/app-menu/tabs/DollPreview.svelte index 71e4e8f..0569124 100644 --- a/src/routes/app-menu/tabs/DollPreview.svelte +++ b/src/routes/app-menu/tabs/DollPreview.svelte @@ -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() {