diff --git a/README.md b/README.md index 5039973..c6fe4d4 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ math-visualizer/ ├── crates/viz-core/ # Rust crate compiled to WebAssembly │ ├── src/ │ │ ├── lib.rs # wasm-bindgen entry point -│ │ ├── jpeg_images.rs # Generated JPEG lab test images (gradient, text, bars) +│ │ ├── jpeg_images.rs # Generated JPEG lab pictures (gradient, text, bars, cosines, shifted cosines, noise) │ │ ├── jpeg_kit.rs # Stateless wasm-bindgen wrappers over the jpeg crate │ │ ├── traits.rs # SceneState, Rule, Visualization, Capabilities, InputEvent │ │ ├── config/ # ConfigSchema trait + JSON Schema helpers @@ -182,6 +182,7 @@ math-visualizer/ │ └── tests/ # Browser tests: wasm.rs (engine), jpeg_kit.rs, jpeg_lab.rs, jpeg_oracle.rs └── web/ # Vite + Svelte 5 app ├── public/fonts/ # Space Mono (SIL OFL 1.1) + OFL.txt, for the Fourier lab + ├── public/jpeg/ # JPEG lab gallery pictures (PNG) + CREDITS.md ├── src/ │ ├── App.svelte # Top nav + hash-route switch between labs │ ├── main.ts # Svelte 5 mount entry @@ -209,3 +210,13 @@ math-visualizer/ See [docs/superpowers/specs/2026-05-23-math-visualizer-foundation-design.md](docs/superpowers/specs/2026-05-23-math-visualizer-foundation-design.md) for the full design — including the planned Rule/Visualization/Engine abstractions that land in Phase 2+. See [docs/configuration.md](docs/configuration.md) for build-time configuration, [docs/testing.md](docs/testing.md) for measured coverage and test scope, and [docs/licensing.md](docs/licensing.md) for the workspace's declared license. + +## Image credits + +The JPEG lab's gallery pictures are bundled in `web/public/jpeg/`. spring and shuttle are cropped from their Commons originals, and all four Commons-based pictures are scaled. Full credits and crop boxes are in [`web/public/jpeg/CREDITS.md`](web/public/jpeg/CREDITS.md); the scripts that regenerate the pictures are in [`scripts/jpeg-gallery/`](scripts/jpeg-gallery/). + +- spring: "Grand Prismatic Spring, Yellowstone" · Jim Peaco, National Park Service · Public domain · https://commons.wikimedia.org/wiki/File:Grand_prismatic_spring.jpg +- shuttle: "Atlantis (STS-135) on pad 39A" · Bill Ingalls, NASA · Public domain · https://commons.wikimedia.org/wiki/File:Brightly_lit_STS-135_on_launch_pad_39a.jpg +- doge: "Doge (homemade)" · Euterpia, after a photo by Roberto Vasarri · CC0 · https://commons.wikimedia.org/wiki/File:Doge_homemade_meme.jpg +- honest-work: "It ain't much, but it's honest work" · Cakelot1, photo by Dianne Johnson, USDA NRCS · Public domain · https://commons.wikimedia.org/wiki/File:Farmer_meme_with_apostrophe.jpg +- cartoon: "Needs more JPEG" · made for this project · CC0 diff --git a/crates/viz-core/src/jpeg_images.rs b/crates/viz-core/src/jpeg_images.rs index 505dd06..ebbd268 100644 --- a/crates/viz-core/src/jpeg_images.rs +++ b/crates/viz-core/src/jpeg_images.rs @@ -1,12 +1,21 @@ //! Built-in test images for the JPEG lab, generated in code so they need no //! assets: each one is chosen to provoke one artifact. +use std::f32::consts::PI; + use jpeg::Rgb8Image; use crate::rules::rng::splitmix64; /// Ids accepted by `builtin_image`, in picker order. -pub const GENERATED_IMAGES: &[&str] = &["gradient", "text", "bars"]; +pub const GENERATED_IMAGES: &[&str] = &[ + "gradient", + "text", + "bars", + "cosines", + "cosines-shifted", + "noise", +]; /// Largest width or height `builtin_image` will generate (guards against /// enormous allocations requested from JavaScript). @@ -22,6 +31,9 @@ pub fn builtin_image(id: &str, w: usize, h: usize) -> Option { "gradient" => gradient_px, "text" => text_px, "bars" => bars_px, + "cosines" => cosines_px, + "cosines-shifted" => cosines_shifted_px, + "noise" => noise_px, _ => return None, }; let mut rgba = Vec::with_capacity(w * h * 4); @@ -136,9 +148,43 @@ fn bars_px(x: usize, y: usize, w: usize, h: usize) -> [u8; 3] { to_px(rgb.map(|c| c * 255.0)) } +/// A quilt of the 64 DCT basis patterns, one per 8×8 block, on a constant teal +/// tint: block `(bx, by)` carries frequency `(bx % 8, by % 8)`. Every block is +/// exactly one cosine the transform has a coefficient for, so it codes to one +/// or two numbers and the whole picture compresses almost perfectly. +fn cosines_px(x: usize, y: usize, _w: usize, _h: usize) -> [u8; 3] { + let (u, v) = ((x / 8) % 8, (y / 8) % 8); + let c = |k: usize, n: usize| (PI * (2 * n + 1) as f32 * k as f32 / 16.0).cos(); + let l = 128.0 + 90.0 * c(u, x % 8) * c(v, y % 8); + to_px([l - 40.0, l + 10.0, l + 25.0]) +} + +/// The same cosines slid 4 pixels right and down, so every 8×8 block straddles +/// four patterns. The picture is as simple to describe as before, but no block +/// is a single cosine any more, so it costs several times the bytes. The +/// transform only rewards what lines up with its grid. +fn cosines_shifted_px(x: usize, y: usize, w: usize, h: usize) -> [u8; 3] { + cosines_px(x + 4, y + 4, w, h) +} + +/// Seeded random bytes in every channel. There is no structure to find, so +/// compression can only shrink it by destroying it: the file stays large and +/// the decoded picture is a poor copy of the original. +fn noise_px(x: usize, y: usize, w: usize, _h: usize) -> [u8; 3] { + let r = splitmix64(0x4A50_4547 ^ (y * w + x) as u64); + [r as u8, (r >> 8) as u8, (r >> 16) as u8] +} + #[cfg(test)] mod tests { use super::*; + use jpeg::blocks::level_shift; + use jpeg::codec::{decode, encode, HuffmanMode, Options}; + use jpeg::color::rgb_to_ycbcr; + use jpeg::dct::fdct; + use jpeg::metrics::psnr; + use jpeg::quant::{quantize, scale_table, STD_LUMA}; + use jpeg::Subsampling; fn max_neighbour_step(img: &Rgb8Image) -> u8 { let mut m = 0; @@ -180,4 +226,117 @@ mod tests { .count(); assert!(saturated > 256 * 64, "bars are colourful"); } + + fn baseline() -> Options { + Options { + quality: 50, + subsampling: Subsampling::S420, + huffman: HuffmanMode::Optimized, + ..Options::default() + } + } + + fn file_bytes(img: &Rgb8Image, quality: u8) -> usize { + let opts = Options { + quality, + ..baseline() + }; + encode(img, &opts).unwrap().bytes.len() + } + + /// Shannon entropy of the byte values, in bits per byte. + fn entropy(bytes: &[u8]) -> f64 { + let mut freq = [0u32; 256]; + for &b in bytes { + freq[b as usize] += 1; + } + let n = bytes.len() as f64; + freq.iter() + .filter(|&&f| f > 0) + .map(|&f| { + let p = f as f64 / n; + -p * p.log2() + }) + .sum() + } + + #[test] + fn the_generated_list_names_every_picture() { + assert_eq!( + GENERATED_IMAGES, + [ + "gradient", + "text", + "bars", + "cosines", + "cosines-shifted", + "noise" + ] + ); + for id in GENERATED_IMAGES { + assert!(builtin_image(id, 16, 16).is_some(), "{id}"); + } + } + + #[test] + fn every_cosine_block_quantizes_to_at_most_two_numbers() { + let img = builtin_image("cosines", 256, 256).unwrap(); + let table = scale_table(&STD_LUMA, 50); + for by in 0..32 { + for bx in 0..32 { + let mut luma = [0.0f32; 64]; + for j in 0..8 { + for i in 0..8 { + let o = ((bx * 8 + i) + img.w * (by * 8 + j)) * 4; + let [r, g, b] = [0, 1, 2].map(|c| img.rgba[o + c] as f32); + luma[i + 8 * j] = rgb_to_ycbcr(r, g, b)[0]; + } + } + let q = quantize(&fdct(&level_shift(&luma)), &table); + let nonzero = q.iter().filter(|&&v| v != 0).count(); + assert!( + nonzero <= 2, + "block ({bx}, {by}) has {nonzero} non-zero values" + ); + let (u, v) = (bx % 8, by % 8); + if (u, v) != (0, 0) { + assert_ne!( + q[u + 8 * v], + 0, + "block ({bx}, {by}) lost its (u, v) = ({u}, {v}) value" + ); + } + } + } + } + + #[test] + fn shifting_the_cosines_by_four_pixels_costs_at_least_five_times_the_bytes() { + let aligned = file_bytes(&builtin_image("cosines", 256, 256).unwrap(), 50); + let shifted = file_bytes(&builtin_image("cosines-shifted", 256, 256).unwrap(), 50); + assert!(shifted >= 5 * aligned, "{shifted} vs {aligned}"); + } + + #[test] + fn noise_is_seeded_and_does_not_survive_compression() { + let noise = builtin_image("noise", 256, 256).unwrap(); + assert_eq!(noise, builtin_image("noise", 256, 256).unwrap()); + let samples: Vec = noise + .rgba + .chunks_exact(4) + .flat_map(|p| [p[0], p[1], p[2]]) + .collect(); + let h = entropy(&samples); + assert!(h > 7.99, "entropy {h}"); + for quality in [50u8, 90, 100] { + let opts = Options { + quality, + ..baseline() + }; + let bytes = encode(&noise, &opts).unwrap().bytes; + let back = decode(&bytes).unwrap().image; + let db = psnr(&noise, &back).unwrap(); + assert!(db < 15.0, "q{quality}: {db} dB"); + } + } } diff --git a/crates/viz-core/src/visualizations/jpeg.rs b/crates/viz-core/src/visualizations/jpeg.rs index 123f01f..f083821 100644 --- a/crates/viz-core/src/visualizations/jpeg.rs +++ b/crates/viz-core/src/visualizations/jpeg.rs @@ -17,8 +17,10 @@ //! grid with the scan path growing through it at `zigzag_speed` cells a //! second, restarting when the block or the encode changes; the viz reports //! how far it has got through `readout()`), -//! `wipe` (the original with the decoded image over its left `wipe` share) -//! and `error` (per-pixel |decoded − original|, brightened by `error_gain`). +//! `wipe` (the original with the decoded image over its left `wipe` share), +//! `original` and `decoded` (the whole picture before and after compression at +//! full brightness, whatever the scan position) and `error` (per-pixel +//! |decoded − original|, brightened by `error_gain`). use jpeg::color::{rgb_to_ycbcr, ycbcr_to_rgb}; use jpeg::dct::{basis, idct, idct_partial}; @@ -983,6 +985,16 @@ impl Visualization for JpegViz { } } } + "original" | "decoded" => { + if let (Some(q), Some(t)) = (&self.quad, &self.textures) { + let tex = if panel.kind == "original" { + &t.original + } else { + &t.decoded + }; + q.draw(gl, tex, rect, canvas, 1.0); + } + } "wipe" => { if let (Some(q), Some(t)) = (&self.quad, &self.textures) { q.draw(gl, &t.original, rect, canvas, 1.0); diff --git a/crates/viz-core/tests/jpeg_lab.rs b/crates/viz-core/tests/jpeg_lab.rs index e549236..a66aab0 100644 --- a/crates/viz-core/tests/jpeg_lab.rs +++ b/crates/viz-core/tests/jpeg_lab.rs @@ -157,6 +157,8 @@ fn every_panel_kind_renders_without_a_gl_error() { {"kind":"zigzag","rect":[0,200,80,80]}, {"kind":"wipe","rect":[80,200,100,100]}, {"kind":"error","rect":[180,200,100,100]}, + {"kind":"original","rect":[0,300,80,80]}, + {"kind":"decoded","rect":[80,300,80,80]}, {"kind":"unknown","rect":[0,0,1,1]} ],"cube_mix":0.5,"dct_k":6,"hover":[1,2],"zigzag_speed":30,"wipe":0.3,"error_gain":8}"#)) .expect("panels accepted"); @@ -266,3 +268,114 @@ fn panels_render_after_a_re_encode() { engine.frame(48.0); assert_eq!(gl_of(&canvas).get_error(), WebGl2RenderingContext::NO_ERROR); } + +/// The centre pixel of the default framebuffer, read in the task that drew it +/// (the drawing buffer is not preserved once the browser composites). +fn centre_pixel(gl: &WebGl2RenderingContext, side: i32) -> [u8; 4] { + let mut px = [0u8; 4]; + gl.read_pixels_with_opt_u8_array( + side / 2, + side / 2, + 1, + 1, + WebGl2RenderingContext::RGBA, + WebGl2RenderingContext::UNSIGNED_BYTE, + Some(&mut px), + ) + .expect("read_pixels"); + px +} + +#[wasm_bindgen_test] +fn original_and_decoded_panels_ignore_the_scan() { + let canvas = make_canvas("jpeg-undimmed"); + canvas.set_width(64); + canvas.set_height(64); + let mut engine = Engine::new("jpeg-undimmed", Some("jpeg".into())).unwrap(); + let gl = gl_of(&canvas); + // Nothing scanned: an `image` panel shows only the dimmed original. + engine + .dispatch(js(r#"{"kind":"JumpTo","iteration":0}"#)) + .unwrap(); + let render = |engine: &mut Engine, kind: &str| { + engine + .update_viz_config(js(&format!( + r#"{{"panels":[{{"kind":"{kind}","rect":[0,0,64,64]}}]}}"# + ))) + .expect("panels accepted"); + engine.frame(0.0); + engine.frame(16.0); + centre_pixel(&gl, 64) + }; + let decoded = render(&mut engine, "decoded"); + let original = render(&mut engine, "original"); + let image = render(&mut engine, "image"); + assert_eq!(gl.get_error(), WebGl2RenderingContext::NO_ERROR); + + // The 256 × 256 gradient, sampled where the 64-pixel panel's centre lands: + // the textures are sampled with `Filter::Nearest`, so the mean of the four texels around (128, 128) stands in for the texel under the centre. + let src = viz_core::jpeg_images::builtin_image("gradient", 256, 256).unwrap(); + let mean = |c: usize| { + let sum: u32 = [(127, 127), (128, 127), (127, 128), (128, 128)] + .iter() + .map(|&(x, y)| src.rgba[(y * 256 + x) * 4 + c] as u32) + .sum(); + sum as f32 / 4.0 + }; + for c in 0..3 { + let full = mean(c); + let dimmed = full * 0.35; // `dim` defaults to 0.35 + assert!( + (image[c] as f32 - dimmed).abs() <= 3.0, + "image[{c}] = {} should be dimmed ~{dimmed}", + image[c] + ); + assert!( + (original[c] as f32 - full).abs() <= 3.0, + "original[{c}] = {} should be undimmed ~{full}", + original[c] + ); + // Decoded is lossy (quality 75), so it only matches the original loosely. + assert!( + (decoded[c] as f32 - full).abs() <= 12.0, + "decoded[{c}] = {} should be undimmed ~{full}", + decoded[c] + ); + assert!( + decoded[c] as f32 > dimmed + 30.0, + "decoded[{c}] = {} is not brighter than the dimmed {dimmed}", + decoded[c] + ); + } +} + +#[wasm_bindgen_test] +fn a_zero_size_panel_draws_nothing_where_an_empty_list_fits_the_image() { + // The page sends one zero-size panel until its first picture is in: an + // empty list would make the viz fit (and show) its own stand-in gradient. + let canvas = make_canvas("jpeg-blank"); + canvas.set_width(64); + canvas.set_height(64); + let mut engine = Engine::new("jpeg-blank", Some("jpeg".into())).unwrap(); + let gl = gl_of(&canvas); + let render = |engine: &mut Engine, panels: &str| { + engine + .update_viz_config(js(&format!(r#"{{"panels":{panels}}}"#))) + .expect("panels accepted"); + engine.frame(0.0); + engine.frame(16.0); + centre_pixel(&gl, 64) + }; + let nothing = render(&mut engine, r#"[{"kind":"none","rect":[0,0,64,64]}]"#); + let blank = render(&mut engine, r#"[{"kind":"image","rect":[0,0,0,0]}]"#); + let fitted = render(&mut engine, "[]"); + assert_eq!(gl.get_error(), WebGl2RenderingContext::NO_ERROR); + assert_eq!( + blank, nothing, + "a zero-size panel must leave the background" + ); + assert_ne!( + fitted, nothing, + "an empty list fits the image over the background" + ); +} diff --git a/docs/superpowers/plans/2026-10-16-jpeg-lab-4-gallery.md b/docs/superpowers/plans/2026-10-16-jpeg-lab-4-gallery.md new file mode 100644 index 0000000..a6c3ec8 --- /dev/null +++ b/docs/superpowers/plans/2026-10-16-jpeg-lab-4-gallery.md @@ -0,0 +1,372 @@ +# JPEG Lab, Plan 4: overview screen and picture gallery — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Open the JPEG lab on a new "0 · Overview" stage that shows a picture before and after compression, its size, and three byte histograms. Behind it sits an eleven-picture gallery (photos, memes, maths extremes, artifact tests) with Grand Prismatic Spring as the default. + +**Architecture:** +- **Rust:** three new deterministic generators (`cosines`, `cosines-shifted`, `noise`) and two undimmed panel kinds (`original`, `decoded`) for the viz. +- **Static files:** the five prepared pictures are committed under `web/public/jpeg/` with their credits and the scripts that make them. +- **Page:** pure modules (a picture catalog with loading, byte statistics) and an overview stage made of layout, components and wiring. The bezel's image radios become one grouped "Picture" select, and the thumbnail gallery sits under the canvas on the overview. + +**Tech Stack:** Rust (`crates/jpeg`, `crates/viz-core`, wasm-bindgen, WebGL2), Svelte 5 + TypeScript, Vitest, wasm-pack in headless Chrome, Python 3 + Pillow for the asset scripts only. + +**Spec:** `docs/superpowers/specs/2026-10-16-jpeg-lab-gallery-design.md`, which extends `docs/superpowers/specs/2026-10-15-jpeg-lab-design.md`. Read the gallery design's "Prototype measurements" table: the thresholds below come from it. + +## Global Constraints + +- **Branch:** `jpeg-lab-gallery`, already created from `main` after PR #40 with the design commits on it. Nothing is pushed until the end; the controller opens the PR after the final review. +- **Validation (AGENTS.md):** + - `cargo fmt --all --check` + - `cargo clippy --all-targets --all-features`: no new warnings outside `sierpinski_*`. + - `cargo test --all-features` + - `wasm-pack test --chrome --headless --chromedriver= crates/viz-core` (the flag goes before the crate path). + - `wasm-pack build crates/viz-core --target web --out-dir pkg` after any Rust change the page uses. + - `cd web && npx vitest run` and `npx svelte-check --tsconfig ./tsconfig.json` +- **Picture ids, used verbatim in Rust, TS and the share link**, in gallery order: + - photos: `spring`, `shuttle` + - memes: `doge`, `honest-work`, `cartoon` + - maths: `cosines`, `cosines-shifted`, `noise` + - tests: `gradient`, `text`, `bars` +- **Defaults:** + - `img` is `spring` and `stage` is `overview`. Both are omitted from the link when they are the default. + - A link without `img=` opens the spring. Links naming any other id are unchanged. +- **Sizes:** + - Generated pictures are 256 × 256 (`BUILTIN_SIDE`). + - Bundled pictures are spring and shuttle at 256 × 256, doge at 272 × 320, honest-work at 320 × 203 and cartoon at 320 × 240. + - Each PNG is at most 150,000 bytes. +- **Bundled files:** + - They are loaded from `${import.meta.env.BASE_URL}jpeg/.png`, so they work under the GitHub Pages base path, the same way `fourier/font.ts` loads its font. + - They are never fetched from Wikimedia at run time. +- **Licences** (exact credit lines; they appear on the overview, in `web/public/jpeg/CREDITS.md` and in the README): + - spring: "Grand Prismatic Spring, Yellowstone" · Jim Peaco, National Park Service · Public domain · https://commons.wikimedia.org/wiki/File:Grand_prismatic_spring.jpg + - shuttle: "Atlantis (STS-135) on pad 39A" · Bill Ingalls, NASA · Public domain · https://commons.wikimedia.org/wiki/File:Brightly_lit_STS-135_on_launch_pad_39a.jpg + - doge: "Doge (homemade)" · Euterpia, after a photo by Roberto Vasarri · CC0 · https://commons.wikimedia.org/wiki/File:Doge_homemade_meme.jpg + - honest-work: "It ain't much, but it's honest work" · Cakelot1, photo by Dianne Johnson, USDA NRCS · Public domain · https://commons.wikimedia.org/wiki/File:Farmer_meme_with_apostrophe.jpg + - cartoon: "Needs more JPEG" · made for this project · CC0 +- **Copy:** sentence case. No emoji or arrow glyphs in `.svelte` files (a repo test scans them); glyphs go in `.ts`. Doc comments on every pub Rust item and exported TS item, with comment density like the neighbours. Conventional commits. +- **wasm-bindgen:** `None` crosses as `undefined`, so parse with `== null`. + +## Review Focus + +1. **A bundled picture fails to load** (404, offline, or a wrong base path). The current picture stays, an inline error says why, and the Picture select shows the picture in use. Task 5: `keeps the current picture when a bundled one fails to load`. +2. **The site is served under `/math-visualizer/`.** Picture URLs must carry the base path. Task 4: `builds picture URLs from BASE_URL`. +3. **Non-square pictures** (272 × 320, 320 × 240, 320 × 203): + - The overview fits both panels without distortion. + - The byte statistics count `w·h·3` samples. + - A shared `b=` is clamped to that picture's own block grid. + - Tests: Task 4's `neighbourDiffs` on a 3 × 2 image, and Task 5's `clamps a shared block to the honest-work grid` (40 × 26 blocks, so `b=50,50` becomes `39,25`). +4. **Old share links:** `img=gradient&stage=dct&b=12,7` still opens the gradient on DCT with that block. A link with no `img=` opens the spring on the overview. Task 5: `still opens an old gradient link` and `opens the spring overview by default`. +5. **Changing picture from a deep stage** (Huffman, through the Picture select) keeps the stage, re-encodes, and writes `img=` into the link. Task 5: `changes picture from the Huffman stage`. + +--- + +## Task 1: Maths pictures in Rust + +**Files:** +- Modify: `crates/viz-core/src/jpeg_images.rs`: the generators plus a `mod tests` block. + +**Interfaces:** +- Produces: + - `GENERATED_IMAGES == ["gradient", "text", "bars", "cosines", "cosines-shifted", "noise"]`. + - `builtin_image("cosines" | "cosines-shifted" | "noise", w, h)`. + - The kit's existing `jpeg_builtin_image(id, w, h)` serves the new ids unchanged. + +- [ ] **Step 1: Write the failing tests** in `jpeg_images.rs`'s test module. All use 256 × 256 and the encoder from `jpeg::codec` with `Options { quality: 50, subsampling: Subsampling::S420, huffman: HuffmanMode::Optimized, ..Options::default() }`. + +```rust +#[test] +fn the_generated_list_names_every_picture() { + assert_eq!(GENERATED_IMAGES, ["gradient", "text", "bars", "cosines", "cosines-shifted", "noise"]); + for id in GENERATED_IMAGES { assert!(builtin_image(id, 16, 16).is_some(), "{id}"); } +} + +#[test] +fn every_cosine_block_quantizes_to_at_most_two_numbers() { + // Luma of each 8×8 block → level shift → fdct → quantize with scale_table(&STD_LUMA, 50): + // at most 2 non-zero values, and when (u, v) = (bx % 8, by % 8) ≠ (0, 0) one of them sits at u + 8v. +} + +#[test] +fn shifting_the_cosines_by_four_pixels_costs_at_least_five_times_the_bytes() { + // bytes(cosines-shifted) >= 5 * bytes(cosines) (prototype: 17,027 vs 2,461) +} + +#[test] +fn noise_is_seeded_and_does_not_survive_compression() { + // builtin_image("noise", 256, 256) == builtin_image("noise", 256, 256) + // byte entropy of its RGB samples > 7.99 bits + // PSNR(decode(encode(noise, q))) < 15 dB for q in [50, 90, 100] (prototype: 11.8–12.7) +} +``` + + Use `jpeg::color::rgb_to_ycbcr`, `jpeg::blocks::level_shift`, `jpeg::dct::fdct`, `jpeg::quant::{scale_table, quantize, STD_LUMA}` and `jpeg::metrics::psnr`. A local `entropy(&[u8]) -> f64` helper in the test module is fine. + +- [ ] **Step 2: Run** `cargo test -p viz-core jpeg_images`. Expected: FAIL. The list has 3 entries, and the new ids return `None`. + +- [ ] **Step 3: Implement the generators** (same `fn(x, y, w, h) -> [u8; 3]` shape as `gradient_px`). + - **`cosines_px`:** block `(bx, by) = (x / 8, y / 8)` carries the DCT basis pattern `(u, v) = (bx % 8, by % 8)` on a constant teal tint. + + ```rust + let c = |k: usize, n: usize| (PI * (2 * n + 1) as f32 * k as f32 / 16.0).cos(); + let l = 128.0 + 90.0 * c(u, x % 8) * c(v, y % 8); + to_px([l - 40.0, l + 10.0, l + 25.0]) + ``` + + - **`cosines-shifted`:** `cosines_px(x + 4, y + 4, w, h)`. + - **`noise_px`:** `let r = splitmix64(0x4A50_4547 ^ (y * w + x) as u64); [r as u8, (r >> 8) as u8, (r >> 16) as u8]`. Doc comments say what each one teaches. + +- [ ] **Step 4: Run** `cargo test -p viz-core jpeg_images` (PASS), then `cargo test --all-features`. + +- [ ] **Step 5: Lint and commit.** `cargo fmt --all`, plus the clippy filter. + + ```bash + git commit -m "feat(jpeg): maths pictures — cosines, cosines shifted and noise" + ``` + +--- + +## Task 2: Undimmed `original` and `decoded` panels + +**Files:** +- Modify: `crates/viz-core/src/visualizations/jpeg.rs` (the panel `match` near `"image" | "image_grid"`, and the module doc's panel-kind list) and `crates/viz-core/tests/jpeg_lab.rs`. + +**Interfaces:** +- Consumes: the existing `Textures { original, decoded, … }` and `TextureQuad::draw(gl, &tex, rect, canvas, dim)`. +- Produces: panel kinds `original` (the original at full brightness) and `decoded` (the whole decoded image at full brightness). Neither depends on the scan position; Task 5 pushes them on the overview. + +- [ ] **Step 1: Write the failing test.** In `jpeg_lab.rs` `every_panel_kind_renders_without_a_gl_error`, add `{"kind":"original","rect":[0,300,80,80]}` and `{"kind":"decoded","rect":[80,300,80,80]}` to the panel list. + + Add a new browser test, `original_and_decoded_panels_ignore_the_scan`. With the engine at `JumpTo 0` (nothing scanned), render one `decoded` panel filling a 64 × 64 canvas. Read back the centre pixel with `gl.read_pixels`. It must not be dark: its brightness must exceed the `dim × 255` level an unscanned `image` panel would give. Then render an `image` panel the same way and assert the centre pixel is the dimmed one. Tolerances: ±3 per channel. + +- [ ] **Step 2: Run** `wasm-pack test … crates/viz-core --test jpeg_lab`. Expected: FAIL (the new test). + +- [ ] **Step 3: Implement the two arms:** `quad.draw(gl, &tex.original | &tex.decoded, rect, canvas, 1.0)`. Update the module doc's kind list. + +- [ ] **Step 4: Run** the browser tests (`jpeg_lab` 8 passed) and `cargo test -p viz-core`. Then rebuild the pkg. + +- [ ] **Step 5: Lint and commit.** + + ```bash + git commit -m "feat(jpeg): undimmed original and decoded panels" + ``` + +--- + +## Task 3: The bundled pictures, their scripts and credits + +The four Commons originals are in this session's scratchpad (`gallery-src/`), downloaded with the user's permission. The prepared PNGs (`gallery-out/*.png`) and the two scripts are there too: + +`/private/tmp/claude-501/-Users-bsmall-dev-math-visualizer/9f5e6f00-2b13-4952-8009-b31a54f0fe14/scratchpad/` + +**Files:** +- Create: + - `web/public/jpeg/{spring,shuttle,doge,honest-work,cartoon}.png`, copied from `gallery-out/` + - `web/public/jpeg/CREDITS.md` + - `scripts/jpeg-gallery/make_gallery.py` and `scripts/jpeg-gallery/make_cartoon.py`, copied from the scratchpad + - `scripts/jpeg-gallery/README.md` +- Modify: `README.md` (an "Image credits" section, plus `web/public/jpeg/` in the project layout). + +- [ ] **Step 1: Copy the files and check them.** Copy the five PNGs and the two scripts. Run `python3 -c` with Pillow to print each PNG's size; expect spring 256×256, shuttle 256×256, doge 272×320, honest-work 320×203, cartoon 320×240. `ls -l` must show every PNG ≤ 150,000 bytes. Look at each one and confirm it is the expected picture: the pool, the shuttle, Doge with its wiki captions, the farmer, and the orange blob holding a blocky photo. + +- [ ] **Step 2: Write the credits.** `CREDITS.md` has one entry per picture with exactly the credit lines from Global Constraints. It adds that the four Commons pictures were cropped and scaled, and names the crop boxes from `make_gallery.py`. `scripts/jpeg-gallery/README.md` says how to regenerate the pictures: + - Download the four originals from the URLs in CREDITS. + - Run `python3 make_gallery.py `. + - Run `python3 make_cartoon.py `. The font is only used to render the PNG and is never committed. + + The README gets the same credit lines under "Image credits". + +- [ ] **Step 3: Commit.** + + ```bash + git add web/public/jpeg scripts/jpeg-gallery README.md + git commit -m "feat(web): JPEG lab gallery pictures with credits and the scripts that make them" + ``` + +--- + +## Task 4: The picture catalog and byte statistics (pure modules) + +These changes alter no behaviour on the page: defaults and stages are untouched until Task 5. + +**Files:** +- Create: `web/src/lib/jpeg/bytes.ts`, `web/src/lib/jpeg/__tests__/bytes.test.ts`, `web/src/lib/jpeg/__tests__/images.test.ts` +- Modify: `web/src/lib/jpeg/images.ts`, `web/src/lib/jpeg/params.ts` (the `ImageId` union and `IMAGE_IDS` only; `DEFAULTS` is untouched) + +**Interfaces:** +- Produces, for Task 5: + - **`params.ts`:** + - `ImageId` is the union of the eleven ids. + - `IMAGE_IDS` lists them in gallery order. + - `DEFAULTS.img` stays `'gradient'` until Task 5. + - **`images.ts`:** + - `export type PictureGroup = 'photos' | 'memes' | 'maths' | 'tests'` + - `export const GROUP_LABELS: Record`: "Photos", "Memes", "Maths", "Artifact tests". + - `export interface Credit { title: string; author: string; licence: string; url?: string }` + - `IMAGES: readonly { id: ImageId; label: string; title: string; group: PictureGroup; file?: { w: number; h: number }; credit?: Credit }[]` covers all eleven in gallery order. `file` is set exactly for the five bundled pictures. + - `export function pictureUrl(id: ImageId): string` returns `${import.meta.env.BASE_URL}jpeg/${id}.png`. + - `export async function loadPicture(id: ImageId): Promise`: for a bundled id it fetches `pictureUrl`, rejects on a non-OK response with a reader-facing message, and decodes through `decodeUpload`. For a generated id it uses `loadBuiltin`. + - `export async function thumbnail(id: ImageId, side: number): Promise`: for a bundled id it returns `pictureUrl(id)`. For a generated id it returns a PNG data URL drawn from `jpeg_builtin_image(id, side, side)` on a canvas. + - **`bytes.ts`:** + - `rgbSamples(rgba: Uint8Array): Uint8Array` drops every 4th byte. + - `neighbourDiffs(rgba: Uint8Array, w: number, h: number): Uint8Array` gives each RGB sample minus the same channel of its left neighbour, mod 256. The first column subtracts 0. The length is `w·h·3`. + - `histogram(bytes: Uint8Array): Uint32Array` has 256 bins. + - `entropyBits(hist: Uint32Array): number` is the Shannon entropy in bits per byte (0 for an empty histogram). + +- [ ] **Step 1: Write the failing tests.** + +```ts +// bytes.test.ts +it('drops alpha', () => expect([...rgbSamples(new Uint8Array([1,2,3,255, 4,5,6,255]))]).toEqual([1,2,3,4,5,6])); +it('takes neighbour differences per channel, wrapping mod 256', () => { + // 3×2 image; row 0: (10,20,30) (12,20,25) (12,250,25); row 1: (0,0,0) (255,1,1) (0,0,0) + expect([...neighbourDiffs(rgba, 3, 2)]).toEqual([10,20,30, 2,0,251, 0,230,0, 0,0,0, 255,1,1, 1,255,255]); +}); +it('counts bytes', () => expect(histogram(new Uint8Array([0,0,7]))[0]).toBe(2)); +it('measures entropy', () => { + expect(entropyBits(histogram(new Uint8Array(64).fill(9)))).toBe(0); + expect(entropyBits(histogram(new Uint8Array([0,1,0,1])))).toBeCloseTo(1); + expect(entropyBits(histogram(Uint8Array.from({ length: 256 }, (_, i) => i)))).toBeCloseTo(8); + expect(entropyBits(new Uint32Array(256))).toBe(0); +}); +// images.test.ts +it('lists the eleven pictures in gallery order and groups', /* ids === IMAGE_IDS; groups photos×2, memes×3, maths×3, tests×3 */); +it('has a credit for every bundled picture and none for generated ones'); +it('builds picture URLs from BASE_URL', () => { vi.stubEnv('BASE_URL', '/math-visualizer/'); expect(pictureUrl('spring')).toBe('/math-visualizer/jpeg/spring.png'); }); +it('ships every bundled picture at its catalog size and under 150 KB', /* read web/public/jpeg/.png with node:fs; size <= 150_000; IHDR width/height (bytes 16–23, big-endian) === file.w/h */); +it('rejects a bundled picture that cannot be fetched', /* stub fetch → { ok: false, status: 404 }; loadPicture('spring') rejects with a message naming the picture */); +``` + +- [ ] **Step 2: Run** `cd web && npx vitest run src/lib/jpeg`. Expected: FAIL (missing exports). + +- [ ] **Step 3: Implement** to the interfaces above. The label and title strings are free text: short, sentence case, saying what each picture shows off. For example, spring: "Grand Prismatic Spring", title "Vivid colour: watch chroma bleed". cosines-shifted: "Cosines, shifted 4 px", title "Same detail, off the 8 × 8 grid". + +- [ ] **Step 4: Run** `npx vitest run` (the whole suite stays green: the page still renders its radios from `IMAGES`) and svelte-check. + +- [ ] **Step 5: Commit.** + + ```bash + git commit -m "feat(web): picture catalog with bundled loading, and byte statistics" + ``` + +--- + +## Task 5: The overview stage, the gallery and the Picture select + +**Files:** +- Create: + - `web/src/lib/components/jpeg/Overview.svelte`: sizes and the three histograms, drawn in the `stats` slot. + - `web/src/lib/components/jpeg/Gallery.svelte`: the thumbnail grid, shown under the canvas in the stage card on the overview. +- Modify: + - `web/src/lib/jpeg/{stages,params,layout,formulas}.ts` + - `web/src/lib/components/labs/JpegLab.svelte` + - tests: `params.test.ts`, `layout.test.ts`, `summary.test.ts` (NOTICE/stageTex), `JpegLab.test.ts` + +**Interfaces:** +- Consumes: + - Task 1's ids through `loadPicture`. + - Task 2's panel kinds `original` and `decoded`. + - Task 4's catalog, `loadPicture`, `thumbnail` and byte statistics. + - `api.engine.rule_bytes()` for the JPEG file. +- Produces: + - **`stages.ts`:** `StageId` gains `'overview'`, first in `STAGES` with `{ mark: '0', title: 'Overview', subtitle: 'What JPEG did to this picture', ready: true }`. `DEFAULT_STAGE = 'overview'`. + - **`params.ts`:** `DEFAULTS.img = 'spring'`. + - **`layout.ts`:** + - `PanelKind` gains `'original' | 'decoded'`, and `SlotName` gains `'stats'`. + - The `overview` case lays out a top band (58% of the height after margins) of two equal columns: `original` ("Original") and `decoded` ("After JPEG"). + - Each is fitted to the picture's shape. + - `slots.stats` takes the full width below. + - `layout.image` is the `original` rect, so a click still picks a block. + - At phone widths the same structure applies. + - **`formulas.ts`:** + - `NOTICE.overview` is free text in the style of the others. It covers the three histograms: raw bytes look random, neighbours give the pattern away, and the JPEG's bytes look random again but there are far fewer. + - `stageTex('overview')` returns `[ENTROPY_TEX]`, with `export const ENTROPY_TEX = String.raw\`H = -\sum_{b=0}^{255} p_b \log_2 p_b \;\text{bits per byte}\``. + - **`Overview.svelte`** props: `{ rect, image: RgbaImage, jpeg: Uint8Array | null, quality: number, credit?: Credit }`. It shows: + - Two size bars to scale, raw `w·h·3` against JPEG length, labelled with `formatBytes` and the ratio. + - Three 256-bin histograms (SVG): "Raw bytes", "Neighbour differences" and "JPEG file", each headed by `entropyBits` to two decimals and "bits / byte". + - A one-line caption. + - The credit line with a link when `credit` is set. + - The histograms are recomputed only when `image` or `jpeg` changes. + - **`Gallery.svelte`** props: `{ current: ImageId | 'upload', onpick: (id: ImageId) => void, onupload: () => void }`. It shows: + - One labelled group per `PictureGroup` (`GROUP_LABELS`). + - A button per picture with its thumbnail (`thumbnail(id, 64)`, `loading="lazy"` for files) and label, with `aria-pressed` on the current one. + - An Upload tile at the end. + - **`JpegLab.svelte`:** + - The radio group becomes ` void chooseImage(im.id)} />{im.label} - - {/each} +
+ Picture + @@ -563,7 +620,7 @@ {#each layout.panels as p (p.kind)} {p.label} {/each} - {#if layout.image.w > 0} + {#if layout.image.w > 0 && stage !== 'overview'} {/if} + {#if stage === 'overview' && layout.slots.stats && image} + + {/if} + {#if stage === 'color' && panel('cube')} {@const r = panel('cube')!}
{/snippet} @@ -730,8 +794,23 @@ {/each}

{NOTICE[stage]}

+ {#if stage === 'overview' && credit} + +

+ {#if credit.url}{credit.title}{:else}{credit.title}{/if} + · {credit.author} · {credit.licence} +

+ {/if} + {#if stage === 'overview'} + + {/if} + {#if stage === 'overview'} +

Pictures

+ void chooseImage(id)} onupload={() => fileInput?.click()} /> + {/if} +

How it works

A JPEG file is the picture written as a recipe, not as pixels. The encoder turns colours into @@ -740,9 +819,9 @@ packs what is left into as few bits as it can.

- Play runs the scan block by block: the image fills in as it would arrive over a slow connection. - Click anywhere on the image to follow that block through every stage, and use the left and right arrow keys to move - between stages. + On stages 1–9, Play runs the scan block by block: the image fills in as it would arrive over a slow connection, and + clicking anywhere on the image follows that block through every stage. The left and right arrow keys move between + stages, the overview included.

{/snippet} @@ -752,6 +831,18 @@ .quality input { width: 140px; } .quality .value { width: 3ch; text-align: right; color: var(--ink); } .name { color: var(--stone); font-size: 14px; } + .picture { + height: 36px; + max-width: 220px; + padding: 0 10px; + border: 1px solid var(--line); + border-radius: var(--radius-button); + background: var(--paper); + color: var(--ink); + font-size: 14px; + font-weight: 500; + } + .picture:hover { border-color: var(--stone); } /* Radio inputs drawn as pills, as in the Notes lab. */ .pill input { position: absolute; opacity: 0; pointer-events: none; } .pill:has(input:checked) { border-color: var(--accent); background: var(--tint); } @@ -829,4 +920,19 @@ font-size: 14px; } .sub { color: var(--stone); margin-top: -4px; } + .credit { margin: 0 0 16px; color: var(--stone); font-size: 14px; } + .credit a { color: inherit; text-decoration: underline; } + .step-btn { + height: 40px; + padding: 0 18px; + border: 1px solid var(--accent); + border-radius: var(--radius-button); + background: var(--accent); + color: var(--paper); + font-size: 15px; + font-weight: 500; + cursor: pointer; + } + .step-btn:hover { background: var(--accent-deep); border-color: var(--accent-deep); } + .step-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } diff --git a/web/src/lib/jpeg/__tests__/bytes.test.ts b/web/src/lib/jpeg/__tests__/bytes.test.ts new file mode 100644 index 0000000..634ed23 --- /dev/null +++ b/web/src/lib/jpeg/__tests__/bytes.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { entropyBits, histogram, neighbourDiffs, rgbSamples } from '../bytes'; + +describe('byte statistics', () => { + it('drops alpha', () => { + expect([...rgbSamples(new Uint8Array([1, 2, 3, 255, 4, 5, 6, 255]))]).toEqual([1, 2, 3, 4, 5, 6]); + }); + + it('takes neighbour differences per channel, wrapping mod 256', () => { + // 3×2 image; row 0: (10,20,30) (12,20,25) (12,250,25); row 1: (0,0,0) (255,1,1) (0,0,0) + // prettier-ignore + const rgba = new Uint8Array([ + 10, 20, 30, 255, 12, 20, 25, 255, 12, 250, 25, 255, + 0, 0, 0, 255, 255, 1, 1, 255, 0, 0, 0, 255, + ]); + const d = neighbourDiffs(rgba, 3, 2); + expect(d.length).toBe(3 * 2 * 3); + expect([...d]).toEqual([10, 20, 30, 2, 0, 251, 0, 230, 0, 0, 0, 0, 255, 1, 1, 1, 255, 255]); + }); + + it('counts bytes', () => { + const h = histogram(new Uint8Array([0, 0, 7])); + expect(h.length).toBe(256); + expect(h[0]).toBe(2); + expect(h[7]).toBe(1); + }); + + it('measures entropy', () => { + expect(entropyBits(histogram(new Uint8Array(64).fill(9)))).toBe(0); + expect(entropyBits(histogram(new Uint8Array([0, 1, 0, 1])))).toBeCloseTo(1); + expect(entropyBits(histogram(Uint8Array.from({ length: 256 }, (_, i) => i)))).toBeCloseTo(8); + expect(entropyBits(new Uint32Array(256))).toBe(0); + }); +}); diff --git a/web/src/lib/jpeg/__tests__/images.test.ts b/web/src/lib/jpeg/__tests__/images.test.ts new file mode 100644 index 0000000..39a1fe9 --- /dev/null +++ b/web/src/lib/jpeg/__tests__/images.test.ts @@ -0,0 +1,178 @@ +import { readFileSync, statSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { loadVizCore } from '../../wasm/loader'; +import { IMAGE_IDS } from '../params'; +import { GROUP_LABELS, IMAGES, loadPicture, pictureUrl, thumbnail } from '../images'; + +vi.mock('../../wasm/loader', async () => { + const { makeVizMock } = await import('../../test/fakeViz'); + return { loadVizCore: vi.fn(() => Promise.resolve(makeVizMock())) }; +}); + +const BUNDLED = ['spring', 'shuttle', 'doge', 'honest-work', 'cartoon']; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('picture catalog', () => { + it('lists the eleven pictures in gallery order and groups', () => { + expect(IMAGES.map((i) => i.id)).toEqual([...IMAGE_IDS]); + expect(IMAGE_IDS).toEqual([ + 'spring', + 'shuttle', + 'doge', + 'honest-work', + 'cartoon', + 'cosines', + 'cosines-shifted', + 'noise', + 'gradient', + 'text', + 'bars', + ]); + const count = (g: string) => IMAGES.filter((i) => i.group === g).length; + expect([count('photos'), count('memes'), count('maths'), count('tests')]).toEqual([2, 3, 3, 3]); + expect(IMAGES.map((i) => i.group)).toEqual([ + ...Array(2).fill('photos'), + ...Array(3).fill('memes'), + ...Array(3).fill('maths'), + ...Array(3).fill('tests'), + ]); + expect(GROUP_LABELS).toEqual({ photos: 'Photos', memes: 'Memes', maths: 'Maths', tests: 'Artifact tests' }); + for (const im of IMAGES) { + expect(im.label).not.toBe(''); + expect(im.title).not.toBe(''); + } + }); + + it('has a credit for every bundled picture and none for generated ones', () => { + for (const im of IMAGES) { + if (BUNDLED.includes(im.id)) { + expect(im.file, im.id).toBeDefined(); + expect(im.credit?.title, im.id).toBeTruthy(); + expect(im.credit?.author, im.id).toBeTruthy(); + expect(im.credit?.licence, im.id).toBeTruthy(); + } else { + expect(im.file, im.id).toBeUndefined(); + expect(im.credit, im.id).toBeUndefined(); + } + } + expect(IMAGES.find((i) => i.id === 'cartoon')?.credit?.url).toBeUndefined(); + expect(IMAGES.find((i) => i.id === 'spring')?.credit).toEqual({ + title: 'Grand Prismatic Spring, Yellowstone', + author: 'Jim Peaco, National Park Service', + licence: 'Public domain', + url: 'https://commons.wikimedia.org/wiki/File:Grand_prismatic_spring.jpg', + }); + }); + + it('builds picture URLs from BASE_URL', () => { + vi.stubEnv('BASE_URL', '/math-visualizer/'); + expect(pictureUrl('spring')).toBe('/math-visualizer/jpeg/spring.png'); + }); + + it('ships every bundled picture at its catalog size and under 150 KB', () => { + for (const im of IMAGES.filter((i) => i.file)) { + const path = resolve(__dirname, '../../../../public/jpeg', `${im.id}.png`); + expect(statSync(path).size, im.id).toBeLessThanOrEqual(150_000); + const png = readFileSync(path); + expect(png.readUInt32BE(16), `${im.id} width`).toBe(im.file!.w); + expect(png.readUInt32BE(20), `${im.id} height`).toBe(im.file!.h); + } + expect(IMAGES.filter((i) => i.file).map((i) => i.id)).toEqual(BUNDLED); + }); + + it('rejects a bundled picture that cannot be fetched', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: false, status: 404 }))); + await expect(loadPicture('spring')).rejects.toThrow(/Grand Prismatic Spring/); + }); + + it('loads a generated picture through the kit', async () => { + const im = await loadPicture('cosines'); + expect([im.w, im.h, im.rgba.length]).toEqual([256, 256, 256 * 256 * 4]); + }); + + it('uses the file itself as the thumbnail of a bundled picture', async () => { + vi.stubEnv('BASE_URL', '/math-visualizer/'); + expect(await thumbnail('doge', 64)).toBe('/math-visualizer/jpeg/doge.png'); + }); + + /** Run `thumbnail(id, side)` against a recording canvas; returns the source and output canvases' contexts. */ + async function drawThumbnail(id: Parameters[0], side: number) { + const builtin = vi.fn((_id: string, w: number, h: number) => new Uint8Array(w * h * 4)); + vi.mocked(loadVizCore).mockResolvedValueOnce({ jpeg_builtin_image: builtin } as never); + const drawn: { canvas: HTMLCanvasElement; ctx: Record> }[] = []; + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(function (this: HTMLCanvasElement) { + const ctx = { + createImageData: vi.fn((w: number, h: number) => ({ width: w, height: h, data: new Uint8ClampedArray(w * h * 4) })), + putImageData: vi.fn(), + drawImage: vi.fn(), + imageSmoothingEnabled: false, + imageSmoothingQuality: 'low', + }; + drawn.push({ canvas: this, ctx: ctx as never }); + return ctx as unknown as CanvasRenderingContext2D; + } as never); + const toDataURL = vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue('data:image/png;base64,AAAA'); + expect(await thumbnail(id, side)).toBe('data:image/png;base64,AAAA'); + // The kit is asked for the picture the reader gets, not one at thumbnail size. + expect(builtin).toHaveBeenCalledWith(id, 256, 256); + const source = drawn.find((d) => d.canvas.width === 256); + const out = drawn.find((d) => d.canvas.width === side); + expect(source?.ctx.putImageData).toHaveBeenCalledTimes(1); + expect(out?.canvas.height).toBe(side); + expect(toDataURL.mock.contexts[0]).toBe(out?.canvas); + return { source, out }; + } + + it('draws a smooth generated thumbnail by scaling the full-size picture', async () => { + const { source, out } = await drawThumbnail('gradient', 32); + expect(out?.ctx.drawImage).toHaveBeenCalledWith(source?.canvas, 0, 0, 32, 32); + expect(out?.ctx.imageSmoothingEnabled).toBe(true); + }); + + it('cuts a pattern thumbnail 1:1 from the centre instead of scaling it', async () => { + const { source, out } = await drawThumbnail('cosines-shifted', 64); + expect(out?.ctx.drawImage).toHaveBeenCalledWith(source?.canvas, 96, 96, 64, 64, 0, 0, 64, 64); + expect(out?.ctx.imageSmoothingEnabled).toBe(false); + }); + + it('labels the noise picture "Pure noise"', () => { + expect(IMAGES.find((i) => i.id === 'noise')?.label).toBe('Pure noise'); + }); + + it('makes a generated thumbnail once per picture and size, however often the gallery opens', async () => { + await drawThumbnail('bars', 40); + const loads = vi.mocked(loadVizCore).mock.calls.length; + // The second visit is answered from memory: the kit is not asked again and nothing is drawn. + expect(await thumbnail('bars', 40)).toBe('data:image/png;base64,AAAA'); + expect(vi.mocked(loadVizCore).mock.calls.length).toBe(loads); + // Another size is another thumbnail. + await drawThumbnail('bars', 48); + expect(vi.mocked(loadVizCore).mock.calls.length).toBe(loads + 1); + }); + + it('does not cache a thumbnail that failed to draw', async () => { + vi.mocked(loadVizCore).mockRejectedValueOnce(new Error('wasm down')); + await expect(thumbnail('cosines', 24)).rejects.toThrow('wasm down'); + await drawThumbnail('cosines', 24); + }); + + it('crops the fine-pattern pictures and scales the smooth ones', () => { + const crop = IMAGES.filter((i) => i.thumb === 'crop').map((i) => i.id); + expect(crop).toEqual(['cosines', 'cosines-shifted', 'noise', 'text']); + expect(IMAGES.filter((i) => i.thumb === 'scale').map((i) => i.id)).toEqual(['gradient', 'bars']); + // Bundled pictures use their own file and need neither. + expect(IMAGES.filter((i) => i.file && i.thumb)).toEqual([]); + }); + + it('says so when a bundled picture arrives but is not a readable image', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: true, status: 200, blob: () => Promise.resolve(new Blob([''])) }))); + vi.stubGlobal('createImageBitmap', vi.fn(() => Promise.reject(new Error('bad')))); + await expect(loadPicture('doge')).rejects.toThrow('Could not load the picture "Doge" (the file is not a readable image).'); + }); +}); diff --git a/web/src/lib/jpeg/__tests__/layout.test.ts b/web/src/lib/jpeg/__tests__/layout.test.ts index 106ad1f..5979d44 100644 --- a/web/src/lib/jpeg/__tests__/layout.test.ts +++ b/web/src/lib/jpeg/__tests__/layout.test.ts @@ -9,6 +9,7 @@ import { cellAt, cells, fit, + BLANK_PANELS, jpegLayout, toDevicePanels, zoomWindow, @@ -20,7 +21,7 @@ const inside = (r: Rect, w: number, h: number) => r.x >= 0 && r.y >= 0 && r.x + const overlap = (a: Rect, b: Rect) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h; describe('jpegLayout', () => { - const stages: StageId[] = ['color', 'subsample', 'blocks', 'dct', 'quantize', 'zigzag', 'rle', 'huffman', 'decode']; + const stages: StageId[] = ['overview', 'color', 'subsample', 'blocks', 'dct', 'quantize', 'zigzag', 'rle', 'huffman', 'decode']; for (const [w, h] of [ [1134, 585], @@ -102,6 +103,50 @@ describe('jpegLayout', () => { expect(l.slots.rd!.x).toBeGreaterThanOrEqual(rebuild.x + CAPTION_MIN); }); + it('overview places original and decoded side by side above the stats slot', () => { + const l = jpegLayout('overview', 1134, 585, 256, 256); + const original = l.panels.find((p) => p.kind === 'original')!; + const decoded = l.panels.find((p) => p.kind === 'decoded')!; + expect(original.label).toBe('Original'); + expect(decoded.label).toBe('After JPEG'); + expect(decoded.rect.h).toBeCloseTo(original.rect.h); + expect(decoded.rect.x).toBeGreaterThan(original.rect.x + original.rect.w); + expect(l.slots.stats!.y).toBeGreaterThanOrEqual(original.rect.y + original.rect.h); + expect(l.image).toEqual(original.rect); + // The phone keeps the same structure. + const phone = jpegLayout('overview', 343, 520, 256, 256); + const o2 = phone.panels.find((p) => p.kind === 'original')!.rect; + const d2 = phone.panels.find((p) => p.kind === 'decoded')!.rect; + expect(d2.x).toBeGreaterThan(o2.x + o2.w); + expect(phone.slots.stats!.y).toBeGreaterThanOrEqual(o2.y + o2.h); + }); + + it('overview gives a phone room for its statistics instead of padding the picture band', () => { + const l = jpegLayout('overview', 343, 520, 256, 256); + expect(l.slots.stats!.h).toBeGreaterThanOrEqual(280); + // The band is as tall as the pictures need (caption included), no taller. + const original = l.panels.find((p) => p.kind === 'original')!.rect; + expect(l.slots.stats!.y).toBeLessThanOrEqual(original.y + original.h + MARGIN + 1); + }); + + it('overview keeps its desktop panels at 58% of the height', () => { + const l = jpegLayout('overview', 1134, 585, 256, 256); + const expected = (585 - 2 * MARGIN) * 0.58 - LABEL; + for (const kind of ['original', 'decoded']) { + const r = l.panels.find((p) => p.kind === kind)!.rect; + expect(Math.abs(r.h - expected)).toBeLessThan(2); + expect(Math.abs(r.w - expected)).toBeLessThan(2); + } + }); + + it('overview fits a non-square picture without distortion', () => { + const l = jpegLayout('overview', 1134, 585, 320, 203); + for (const kind of ['original', 'decoded']) { + const r = l.panels.find((p) => p.kind === kind)!.rect; + expect(Math.abs(r.w / r.h / (320 / 203) - 1)).toBeLessThan(0.01); + } + }); + it('gives a stage too small for anything no panels', () => { expect(jpegLayout('dct', 30, 30, 256, 256).panels).toEqual([]); expect(jpegLayout('dct', 800, 600, 0, 0).panels).toEqual([]); @@ -144,3 +189,11 @@ describe('geometry helpers', () => { expect(cellAt(80, 15, { x: 0, y: 0, w: 80, h: 80 })).toBeNull(); }); }); + +describe('BLANK_PANELS', () => { + it('is a non-empty list of zero-size panels (an empty list makes the viz fit the image)', () => { + const device = toDevicePanels(BLANK_PANELS, 2); + expect(device.length).toBeGreaterThan(0); + for (const p of device) expect(p.rect[2] * p.rect[3]).toBe(0); + }); +}); diff --git a/web/src/lib/jpeg/__tests__/params.test.ts b/web/src/lib/jpeg/__tests__/params.test.ts index d5f4c93..39c18d0 100644 --- a/web/src/lib/jpeg/__tests__/params.test.ts +++ b/web/src/lib/jpeg/__tests__/params.test.ts @@ -6,6 +6,8 @@ describe('params', () => { it('reads an empty link as the defaults and writes the defaults as an empty link', () => { expect(parseParams('')).toEqual(DEFAULTS); expect(buildParams(DEFAULTS)).toBe(''); + expect(parseParams('').img).toBe('spring'); + expect(parseParams('').stage).toBe('overview'); }); it('round-trips every field', () => { @@ -51,16 +53,18 @@ describe('params', () => { }); describe('stages', () => { - it('lists nine stages, all ready', () => { - expect(STAGES.map((s) => s.id)).toEqual(['color', 'subsample', 'blocks', 'dct', 'quantize', 'zigzag', 'rle', 'huffman', 'decode']); + it('lists ten stages, the overview first, all ready', () => { + expect(STAGES.map((s) => s.id)).toEqual(['overview', 'color', 'subsample', 'blocks', 'dct', 'quantize', 'zigzag', 'rle', 'huffman', 'decode']); expect(STAGES.every((s) => s.ready)).toBe(true); }); - it('lands on a named stage and steps through all nine, stopping at the ends', () => { + it('lands on a named stage and steps through all ten, stopping at the ends', () => { expect(readyStage('huffman')).toBe('huffman'); - expect(readyStage('nope')).toBe('color'); - expect(readyStage(undefined)).toBe('color'); - expect(stepStage('color', -1)).toBe('color'); + expect(readyStage('nope')).toBe('overview'); + expect(readyStage(undefined)).toBe('overview'); + expect(stepStage('color', -1)).toBe('overview'); + expect(stepStage('overview', -1)).toBe('overview'); + expect(stepStage('overview', 1)).toBe('color'); expect(stepStage('dct', 1)).toBe('quantize'); expect(stepStage('huffman', 1)).toBe('decode'); expect(stepStage('decode', 1)).toBe('decode'); diff --git a/web/src/lib/jpeg/__tests__/summary.test.ts b/web/src/lib/jpeg/__tests__/summary.test.ts index 5cd5e9a..c2c33a4 100644 --- a/web/src/lib/jpeg/__tests__/summary.test.ts +++ b/web/src/lib/jpeg/__tests__/summary.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { formatBytes, readSummary, readoutOf } from '../summary'; import { busiestLine, cumulativeEnergy, mirrored, periodic, psnrOf, termsFor } from '../math'; -import { chromaKept, lastNonZero, samplesKept, stageTex, NOTICE } from '../formulas'; +import { ENTROPY_TEX, chromaKept, lastNonZero, samplesKept, stageTex, NOTICE } from '../formulas'; import { STAGES } from '../stages'; import { decodeUpload, fitWithin } from '../images'; import { jpegSummaryFixture } from '../../test/fakeViz'; @@ -120,8 +120,15 @@ describe('formulas', () => { expect(line).not.toContain('#0'); }); + it('explains entropy on the overview', () => { + expect(stageTex('overview', null, null)[0]).toBe(ENTROPY_TEX); + expect(stageTex('overview', readSummary(jpegSummaryFixture), null)).toEqual([ENTROPY_TEX]); + }); + it('has a notice for every stage', () => { for (const { id } of STAGES) expect(NOTICE[id].length).toBeGreaterThan(20); + // Raw bytes are only random-looking for photographs (a gradient or a test card is not). + expect(NOTICE.overview).toContain('For a photo, raw pixel bytes look almost random'); }); it('finds the last non-zero coefficient (-1 when there is none)', () => { diff --git a/web/src/lib/jpeg/bytes.ts b/web/src/lib/jpeg/bytes.ts new file mode 100644 index 0000000..6f4d494 --- /dev/null +++ b/web/src/lib/jpeg/bytes.ts @@ -0,0 +1,52 @@ +// Byte statistics for the overview: what the pixels look like as raw bytes, +// before and after taking neighbour differences, and how many bits per byte +// an ideal entropy coder would need for them. + +/** The RGB bytes of RGBA pixels: every 4th byte (alpha) dropped. */ +export function rgbSamples(rgba: Uint8Array): Uint8Array { + const n = Math.floor(rgba.length / 4); + const out = new Uint8Array(n * 3); + for (let i = 0; i < n; i++) { + out[i * 3] = rgba[i * 4]; + out[i * 3 + 1] = rgba[i * 4 + 1]; + out[i * 3 + 2] = rgba[i * 4 + 2]; + } + return out; +} + +/** + * Each RGB sample minus the same channel of its left neighbour, mod 256. The + * first column subtracts 0. The result has `w × h × 3` bytes. + */ +export function neighbourDiffs(rgba: Uint8Array, w: number, h: number): Uint8Array { + const out = new Uint8Array(w * h * 3); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const p = y * w + x; + for (let c = 0; c < 3; c++) { + const left = x === 0 ? 0 : rgba[(p - 1) * 4 + c]; + out[p * 3 + c] = (rgba[p * 4 + c] - left) & 0xff; + } + } + } + return out; +} + +/** How many times each byte value occurs: 256 bins. */ +export function histogram(bytes: Uint8Array): Uint32Array { + const bins = new Uint32Array(256); + for (let i = 0; i < bytes.length; i++) bins[bytes[i]]++; + return bins; +} + +/** Shannon entropy of a histogram in bits per byte (0 when it is empty). */ +export function entropyBits(hist: Uint32Array): number { + let total = 0; + for (const c of hist) total += c; + if (total === 0) return 0; + let bits = 0; + for (const c of hist) { + if (c > 0) bits -= (c / total) * Math.log2(c / total); + } + return bits; +} diff --git a/web/src/lib/jpeg/formulas.ts b/web/src/lib/jpeg/formulas.ts index 56f1381..7a7e10c 100644 --- a/web/src/lib/jpeg/formulas.ts +++ b/web/src/lib/jpeg/formulas.ts @@ -7,6 +7,9 @@ const COMPONENT = ['Y', 'C_b', 'C_r']; const fmt = (v: number, digits = 0) => (Math.abs(v) < 1e-9 ? '0' : v.toFixed(digits)); +/** The overview's formula: Shannon entropy of a byte histogram, in bits per byte (8 means no better than random). */ +export const ENTROPY_TEX = String.raw`H = -\sum_{b=0}^{255} p_b \log_2 p_b \;\text{bits per byte}`; + /** The Color stage's formula: RGB to YCbCr as the standard matrix plus offset (static; no per-pixel RGB is available). */ export const COLOR_TEX = String.raw`\begin{bmatrix} Y \\ C_b \\ C_r \end{bmatrix} = \begin{bmatrix} 0.299 & 0.587 & 0.114 \\ -0.1687 & -0.3313 & 0.5 \\ 0.5 & -0.4187 & -0.0813 \end{bmatrix} @@ -47,6 +50,8 @@ export function samplesKept(subsampling: string | null): number { export function stageTex(stage: StageId, s: JpegSummary | null, hover: [number, number] | null): string[] { const sel = s?.selected ?? null; switch (stage) { + case 'overview': + return [ENTROPY_TEX]; case 'color': return [COLOR_TEX]; case 'subsample': { @@ -120,6 +125,8 @@ export function stageTex(stage: StageId, s: JpegSummary | null, hover: [number, /** What to look for on each stage. */ export const NOTICE: Record = { + overview: + 'For a photo, raw pixel bytes look almost random, so the first histogram tells you little. Subtract each sample from its left neighbour and the pattern gives itself away: the differences pile up near zero. The JPEG file looks random again, which is what good compression leaves behind, but there are far fewer bytes of it.', color: 'Y looks like the picture itself; Cb and Cr are flat and soft. Turn the cube: the grey diagonal becomes the Y axis, and most pixels huddle close to it.', subsample: diff --git a/web/src/lib/jpeg/images.ts b/web/src/lib/jpeg/images.ts index f423965..f219362 100644 --- a/web/src/lib/jpeg/images.ts +++ b/web/src/lib/jpeg/images.ts @@ -1,6 +1,6 @@ -// Where the lab's pictures come from: the generated built-ins (made in Rust -// by the kit, so the engine, the tests and the page all see the same pixels) -// and the reader's own uploads, decoded by the browser and scaled down to fit +// Where the lab's pictures come from: the bundled gallery files, the generated +// built-ins (made in Rust by the kit, so the engine, the tests and the page all +// see the same pixels) and the reader's own uploads, decoded by the browser and scaled down to fit // `MAX_SIDE`. import { loadVizCore } from '../wasm/loader'; import type { ImageId } from './params'; @@ -20,13 +20,209 @@ export const BUILTIN_SIDE = 256; /** Uploads are scaled down so neither side exceeds this. */ export const MAX_SIDE = 512; -/** The built-in pictures the lab offers, in picker order, with the line shown on hover. */ -export const IMAGES: readonly { id: ImageId; label: string; title: string }[] = [ - { id: 'gradient', label: 'Gradient', title: 'A smooth sky: low quality shows blocks' }, - { id: 'text', label: 'Text', title: 'Hard black-on-white edges: low quality rings' }, - { id: 'bars', label: 'Bars', title: 'Saturated colour: subsampling bleeds' }, +/** The gallery's groups, in the order the overview shows them. */ +export type PictureGroup = 'photos' | 'memes' | 'maths' | 'tests'; + +/** The heading shown for each group. */ +export const GROUP_LABELS: Record = { + photos: 'Photos', + memes: 'Memes', + maths: 'Maths', + tests: 'Artifact tests', +}; + +/** Who made a bundled picture, and on what terms it may be shown. */ +export interface Credit { + /** The work's title. */ + title: string; + /** Who made it. */ + author: string; + /** The licence it is shown under. */ + licence: string; + /** Where the original lives, when it came from somewhere. */ + url?: string; +} + +/** + * The gallery, in order. `file` is set exactly for the five bundled pictures + * (their size in pixels); the rest are generated by the kit at `BUILTIN_SIDE²`. + * `title` is the line shown on hover: what the picture shows off. + */ +export const IMAGES: readonly { + id: ImageId; + label: string; + title: string; + group: PictureGroup; + file?: { w: number; h: number }; + credit?: Credit; + /** For a generated picture: how its gallery thumbnail is made, `scale`d down or a 1:1 `crop` of its centre (fine patterns alias when scaled). */ + thumb?: 'scale' | 'crop'; +}[] = [ + { + id: 'spring', + label: 'Grand Prismatic Spring', + title: 'Vivid colour: watch chroma bleed', + group: 'photos', + file: { w: 256, h: 256 }, + credit: { + title: 'Grand Prismatic Spring, Yellowstone', + author: 'Jim Peaco, National Park Service', + licence: 'Public domain', + url: 'https://commons.wikimedia.org/wiki/File:Grand_prismatic_spring.jpg', + }, + }, + { + id: 'shuttle', + label: 'Shuttle on the pad', + title: 'Night, floodlights and fine structure: noise and ringing', + group: 'photos', + file: { w: 256, h: 256 }, + credit: { + title: 'Atlantis (STS-135) on pad 39A', + author: 'Bill Ingalls, NASA', + licence: 'Public domain', + url: 'https://commons.wikimedia.org/wiki/File:Brightly_lit_STS-135_on_launch_pad_39a.jpg', + }, + }, + { + id: 'doge', + label: 'Doge', + title: 'A meme that has been re-saved too often: text and fur', + group: 'memes', + file: { w: 272, h: 320 }, + credit: { + title: 'Doge (homemade)', + author: 'Euterpia, after a photo by Roberto Vasarri', + licence: 'CC0', + url: 'https://commons.wikimedia.org/wiki/File:Doge_homemade_meme.jpg', + }, + }, + { + id: 'honest-work', + label: 'Honest work', + title: 'Caption text on a photo: edges ring', + group: 'memes', + file: { w: 320, h: 203 }, + credit: { + title: "It ain't much, but it's honest work", + author: 'Cakelot1, photo by Dianne Johnson, USDA NRCS', + licence: 'Public domain', + url: 'https://commons.wikimedia.org/wiki/File:Farmer_meme_with_apostrophe.jpg', + }, + }, + { + id: 'cartoon', + label: 'Needs more JPEG', + title: 'Flat colour and hard outlines: the worst case for blocks', + group: 'memes', + file: { w: 320, h: 240 }, + credit: { title: 'Needs more JPEG', author: 'made for this project', licence: 'CC0' }, + }, + { + id: 'cosines', + label: 'Made of cosines', + title: 'Pure DCT basis patterns: each lands in one coefficient', + group: 'maths', + thumb: 'crop', + }, + { + id: 'cosines-shifted', + label: 'Cosines, shifted 4 px', + title: 'Same detail, off the 8 × 8 grid', + group: 'maths', + thumb: 'crop', + }, + { id: 'noise', label: 'Pure noise', title: 'Random pixels: nothing to compress', group: 'maths', thumb: 'crop' }, + { id: 'gradient', label: 'Gradient', title: 'A smooth sky: low quality shows blocks', group: 'tests', thumb: 'scale' }, + { id: 'text', label: 'Text', title: 'Hard black-on-white edges: low quality rings', group: 'tests', thumb: 'crop' }, + { id: 'bars', label: 'Bars', title: 'Saturated colour: subsampling bleeds', group: 'tests', thumb: 'scale' }, ]; +/** The URL a bundled picture is served from; carries the site's base path, as the Fourier font does. */ +export function pictureUrl(id: ImageId): string { + return `${import.meta.env.BASE_URL}jpeg/${id}.png`; +} + +/** The catalog entry for `id`. */ +function entry(id: ImageId) { + return IMAGES.find((im) => im.id === id); +} + +/** + * Load a gallery picture as pixels: a bundled one is fetched from the site and + * decoded like an upload; a generated one comes from the kit. Rejects, with a + * reader-facing message naming the picture, when a bundled file cannot be fetched. + */ +export async function loadPicture(id: ImageId): Promise { + const im = entry(id); + if (!im?.file) return loadBuiltin(id); + const name = im.label; + let res: Response; + try { + res = await fetch(pictureUrl(id)); + } catch { + throw new Error(`Could not load the picture "${name}". Check your connection and try again.`); + } + if (!res.ok) throw new Error(`Could not load the picture "${name}" (the server answered ${res.status}).`); + try { + return await decodeUpload(await res.blob()); + } catch { + // A wrong base path can answer 200 with the site's index page, and a file can be corrupt. + throw new Error(`Could not load the picture "${name}" (the file is not a readable image).`); + } +} + +/** + * A thumbnail `side` pixels square for the gallery: a bundled picture's own + * file URL, or a PNG data URL for a generated one. The generated picture is + * made at `BUILTIN_SIDE²`, the size the reader gets, because several generators + * (text cells, 8 × 8 cosine blocks, the noise hash) do not scale with the size + * asked for. Smooth pictures are then scaled down; fine patterns would alias to + * a flat tint, so those show a 1:1 window cut from the centre instead. + */ +export function thumbnail(id: ImageId, side: number): Promise { + if (entry(id)?.file) return Promise.resolve(pictureUrl(id)); + // The overview is visited again and again; a generated thumbnail is made once per picture and size. + const key = `${id}@${side}`; + let made = thumbnails.get(key); + if (!made) { + made = drawThumbnail(id, side); + thumbnails.set(key, made); + made.catch(() => thumbnails.delete(key)); // a failure is not remembered: the next visit tries again + } + return made; +} + +/** Generated thumbnails already made (or being made), by `id@side`. */ +const thumbnails = new Map>(); + +/** Draw the thumbnail `thumbnail` caches. */ +async function drawThumbnail(id: ImageId, side: number): Promise { + const viz = await loadVizCore(); + const full = document.createElement('canvas'); + full.width = BUILTIN_SIDE; + full.height = BUILTIN_SIDE; + const fullCtx = full.getContext('2d'); + const canvas = document.createElement('canvas'); + canvas.width = side; + canvas.height = side; + const ctx = canvas.getContext('2d'); + if (!fullCtx || !ctx) throw new Error('This browser cannot draw thumbnails.'); + const pixels = fullCtx.createImageData(BUILTIN_SIDE, BUILTIN_SIDE); + pixels.data.set(viz.jpeg_builtin_image(id, BUILTIN_SIDE, BUILTIN_SIDE)); + fullCtx.putImageData(pixels, 0, 0); + if (entry(id)?.thumb === 'crop') { + const from = Math.max(0, Math.floor((BUILTIN_SIDE - side) / 2)); + const span = Math.min(side, BUILTIN_SIDE); + ctx.drawImage(full, from, from, span, span, 0, 0, side, side); + } else { + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(full, 0, 0, side, side); + } + return canvas.toDataURL('image/png'); +} + /** A generated built-in at `BUILTIN_SIDE²`. */ export async function loadBuiltin(id: ImageId): Promise { const viz = await loadVizCore(); diff --git a/web/src/lib/jpeg/layout.ts b/web/src/lib/jpeg/layout.ts index ee3f81d..59bfa70 100644 --- a/web/src/lib/jpeg/layout.ts +++ b/web/src/lib/jpeg/layout.ts @@ -19,6 +19,8 @@ export interface Rect { /** Panel kinds the Rust viz draws (see `visualizations/jpeg.rs`). */ export type PanelKind = + | 'original' + | 'decoded' | 'image' | 'image_grid' | 'plane_y' @@ -41,7 +43,7 @@ export type PanelKind = * Named areas the page fills with DOM content (tables, charts, chips) rather * than viz panels. Each rect includes room for its own heading at the top. */ -export type SlotName = 'charts' | 'qtable' | 'rd' | 'sequence' | 'tokens' | 'huffman' | 'resave'; +export type SlotName = 'stats' | 'charts' | 'qtable' | 'rd' | 'sequence' | 'tokens' | 'huffman' | 'resave'; /** One thing the viz draws, and where, with the caption the overlay prints above it. */ export interface Panel { @@ -118,6 +120,32 @@ function columns(r: Rect, n: number, gap: number): Rect[] { return Array.from({ length: n }, (_, i) => ({ x: r.x + i * (w + gap), y: r.y, w: Math.max(0, w), h: r.h })); } +/** + * The overview: the picture before and after JPEG side by side in a top band, + * the stats slot across the full width below. The band is at most 58% of the + * height after margins and no taller than the pictures need (their caption + * included), so a narrow phone hands the rest to the statistics. Clicks pick a + * block on the original. + */ +function overviewLayout(cssW: number, cssH: number, imgW: number, imgH: number): StageLayout { + const m = MARGIN; + const area: Rect = { x: m, y: m, w: cssW - 2 * m, h: cssH - 2 * m }; + const colW = (area.w - m) / 2; + const topH = Math.min(area.h * 0.58, colW * (imgH / imgW) + LABEL); + const [left, right] = columns({ x: area.x, y: area.y, w: area.w, h: topH }, 2, m); + const original = fit(belowLabel(left), imgW, imgH); + const decoded = fit(belowLabel(right), imgW, imgH); + const y = area.y + topH + m; + return { + image: original, + panels: [ + { kind: 'original', rect: original, label: 'Original' }, + { kind: 'decoded', rect: decoded, label: 'After JPEG' }, + ], + slots: { stats: { x: area.x, y, w: area.w, h: Math.max(0, area.y + area.h - y) } }, + }; +} + /** * The stage's panels. Landscape: the image at the left (up to 45% of the * width), the stage's focus panels to its right. Portrait (narrower than @@ -137,6 +165,7 @@ export function jpegLayout( if (!(cssW > 2 * m + 40 && cssH > 2 * m + LABEL + 40 && imgW > 0 && imgH > 0)) { return { image: ZERO, panels: [], slots: {} }; } + if (stage === 'overview') return overviewLayout(cssW, cssH, imgW, imgH); let imageArea: Rect; let focus: Rect; if (cssW >= PORTRAIT_BELOW) { @@ -261,6 +290,13 @@ export function jpegLayout( return { image, panels, slots }; } +/** + * What the viz is given before the first picture is in: a list that is not + * empty (an empty list means "fit the image", which would show the viz's own + * stand-in gradient) but whose only panel has no area, so nothing is drawn. + */ +export const BLANK_PANELS: Panel[] = [{ kind: 'image', rect: { x: 0, y: 0, w: 0, h: 0 }, label: '' }]; + /** Panels as the viz config wants them: `[x, y, w, h]` in device pixels. */ export function toDevicePanels( panels: Panel[], diff --git a/web/src/lib/jpeg/params.ts b/web/src/lib/jpeg/params.ts index efe7acd..20ccd37 100644 --- a/web/src/lib/jpeg/params.ts +++ b/web/src/lib/jpeg/params.ts @@ -5,19 +5,42 @@ import { buildQuery } from '../router'; import { DEFAULT_STAGE, readyStage, type StageId } from './stages'; -/** A built-in picture. */ -export type ImageId = 'gradient' | 'text' | 'bars'; +/** A picture in the gallery: five bundled files and six generated by the kit. */ +export type ImageId = + | 'spring' + | 'shuttle' + | 'doge' + | 'honest-work' + | 'cartoon' + | 'cosines' + | 'cosines-shifted' + | 'noise' + | 'gradient' + | 'text' + | 'bars'; /** Chroma subsampling mode, as in the share link. */ export type SubsamplingId = '444' | '422' | '420'; /** Where the Huffman code lengths come from. */ export type HuffmanId = 'optimized' | 'standard'; -/** Every built-in picture id, in picker order. */ -export const IMAGE_IDS: readonly ImageId[] = ['gradient', 'text', 'bars']; +/** Every picture id, in gallery order. */ +export const IMAGE_IDS: readonly ImageId[] = [ + 'spring', + 'shuttle', + 'doge', + 'honest-work', + 'cartoon', + 'cosines', + 'cosines-shifted', + 'noise', + 'gradient', + 'text', + 'bars', +]; /** Everything a share link carries. */ export interface JpegParams { - /** Which built-in picture. */ + /** Which gallery picture. */ img: ImageId; /** 1–100. */ quality: number; @@ -33,7 +56,7 @@ export interface JpegParams { /** The settings an empty link means; they are left out of written links. */ export const DEFAULTS: JpegParams = { - img: 'gradient', + img: 'spring', quality: 50, subsampling: '420', huffman: 'optimized', diff --git a/web/src/lib/jpeg/stages.ts b/web/src/lib/jpeg/stages.ts index 8b5ab2b..b144d32 100644 --- a/web/src/lib/jpeg/stages.ts +++ b/web/src/lib/jpeg/stages.ts @@ -1,9 +1,10 @@ -// The JPEG lab's pipeline: nine stages from pixels to bits and back. The rail -// shows all of them; a stage is `ready` once its screen exists (the rest are +// The JPEG lab's pipeline: an overview, then nine stages from pixels to bits +// and back. The rail shows all of them; a stage is `ready` once its screen exists (the rest are // shown, disabled, so the whole pipeline is visible from the start). /** A pipeline stage's id, as written in the link and used by the rail. */ export type StageId = + | 'overview' | 'color' | 'subsample' | 'blocks' @@ -18,7 +19,7 @@ export type StageId = export interface StageInfo { /** The stage's id. */ id: StageId; - /** What the rail prints in the step's circle: 1–8, then ↺ for decode. */ + /** What the rail prints in the step's circle: 0 for the overview, 1–8, then ↺ for decode. */ mark: string; /** Name on the rail and the story card. */ title: string; @@ -28,8 +29,9 @@ export interface StageInfo { ready: boolean; } -/** All nine stages in pipeline order. */ +/** The overview and the nine stages, in pipeline order. */ export const STAGES: readonly StageInfo[] = [ + { id: 'overview', mark: '0', title: 'Overview', subtitle: 'What JPEG did to this picture', ready: true }, { id: 'color', mark: '1', title: 'Color', subtitle: 'RGB turns into brightness + two colour differences', ready: true }, { id: 'subsample', mark: '2', title: 'Subsample', subtitle: 'Keep less colour than brightness', ready: true }, { id: 'blocks', mark: '3', title: 'Blocks', subtitle: 'Cut each plane into 8 × 8 tiles', ready: true }, @@ -42,7 +44,7 @@ export const STAGES: readonly StageInfo[] = [ ]; /** The stage a link without `stage=` opens on. */ -export const DEFAULT_STAGE: StageId = 'color'; +export const DEFAULT_STAGE: StageId = 'overview'; /** Whether `s` names a stage (ready or not). */ export function isStageId(s: unknown): s is StageId {