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;
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 },
];
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")
}
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)?])
}
#[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>,
}
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;
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 {
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
}
pub struct Webcam1999;
impl Crapifier for Webcam1999 {
type Args = Webcam1999Args;
fn run(&self, img: DynamicImage, args: &Self::Args) -> Result<DynamicImage, CrapifyError> {
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,
);
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))
}
}
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)
},
}
}
#[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() {
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() {
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() {
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() {
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]);
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() {
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);
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() {
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"
);
}
}
}
}