crapify 0.4.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
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
use std::time::{SystemTime, UNIX_EPOCH};

use clap::{Args, FromArgMatches};
use image::imageops::FilterType;
use image::{DynamicImage, RgbImage};

use super::prng::Xorshift64;
use crate::crapifier::{Crapifier, CrapifierEntry};
use crate::error::CrapifyError;

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

struct WebcamLevel {
    name: &'static str,
    slug: &'static str,
    description: &'static str,
    downsample_w: u32,
    downsample_h: u32,
    sharpen_radius: f32,
    saturation: f32,
    tint: [u8; 3],
    scanline_drop_rate: f32,
}

#[rustfmt::skip]
const WEBCAM_LEVELS: &[WebcamLevel] = &[
    WebcamLevel { name: "QuickCam VC",                  slug: "quickcam-vc",                  description: "Top of the era's lineup. Slight warm CCD cast, almost holds up.",           downsample_w: 640, downsample_h: 480, sharpen_radius: 0.6, saturation: 0.85, tint: [0xFF, 0xF4, 0xE8], scanline_drop_rate: 0.005 },
    WebcamLevel { name: "AIM buddy",                    slug: "aim-buddy",                    description: "Cool CCD cast, soft, jittery. The canonical 2001 vibe (also the default).", downsample_w: 320, downsample_h: 240, sharpen_radius: 0.8, saturation: 0.75, tint: [0xE6, 0xF0, 0xFF], scanline_drop_rate: 0.02  },
    WebcamLevel { name: "Parents bought the cheap one", slug: "parents-bought-the-cheap-one", description: "Dingy auto-WB, chunky pixels, visibly torn rows.",                         downsample_w: 160, downsample_h: 120, sharpen_radius: 1.2, saturation: 0.55, tint: [0xEC, 0xE4, 0xCC], scanline_drop_rate: 0.06  },
];

// --- Defaults --------------------------------------------------------------

const DEFAULT_PRESET_SLUG: &str = "aim-buddy";

fn default_preset() -> &'static WebcamLevel {
    WEBCAM_LEVELS
        .iter()
        .find(|l| l.slug == DEFAULT_PRESET_SLUG)
        .expect("DEFAULT_PRESET_SLUG must reference a real WEBCAM_LEVELS row")
}

// --- Hex tint parser --------------------------------------------------------

fn parse_tint(s: &str) -> Result<[u8; 3], String> {
    let rest = s
        .strip_prefix('#')
        .ok_or_else(|| format!("expected #rrggbb, got {s:?}"))?;
    if rest.len() != 6 {
        return Err(format!(
            "expected #rrggbb (6 hex chars after #), got {} chars",
            rest.len()
        ));
    }
    let p = |i: usize| {
        u8::from_str_radix(&rest[i..i + 2], 16).map_err(|_| format!("non-hex digits in {s:?}"))
    };
    Ok([p(0)?, p(2)?, p(4)?])
}

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

#[derive(Args, Debug)]
pub struct Webcam1999Args {
    #[arg(
        long,
        value_parser = clap::builder::PossibleValuesParser::new(
            WEBCAM_LEVELS.iter().map(|l| l.slug)
        ),
        help = "Named recipe preset (overridable by raw knobs)",
        long_help = "Named recipe preset — picks a 1999-webcam aesthetic without tuning every \
                     knob by hand.\n\n\
                     Each preset is a complete recipe: downsample width and height, sharpen \
                     radius, saturation, tint color, and scanline-drop rate 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 = "downsample-w",
        value_parser = clap::value_parser!(u32).range(1..),
        help = "Internal-buffer width in pixels (presets bind 640/320/160)",
        long_help = "Width of the internal downsample buffer, in pixels. The input is \
                     nearest-neighbor downsampled to (downsample-w, downsample-h), then \
                     nearest-neighbor upsampled back to the input's original dimensions — that \
                     round-trip is what produces the chunky pixel blocks. 640 (quickcam-vc) is \
                     the top of the era's lineup, 320 (aim-buddy) is the canonical AIM-buddy \
                     resolution, 160 (parents-bought-the-cheap-one) is brutal.\n\n\
                     Presets bind both width and height to fixed 4:3 dimensions (640×480, \
                     320×240, 160×120). Without `--preserve-aspect`, that means non-4:3 inputs \
                     get squished to 4:3 internally — exactly what a real fixed-sensor webcam \
                     did. Pass `--preserve-aspect` to skip the squish."
    )]
    downsample_w: Option<u32>,

    #[arg(
        long = "downsample-h",
        value_parser = clap::value_parser!(u32).range(1..),
        help = "Internal-buffer height in pixels (presets bind 480/240/120)",
        long_help = "Height of the internal downsample buffer, in pixels. Pairs with \
                     `--downsample-w` to fix the (w, h) target the input gets squished into \
                     before the nearest-neighbor upsample restores the original dimensions.\n\n\
                     Ignored when `--preserve-aspect` is set; in that case the height is \
                     recomputed from `--downsample-w` and the input's aspect ratio."
    )]
    downsample_h: Option<u32>,

    #[arg(
        long = "preserve-aspect",
        help = "Recompute downsample height from input aspect (skip the 4:3 squish)",
        long_help = "Escape hatch from the default aspect-mangle. By default, presets bind a \
                     fixed 4:3 downsample buffer (e.g. 320×240 for aim-buddy) and non-4:3 inputs \
                     get squished into it — a 16:9 photo comes back vertically stretched, just \
                     like a real fixed-4:3-sensor webcam would have produced.\n\n\
                     With `--preserve-aspect`, the downsample height is recomputed from the \
                     resolved width and the input's aspect ratio (so a 1600×900 input on \
                     aim-buddy becomes a 320×180 internal buffer instead of 320×240). The \
                     resolution loss still happens — your output is still chunky and sharpened — \
                     but the shape is preserved."
    )]
    preserve_aspect: bool,

    #[arg(
        long = "sharpen-radius",
        help = "Unsharp-mask radius in pixels (0 = off; ~0.6 mild, ~1.2 heavy ringing)",
        long_help = "Gaussian-blur radius (sigma, in pixels) for the unsharp-mask sharpen pass — \
                     same parameter as deep-fry's `--sharpen-radius`. The sharpen runs on the \
                     downsampled internal buffer (before the nearest-neighbor upsample), so the \
                     resulting halos get block-copied by the upsample into the block-scale \
                     stripes you see around every chunky pixel boundary — that's the canonical \
                     1999-webcam ringing.\n\n\
                     0.0 disables sharpening entirely. 0.6 (quickcam-vc) is a light bite around \
                     edges; 0.8 (aim-buddy) is the canonical visible sharpen; 1.2 \
                     (parents-bought-the-cheap-one) is heavy edge-ringing. Beyond ~2.0 the halos \
                     start to dominate the image."
    )]
    sharpen_radius: Option<f32>,

    #[arg(
        long,
        help = "Saturation lerp toward Rec. 601 gray (1.0 = unchanged, 0.0 = grayscale)",
        long_help = "Saturation as a linear lerp toward Rec. 601 luma \
                     (0.299·R + 0.587·G + 0.114·B). 1.0 leaves channels alone; 0.0 collapses \
                     every pixel to its luma value (pure grayscale); values between fade \
                     channels toward gray proportionally. 0.85 (quickcam-vc) is a barely-there \
                     pull, 0.75 (aim-buddy) reads as slightly washed, 0.55 \
                     (parents-bought-the-cheap-one) is heavily desaturated.\n\n\
                     Useful range is 0.0..=1.5. Values above 1.0 boost saturation (channels \
                     pulled away from luma) and clip when they exceed 0..=255. Negative values \
                     are accepted and produce a broken-white-balance complement look — outputs \
                     get strange fast.\n\n\
                     This is intentionally a linear lerp toward gray rather than an HSV \
                     saturation multiply: cheap CCDs lost chroma faster than luma, so a \
                     saturated red shouldn't preserve its hue at low saturation — it should \
                     fade toward muddy half-luma red, which is what this math produces."
    )]
    saturation: Option<f32>,

    #[arg(
        long,
        value_parser = parse_tint,
        help = "CCD color cast as #rrggbb hex (per-channel multiply; #ffffff = no-op)",
        long_help = "Color cast applied as a per-channel multiply on every pixel: \
                     out_r = r * tint_r / 255, and the same for g and b. #ffffff leaves the \
                     image alone; values below 255 on a channel attenuate it. \n\n\
                     #fff4e8 (quickcam-vc) is the slight warm cast Connectix-era CCDs leaned \
                     toward. #e6f0ff (aim-buddy) is ~10% red attenuation that reads cool/cyan on \
                     skin tones without crossing into deliberate teal-and-orange grading. \
                     #ece4cc (parents-bought-the-cheap-one) is the yellow-green dingy cast cheap \
                     CMOS produced when auto-white-balance lost the plot.\n\n\
                     The tint multiplies a desaturated buffer when --saturation < 1.0, so the \
                     same hex shifts more visibly on a low-sat preset than on a near-neutral \
                     one."
    )]
    tint: Option<[u8; 3]>,

    #[arg(
        long = "scanline-drop-rate",
        help = "Fraction of rows replaced by the previous row (0.0 = off, 1.0 = all)",
        long_help = "Per-row probability that a scanline's contents get replaced by the \
                     previous row's pixels. Reads as the comb-tooth horizontal tear cheap USB \
                     webcams produced when a row's data was lost mid-frame. 0.0 disables. 0.005 \
                     (quickcam-vc) is occasional; 0.02 (aim-buddy) is a visible glitch every \
                     dozen rows or so; 0.06 (parents-bought-the-cheap-one) is heavily glitched.\n\n\
                     At 1.0 every row beyond the first replicates its predecessor, which itself \
                     was replicated — the image collapses to row 0 stretched downward. Row 0 is \
                     never a replacement target (there's nothing above it to copy)."
    )]
    scanline_drop_rate: Option<f32>,
}

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

fn resolve_target(input: (u32, u32), wanted: (u32, u32), preserve_aspect: bool) -> (u32, u32) {
    if !preserve_aspect {
        return wanted;
    }
    let (iw, ih) = input;
    let (ww, _) = wanted;
    // max(1) so a 1px-tall input doesn't yield a zero-height buffer.
    let h = ((ww as u64 * ih as u64 + iw as u64 / 2) / iw as u64).max(1) as u32;
    (ww, h)
}

fn saturate(mut rgb: RgbImage, factor: f32) -> RgbImage {
    if factor == 1.0 {
        return rgb;
    }
    let inv = 1.0 - factor;
    for pixel in rgb.pixels_mut() {
        let [r, g, b] = pixel.0;
        let luma = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32;
        let gray = luma * inv;
        let lerp = |c: u8| (c as f32 * factor + gray).clamp(0.0, 255.0) as u8;
        pixel.0 = [lerp(r), lerp(g), lerp(b)];
    }
    rgb
}

fn tint(mut rgb: RgbImage, color: [u8; 3]) -> RgbImage {
    if color == [255, 255, 255] {
        return rgb;
    }
    let [tr, tg, tb] = color;
    for pixel in rgb.pixels_mut() {
        let [r, g, b] = pixel.0;
        pixel.0 = [
            ((r as u16 * tr as u16) / 255) as u8,
            ((g as u16 * tg as u16) / 255) as u8,
            ((b as u16 * tb as u16) / 255) as u8,
        ];
    }
    rgb
}

fn resample_nearest(rgb: &RgbImage, w: u32, h: u32) -> RgbImage {
    image::imageops::resize(rgb, w, h, FilterType::Nearest)
}

fn sharpen(rgb: RgbImage, sigma: f32) -> RgbImage {
    // imageproc::filter::gaussian_blur_f32 panics on sigma <= 0.
    if sigma <= 0.0 {
        return rgb;
    }
    image::imageops::unsharpen(&rgb, sigma, 0)
}

fn scanline_drop(mut rgb: RgbImage, rate: f32, rng: &mut Xorshift64) -> RgbImage {
    if rate <= 0.0 {
        return rgb;
    }
    let (w, h) = rgb.dimensions();
    if h < 2 || w == 0 {
        return rgb;
    }
    let row_bytes = (w as usize) * 3;
    let buf: &mut [u8] = &mut rgb;
    for y in 1..h as usize {
        if rng.next_f32() < rate {
            buf.copy_within((y - 1) * row_bytes..y * row_bytes, y * row_bytes);
        }
    }
    rgb
}

// --- Crapifier impl + pipeline assembly ------------------------------------

pub struct Webcam1999;

impl Crapifier for Webcam1999 {
    type Args = Webcam1999Args;

    fn run(&self, img: DynamicImage, args: &Self::Args) -> Result<DynamicImage, CrapifyError> {
        // Cascade: explicit CLI knob > preset (user-named or default) value.
        // clap rejects unknown slugs at parse time and an absent --preset falls
        // back to default_preset(), so an effective preset always exists.
        let preset = args
            .preset
            .as_deref()
            .and_then(|slug| WEBCAM_LEVELS.iter().find(|lvl| lvl.slug == slug))
            .unwrap_or_else(default_preset);

        let wanted_w = args.downsample_w.unwrap_or(preset.downsample_w);
        let wanted_h = args.downsample_h.unwrap_or(preset.downsample_h);
        let sharpen_radius = args.sharpen_radius.unwrap_or(preset.sharpen_radius);
        let sat = args.saturation.unwrap_or(preset.saturation);
        let tint_color = args.tint.unwrap_or(preset.tint);
        let drop_rate = args.scanline_drop_rate.unwrap_or(preset.scanline_drop_rate);

        let rgb = img.into_rgb8();
        let (input_w, input_h) = rgb.dimensions();

        let (target_w, target_h) = resolve_target(
            (input_w, input_h),
            (wanted_w, wanted_h),
            args.preserve_aspect,
        );

        // Everything except the final scanline-drop runs on the small downsampled
        // buffer. Sat and tint commute with nearest-neighbor resampling so the
        // result is identical to running them full-res; sharpen does NOT commute,
        // and that's the point — sharpening the small buffer first means the
        // unsharp halos get nearest-neighbor-copied into block-scale stripes
        // around the chunky pixel boundaries, which is the canonical 1999-webcam
        // ringing. Sharpening after upsample produces finer sub-block halos
        // (cleaner-looking but not webcam-shaped).
        let rgb = resample_nearest(&rgb, target_w, target_h);
        let rgb = saturate(rgb, sat);
        let rgb = tint(rgb, tint_color);
        let rgb = sharpen(rgb, sharpen_radius);
        let rgb = resample_nearest(&rgb, input_w, input_h);

        let base_seed = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_nanos() as u64)
            .unwrap_or(1);
        let mut rng = Xorshift64::new(base_seed);
        let rgb = scanline_drop(rgb, drop_rate, &mut rng);

        Ok(DynamicImage::ImageRgb8(rgb))
    }
}

// --- Help wiring ------------------------------------------------------------

const STAGE_ABOUT: &str =
    "Run an image through a 1999 USB webcam: chunky pixels, CCD cast, the occasional torn row.";

fn build_help_footer() -> String {
    let mut s = String::from(
        "Downsamples nearest-neighbor to a small internal buffer, recolors with a CCD-style \
         saturation/tint pair, oversharpens the small buffer, then upsamples nearest-neighbor \
         back to the input's original dimensions — that final upsample is what produces the \
         chunky pixel blocks and what blows the sharpen halos up into block-scale ringing \
         around every boundary. Finally, occasionally replicates a scanline from the row \
         above for the comb-tooth tear cheap USB webcams produced.\n\n\
         By default the downsample target is a fixed 4:3 buffer (640×480, 320×240, or 160×120 \
         depending on preset) and non-4:3 inputs get aspect-mangled — same as a real \
         fixed-sensor webcam. Pass --preserve-aspect to keep your input's ratio.\n\n\
         PRESETS (via --preset <slug>):\n",
    );
    let slug_col = WEBCAM_LEVELS
        .iter()
        .map(|l| l.slug.len())
        .max()
        .unwrap_or(0);
    let name_col = WEBCAM_LEVELS
        .iter()
        .map(|l| l.name.len() + 2)
        .max()
        .unwrap_or(0);
    for lvl in WEBCAM_LEVELS {
        let quoted = format!("\"{}\"", lvl.name);
        s.push_str(&format!(
            "  {slug:<slug_w$}  {quoted:<name_w$}  {w}×{h} buffer, sharpen {sg:.1}, sat {sa:.2}, tint #{tr:02x}{tg:02x}{tb:02x}, drops {dr:.3}. {desc}\n",
            slug = lvl.slug,
            quoted = quoted,
            w = lvl.downsample_w,
            h = lvl.downsample_h,
            sg = lvl.sharpen_radius,
            sa = lvl.saturation,
            tr = lvl.tint[0],
            tg = lvl.tint[1],
            tb = lvl.tint[2],
            dr = lvl.scanline_drop_rate,
            desc = lvl.description,
            slug_w = slug_col,
            name_w = name_col,
        ));
    }
    s.push_str(
        "\nKnobs override preset values when both are supplied:\n  \
         crapify webcam-1999 --preset aim-buddy --tint '#ff8866' in.png out.png\n\n\
         With no flags, defaults resolve to the 'aim-buddy' preset.",
    );
    s
}

inventory::submit! {
    CrapifierEntry {
        name: "webcam-1999",
        augment_command: |cmd| {
            Webcam1999Args::augment_args(
                cmd.about(STAGE_ABOUT).after_long_help(build_help_footer()),
            )
        },
        run: |img, matches| {
            let args = Webcam1999Args::from_arg_matches(matches).map_err(CrapifyError::Clap)?;
            Webcam1999.run(img, &args)
        },
    }
}

// --- Tests ------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use image::Rgb;

    #[test]
    fn default_preset_slug_points_to_real_row() {
        let _ = default_preset();
    }

    #[test]
    fn preset_slugs_are_unique_and_dash_lowercase() {
        let mut seen = std::collections::HashSet::new();
        for lvl in WEBCAM_LEVELS {
            assert!(
                lvl.slug
                    .chars()
                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
                "slug {:?} has chars outside [a-z0-9-]",
                lvl.slug
            );
            assert!(seen.insert(lvl.slug), "duplicate slug: {:?}", lvl.slug);
        }
    }

    #[test]
    fn help_footer_includes_every_preset_name_slug_and_description() {
        let s = build_help_footer();
        for lvl in WEBCAM_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 parse_tint_accepts_hex() {
        assert_eq!(parse_tint("#ff8866"), Ok([0xff, 0x88, 0x66]));
        assert_eq!(parse_tint("#000000"), Ok([0, 0, 0]));
        assert_eq!(parse_tint("#ffffff"), Ok([255, 255, 255]));
    }

    #[test]
    fn parse_tint_rejects_missing_hash() {
        assert!(parse_tint("ff8866").is_err());
    }

    #[test]
    fn parse_tint_rejects_wrong_length() {
        assert!(parse_tint("#abc").is_err());
        assert!(parse_tint("#abcdefg").is_err());
        assert!(parse_tint("#").is_err());
    }

    #[test]
    fn parse_tint_rejects_non_hex() {
        assert!(parse_tint("#zzzzzz").is_err());
        assert!(parse_tint("#gg0000").is_err());
    }

    #[test]
    fn resolve_target_no_preserve_returns_wanted_verbatim() {
        assert_eq!(resolve_target((16, 16), (320, 240), false), (320, 240));
        assert_eq!(resolve_target((1600, 900), (320, 240), false), (320, 240));
    }

    #[test]
    fn resolve_target_preserve_square_in_square_out() {
        // Square input → preserve_aspect keeps width 320 and recomputes
        // height to also be 320, since the input is 1:1.
        assert_eq!(resolve_target((16, 16), (320, 240), true), (320, 320));
        assert_eq!(resolve_target((1024, 1024), (320, 240), true), (320, 320));
    }

    #[test]
    fn resolve_target_preserve_widescreen_round_trip() {
        // 16:9 input with preset 320×240: keep w=320, h = 320*900/1600 = 180.
        assert_eq!(resolve_target((1600, 900), (320, 240), true), (320, 180));
        assert_eq!(resolve_target((1280, 720), (320, 240), true), (320, 180));
    }

    #[test]
    fn resolve_target_preserve_handles_pathological_aspect() {
        // 1000:1 input — naive h would round to 0; max(1) guard kicks in so
        // downstream resize doesn't panic on a zero-height buffer.
        assert_eq!(resolve_target((1000, 1), (320, 240), true), (320, 1));
    }

    #[test]
    fn saturate_factor_1_is_identity() {
        let img = RgbImage::from_pixel(4, 4, Rgb([200, 100, 50]));
        let out = saturate(img.clone(), 1.0);
        assert_eq!(out, img);
    }

    #[test]
    fn saturate_factor_0_collapses_each_pixel_to_its_luma() {
        // Rec. 601 luma for (200, 100, 50) is 0.299*200 + 0.587*100 + 0.114*50
        //                                   = 59.8 + 58.7 + 5.7 = 124.2 → 124u8.
        let img = RgbImage::from_pixel(2, 2, Rgb([200, 100, 50]));
        let out = saturate(img, 0.0);
        for px in out.pixels() {
            assert_eq!(px.0[0], px.0[1]);
            assert_eq!(px.0[1], px.0[2]);
            // Allow ±1 for f32→u8 rounding.
            let v = px.0[0] as i32;
            assert!((123..=125).contains(&v), "expected ≈124, got {v}");
        }
    }

    #[test]
    fn tint_white_is_identity() {
        let img = RgbImage::from_pixel(4, 4, Rgb([200, 100, 50]));
        let out = tint(img.clone(), [255, 255, 255]);
        assert_eq!(out, img);
    }

    #[test]
    fn tint_attenuates_each_channel_independently() {
        // tint = [255, 128, 0] → r unchanged, g halved, b zeroed.
        let img = RgbImage::from_pixel(2, 2, Rgb([200, 200, 200]));
        let out = tint(img, [255, 128, 0]);
        let px = out.get_pixel(0, 0).0;
        assert_eq!(px[0], 200);
        // 200 * 128 / 255 = 100.39 → 100u8 (truncating integer division).
        assert_eq!(px[1], 100);
        assert_eq!(px[2], 0);
    }

    #[test]
    fn sharpen_zero_sigma_is_identity() {
        let img = RgbImage::from_pixel(8, 8, Rgb([100, 150, 200]));
        let out = sharpen(img.clone(), 0.0);
        assert_eq!(out, img);
    }

    #[test]
    fn scanline_drop_zero_rate_is_identity() {
        let img = RgbImage::from_pixel(8, 8, Rgb([100, 150, 200]));
        let mut rng = Xorshift64::new(42);
        let out = scanline_drop(img.clone(), 0.0, &mut rng);
        assert_eq!(out, img);
    }

    #[test]
    fn scanline_drop_preserves_dimensions() {
        let img = RgbImage::from_pixel(32, 24, Rgb([100, 150, 200]));
        let mut rng = Xorshift64::new(42);
        let out = scanline_drop(img, 0.5, &mut rng);
        assert_eq!(out.dimensions(), (32, 24));
    }

    #[test]
    fn scanline_drop_rate_one_collapses_to_row_zero() {
        // Build a 4-row image where each row has a distinct color, then
        // run scanline_drop with rate=1.0. Every row beyond row 0 should
        // get replaced by the previous row's pixels, which propagates
        // row 0 downward across the whole image.
        let mut img = RgbImage::new(2, 4);
        for y in 0..4u32 {
            for x in 0..2u32 {
                img.put_pixel(x, y, Rgb([y as u8 * 50, 0, 0]));
            }
        }
        let mut rng = Xorshift64::new(42);
        let out = scanline_drop(img, 1.0, &mut rng);
        for y in 0..4u32 {
            for x in 0..2u32 {
                assert_eq!(
                    out.get_pixel(x, y).0,
                    [0, 0, 0],
                    "row {y} should equal row 0 after rate=1.0"
                );
            }
        }
    }
}