crapify 0.2.0

Deep-fry your images, and other crimes against pixels.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
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;

// --- Preset table -----------------------------------------------------------

struct FryLevel {
    // Display name from candles — shown in --help and is the user-facing
    // doneness label ("Forwarded by your dad" carries more meaning than
    // its slug ever could). The slug is the CLI contract; the name is the
    // brand.
    name: &'static str,
    slug: &'static str,
    description: &'static str,
    quality: u8,
    passes: u32,
    saturation: f32,
    contrast: f32,
    brightness: f32,
    sharpen_radius: f32,
    noise: f32,
}

// Quality / passes / saturation / contrast / brightness are lifted verbatim
// from candles-meme-generator's FRY_LEVELS (quality rescaled from the
// 0.0..=1.0 fraction convention to JpegEncoder's native u8 1..=100, CSS
// filter string unpacked into explicit fields). sharpen_radius and noise
// are new in crapify — they ramp with doneness so the preset is the full
// recipe, not just five of seven knobs. Deep-fried's (2.0, 12.0) preserves
// the previously-standalone defaults.
#[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 },
];

// --- Defaults --------------------------------------------------------------
// Every default lives in FRY_LEVELS. The bare-flags invocation resolves to
// `default_preset()` (the "deep-fried" row) and every knob falls back to that.

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")
}

// --- Args -------------------------------------------------------------------

#[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. 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 — there's no --seed flag yet, \
                     so you can't reproduce the exact same speckle twice."
    )]
    noise: Option<f32>,
}

// --- Pixel ops --------------------------------------------------------------

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> {
    // to_rgb8() is the alpha-dropping point (JPEG has no alpha).
    let mut rgb = img.to_rgb8();
    for _ in 0..passes {
        rgb = reencode_jpeg(&rgb, quality)?;
    }
    Ok(rgb)
}

// Hue in [0, 360), saturation and value in [0, 1].
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), // covers 5 and any rare floor=6 from h≈360
    };
    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 {
    // image::imageops::unsharpen blurs with the given sigma and returns
    // clamp(orig + (orig - blur), 0, max) per channel — exactly the unsharp
    // mask formula. The threshold=0 form gates no pixels. Skip the call when
    // sigma is 0 because gaussian blur with sigma 0 is a no-op anyway.
    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;
    }
    // Fresh seed per invocation; v1 deliberately has no --seed flag.
    // Falling back to 0 only triggers on a clock-before-epoch system.
    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
}

// --- DeepFry crapifier + pipeline assembly ---------------------------------

pub struct DeepFry;

impl Crapifier for DeepFry {
    type Args = DeepFryArgs;

    fn run(&self, img: DynamicImage, args: &Self::Args) -> Result<DynamicImage, CrapifyError> {
        // Cascade: explicit CLI knob > preset (user-named or default) value.
        // There's always an effective preset — clap rejects unknown slugs at
        // parse time, and an absent --preset falls back to default_preset().
        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| {
            // about → Usage → Args → Options → footer is the conventional
            // top-down read order. The recipe blurb + preset table + override
            // note belong AFTER Options, not above Usage, so they go through
            // after_long_help rather than long_about.
            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() {
        // `default_preset()` panics if DEFAULT_PRESET_SLUG doesn't match a
        // FRY_LEVELS row, so this test fails loudly at the right call site.
        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() {
        // The preset slug is the user contract — typos and duplicates here
        // become silent CLI surprises. Test guards against both at once.
        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);
        // HSV roundtrip is lossy to ±1 in the worst case.
        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);
    }
}