use std::io::Cursor;
use std::time::{SystemTime, UNIX_EPOCH};
use clap::{Args, FromArgMatches};
use image::{DynamicImage, ImageReader, RgbImage, codecs::jpeg::JpegEncoder};
use crate::crapifier::{Crapifier, CrapifierEntry};
use crate::error::CrapifyError;
struct FryLevel {
name: &'static str,
slug: &'static str,
description: &'static str,
quality: u8,
passes: u32,
saturation: f32,
contrast: f32,
brightness: f32,
sharpen_radius: f32,
noise: f32,
}
#[rustfmt::skip]
const FRY_LEVELS: &[FryLevel] = &[
FryLevel { name: "Raw", slug: "raw", description: "Barely touched.", quality: 95, passes: 1, saturation: 1.00, contrast: 1.00, brightness: 1.00, sharpen_radius: 0.0, noise: 0.0 },
FryLevel { name: "Pan seared", slug: "pan-seared", description: "Light crunch.", quality: 78, passes: 1, saturation: 1.10, contrast: 1.05, brightness: 0.97, sharpen_radius: 1.0, noise: 4.0 },
FryLevel { name: "Chicken fried", slug: "chicken-fried", description: "Visible JPEG mess.", quality: 60, passes: 2, saturation: 1.20, contrast: 1.08, brightness: 0.95, sharpen_radius: 1.5, noise: 8.0 },
FryLevel { name: "Deep fried", slug: "deep-fried", description: "The canonical look (also the default).", quality: 50, passes: 3, saturation: 1.30, contrast: 1.12, brightness: 0.92, sharpen_radius: 2.0, noise: 12.0 },
FryLevel { name: "Forwarded by your dad", slug: "forwarded-by-your-dad", description: "Maximum doneness.", quality: 32, passes: 5, saturation: 1.55, contrast: 1.30, brightness: 0.85, sharpen_radius: 3.5, noise: 30.0 },
];
const DEFAULT_PRESET_SLUG: &str = "deep-fried";
fn default_preset() -> &'static FryLevel {
FRY_LEVELS
.iter()
.find(|l| l.slug == DEFAULT_PRESET_SLUG)
.expect("DEFAULT_PRESET_SLUG must reference a real FRY_LEVELS row")
}
#[derive(Args, Debug)]
pub struct DeepFryArgs {
#[arg(
long,
value_parser = clap::builder::PossibleValuesParser::new(
FRY_LEVELS.iter().map(|l| l.slug)
),
help = "Named recipe preset (overridable by raw knobs)",
long_help = "Named recipe preset — the easy way to pick a doneness level without \
tuning seven knobs by hand.\n\n\
Each preset is a complete recipe: all seven knobs (quality, passes, saturation, \
contrast, brightness, sharpen-radius, noise) at once. Passing an individual \
knob on the command line overrides just that one value; everything else stays \
from the preset. See the PRESETS table at the bottom of `--help` for what \
each preset's recipe actually is."
)]
preset: Option<String>,
#[arg(
long,
value_parser = clap::value_parser!(u8).range(1..=100),
help = "JPEG quality, 1 (worst) to 100 (best)",
long_help = "JPEG save quality, 1 (worst) to 100 (best). Lower numbers make the picture \
more obviously squashed by JPEG compression — those blocky 8×8-pixel patches \
of color and the smeary edges you see on a heavily-saved image. The default \
'deep-fried' preset uses 50; 'forwarded-by-your-dad' uses 32; 'raw' uses 95."
)]
quality: Option<u8>,
#[arg(
long,
value_parser = clap::value_parser!(u32).range(1..),
help = "How many times to re-save as a low-quality JPEG (1 or more)",
long_help = "How many times to re-save the picture as a low-quality JPEG in a row. Each \
round-trip makes the damage worse — colors get smeary, edges grow halos, the \
blocky compression patches get more obvious. 1 is a single re-save; 3 is the \
default 'deep-fried' setting; 5 is what 'forwarded-by-your-dad' does."
)]
passes: Option<u32>,
#[arg(
long,
help = "Color intensity (1.0 = unchanged, >1 = more vivid, <1 = fade)",
long_help = "Color intensity. 1.0 leaves the picture alone; values above 1.0 make every \
color more vivid (1.3 — the 'deep-fried' default — pushes skin tones orange; \
1.55+ goes nuclear); values below 1.0 fade toward gray. 0.0 is fully grayscale."
)]
saturation: Option<f32>,
#[arg(
long,
help = "Contrast (1.0 = unchanged, >1 = punchier, <1 = flatter)",
long_help = "Contrast. 1.0 leaves the picture alone; values above 1.0 push dark areas \
darker and bright areas brighter (1.12 is the 'deep-fried' default; above ~1.5 \
starts crushing the picture toward pure black-and-white); values below 1.0 \
flatten everything toward mid-gray."
)]
contrast: Option<f32>,
#[arg(
long,
help = "Brightness (1.0 = unchanged, <1 = darker, >1 = brighter)",
long_help = "Brightness. 1.0 leaves the picture alone; values below 1.0 darken the whole \
image (0.92 is the 'deep-fried' default — a slight dimming that lets the \
boosted colors stand out); below 0.7 starts losing shadow detail. Values above \
1.0 brighten but quickly blow out highlights to pure white."
)]
brightness: Option<f32>,
#[arg(
long,
help = "Edge-sharpening strength in pixels (0 = off, ~2 = balanced, 4+ = dramatic)",
long_help = "How hard to sharpen edges (in pixels). Higher values make outlines pop more \
and add wider, more obvious halos around them. 2.0 is the 'deep-fried' \
default; 3.5 is what 'forwarded-by-your-dad' uses; 0.0 turns sharpening off \
entirely (used by the 'raw' preset)."
)]
sharpen_radius: Option<f32>,
#[arg(
long,
help = "How much grainy speckle to add (0 = none, ~12 = subtle, ~30 = heavy)",
long_help = "How much grainy speckle to sprinkle on top of the picture, in raw 0..=255 \
pixel units (not a 0..=1 fraction). 12 is the 'deep-fried' default (subtle \
grain); 30 is 'forwarded-by-your-dad' (heavy); beyond ~50 the grain starts \
to dominate the picture. 0 turns noise off (used by 'raw'). The grain \
pattern is random per run — outputs aren't bit-reproducible."
)]
noise: Option<f32>,
}
fn reencode_jpeg(rgb: &RgbImage, quality: u8) -> Result<RgbImage, CrapifyError> {
let mut buf: Vec<u8> = Vec::new();
{
let mut encoder = JpegEncoder::new_with_quality(&mut buf, quality);
encoder
.encode_image(rgb)
.map_err(CrapifyError::EncoderError)?;
}
let decoded = ImageReader::new(Cursor::new(buf))
.with_guessed_format()?
.decode()
.map_err(CrapifyError::DecoderError)?;
Ok(decoded.to_rgb8())
}
fn jpeg_passes(img: &DynamicImage, quality: u8, passes: u32) -> Result<RgbImage, CrapifyError> {
let mut rgb = img.to_rgb8();
for _ in 0..passes {
rgb = reencode_jpeg(&rgb, quality)?;
}
Ok(rgb)
}
fn rgb_to_hsv(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
let rf = r as f32 / 255.0;
let gf = g as f32 / 255.0;
let bf = b as f32 / 255.0;
let max = rf.max(gf).max(bf);
let min = rf.min(gf).min(bf);
let delta = max - min;
let v = max;
let s = if max <= 0.0 { 0.0 } else { delta / max };
let h = if delta == 0.0 {
0.0
} else if (max - rf).abs() < f32::EPSILON {
60.0 * ((gf - bf) / delta).rem_euclid(6.0)
} else if (max - gf).abs() < f32::EPSILON {
60.0 * ((bf - rf) / delta + 2.0)
} else {
60.0 * ((rf - gf) / delta + 4.0)
};
(h, s, v)
}
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
let c = v * s;
let h6 = h.rem_euclid(360.0) / 60.0;
let x = c * (1.0 - (h6 % 2.0 - 1.0).abs());
let (r1, g1, b1) = match h6.floor() as i32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x), };
let m = v - c;
let to_u8 = |f: f32| ((f + m) * 255.0).round().clamp(0.0, 255.0) as u8;
(to_u8(r1), to_u8(g1), to_u8(b1))
}
fn saturate(mut rgb: RgbImage, factor: f32) -> RgbImage {
for pixel in rgb.pixels_mut() {
let [r, g, b] = pixel.0;
let (h, s, v) = rgb_to_hsv(r, g, b);
let (nr, ng, nb) = hsv_to_rgb(h, (s * factor).clamp(0.0, 1.0), v);
pixel.0 = [nr, ng, nb];
}
rgb
}
fn contrast(mut rgb: RgbImage, factor: f32) -> RgbImage {
for pixel in rgb.pixels_mut() {
for c in pixel.0.iter_mut() {
*c = ((*c as f32 - 128.0) * factor + 128.0).clamp(0.0, 255.0) as u8;
}
}
rgb
}
fn brightness(mut rgb: RgbImage, factor: f32) -> RgbImage {
for pixel in rgb.pixels_mut() {
for c in pixel.0.iter_mut() {
*c = (*c as f32 * factor).clamp(0.0, 255.0) as u8;
}
}
rgb
}
fn unsharp(rgb: RgbImage, sigma: f32) -> RgbImage {
if sigma <= 0.0 {
return rgb;
}
image::imageops::unsharpen(&rgb, sigma, 0)
}
fn noisify(mut rgb: RgbImage, stddev: f32) -> RgbImage {
if stddev <= 0.0 {
return rgb;
}
let seed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
imageproc::noise::gaussian_noise_mut(&mut rgb, 0.0, stddev as f64, seed);
rgb
}
pub struct DeepFry;
impl Crapifier for DeepFry {
type Args = DeepFryArgs;
fn run(&self, img: DynamicImage, args: &Self::Args) -> Result<DynamicImage, CrapifyError> {
let preset = args
.preset
.as_deref()
.and_then(|slug| FRY_LEVELS.iter().find(|lvl| lvl.slug == slug))
.unwrap_or_else(default_preset);
let quality = args.quality.unwrap_or(preset.quality);
let passes = args.passes.unwrap_or(preset.passes);
let sat = args.saturation.unwrap_or(preset.saturation);
let con = args.contrast.unwrap_or(preset.contrast);
let bri = args.brightness.unwrap_or(preset.brightness);
let sigma = args.sharpen_radius.unwrap_or(preset.sharpen_radius);
let noise = args.noise.unwrap_or(preset.noise);
let rgb = jpeg_passes(&img, quality, passes)?;
let rgb = saturate(rgb, sat);
let rgb = contrast(rgb, con);
let rgb = brightness(rgb, bri);
let rgb = unsharp(rgb, sigma);
let rgb = noisify(rgb, noise);
Ok(DynamicImage::ImageRgb8(rgb))
}
}
const STAGE_ABOUT: &str =
"Deep-fry an image: crusty JPEG, boosted colors, sharpened edges, sprinkled with grain.";
fn build_help_footer() -> String {
let mut s = String::from(
"Re-saves the image as a low-quality JPEG several times (each round-trip makes the \
compression damage more obvious), pumps up the colors and contrast, sharpens the edges \
until they ring, and dusts grain over the whole thing. The classic 'deep-fried meme' \
look.\n\nPRESETS (via --preset <slug>):\n",
);
let slug_col = FRY_LEVELS.iter().map(|l| l.slug.len()).max().unwrap_or(0);
let name_col = FRY_LEVELS
.iter()
.map(|l| l.name.len() + 2)
.max()
.unwrap_or(0);
for lvl in FRY_LEVELS {
let passes_word = if lvl.passes == 1 { "pass" } else { "passes" };
let quoted = format!("\"{}\"", lvl.name);
s.push_str(&format!(
" {slug:<slug_w$} {quoted:<name_w$} q={q}, {p} {pw}. {desc}\n",
slug = lvl.slug,
quoted = quoted,
q = lvl.quality,
p = lvl.passes,
pw = passes_word,
desc = lvl.description,
slug_w = slug_col,
name_w = name_col,
));
}
s.push_str(
"\nKnobs override preset values when both are supplied:\n \
crapify deep-fry --preset deep-fried --saturation 2.0 in.jpg out.png\n\n\
With no flags, defaults resolve to the 'deep-fried' preset.",
);
s
}
inventory::submit! {
CrapifierEntry {
name: "deep-fry",
augment_command: |cmd| {
DeepFryArgs::augment_args(
cmd.about(STAGE_ABOUT).after_long_help(build_help_footer()),
)
},
run: |img, matches| {
let args = DeepFryArgs::from_arg_matches(matches).map_err(CrapifyError::Clap)?;
DeepFry.run(img, &args)
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use image::Rgb;
#[test]
fn default_preset_slug_points_to_real_row() {
let _ = default_preset();
}
#[test]
fn help_footer_includes_every_preset_name_slug_and_description() {
let s = build_help_footer();
for lvl in FRY_LEVELS {
assert!(s.contains(lvl.name), "missing display name: {:?}", lvl.name);
assert!(s.contains(lvl.slug), "missing slug: {:?}", lvl.slug);
assert!(
s.contains(lvl.description),
"missing desc: {:?}",
lvl.description
);
}
}
#[test]
fn preset_slugs_are_unique_and_dash_lowercase() {
let mut seen = std::collections::HashSet::new();
for lvl in FRY_LEVELS {
assert!(
lvl.slug.chars().all(|c| c.is_ascii_lowercase() || c == '-'),
"slug {:?} has chars outside [a-z-]",
lvl.slug
);
assert!(seen.insert(lvl.slug), "duplicate slug: {:?}", lvl.slug);
}
}
#[test]
fn contrast_identity_at_factor_1() {
let img = RgbImage::from_pixel(2, 2, Rgb([42, 128, 200]));
let out = contrast(img.clone(), 1.0);
assert_eq!(out, img);
}
#[test]
fn contrast_fixed_point_at_128() {
let img = RgbImage::from_pixel(2, 2, Rgb([128, 128, 128]));
let out = contrast(img.clone(), 2.5);
assert_eq!(out, img);
}
#[test]
fn brightness_identity_at_factor_1() {
let img = RgbImage::from_pixel(4, 4, Rgb([42, 100, 200]));
let out = brightness(img.clone(), 1.0);
assert_eq!(out, img);
}
#[test]
fn saturate_identity_at_factor_1() {
let img = RgbImage::from_pixel(4, 4, Rgb([200, 100, 50]));
let out = saturate(img.clone(), 1.0);
for (o, n) in img.pixels().zip(out.pixels()) {
for i in 0..3 {
assert!(
(o.0[i] as i32 - n.0[i] as i32).abs() <= 1,
"channel {} drifted: {} -> {}",
i,
o.0[i],
n.0[i]
);
}
}
}
#[test]
fn hsv_roundtrip_within_one() {
for r in (0..=255).step_by(37) {
for g in (0..=255).step_by(41) {
for b in (0..=255).step_by(43) {
let (h, s, v) = rgb_to_hsv(r as u8, g as u8, b as u8);
let (nr, ng, nb) = hsv_to_rgb(h, s, v);
assert!((nr as i32 - r).abs() <= 1, "R: {} -> {}", r, nr);
assert!((ng as i32 - g).abs() <= 1, "G: {} -> {}", g, ng);
assert!((nb as i32 - b).abs() <= 1, "B: {} -> {}", b, nb);
}
}
}
}
#[test]
fn noisify_zero_is_identity() {
let img = RgbImage::from_pixel(8, 8, Rgb([100, 150, 200]));
let out = noisify(img.clone(), 0.0);
assert_eq!(out, img);
}
#[test]
fn noisify_preserves_dimensions() {
let img = RgbImage::from_pixel(32, 24, Rgb([100, 150, 200]));
let out = noisify(img, 12.0);
assert_eq!(out.dimensions(), (32, 24));
}
#[test]
fn unsharp_zero_sigma_is_identity() {
let img = RgbImage::from_pixel(8, 8, Rgb([100, 150, 200]));
let out = unsharp(img.clone(), 0.0);
assert_eq!(out, img);
}
}