use image::{Rgba, RgbaImage};
#[derive(Debug, Clone)]
pub struct OnionConfig {
pub count: u32,
pub opacity: f32,
pub prev_color: Rgba<u8>,
pub next_color: Rgba<u8>,
pub fade: bool,
}
impl Default for OnionConfig {
fn default() -> Self {
Self {
count: 2,
opacity: 0.3,
prev_color: Rgba([0, 0, 255, 255]), next_color: Rgba([0, 255, 0, 255]), fade: false,
}
}
}
fn calculate_opacity(base_opacity: f32, distance: u32, fade: bool) -> f32 {
if !fade || distance == 0 {
return base_opacity;
}
let fade_factor = 1.0 - ((distance - 1) as f32 / 3.0);
base_opacity * fade_factor.max(0.0)
}
fn apply_tint(image: &RgbaImage, tint: Rgba<u8>, opacity: f32) -> RgbaImage {
let mut result = image.clone();
let opacity_u8 = (opacity * 255.0).clamp(0.0, 255.0) as u8;
for pixel in result.pixels_mut() {
if pixel[3] > 0 {
let r = ((pixel[0] as f32 * tint[0] as f32) / 255.0) as u8;
let g = ((pixel[1] as f32 * tint[1] as f32) / 255.0) as u8;
let b = ((pixel[2] as f32 * tint[2] as f32) / 255.0) as u8;
let a = ((pixel[3] as f32 * opacity_u8 as f32) / 255.0) as u8;
*pixel = Rgba([r, g, b, a]);
}
}
result
}
fn composite_over(dest: &mut RgbaImage, src: &RgbaImage) {
if dest.dimensions() != src.dimensions() {
return;
}
for (dest_pixel, src_pixel) in dest.pixels_mut().zip(src.pixels()) {
if src_pixel[3] == 0 {
continue;
}
let src_a = src_pixel[3] as f32 / 255.0;
let dest_a = dest_pixel[3] as f32 / 255.0;
let out_a = src_a + dest_a * (1.0 - src_a);
if out_a > 0.0 {
let blend = |s: u8, d: u8| -> u8 {
let sf = s as f32 / 255.0;
let df = d as f32 / 255.0;
let out = (sf * src_a + df * dest_a * (1.0 - src_a)) / out_a;
(out * 255.0).clamp(0.0, 255.0) as u8
};
dest_pixel[0] = blend(src_pixel[0], dest_pixel[0]);
dest_pixel[1] = blend(src_pixel[1], dest_pixel[1]);
dest_pixel[2] = blend(src_pixel[2], dest_pixel[2]);
dest_pixel[3] = (out_a * 255.0).clamp(0.0, 255.0) as u8;
}
}
}
pub fn render_onion_skin(
frames: &[RgbaImage],
current_frame: usize,
config: &OnionConfig,
) -> RgbaImage {
if frames.is_empty() {
return RgbaImage::new(1, 1);
}
let current_frame = current_frame.min(frames.len() - 1);
let current = &frames[current_frame];
let mut result = RgbaImage::from_pixel(current.width(), current.height(), Rgba([0, 0, 0, 0]));
for i in (1..=config.count).rev() {
let frame_idx = current_frame as i32 - i as i32;
if frame_idx >= 0 {
let opacity = calculate_opacity(config.opacity, i, config.fade);
let tinted = apply_tint(&frames[frame_idx as usize], config.prev_color, opacity);
composite_over(&mut result, &tinted);
}
}
for i in (1..=config.count).rev() {
let frame_idx = current_frame + i as usize;
if frame_idx < frames.len() {
let opacity = calculate_opacity(config.opacity, i, config.fade);
let tinted = apply_tint(&frames[frame_idx], config.next_color, opacity);
composite_over(&mut result, &tinted);
}
}
composite_over(&mut result, current);
result
}
pub fn parse_hex_color(hex: &str) -> Option<Rgba<u8>> {
let hex = hex.strip_prefix('#').unwrap_or(hex);
match hex.len() {
3 => {
let r = u8::from_str_radix(&hex[0..1], 16).ok()? * 17;
let g = u8::from_str_radix(&hex[1..2], 16).ok()? * 17;
let b = u8::from_str_radix(&hex[2..3], 16).ok()? * 17;
Some(Rgba([r, g, b, 255]))
}
4 => {
let r = u8::from_str_radix(&hex[0..1], 16).ok()? * 17;
let g = u8::from_str_radix(&hex[1..2], 16).ok()? * 17;
let b = u8::from_str_radix(&hex[2..3], 16).ok()? * 17;
let a = u8::from_str_radix(&hex[3..4], 16).ok()? * 17;
Some(Rgba([r, g, b, a]))
}
6 => {
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some(Rgba([r, g, b, 255]))
}
8 => {
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
let a = u8::from_str_radix(&hex[6..8], 16).ok()?;
Some(Rgba([r, g, b, a]))
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_hex_color_6digit() {
assert_eq!(parse_hex_color("#FF0000"), Some(Rgba([255, 0, 0, 255])));
assert_eq!(parse_hex_color("#00FF00"), Some(Rgba([0, 255, 0, 255])));
assert_eq!(parse_hex_color("#0000FF"), Some(Rgba([0, 0, 255, 255])));
assert_eq!(parse_hex_color("FFFFFF"), Some(Rgba([255, 255, 255, 255])));
}
#[test]
fn test_parse_hex_color_3digit() {
assert_eq!(parse_hex_color("#F00"), Some(Rgba([255, 0, 0, 255])));
assert_eq!(parse_hex_color("#0F0"), Some(Rgba([0, 255, 0, 255])));
assert_eq!(parse_hex_color("#00F"), Some(Rgba([0, 0, 255, 255])));
}
#[test]
fn test_parse_hex_color_8digit() {
assert_eq!(parse_hex_color("#FF000080"), Some(Rgba([255, 0, 0, 128])));
assert_eq!(parse_hex_color("#00FF00FF"), Some(Rgba([0, 255, 0, 255])));
}
#[test]
fn test_parse_hex_color_invalid() {
assert_eq!(parse_hex_color("invalid"), None);
assert_eq!(parse_hex_color("#GG0000"), None);
assert_eq!(parse_hex_color("#12345"), None);
}
#[test]
fn test_calculate_opacity_no_fade() {
let base = 0.3;
assert_eq!(calculate_opacity(base, 1, false), 0.3);
assert_eq!(calculate_opacity(base, 2, false), 0.3);
assert_eq!(calculate_opacity(base, 3, false), 0.3);
}
#[test]
fn test_calculate_opacity_with_fade() {
let base = 0.3;
assert!((calculate_opacity(base, 1, true) - 0.3).abs() < 0.001);
assert!((calculate_opacity(base, 2, true) - 0.2).abs() < 0.001);
assert!((calculate_opacity(base, 3, true) - 0.1).abs() < 0.001);
}
#[test]
fn test_onion_config_default() {
let config = OnionConfig::default();
assert_eq!(config.count, 2);
assert!((config.opacity - 0.3).abs() < 0.001);
assert_eq!(config.prev_color, Rgba([0, 0, 255, 255]));
assert_eq!(config.next_color, Rgba([0, 255, 0, 255]));
assert!(!config.fade);
}
#[test]
fn test_render_onion_skin_single_frame() {
let frame = RgbaImage::from_pixel(4, 4, Rgba([255, 0, 0, 255]));
let frames = vec![frame];
let config = OnionConfig::default();
let result = render_onion_skin(&frames, 0, &config);
assert_eq!(result.width(), 4);
assert_eq!(result.height(), 4);
assert_eq!(*result.get_pixel(0, 0), Rgba([255, 0, 0, 255]));
}
#[test]
fn test_render_onion_skin_empty_frames() {
let frames: Vec<RgbaImage> = vec![];
let config = OnionConfig::default();
let result = render_onion_skin(&frames, 0, &config);
assert_eq!(result.width(), 1);
assert_eq!(result.height(), 1);
}
#[test]
fn test_render_onion_skin_with_ghosts() {
let frames = vec![
RgbaImage::from_pixel(4, 4, Rgba([255, 0, 0, 255])),
RgbaImage::from_pixel(4, 4, Rgba([0, 255, 0, 255])),
RgbaImage::from_pixel(4, 4, Rgba([0, 0, 255, 255])),
];
let config = OnionConfig {
count: 1,
opacity: 0.5,
prev_color: Rgba([255, 255, 255, 255]), next_color: Rgba([255, 255, 255, 255]), fade: false,
};
let result = render_onion_skin(&frames, 1, &config);
let pixel = result.get_pixel(0, 0);
assert!(pixel[1] > pixel[0]); assert!(pixel[1] > pixel[2]); }
#[test]
fn test_apply_tint() {
let image = RgbaImage::from_pixel(2, 2, Rgba([255, 255, 255, 255]));
let tint = Rgba([255, 0, 0, 255]); let opacity = 0.5;
let result = apply_tint(&image, tint, opacity);
let pixel = result.get_pixel(0, 0);
assert_eq!(pixel[0], 255); assert_eq!(pixel[1], 0); assert_eq!(pixel[2], 0); assert_eq!(pixel[3], 127); }
#[test]
fn test_composite_over_transparent_src() {
let mut dest = RgbaImage::from_pixel(2, 2, Rgba([255, 0, 0, 255]));
let src = RgbaImage::from_pixel(2, 2, Rgba([0, 0, 0, 0]));
composite_over(&mut dest, &src);
assert_eq!(*dest.get_pixel(0, 0), Rgba([255, 0, 0, 255]));
}
#[test]
fn test_composite_over_opaque_src() {
let mut dest = RgbaImage::from_pixel(2, 2, Rgba([255, 0, 0, 255]));
let src = RgbaImage::from_pixel(2, 2, Rgba([0, 255, 0, 255]));
composite_over(&mut dest, &src);
assert_eq!(*dest.get_pixel(0, 0), Rgba([0, 255, 0, 255]));
}
}