From 8ef9ed5e8252353f44452821246036a70913cf2a Mon Sep 17 00:00:00 2001 From: benjamin-small Date: Fri, 16 Oct 2026 13:11:06 -0400 Subject: [PATCH 01/14] =?UTF-8?q?docs:=20JPEG=20lab=20overview=20screen=20?= =?UTF-8?q?and=20picture=20gallery=20=E2=80=94=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-10-16-jpeg-lab-gallery-design.md | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-16-jpeg-lab-gallery-design.md diff --git a/docs/superpowers/specs/2026-10-16-jpeg-lab-gallery-design.md b/docs/superpowers/specs/2026-10-16-jpeg-lab-gallery-design.md new file mode 100644 index 0000000..c982312 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-jpeg-lab-gallery-design.md @@ -0,0 +1,94 @@ +# JPEG lab: overview screen and picture gallery — Design + +Status: approved for planning, 2026-10-16. Extends `2026-10-15-jpeg-lab-design.md`, whose Delivery item 4 listed "the photo"; this design replaces that item. The rest of item 4 (home tile, nav link, README lab entry, the Color stage's Y-only/chroma-only toggles, the five-tile home grid, mobile polish) moves to the plan after this one. + +## Intent + +A first-time visitor should see what JPEG *did* before they see how. The lab opens on an overview of one picture: the original and the JPEG side by side, how much smaller the file is, and how the bytes are distributed before and after. The picture comes from a small gallery of real photos, internet memes and mathematical extremes. Each picture is chosen because it teaches something about compression. Grand Prismatic Spring replaces the generated gradient as the default. + +## Goals + +1. **The overview is a new first screen.** It comes before the nine stages and is the stage the lab opens on. It shows: + - **Before and after:** the original and its JPEG (decoded), side by side. Both use the current quality, chroma and Huffman settings and update live as they change. + - **Size:** raw bytes against JPEG bytes, as two bars to scale and a ratio (for example "192 KB → 4 KB · 48 : 1"). + - **Byte frequency:** two histograms of byte values 0–255. One is the raw image's bytes, the RGB samples as a file would store them. The other is the JPEG file's bytes. The raw one is lumpy and the JPEG one nearly flat: a well-compressed file looks like noise. + - **The gallery:** every picture as a thumbnail on this screen, grouped by kind, with Upload as the last tile. + - **A way into the steps:** an obvious route into stage 1. + - **It is step 0 on the rail** ("0 · Overview", before Color). The lab opens there and the arrow keys reach it. The encode is shown finished there, without the scan animation or scan dimming. Moving into stage 1 behaves as today. +2. **The gallery**, in four groups: + - **Photos:** + - *Grand Prismatic Spring* (Yellowstone; public domain, Jim Peaco / NPS), for chroma bleed and blocking. **The default picture.** + - *Space Shuttle Atlantis, STS-135, on pad 39A* (public domain, Bill Ingalls / NASA), for ringing and banding in the night sky. + - **Memes:** + - *Doge*: the CC0 recreation "Doge homemade meme.jpg" (Euterpia), built on a public-domain Shiba Inu photo. It is not the Kabosu photo. + - *"It ain't much, but it's honest work"*: "Farmer meme with apostrophe.jpg", public domain. The text is PD-self by Cakelot1 and the photo is Dianne Johnson / USDA. + - *An original cartoon meme* made for this repo, so the gallery has the flat-colour, bold-outline, chunky-caption look of cartoon memes. Famous cartoon memes are owned and cannot ship. + - **Maths:** generated in Rust, like the current built-ins. + - *Made of cosines*: every 8 × 8 block is one of the DCT's own basis patterns, aligned to the block grid, so each block codes to one or two non-zero coefficients. It is tiny and near-perfect. + - *Cosines, shifted 4 px*: the same pattern offset by 4 pixels in x and y, so every pattern straddles blocks. It has the same detail and compresses far worse. + - *Pure noise*: seeded random pixels. There is nothing to squeeze, so the raw byte histogram is already flat and JPEG barely shrinks it. It may grow at high quality, and low quality smears it. + - **Artifact tests:** the existing Gradient, Text and Bars, unchanged. +3. **Credits.** Each bundled photo or meme shows its title, author and licence in a small caption on the overview. The same attributions go in the README, and a credits file sits next to the images. + +## Non-goals + +- The home tile, nav link, README lab entry, Color toggles, five-tile home grid and mobile polish. They come in the next plan. The home tile may keep the gradient. +- Any change to the nine stages beyond what the new default picture and image ids need. +- Server-side anything: the site stays static on GitHub Pages. +- Fetching images from Wikimedia at run time. + +## Anti-goals + +- Shipping any image whose licence does not allow public redistribution. This includes famous memes that are owned: Success Kid, Distracted Boyfriend, This Is Fine, Pepe, Trollface, Nyan Cat, Grumpy Cat, Disaster Girl and others. The licensing research is in the session scratchpad report `gallery-recon.md`. Its findings are summarised in the licence table the builder writes. +- An overview so busy that the before/after comparison stops being the first thing the eye lands on. +- Slowing the first paint: the default photo must not make the lab noticeably slower to open than the generated gradient does today. + +## Constraints + +- **Stack:** Rust (`crates/jpeg`, `crates/viz-core`) → WASM → WebGL2, and Svelte 5 + TypeScript (`web/`). Nothing new is added. +- **Bundled images** are static files under `web/public/jpeg/`. + - They are fetched and decoded by the browser, then pushed through the existing `update_rule_config_with_image` path, the same way as an upload. + - Each is cropped square, downscaled from the Commons original, and committed losslessly as PNG. + - Downscaling washes out the originals' own JPEG artifacts, so the lab compresses a clean image. +- **The maths images** are new generators in `crates/viz-core/src/jpeg_images.rs`, next to `gradient`/`text`/`bars`, exposed through `jpeg_builtin_image`. They must be deterministic: the noise uses a fixed seed. +- **Share links** keep working: + - `img=` takes the new ids. + - The default image becomes the spring and the default stage becomes the overview. Both are omitted from the link when they are the default. + - Old links that name gradient, text or bars still load. +- Repo rules: AGENTS.md validation, docs and tests kept in sync, no emoji or arrow glyphs in `.svelte` files, sentence case, PR into protected `main`. + +## Decided in the conversation + +- The overview is the lab's new first screen. +- Byte frequency means the byte-value histogram, raw vs JPEG. +- The gallery has photos, popular memes and maths images. +- The maths images are one that compresses well and one that compresses badly: cosines and noise, plus the 4-px shifted twist. +- The memes are popular ones only where they are genuinely free to ship (Doge recreation, Honest Work), plus an original cartoon. +- Grand Prismatic Spring is the default picture. +- The overview is step 0 on the rail. +- On every other stage, a compact "Picture" dropdown in the bezel replaces today's row of image buttons. The full thumbnail gallery lives on the overview. Upload stays available in the bezel. + +## My calls (for you to check) + +- **The original cartoon meme.** Its subject and caption are the builder's to propose. It must be original, with no recognisable owned character. It needs flat colours, black outlines and a white Impact-style caption with a black stroke, and is drawn as an SVG in the repo and rasterised to the committed PNG. +- **Gradient, Text and Bars stay** as an "artifact tests" group. The Decode hints and the test suites rely on them. +- **Bundled pictures keep their framing.** Photos are hand-cropped square at 256 × 256, like the built-ins, so size readouts stay comparable and the scan stays short. Memes keep their full frame so the caption survives, with the long side scaled to 320 px. The pipeline already handles non-square images and partial blocks. Each committed PNG stays under 150 KB. +- **Loading:** only the default picture loads up front. Gallery thumbnails load lazily. Generated pictures are drawn to a small canvas from the kit's RGBA, and bundled ones are the PNGs scaled in CSS. +- **Byte histogram details:** + - It shows each distribution's Shannon entropy in bits per byte, the maths behind "looks like noise". + - The raw histogram counts the RGB samples, not alpha. + - The JPEG histogram counts the whole file, headers included. +- **Doge's caption gets checked before shipping**, since the research didn't read its overlay text. The builder judges it: it must be ordinary Doge-style words with nothing offensive or pointed at a real person. If it fails, the builder recaptions the public-domain base photo `Shiba_inu_taiki.jpg` in Doge style and updates the credits. +- **The cartoon's caption is drawn as outlined paths in the SVG.** No font file ships; Impact itself is not redistributable. +- **The byte histograms are honest.** The JPEG histogram counts the whole file, so header and byte-stuffing spikes show. The entropy number in bits per byte carries the "looks like noise" message. If the headers dominate on tiny files, a "scan data only" toggle may be added; measure this in the prototype. +- **Old links without `img=` now open on the spring** rather than the gradient. That is acceptable for a lab this new. Links that name a picture are unaffected. + +## Left to the builder + +- The overview's layout: + - It can reuse the existing panels (`image`, the wipe or decoded view) and DOM slots, or add a `decoded` panel kind. + - It has to work at desktop and phone widths, with the before/after comparison dominant. +- Thumbnail rendering and the gallery strip's look; it is fine to scale the full PNGs in CSS. +- The exact maths generators (which basis patterns, the quilt arrangement, grey or colour), provided the cosine image codes to about one or two coefficients per block and the shifted version is clearly larger at the default quality. Measure both in the prototype and record the numbers in the plan. +- Image ids and labels. +- How loading a bundled picture shows progress or failure. It should behave like today's built-in/upload loading. From 4df593df95c28865ee92e0aefa88638b4675e6d0 Mon Sep 17 00:00:00 2001 From: benjamin-small Date: Fri, 16 Oct 2026 13:11:10 -0400 Subject: [PATCH 02/14] docs: gallery design is a draft until approved --- docs/superpowers/specs/2026-10-16-jpeg-lab-gallery-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-10-16-jpeg-lab-gallery-design.md b/docs/superpowers/specs/2026-10-16-jpeg-lab-gallery-design.md index c982312..a1cca1c 100644 --- a/docs/superpowers/specs/2026-10-16-jpeg-lab-gallery-design.md +++ b/docs/superpowers/specs/2026-10-16-jpeg-lab-gallery-design.md @@ -1,6 +1,6 @@ # JPEG lab: overview screen and picture gallery — Design -Status: approved for planning, 2026-10-16. Extends `2026-10-15-jpeg-lab-design.md`, whose Delivery item 4 listed "the photo"; this design replaces that item. The rest of item 4 (home tile, nav link, README lab entry, the Color stage's Y-only/chroma-only toggles, the five-tile home grid, mobile polish) moves to the plan after this one. +Status: draft for review, 2026-10-16. Extends `2026-10-15-jpeg-lab-design.md`, whose Delivery item 4 listed "the photo"; this design replaces that item. The rest of item 4 (home tile, nav link, README lab entry, the Color stage's Y-only/chroma-only toggles, the five-tile home grid, mobile polish) moves to the plan after this one. ## Intent From 4d2ea4d7f0e2061716382b976db69c238423969e Mon Sep 17 00:00:00 2001 From: benjamin-small Date: Fri, 16 Oct 2026 13:16:31 -0400 Subject: [PATCH 03/14] =?UTF-8?q?docs:=20gallery=20design=20=E2=80=94=20th?= =?UTF-8?q?ree=20histograms,=20honest=20noise,=20prototype=20measurements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-10-16-jpeg-lab-gallery-design.md | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-10-16-jpeg-lab-gallery-design.md b/docs/superpowers/specs/2026-10-16-jpeg-lab-gallery-design.md index a1cca1c..22bab5a 100644 --- a/docs/superpowers/specs/2026-10-16-jpeg-lab-gallery-design.md +++ b/docs/superpowers/specs/2026-10-16-jpeg-lab-gallery-design.md @@ -1,6 +1,6 @@ # JPEG lab: overview screen and picture gallery — Design -Status: draft for review, 2026-10-16. Extends `2026-10-15-jpeg-lab-design.md`, whose Delivery item 4 listed "the photo"; this design replaces that item. The rest of item 4 (home tile, nav link, README lab entry, the Color stage's Y-only/chroma-only toggles, the five-tile home grid, mobile polish) moves to the plan after this one. +Status: approved, 2026-10-16 (amended with prototype measurements below). Extends `2026-10-15-jpeg-lab-design.md`, whose Delivery item 4 listed "the photo"; this design replaces that item. The rest of item 4 (home tile, nav link, README lab entry, the Color stage's Y-only/chroma-only toggles, the five-tile home grid, mobile polish) moves to the plan after this one. ## Intent @@ -11,7 +11,10 @@ A first-time visitor should see what JPEG *did* before they see how. The lab ope 1. **The overview is a new first screen.** It comes before the nine stages and is the stage the lab opens on. It shows: - **Before and after:** the original and its JPEG (decoded), side by side. Both use the current quality, chroma and Huffman settings and update live as they change. - **Size:** raw bytes against JPEG bytes, as two bars to scale and a ratio (for example "192 KB → 4 KB · 48 : 1"). - - **Byte frequency:** two histograms of byte values 0–255. One is the raw image's bytes, the RGB samples as a file would store them. The other is the JPEG file's bytes. The raw one is lumpy and the JPEG one nearly flat: a well-compressed file looks like noise. + - **Byte frequency:** three histograms of byte values 0–255, read left to right as a story, each with its Shannon entropy in bits per byte. + 1. **The raw bytes:** the RGB samples as a file would store them. For a photo they already look nearly random. + 2. **Neighbour differences:** each sample minus the same channel of its left neighbour, mod 256. They pile up around 0: the pattern is hidden in neighbours being alike. + 3. **The JPEG file's bytes:** random-looking again, but far fewer of them. - **The gallery:** every picture as a thumbnail on this screen, grouped by kind, with Upload as the last tile. - **A way into the steps:** an obvious route into stage 1. - **It is step 0 on the rail** ("0 · Overview", before Color). The lab opens there and the arrow keys reach it. The encode is shown finished there, without the scan animation or scan dimming. Moving into stage 1 behaves as today. @@ -26,7 +29,7 @@ A first-time visitor should see what JPEG *did* before they see how. The lab ope - **Maths:** generated in Rust, like the current built-ins. - *Made of cosines*: every 8 × 8 block is one of the DCT's own basis patterns, aligned to the block grid, so each block codes to one or two non-zero coefficients. It is tiny and near-perfect. - *Cosines, shifted 4 px*: the same pattern offset by 4 pixels in x and y, so every pattern straddles blocks. It has the same detail and compresses far worse. - - *Pure noise*: seeded random pixels. There is nothing to squeeze, so the raw byte histogram is already flat and JPEG barely shrinks it. It may grow at high quality, and low quality smears it. + - *Pure noise*: seeded random pixels. There is nothing to squeeze. JPEG still makes the file smaller, but only by destroying the picture: PSNR stays near 12 dB at every quality, even 100. - **Artifact tests:** the existing Gradient, Text and Bars, unchanged. 3. **Credits.** Each bundled photo or meme shows its title, author and licence in a small caption on the overview. The same attributions go in the README, and a credits file sits next to the images. @@ -92,3 +95,23 @@ A first-time visitor should see what JPEG *did* before they see how. The lab ope - The exact maths generators (which basis patterns, the quilt arrangement, grey or colour), provided the cosine image codes to about one or two coefficients per block and the shifted version is clearly larger at the default quality. Measure both in the prototype and record the numbers in the plan. - Image ids and labels. - How loading a bundled picture shows progress or failure. It should behave like today's built-in/upload loading. + +## Prototype measurements (2026-10-16) + +All measured at quality 50, 4:2:0, optimized Huffman, with the repo's own encoder, on the prepared pictures. + +| Picture | Size | Raw bytes | JPEG | PSNR | H raw | H neighbour diffs | H JPEG (bits/byte) | +|---|---|---|---|---|---|---|---| +| Grand Prismatic Spring | 256 × 256 | 196,608 | 10,283 | 30.3 dB | 7.77 | 5.66 | 7.95 | +| STS-135 | 256 × 256 | 196,608 | 6,973 | 28.9 dB | 7.02 | 4.51 | 7.94 | +| Doge | 272 × 320 | 261,120 | 9,118 | 27.1 dB | 7.61 | 4.81 | 7.94 | +| Honest work | 320 × 203 | 194,880 | 9,912 | 30.5 dB | 7.65 | 5.08 | 7.95 | +| Made of cosines | 256 × 256 | 196,608 | 2,461 | 38.4 dB | 7.09 | n/a | 7.39 | +| Cosines, shifted 4 px | 256 × 256 | 196,608 | 17,027 | 25.3 dB | 7.09 | n/a | 7.92 | +| Pure noise | 256 × 256 | 196,608 | 25,317 | 11.8 dB | 8.00 | n/a | 7.95 | + +- The shifted cosines are about 7× the aligned ones at the same quality. +- Noise keeps a PSNR of 11.2–12.7 dB from q25 to q100, and its file never exceeds the raw size at 4:2:0. +- The byte frequency of raw photo pixels is already 7.0–7.8 bits per byte, which is why the neighbour-difference histogram was added. +- The prepared PNGs are 100–141 KB each, under the 150 KB budget. +- Doge's caption ("wow.", "many edits", "much wiki", "such neutral" …) passes the check. From b2add48023a796b72e8a2bcc23f169bf83b0ebc5 Mon Sep 17 00:00:00 2001 From: benjamin-small Date: Fri, 16 Oct 2026 13:20:10 -0400 Subject: [PATCH 04/14] =?UTF-8?q?docs:=20JPEG=20lab=20plan=204=20=E2=80=94?= =?UTF-8?q?=20overview=20screen=20and=20picture=20gallery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-10-16-jpeg-lab-4-gallery.md | 372 ++++++++++++++++++ .../2026-10-16-jpeg-lab-gallery-design.md | 3 +- 2 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-10-16-jpeg-lab-4-gallery.md 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 + @@ -576,6 +601,10 @@ > {/if} + {#if stage === 'overview' && layout.slots.stats && image} + + {/if} + {#if stage === 'color' && panel('cube')} {@const r = panel('cube')!}
diff --git a/web/src/lib/components/labs/JpegLab.svelte b/web/src/lib/components/labs/JpegLab.svelte index 408fa4d..911e074 100644 --- a/web/src/lib/components/labs/JpegLab.svelte +++ b/web/src/lib/components/labs/JpegLab.svelte @@ -102,6 +102,7 @@ function currentQuery(): string { return buildParams({ + // A share link cannot carry an uploaded picture, so it names the gradient instead. img: imageId === 'upload' ? 'gradient' : imageId, quality, subsampling, @@ -270,21 +271,36 @@ * (a slow decode must not overwrite a newer choice); a failure leaves the * current image in place and says why. `block` is the luma block to select * once it is in (a shared link names one); otherwise the encoder's default. + * With no picture yet (the first load failed, say offline) the generated + * gradient stands in, so the lab still opens, with the error shown. */ async function loadImage(load: () => Promise, id: ImageId | 'upload', block?: [number, number]) { const ticket = ++loadTicket; let img: RgbaImage; + let message: string | null = null; try { img = await load(); } catch (err) { if (ticket !== loadTicket) return; - loadError = err instanceof Error ? err.message : 'That picture could not be loaded.'; - // The select shows the option the reader chose; put it back on the picture in use. - if (pictureSelect) pictureSelect.value = imageId; - return; + message = err instanceof Error ? err.message : 'That picture could not be loaded.'; + if (image) { + loadError = message; + // The select shows the option the reader chose; put it back on the picture in use. + if (pictureSelect) pictureSelect.value = imageId; + return; + } + try { + img = await loadPicture('gradient'); + } catch { + loadError = message; + return; + } + if (ticket !== loadTicket) return; + id = 'gradient'; + block = undefined; } if (ticket !== loadTicket) return; - loadError = null; + loadError = message; image = img; imageId = id; imageTag = id === 'upload' ? `upload-${++uploads}` : id; @@ -743,7 +759,7 @@ {/if} {#if loadError} - + {/if} {#if summary?.error} @@ -865,6 +881,8 @@ font-size: 13px; pointer-events: auto; } + /* On the overview the bottom of the canvas holds the credit and the axes: the alert goes on top instead. */ + .error.top { top: 8px; right: 16px; bottom: auto; } .stage-card { margin-bottom: 36px; } .stage-card h2 { display: flex; align-items: center; gap: 10px; } .stage-card .mark { diff --git a/web/src/lib/jpeg/__tests__/images.test.ts b/web/src/lib/jpeg/__tests__/images.test.ts index 2ae98f9..6338b7c 100644 --- a/web/src/lib/jpeg/__tests__/images.test.ts +++ b/web/src/lib/jpeg/__tests__/images.test.ts @@ -101,7 +101,8 @@ describe('picture catalog', () => { expect(await thumbnail('doge', 64)).toBe('/math-visualizer/jpeg/doge.png'); }); - it('draws a generated thumbnail by scaling the full-size picture', async () => { + /** 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> }[] = []; @@ -117,16 +118,35 @@ describe('picture catalog', () => { return ctx as unknown as CanvasRenderingContext2D; } as never); const toDataURL = vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue('data:image/png;base64,AAAA'); - expect(await thumbnail('noise', 32)).toBe('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('noise', 256, 256); + expect(builtin).toHaveBeenCalledWith(id, 256, 256); const source = drawn.find((d) => d.canvas.width === 256); - const out = drawn.find((d) => d.canvas.width === 32); + const out = drawn.find((d) => d.canvas.width === side); expect(source?.ctx.putImageData).toHaveBeenCalledTimes(1); - expect(out?.canvas.height).toBe(32); - expect(out?.ctx.drawImage).toHaveBeenCalledWith(source?.canvas, 0, 0, 32, 32); - expect(toDataURL).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('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 () => { diff --git a/web/src/lib/jpeg/__tests__/layout.test.ts b/web/src/lib/jpeg/__tests__/layout.test.ts index ffa8e57..bf6dadd 100644 --- a/web/src/lib/jpeg/__tests__/layout.test.ts +++ b/web/src/lib/jpeg/__tests__/layout.test.ts @@ -120,6 +120,24 @@ describe('jpegLayout', () => { 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']) { diff --git a/web/src/lib/jpeg/images.ts b/web/src/lib/jpeg/images.ts index f18a148..8071871 100644 --- a/web/src/lib/jpeg/images.ts +++ b/web/src/lib/jpeg/images.ts @@ -55,6 +55,8 @@ export const IMAGES: readonly { 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', @@ -116,17 +118,24 @@ export const IMAGES: readonly { file: { w: 320, h: 240 }, credit: { title: 'Needs more JPEG', author: 'made for this project', licence: 'CC0' }, }, - { id: 'cosines', label: 'Cosines', title: 'Pure DCT basis patterns: each lands in one coefficient', group: 'maths' }, + { + 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: 'Noise', title: 'Random pixels: nothing to compress', group: 'maths' }, - { id: 'gradient', label: 'Gradient', title: 'A smooth sky: low quality shows blocks', group: 'tests' }, - { id: 'text', label: 'Text', title: 'Hard black-on-white edges: low quality rings', group: 'tests' }, - { id: 'bars', label: 'Bars', title: 'Saturated colour: subsampling bleeds', group: 'tests' }, + { id: 'noise', label: '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. */ @@ -166,9 +175,10 @@ export async function loadPicture(id: ImageId): Promise { /** * 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, then scaled down, because - * several generators (text cells, 8 × 8 cosine blocks, the noise hash) do not - * scale with the size asked for. + * 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 async function thumbnail(id: ImageId, side: number): Promise { if (entry(id)?.file) return pictureUrl(id); @@ -185,9 +195,15 @@ export async function thumbnail(id: ImageId, side: number): Promise { 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); - ctx.imageSmoothingEnabled = true; - ctx.imageSmoothingQuality = 'high'; - ctx.drawImage(full, 0, 0, side, side); + 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'); } diff --git a/web/src/lib/jpeg/layout.ts b/web/src/lib/jpeg/layout.ts index 2d5eaa3..9c268cb 100644 --- a/web/src/lib/jpeg/layout.ts +++ b/web/src/lib/jpeg/layout.ts @@ -121,14 +121,17 @@ function columns(r: Rect, n: number, gap: number): Rect[] { } /** - * The overview: the picture before and after JPEG side by side in a top band - * (58% of the height after margins), the stats slot across the full width - * below. The same structure serves phones. Clicks pick a block on the original. + * 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 topH = area.h * 0.58; + 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); @@ -162,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) { @@ -173,7 +177,6 @@ export function jpegLayout( imageArea = { x: m, y: m, w: cssW - 2 * m, h: rowH }; focus = { x: m, y: 2 * m + rowH, w: cssW - 2 * m, h: cssH - 3 * m - rowH }; } - if (stage === 'overview') return overviewLayout(cssW, cssH, imgW, imgH); const image = fit(belowLabel(imageArea), imgW, imgH); const panels: Panel[] = [ { kind: stage === 'blocks' ? 'image_grid' : 'image', rect: image, label: 'The image — click a block' }, From 639032cf3b801d68454b27d70359e3ec81f79a51 Mon Sep 17 00:00:00 2001 From: benjamin-small Date: Fri, 16 Oct 2026 15:11:35 -0400 Subject: [PATCH 12/14] fix(web): overview histograms fit their rows; the load alert sits in the stats; measured text budget --- .../lib/components/__tests__/JpegLab.test.ts | 5 +- .../lib/components/__tests__/Overview.test.ts | 62 ++++++++-- web/src/lib/components/jpeg/Overview.svelte | 111 +++++++++++------- web/src/lib/components/labs/JpegLab.svelte | 21 ++-- 4 files changed, 138 insertions(+), 61 deletions(-) diff --git a/web/src/lib/components/__tests__/JpegLab.test.ts b/web/src/lib/components/__tests__/JpegLab.test.ts index 6fcee96..c8f1fc0 100644 --- a/web/src/lib/components/__tests__/JpegLab.test.ts +++ b/web/src/lib/components/__tests__/JpegLab.test.ts @@ -164,8 +164,9 @@ describe('JpegLab.svelte', () => { expect((getByLabelText('Picture') as HTMLSelectElement).value).toBe('gradient'); // The shared link now names what is on screen, and the block that belonged to the lost picture is not carried over. expect(route.query).toBe('img=gradient'); - // On the overview the alert sits at the top, clear of the credit and the axes. - expect(container.querySelector('.error')?.classList.contains('top')).toBe(true); + // On the overview the alert is the first row of its stats, where it hides nothing. + expect(container.querySelector('.overview [role="alert"]')).toBeTruthy(); + expect(container.querySelector('.error')).toBeNull(); }); it('picks a picture from the gallery', async () => { diff --git a/web/src/lib/components/__tests__/Overview.test.ts b/web/src/lib/components/__tests__/Overview.test.ts index d5a4832..b33f342 100644 --- a/web/src/lib/components/__tests__/Overview.test.ts +++ b/web/src/lib/components/__tests__/Overview.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { afterAll, beforeAll, describe, it, expect, vi } from 'vitest'; import { render } from '@testing-library/svelte'; import Overview from '../jpeg/Overview.svelte'; import { IMAGES } from '../../jpeg/images'; @@ -28,20 +28,60 @@ describe('Overview.svelte', () => { expect(c.querySelector('.bar.file')).toBeTruthy(); }); - it('stacks the histograms on a phone and still has room for the caption and the credit', () => { - const c = show(311, 302); - expect(c.querySelector('.overview')?.classList.contains('narrow')).toBe(true); - expect(c.querySelector('.caption')).toBeTruthy(); - expect(c.querySelector('.credit')).toBeTruthy(); + it('stacks the histograms on a narrow slot', () => { + expect(show(311, 302).querySelector('.overview')?.classList.contains('narrow')).toBe(true); }); - it('drops the caption first, then the credit, as the slot gets shorter', () => { - const noCaption = show(311, 250); - expect(noCaption.querySelector('.caption')).toBeNull(); + it('shows messages as the first row of the stats', () => { + const c = render(Overview, { rect: { x: 0, y: 0, w: 600, h: 260 }, image, jpeg, quality: 50, errors: ['Could not load the picture "Doge".'] }).container; + const alert = c.querySelector('[role="alert"]')!; + expect(alert.textContent).toContain('Could not load'); + expect(c.querySelector('.overview')!.firstElementChild).toBe(alert); + }); +}); + +// jsdom does no layout, so stand in for it: the content is 50 px of charts, 50 for the sizes, 60 for the caption and 40 for the credit. +describe('Overview.svelte text budget', () => { + const saved = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollHeight'); + let slot = 200; + beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get(this: HTMLElement) { + if (!this.classList.contains('overview')) return 0; + return 50 + (this.querySelector('.sizes') ? 50 : 0) + (this.querySelector('.caption') ? 60 : 0) + (this.querySelector('.credit') ? 40 : 0); + }, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + get(this: HTMLElement) { + return this.classList.contains('overview') ? slot : 0; + }, + }); + }); + afterAll(() => { + if (saved) Object.defineProperty(HTMLElement.prototype, 'scrollHeight', saved); + else delete (HTMLElement.prototype as unknown as Record).scrollHeight; + delete (HTMLElement.prototype as unknown as Record).clientHeight; + }); + + it('keeps the text while it fits, then drops the caption, the credit and the size bars in that order', async () => { + slot = 200; + const full = show(311, 302); + await vi.waitFor(() => expect(full.querySelector('.caption') && full.querySelector('.credit')).toBeTruthy()); + slot = 150; + const noCaption = show(311, 302); + await vi.waitFor(() => expect(noCaption.querySelector('.caption')).toBeNull()); expect(noCaption.querySelector('.credit')).toBeTruthy(); - const bare = show(311, 205); + slot = 120; + const bare = show(311, 302); + await vi.waitFor(() => expect(bare.querySelector('.credit')).toBeNull()); expect(bare.querySelector('.caption')).toBeNull(); - expect(bare.querySelector('.credit')).toBeNull(); expect(bare.querySelectorAll('svg')).toHaveLength(3); + expect(bare.querySelector('.sizes')).toBeTruthy(); + slot = 70; + const charts = show(311, 302); + await vi.waitFor(() => expect(charts.querySelector('.sizes')).toBeNull()); + expect(charts.querySelectorAll('svg')).toHaveLength(3); }); }); diff --git a/web/src/lib/components/jpeg/Overview.svelte b/web/src/lib/components/jpeg/Overview.svelte index 3e903d7..111b415 100644 --- a/web/src/lib/components/jpeg/Overview.svelte +++ b/web/src/lib/components/jpeg/Overview.svelte @@ -6,10 +6,12 @@ // there are far fewer bytes of it. Each histogram carries its Shannon // entropy in bits per byte. The pictures themselves are drawn by the viz. // - // The slot has a fixed size, so the parts are budgeted against it: the sizes - // and the three histograms always show, the credit is next, and the caption - // goes first when the slot is too short. Wide slots put the histograms side - // by side; narrow ones (phones) stack them, one per row. + // The slot has a fixed size. The sizes and the three histograms always show, + // each histogram keeping a minimum height of its own; the credit and the + // caption are laid out too, then measured: if they push the content past the + // slot, the caption is dropped first, then the credit, then the size bars. Wide slots put the + // histograms side by side; narrow ones (phones) stack them, one per row. + import { tick, untrack } from 'svelte'; import type { Rect } from '../../jpeg/layout'; import type { Credit, RgbaImage } from '../../jpeg/images'; import { entropyBits, histogram, neighbourDiffs, rgbSamples } from '../../jpeg/bytes'; @@ -26,24 +28,16 @@ quality: number; /** Who made the picture, for a bundled one. */ credit?: Credit; + /** Messages to show at the top (a picture that would not load, an encode that failed). */ + errors?: string[]; } - let { rect, image, jpeg, quality, credit }: Props = $props(); + let { rect, image, jpeg, quality, credit, errors = [] }: Props = $props(); /** Height of a histogram's drawing, in viewBox units; the bars scale to the tallest bin. */ const H = 100; /** Below this slot width the histograms stack, one per row. */ const NARROW = 520; - /** Gap between the parts, and the sizes block's height (two bars and the gap between them). */ - const GAP = 8; - const SIZES = 40; - /** The least the histograms need: three stacked rows, or one row of side-by-side figures (heading, chart, axis). */ - const STACKED = 3 * 44 + 2 * 6; - const SIDE_BY_SIDE = 92; - /** Line height and average glyph width (px) of the small text, for estimating how many lines it takes. */ - const LINE = 17; - const CHAR = 6.2; - // The pixel histograms only move with the picture; the file's only with the file. const raw = $derived(histogram(rgbSamples(image.rgba))); const diffs = $derived(histogram(neighbourDiffs(image.rgba, image.w, image.h))); @@ -68,13 +62,40 @@ const creditText = $derived(credit ? `${credit.title} · ${credit.author} · ${credit.licence}` : ''); const narrow = $derived(rect.w < NARROW); - /** Height of `text` set across the slot, estimated from its length. */ - const textH = (text: string, px: number) => Math.ceil((text.length * px) / Math.max(1, rect.w)) * LINE; - const baseH = $derived(SIZES + GAP + (narrow ? STACKED : SIDE_BY_SIDE)); - const creditH = $derived(creditText ? GAP + textH(creditText, CHAR * 0.92) : 0); - const captionH = $derived(GAP + textH(caption, CHAR)); - const showCredit = $derived(creditText !== '' && rect.h >= baseH + creditH); - const showCaption = $derived(rect.h >= baseH + (showCredit ? creditH : 0) + captionH); + + let box = $state(null); + let showCredit = $state(true); + let showCaption = $state(true); + let showSizes = $state(true); + + // Lay everything out, then drop what does not fit (caption first, then credit). + $effect(() => { + void [rect.w, rect.h, narrow, caption, creditText, errors.length]; + const el = box; + if (!el) return; + let alive = true; + untrack(() => { + showCredit = true; + showCaption = true; + showSizes = true; + void (async () => { + await tick(); + const over = () => el.scrollHeight > el.clientHeight + 1; + if (alive && over()) { + showCaption = false; + await tick(); + } + if (alive && over()) { + showCredit = false; + await tick(); + } + if (alive && over()) showSizes = false; // last resort, on a very short slot with a message showing + })(); + }); + return () => { + alive = false; + }; + }); /** The bar heights of a histogram, scaled so the tallest bin fills `H`. */ function bars(hist: Uint32Array): number[] { @@ -85,7 +106,11 @@ {#if rect.w > 160 && rect.h > 80} -
+
+ {#each errors as message (message)} + + {/each} + {#if showSizes}
Raw pixels {formatBytes(rawBytes)} @@ -94,6 +119,7 @@ {#if jpeg}JPEG {formatBytes(jpegBytes)} {ratio}{:else}JPEG: encoding{/if}
+ {/if}
{#each charts as c (c.key)} @@ -104,13 +130,15 @@ {entropyBits(c.hist).toFixed(2)} bits / byte {/if} - - {#if c.hist} - {#each bars(c.hist) as h, i (i)} - {#if h > 0}{/if} - {/each} - {/if} - +
+ + {#if c.hist} + {#each bars(c.hist) as h, i (i)} + {#if h > 0}{/if} + {/each} + {/if} + +
0byte value255
{/each} @@ -149,31 +177,34 @@ .what { color: #fff; white-space: nowrap; line-height: 18px; } .ratio { margin-left: 4px; } - /* Side by side on a wide slot; the figures share the height the sizes and text leave. */ - .charts { display: flex; flex: 1 1 auto; gap: 14px; min-height: 0; overflow: hidden; } + /* The charts never shrink below their own minimum (the overflow check drops the text instead), so nothing overlaps. */ + .charts { display: flex; flex: 1 1 auto; gap: 14px; min-height: 92px; } figure { display: flex; flex-direction: column; flex: 1 1 0; min-width: 0; margin: 0; gap: 2px; } - figcaption { display: flex; flex-wrap: wrap; justify-content: space-between; column-gap: 8px; } + figcaption { display: flex; flex-wrap: wrap; justify-content: space-between; column-gap: 8px; line-height: 16px; } .name { color: rgba(255, 255, 255, 0.9); } - svg { display: block; flex: 1 1 0; min-height: 30px; width: 100%; } + /* The drawing is taken out of the flow, so its aspect ratio cannot decide the row's height. */ + .plot { position: relative; flex: 1 1 0; min-height: 30px; min-width: 0; } + svg { position: absolute; inset: 0; width: 100%; height: 100%; } rect { fill: rgba(255, 255, 255, 0.55); } rect.accent { fill: var(--accent); } .axis { display: flex; justify-content: space-between; font-size: 10px; line-height: 12px; color: rgba(255, 255, 255, 0.45); } - /* Stacked on a phone: the heading at the left of each row, the chart and its axis at the right. */ - .narrow .charts { flex-direction: column; gap: 6px; } + /* Stacked on a phone: the heading at the left of each row (up to three lines), the chart and its axis at the right. */ + .narrow .charts { flex-direction: column; gap: 6px; min-height: 156px; } .narrow figure { display: grid; - grid-template-columns: 112px 1fr; - grid-template-rows: 1fr auto; + grid-template-columns: 112px minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; column-gap: 8px; row-gap: 0; flex: 1 1 0; - min-height: 44px; + min-height: 48px; } .narrow figcaption { grid-row: 1 / 3; flex-direction: column; justify-content: flex-start; } - .narrow svg { grid-column: 2; grid-row: 1; min-height: 30px; } + .narrow .plot { grid-column: 2; grid-row: 1; min-height: 0; } .narrow .axis { grid-column: 2; grid-row: 2; } + .alert { margin: 0; padding: 4px 8px; flex: none; border-radius: 6px; background: rgba(0, 0, 0, 0.6); color: #ffb4a0; line-height: 1.35; } .caption, .credit { margin: 0; line-height: 1.4; flex: none; } .credit { font-size: 11px; color: rgba(255, 255, 255, 0.55); } .credit a { color: inherit; text-decoration: underline; } diff --git a/web/src/lib/components/labs/JpegLab.svelte b/web/src/lib/components/labs/JpegLab.svelte index 911e074..3dc1e5d 100644 --- a/web/src/lib/components/labs/JpegLab.svelte +++ b/web/src/lib/components/labs/JpegLab.svelte @@ -98,6 +98,10 @@ /** Who made the picture in use, when it is a bundled one. */ const credit = $derived(IMAGES.find((im) => im.id === imageId)?.credit); const GROUPS = Object.keys(GROUP_LABELS) as PictureGroup[]; + /** The overview is on screen with its stats slot (it then shows the messages itself). */ + const overviewShown = $derived(stage === 'overview' && !!image && (layout.slots.stats?.w ?? 0) > 160 && (layout.slots.stats?.h ?? 0) > 80); + /** What went wrong, for the messages. */ + const alerts = $derived([loadError, summary?.error ? `This image could not be encoded: ${summary.error}` : null].filter((m): m is string => !!m)); const panel = (kind: Panel['kind']) => layout.panels.find((p) => p.kind === kind)?.rect; function currentQuery(): string { @@ -618,7 +622,7 @@ {/if} {#if stage === 'overview' && layout.slots.stats && image} - + {/if} {#if stage === 'color' && panel('cube')} @@ -758,11 +762,14 @@ /> {/if} - {#if loadError} - - {/if} - {#if summary?.error} - + + {#if !overviewShown} + {#if loadError} + + {/if} + {#if summary?.error} + + {/if} {/if}
{/snippet} @@ -881,8 +888,6 @@ font-size: 13px; pointer-events: auto; } - /* On the overview the bottom of the canvas holds the credit and the axes: the alert goes on top instead. */ - .error.top { top: 8px; right: 16px; bottom: auto; } .stage-card { margin-bottom: 36px; } .stage-card h2 { display: flex; align-items: center; gap: 10px; } .stage-card .mark { From 8a07eab3a98767ef6101d27dfdd3c7fbc1ade947 Mon Sep 17 00:00:00 2001 From: benjamin-small Date: Fri, 16 Oct 2026 15:14:08 -0400 Subject: [PATCH 13/14] =?UTF-8?q?docs:=20JPEG=20lab=20gallery=20=E2=80=94?= =?UTF-8?q?=20spec=20amendment=20and=20test=20counts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../specs/2026-10-15-jpeg-lab-design.md | 33 +++++++++++++------ .../2026-10-16-jpeg-lab-gallery-design.md | 2 +- docs/testing.md | 4 +-- 4 files changed, 27 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 578f9b4..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 diff --git a/docs/superpowers/specs/2026-10-15-jpeg-lab-design.md b/docs/superpowers/specs/2026-10-15-jpeg-lab-design.md index c8b2fe6..e519ada 100644 --- a/docs/superpowers/specs/2026-10-15-jpeg-lab-design.md +++ b/docs/superpowers/specs/2026-10-15-jpeg-lab-design.md @@ -1,7 +1,7 @@ # JPEG Compression lab — Design **Date:** 2026-10-15 -**Status:** Approved (2026-10-15); amended by Plans 1, 2 and 3 (see notes) +**Status:** Approved (2026-10-15); amended by Plans 1, 2, 3 and 4 (see notes) **Scope:** a fifth lab, `#/jpeg`: a new pure-Rust workspace crate `crates/jpeg` (a granular library of JPEG's parts plus a baseline codec built from them), stateless WASM bindings for those parts ("the kit"), a `jpeg` rule + WebGL2 visualization in `crates/viz-core`, two small generic engine additions, a Svelte lab page with a nine-stage stepper, a home tile, docs. ## Amendment (Plan 1, 2026-10-15) @@ -38,6 +38,19 @@ Building stages 5–9 settled these, folded into the text below: 9. Custom tables last until "Standard" is pressed, across pictures and settings, and are not in the share link. While they are in use a "Custom Q tables" chip with a "Standard" button shows in the readout row on every stage, Re-save is disabled (it re-encodes with the quality-scaled tables) with a note to press Standard first, and Save .jpg names the file `jpeg-lab-custom-.jpg`. 10. Stage contents: Quantize shows the decoder's rebuilt block, and the zero count lives on the Zigzag stage ("then N zeros"). The Huffman stage shows each symbol's code in the histogram's tooltips and on the tree's leaves instead of a separate code table. On a phone-width stage the Q-table editor is hidden (its column is under 120 px); the hover formula still names each entry. The Decode tools fit short stages and scroll, and the zigzag strip scrolls rather than clipping its zero tail. Mobile polish moves to Plan 4. +## Amendment (Plan 4, 2026-10-16) + +The gallery and overview are specified in [`2026-10-16-jpeg-lab-gallery-design.md`](2026-10-16-jpeg-lab-gallery-design.md) and implemented on the `jpeg-lab-gallery` branch. These decisions are folded into the text below: +11. **The overview is stage 0.** The rail reads "0 · Overview" before Color, `overview` is the default `stage`, and the arrow keys reach it. It shows the original and the decoded JPEG side by side (live with quality, subsampling and Huffman), raw against JPEG bytes as two bars to scale with a ratio, three byte histograms with their Shannon entropy, the picture gallery with Upload, credits for bundled pictures, and a way into stage 1. The encode is shown finished: no scan animation or dimming. On narrow stages the three histograms stack one per row. The load alert sits at the top of the stats area rather than over the canvas. +12. **Eleven pictures**, ids used verbatim in Rust, TypeScript and the share link, in gallery order: photos `spring`, `shuttle`; memes `doge`, `honest-work`, `cartoon`; maths `cosines`, `cosines-shifted`, `noise`; tests `gradient`, `text`, `bars`. The default is `spring` (it replaces the gradient). `img` and `stage` are omitted from the link when they are the defaults. A link without `img=` opens the spring; a link naming any id, including `gradient`, is unchanged. An uploaded picture is written to the link as `gradient`. If the very first picture fails to load, the lab falls back to the generated gradient and shows the alert. +13. **Picture select.** A compact "Picture" select in the bezel, on every stage including the overview, replaces the row of image buttons, with Upload beside it. The full thumbnail gallery, with the credits, is on the overview. Changing picture keeps the current stage, re-encodes and writes `img=` into the link. A bundled picture that fails to load leaves the current one in place with an inline error, and the select keeps showing the picture in use. A shared `b=` block is clamped to the new picture's own block grid. +14. **Three histograms**, each with its entropy in bits per byte: the raw RGB samples (`w·h·3`, no alpha); the neighbour differences (each sample minus the same channel of its left neighbour, mod 256); and the whole JPEG file, headers included. The maths is in `web/src/lib/jpeg/bytes.ts`. At q50 the spring reads about 7.77 / 5.66 / 7.95. +15. **New panel kinds `original` and `decoded`**: the picture and its decoded JPEG drawn undimmed, for the overview. +16. **Three maths pictures**, generated in Rust and deterministic (the noise uses a fixed seed), 256 × 256. *Made of cosines* is one DCT basis pattern per 8 × 8 block, aligned to the grid (about 2.5 KB at q50). *Cosines, shifted 4 px* is the same pattern offset by 4 px in x and y (about 17 KB, roughly 7 times larger). *Pure noise* has PSNR near 12 dB at every quality. The picture label is "Made of cosines". +17. **Bundled PNGs** live in `web/public/jpeg/` (spring, shuttle, doge, honest-work, cartoon, each at most 150 KB, with `CREDITS.md`). They load from `${import.meta.env.BASE_URL}jpeg/.png`, so they work under the GitHub Pages base path, and are never fetched from Wikimedia at run time. spring and shuttle are 256 × 256, doge 272 × 320, honest-work 320 × 203 and cartoon 320 × 240. Credits are on the overview and in the README. The scripts that regenerate them are in `scripts/jpeg-gallery/`. +18. **Gallery thumbnails.** Bundled pictures are the PNGs scaled in CSS. For generated pictures the pattern ones (cosines, cosines-shifted, noise, text) show a 1:1 centre window, because fine patterns alias when scaled; gradient and bars are scaled. Only the default picture loads up front; thumbnails load lazily. +19. **Delivery item 4 is split.** This gallery is the first part (the photo becomes a gallery). The home tile, nav link, README lab entry, the Color stage's Y-only and chroma-only toggles, the five-tile home grid and mobile polish move to the next plan. The home tile keeps the gradient until then. + ## Summary A learning tool that follows one image, and one 8×8 block of it, through every step of baseline JPEG and back: colour transform, chroma subsampling, blocking, the 2D DCT, quantization, zigzag, run-length coding, Huffman coding, and decoding. The maths is the point: every stage shows its formula with live numbers for the selected block, and every stage's visual is the maths made visible (a rotating colour cube, the 64 cosine basis patterns, a block rebuilt term by term, a rate–distortion curve, a Huffman code tree, the exact bits a block became). @@ -48,16 +61,16 @@ The codec is not a black box. `crates/jpeg` is a **library of parts**: every sta 1. A granular, natively tested Rust library covering every baseline JPEG stage in both directions, exposing its constants and intermediate values. 2. A baseline JFIF encoder/decoder composed from it, whose output the browser's own decoder accepts and agrees with (the test oracle). -3. A lab page with a pipeline rail of nine stages (1 Color · 2 Subsample · 3 Blocks · 4 DCT · 5 Quantize · 6 Zigzag · 7 Run-length · 8 Huffman · ⟲ Decode); the image and a selected block carry across every stage. +3. A lab page with a pipeline rail of an overview and nine stages (0 Overview · 1 Color · 2 Subsample · 3 Blocks · 4 DCT · 5 Quantize · 6 Zigzag · 7 Run-length · 8 Huffman · ⟲ Decode); the image and a selected block carry across every stage. 4. Live controls: quality 1–100, subsampling 4:4:4 / 4:2:2 / 4:2:0, Huffman tables optimized / standard; changes reshape every view without restarting the scan. -5. Four built-in images chosen to provoke one artifact each, plus upload of your own image. +5. A gallery of eleven pictures (two photos, three memes, three maths pictures and the three artifact tests), each chosen to teach something about compression, plus upload of your own image (amendment 12). 6. A home tile, a share link, a Save .jpg button. ## Non-goals - Progressive JPEG, arithmetic coding, restart intervals (DRI/RSTn), 12-bit precision, CMYK, EXIF/ICC handling. The decoder reports these as `Unsupported(kind)`. Each is a clean follow-up on the same modules. - An animated, stage-by-stage decoder walk. Decode is one screen; each inverse is already shown in the stage that owns it. -- Sharing uploaded images through the link (the link falls back to the default built-in). +- Sharing uploaded images through the link (the link names the generated gradient instead). - Changing other labs' behaviour. Shared-code changes are limited to the two generic engine hooks below, a new `render/texture_quad.rs`, and the home grid growing to five tiles. ## Decisions @@ -69,7 +82,7 @@ The codec is not a black box. `crates/jpeg` is a **library of parts**: every sta - **Stage stepper** (user choice) rather than a dashboard or scroll story. - **Built-ins + upload** (user choice); no paintable block. - **The engine clock counts MCUs scanned.** Play walks the scan across the image; views fill in behind it; the clock line completes when the bitstream does. -- Images are 256×256 for built-ins; uploads are downscaled to fit 512×512. +- Generated pictures are 256×256; bundled pictures are 256×256 (photos) or up to 320 px on the long side (memes); uploads are downscaled to fit 512×512. ## Part 1 — `crates/jpeg`: the library of parts @@ -116,7 +129,7 @@ The codec supports 1 or 3 components (grayscale input is encoded as one componen - **Actions (`rule_action`, no playback reset):** settings arrive in rule-config pushes; `select_block` is the only action. - **`rule_summary()`** (read every frame, kept small): `{ version, n, total, width, height, bytes, raw_bytes, bits_so_far, payload_bits, entropy_bits, psnr, quality, subsampling, mcu: [mcu_w, mcu_h, cols, rows], grids: [[cols, rows] per component], error, selected: { component, bx, by, mcu, pixels[64], shifted[64], dct[64], quant[64], table[64], zigzag[64], dc_prev, dc_diff, tokens[{ run, size, amp, symbol, code, amp_bits }], dc_size, dc_code, dc_amp_bits, bit_span, bits: String }, hints: { blocking, ringing | null, bleed } }`. `quality` and `subsampling` echo the encoded options, `psnr` is `null` when infinite, and `error` carries the encode failure message if any. JS parses with loose `== null` checks (wasm-bindgen sends `None` as `undefined`). - **`rule_inspect(key)`**, called when a stage opens or `version` changes. `"huffman"` → four tables in the order `dc_luma`, `ac_luma`, `dc_chroma`, `ac_chroma`, each `{ name, bits, vals, codes: [{symbol, code}], counts: [[symbol, count]], entropy_bits, actual_bits }` (the tree is drawn from `bits`/`vals` with the kit's `jpeg_huffman_tree`); `"segments"` → `[{ marker, kind, offset, len }]`; `"tables"` → both scaled Q tables. -- **Built-in generators** (Rust, `viz-core/src/jpeg_images.rs`, shared by the rule, the kit and the tests): `gradient` (smooth sky → shows blocking), `text` (sharp black-on-white lines and glyph-like strokes → shows ringing), `bars` (saturated colour bars and a colour wheel → shows chroma bleed). A fourth, `photo`, is a bundled public-domain photograph (e.g. NASA), cropped to 256×256 and stored as a PNG under 150 KB in `web/public/jpeg/`, chosen in slice 4 and credited in the story; it is loaded by JS and pushed through `apply_image`. +- **Built-in generators** (Rust, `viz-core/src/jpeg_images.rs`, shared by the rule, the kit and the tests): `gradient` (smooth sky → shows blocking), `text` (sharp black-on-white lines and glyph-like strokes → shows ringing), `bars` (saturated colour bars and a colour wheel → shows chroma bleed). Plan 4 adds the maths pictures `cosines`, `cosines-shifted` and `noise` (amendment 16). The photos and memes are bundled PNGs under 150 KB in `web/public/jpeg/` (amendment 17), loaded by JS and pushed through `apply_image`. ### The kit (`viz-core/src/jpeg_kit.rs`; the `web/src/lib/jpeg/kit.ts` wrapper is deferred) @@ -138,8 +151,8 @@ Numeric 8×8 grids, the KaTeX formula panel (KaTeX is already a dependency, lazi ### Frame -- `LabShell labId="jpeg" title="JPEG Compression"`. Bezel, in order: image picker (Gradient, Text, Bars, Upload…; Photo joins in Plan 4), Quality linear slider 1–100 with mono readout, Subsampling radio (real `type="radio"` inputs drawn as pills, Notes-lab pattern), Huffman radio (Optimized / Standard), scan Play/Pause + Reset, Save .jpg. -- **Pipeline rail** under the bezel: nine numbered steps, the current one marked (`aria-current="step"`); click or ←/→ (when focus is not in a text field). Each step has a one-line subtitle. +- `LabShell labId="jpeg" title="JPEG Compression"`. Bezel, in order: Picture select (all eleven pictures, with Upload, amendment 13), Quality linear slider 1–100 with mono readout, Subsampling radio (real `type="radio"` inputs drawn as pills, Notes-lab pattern), Huffman radio (Optimized / Standard), scan Play/Pause + Reset, Save .jpg. +- **Pipeline rail** under the bezel: the overview (step 0) and nine numbered steps, the current one marked (`aria-current="step"`); click or ←/→ (when focus is not in a text field). Each step has a one-line subtitle. - **Readout strip:** `18.4 KB of 192 KB · 10.4 : 1 · 2.30 bits/px · PSNR 34.1 dB`. - **Stage area:** landscape (canvas at least 640 px wide, `PORTRAIT_BELOW` in `web/src/lib/jpeg/layout.ts`) → the image at the left (up to 45% of the width), the stage's focus panels to its right; narrower → the image on top (42% of the height), focus below. The page turns this into the viz's `panels` list; the rects come from `web/src/lib/jpeg/layout.ts` and are pushed like the Notes lab's (`ResizeObserver` + window-resize fallback + once in `onReady`). Clicking the image selects a block in every stage. - **Formula panel** per stage with live numbers for the selected block or hovered cell, and a short "Notice" paragraph. The article below holds "How it works" for the whole pipeline. @@ -158,7 +171,7 @@ Numeric 8×8 grids, the KaTeX formula panel (KaTeX is already a dependency, lazi ### Share link, tile, download, upload -- **Link:** `#/jpeg?img=gradient&q=50&s=420&h=opt&stage=dct&b=12,7` (sanitised; canonical rewrite on load; uploads fall back to `gradient`; `buildQuery` keeps raw commas as in the Notes lab). +- **Link:** `#/jpeg?img=gradient&q=50&s=420&h=opt&stage=dct&b=12,7` (sanitised; canonical rewrite on load; `img` defaults to `spring` and `stage` to `overview` and are omitted when default; uploads still fall back to `gradient`; `buildQuery` keeps raw commas as in the Notes lab). - **Home tile:** `gradient` at q = 50 scanning; readout "41 % scanned · 18.4 KB"; `done` when `n == total`; `progress = n / total`. The home grid grows to five tiles (layout set in slice 4). - **Save .jpg:** `engine.rule_bytes()` → Blob → download `jpeg-lab-q50-420.jpg`. - **Upload:** file input and drag-and-drop on the stage; decoded with `createImageBitmap`, downscaled to fit 512×512 on a DOM canvas (painted white first, so transparent areas are not black; no `OffscreenCanvas`), pushed through `update_rule_config_with_image`. A failure shows an inline message and keeps the current image. @@ -187,6 +200,6 @@ Four PRs into protected `main`, each green on `cargo fmt --all --check`, `cargo 1. **`crates/jpeg` + the kit + the oracle test.** The library is complete and proven before any UI exists. 2. **The engine lab + stages 1–4:** the generic engine hooks, the rule, `JpegViz`, `texture_quad`, the page frame (bezel, rail, readout, layout) and Color, Subsample, Blocks, DCT. 3. **Stages 5–8 + Decode:** quantization with editable tables and the R–D curve, zigzag, run-length, Huffman tree and byte bar, decode with wipe, error map and generation loss. -4. **Home tile, nav link, the photo, the Color stage's Y-only and chroma-only toggles, share links, README, story and the five-tile home grid.** +4. **The photo, the Color stage's Y-only and chroma-only toggles, home tile, nav link, share links, README, story and the five-tile home grid.** Split by Plan 4 (amendment 19): the gallery (overview, eleven pictures, Picture select, share-link defaults) ships first; the home tile, nav link, README lab entry, Color toggles, five-tile grid and mobile polish follow in the next plan. Within slices 2 and 3, Rust and web pieces run as parallel subagents on disjoint files. diff --git a/docs/superpowers/specs/2026-10-16-jpeg-lab-gallery-design.md b/docs/superpowers/specs/2026-10-16-jpeg-lab-gallery-design.md index 84e0455..6008641 100644 --- a/docs/superpowers/specs/2026-10-16-jpeg-lab-gallery-design.md +++ b/docs/superpowers/specs/2026-10-16-jpeg-lab-gallery-design.md @@ -1,6 +1,6 @@ # JPEG lab: overview screen and picture gallery — Design -Status: approved, 2026-10-16 (amended with prototype measurements below). Extends `2026-10-15-jpeg-lab-design.md`, whose Delivery item 4 listed "the photo"; this design replaces that item. The rest of item 4 (home tile, nav link, README lab entry, the Color stage's Y-only/chroma-only toggles, the five-tile home grid, mobile polish) moves to the plan after this one. +Status: implemented, 2026-10-16 (Plan 4, branch `jpeg-lab-gallery`; amended with prototype measurements below). Execution decisions are recorded as Plan 4 amendments 11–19 in the main spec. Extends `2026-10-15-jpeg-lab-design.md`, whose Delivery item 4 listed "the photo"; this design replaces that item. The rest of item 4 (home tile, nav link, README lab entry, the Color stage's Y-only/chroma-only toggles, the five-tile home grid, mobile polish) moves to the plan after this one. ## Intent diff --git a/docs/testing.md b/docs/testing.md index 06eaf5d..84b10d0 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -9,8 +9,8 @@ npm test npm run coverage ``` -The Rust suite currently contains 396 passing tests: 85 in the dependency-free `jpeg` crate (every stage's round trip and invariants, the published Wikipedia DCT and quantization values, the Annex K Huffman codes, and encoder/decoder end-to-end cases including corrupt and hostile input) and 311 in `viz-core`. It exercises playback reduction, configuration schemas, deterministic seeded rules, geometric invariants, 2D and 3D camera behavior, serialization, type-erased dispatch, input handling, and visualization state. The separate browser-targeted WASM smoke suite currently contains 47 passing tests (33 engine smoke tests, 6 JPEG kit tests, 7 JPEG lab engine tests, and the JPEG browser-decoder oracle, which checks that Chrome decodes our encoder's files to within 3 levels per channel of our own decoder) and is run with `wasm-pack test --chrome --headless crates/viz-core` as documented in the root README. +The Rust suite currently contains 400 passing tests: 85 in the dependency-free `jpeg` crate (every stage's round trip and invariants, the published Wikipedia DCT and quantization values, the Annex K Huffman codes, and encoder/decoder end-to-end cases including corrupt and hostile input) and 315 in `viz-core`. It exercises playback reduction, configuration schemas, deterministic seeded rules, geometric invariants, 2D and 3D camera behavior, serialization, type-erased dispatch, input handling, and visualization state. The separate browser-targeted WASM smoke suite currently contains 48 passing tests (33 engine smoke tests, 6 JPEG kit tests, 8 JPEG lab engine tests, and the JPEG browser-decoder oracle, which checks that Chrome decodes our encoder's files to within 3 levels per channel of our own decoder) and is run with `wasm-pack test --chrome --headless crates/viz-core` as documented in the root README. -As measured on October 4, 2026, the 375 web tests (across 30 test files) cover 91.03% of statements, 89.29% of branches, 89.55% of functions, and 91.03% of lines. They exercise the single-flight WASM loader, the shared engine lifecycle helper, the theme and token contracts, the sorting lab's summary parsing and sonification planner (against a stub AudioContext), the notes lab's theory table, log speed mapping, layout, summary parsing and voice planner (against the same stub), the gallery home's four live tiles, and representative Svelte mount and interaction paths across all four labs. Since then the web suite has grown to 526 tests in 37 files, adding the JPEG lab's stages, share-link, summary, layout, maths and formulas modules, the R–D cache, the Huffman view data, and the five new stages, plus its component test against the fake engine. +As measured on October 4, 2026, the 375 web tests (across 30 test files) cover 91.03% of statements, 89.29% of branches, 89.55% of functions, and 91.03% of lines. They exercise the single-flight WASM loader, the shared engine lifecycle helper, the theme and token contracts, the sorting lab's summary parsing and sonification planner (against a stub AudioContext), the notes lab's theory table, log speed mapping, layout, summary parsing and voice planner (against the same stub), the gallery home's four live tiles, and representative Svelte mount and interaction paths across all four labs. Since then the web suite has grown to 562 tests in 40 files, adding the JPEG lab's stages, share-link, summary, layout, maths and formulas modules, the R–D cache, the Huffman view data, the byte-statistics (`bytes`) and picture-catalog (`images`) modules behind the overview and gallery, and the five new stages, plus its component test against the fake engine. Instrumentation-based Rust source coverage is currently 0% because the Cargo test gate does not configure a Rust coverage reporter. The web report's principal gaps are the Svelte components' pointer, zoom and speed-ramp paths, the pre-paint theme script in index.html, and the browser entry point. Real WebGL behavior remains outside the DOM-based unit-test environment. From 92a835285039869ce6cacb02f7dcde7dd2ceee42 Mon Sep 17 00:00:00 2001 From: benjamin-small Date: Fri, 16 Oct 2026 15:26:23 -0400 Subject: [PATCH 14/14] fix(web): credits always visible, blank stage until the first picture, honest copy, Pure noise, cached thumbnails --- crates/viz-core/tests/jpeg_lab.rs | 36 ++++++- .../specs/2026-10-15-jpeg-lab-design.md | 14 +-- docs/testing.md | 4 +- scripts/jpeg-gallery/README.md | 4 +- scripts/jpeg-gallery/make_gallery.py | 2 - .../lib/components/__tests__/JpegLab.test.ts | 96 +++++++++++++++++-- .../lib/components/__tests__/Overview.test.ts | 10 ++ web/src/lib/components/jpeg/Overview.svelte | 8 +- web/src/lib/components/labs/JpegLab.svelte | 61 ++++++++---- web/src/lib/jpeg/__tests__/images.test.ts | 21 ++++ web/src/lib/jpeg/__tests__/layout.test.ts | 9 ++ web/src/lib/jpeg/__tests__/summary.test.ts | 2 + web/src/lib/jpeg/formulas.ts | 2 +- web/src/lib/jpeg/images.ts | 22 ++++- web/src/lib/jpeg/layout.ts | 7 ++ 15 files changed, 249 insertions(+), 49 deletions(-) diff --git a/crates/viz-core/tests/jpeg_lab.rs b/crates/viz-core/tests/jpeg_lab.rs index 6bcbc9a..a66aab0 100644 --- a/crates/viz-core/tests/jpeg_lab.rs +++ b/crates/viz-core/tests/jpeg_lab.rs @@ -313,7 +313,7 @@ fn original_and_decoded_panels_ignore_the_scan() { assert_eq!(gl.get_error(), WebGl2RenderingContext::NO_ERROR); // The 256 × 256 gradient, sampled where the 64-pixel panel's centre lands: - // the linear filter averages the four texels around (128, 128). + // 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)] @@ -347,7 +347,35 @@ fn original_and_decoded_panels_ignore_the_scan() { decoded[c] ); } - web_sys::console::log_1(&JsValue::from_str(&format!( - "pixels decoded={decoded:?} original={original:?} image={image:?}" - ))); +} + +#[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/specs/2026-10-15-jpeg-lab-design.md b/docs/superpowers/specs/2026-10-15-jpeg-lab-design.md index e519ada..0842dbc 100644 --- a/docs/superpowers/specs/2026-10-15-jpeg-lab-design.md +++ b/docs/superpowers/specs/2026-10-15-jpeg-lab-design.md @@ -2,7 +2,7 @@ **Date:** 2026-10-15 **Status:** Approved (2026-10-15); amended by Plans 1, 2, 3 and 4 (see notes) -**Scope:** a fifth lab, `#/jpeg`: a new pure-Rust workspace crate `crates/jpeg` (a granular library of JPEG's parts plus a baseline codec built from them), stateless WASM bindings for those parts ("the kit"), a `jpeg` rule + WebGL2 visualization in `crates/viz-core`, two small generic engine additions, a Svelte lab page with a nine-stage stepper, a home tile, docs. +**Scope:** a fifth lab, `#/jpeg`: a new pure-Rust workspace crate `crates/jpeg` (a granular library of JPEG's parts plus a baseline codec built from them), stateless WASM bindings for those parts ("the kit"), a `jpeg` rule + WebGL2 visualization in `crates/viz-core`, two small generic engine additions, a Svelte lab page with an overview and nine stages, a home tile, docs. ## Amendment (Plan 1, 2026-10-15) @@ -41,12 +41,12 @@ Building stages 5–9 settled these, folded into the text below: ## Amendment (Plan 4, 2026-10-16) The gallery and overview are specified in [`2026-10-16-jpeg-lab-gallery-design.md`](2026-10-16-jpeg-lab-gallery-design.md) and implemented on the `jpeg-lab-gallery` branch. These decisions are folded into the text below: -11. **The overview is stage 0.** The rail reads "0 · Overview" before Color, `overview` is the default `stage`, and the arrow keys reach it. It shows the original and the decoded JPEG side by side (live with quality, subsampling and Huffman), raw against JPEG bytes as two bars to scale with a ratio, three byte histograms with their Shannon entropy, the picture gallery with Upload, credits for bundled pictures, and a way into stage 1. The encode is shown finished: no scan animation or dimming. On narrow stages the three histograms stack one per row. The load alert sits at the top of the stats area rather than over the canvas. -12. **Eleven pictures**, ids used verbatim in Rust, TypeScript and the share link, in gallery order: photos `spring`, `shuttle`; memes `doge`, `honest-work`, `cartoon`; maths `cosines`, `cosines-shifted`, `noise`; tests `gradient`, `text`, `bars`. The default is `spring` (it replaces the gradient). `img` and `stage` are omitted from the link when they are the defaults. A link without `img=` opens the spring; a link naming any id, including `gradient`, is unchanged. An uploaded picture is written to the link as `gradient`. If the very first picture fails to load, the lab falls back to the generated gradient and shows the alert. +11. **The overview is stage 0.** The rail reads "0 · Overview" before Color, `overview` is the default `stage`, and the arrow keys reach it. It shows the original and the decoded JPEG side by side (live with quality, subsampling and Huffman), raw against JPEG bytes as two bars to scale with a ratio, three byte histograms with their Shannon entropy, the picture gallery with Upload, credits for bundled pictures, and a way into stage 1. The encode is shown finished: no scan animation or dimming. On narrow stages the three histograms stack one per row. The load alert sits at the top of the stats area rather than over the canvas. The current picture's credit also shows in the story column under the notice, so it never depends on how much room the stage has; the block-picking area is off on the overview, where there is no block to pick. +12. **Eleven pictures**, ids used verbatim in Rust, TypeScript and the share link, in gallery order: photos `spring`, `shuttle`; memes `doge`, `honest-work`, `cartoon`; maths `cosines`, `cosines-shifted`, `noise`; tests `gradient`, `text`, `bars`. The default is `spring` (it replaces the gradient). `img` and `stage` are omitted from the link when they are the defaults. A link without `img=` opens the spring; a link naming any id, including `gradient`, is unchanged. An uploaded picture is written to the link as `img=gradient`, outright: it is not the default, so a bare link would open the spring instead. If the very first picture fails to load, the lab falls back to the generated gradient and shows the alert, and the address keeps naming the picture that was asked for. The first picture's fetch starts when the page does, in parallel with the WASM load, and until it is in nothing is drawn and no readout shows. 13. **Picture select.** A compact "Picture" select in the bezel, on every stage including the overview, replaces the row of image buttons, with Upload beside it. The full thumbnail gallery, with the credits, is on the overview. Changing picture keeps the current stage, re-encodes and writes `img=` into the link. A bundled picture that fails to load leaves the current one in place with an inline error, and the select keeps showing the picture in use. A shared `b=` block is clamped to the new picture's own block grid. 14. **Three histograms**, each with its entropy in bits per byte: the raw RGB samples (`w·h·3`, no alpha); the neighbour differences (each sample minus the same channel of its left neighbour, mod 256); and the whole JPEG file, headers included. The maths is in `web/src/lib/jpeg/bytes.ts`. At q50 the spring reads about 7.77 / 5.66 / 7.95. 15. **New panel kinds `original` and `decoded`**: the picture and its decoded JPEG drawn undimmed, for the overview. -16. **Three maths pictures**, generated in Rust and deterministic (the noise uses a fixed seed), 256 × 256. *Made of cosines* is one DCT basis pattern per 8 × 8 block, aligned to the grid (about 2.5 KB at q50). *Cosines, shifted 4 px* is the same pattern offset by 4 px in x and y (about 17 KB, roughly 7 times larger). *Pure noise* has PSNR near 12 dB at every quality. The picture label is "Made of cosines". +16. **Three maths pictures**, generated in Rust and deterministic (the noise uses a fixed seed), 256 × 256. *Made of cosines* is one DCT basis pattern per 8 × 8 block, aligned to the grid (about 2.5 KB at q50). *Cosines, shifted 4 px* is the same pattern offset by 4 px in x and y (about 17 KB, roughly 7 times larger). *Pure noise* has PSNR near 12 dB at every quality. The pictures are labelled "Made of cosines", "Cosines, shifted 4 px" and "Pure noise". 17. **Bundled PNGs** live in `web/public/jpeg/` (spring, shuttle, doge, honest-work, cartoon, each at most 150 KB, with `CREDITS.md`). They load from `${import.meta.env.BASE_URL}jpeg/.png`, so they work under the GitHub Pages base path, and are never fetched from Wikimedia at run time. spring and shuttle are 256 × 256, doge 272 × 320, honest-work 320 × 203 and cartoon 320 × 240. Credits are on the overview and in the README. The scripts that regenerate them are in `scripts/jpeg-gallery/`. 18. **Gallery thumbnails.** Bundled pictures are the PNGs scaled in CSS. For generated pictures the pattern ones (cosines, cosines-shifted, noise, text) show a 1:1 centre window, because fine patterns alias when scaled; gradient and bars are scaled. Only the default picture loads up front; thumbnails load lazily. 19. **Delivery item 4 is split.** This gallery is the first part (the photo becomes a gallery). The home tile, nav link, README lab entry, the Color stage's Y-only and chroma-only toggles, the five-tile home grid and mobile polish move to the next plan. The home tile keeps the gradient until then. @@ -70,7 +70,7 @@ The codec is not a black box. `crates/jpeg` is a **library of parts**: every sta - Progressive JPEG, arithmetic coding, restart intervals (DRI/RSTn), 12-bit precision, CMYK, EXIF/ICC handling. The decoder reports these as `Unsupported(kind)`. Each is a clean follow-up on the same modules. - An animated, stage-by-stage decoder walk. Decode is one screen; each inverse is already shown in the stage that owns it. -- Sharing uploaded images through the link (the link names the generated gradient instead). +- Sharing uploaded images through the link (the link names the generated gradient instead, as `img=gradient`). - Changing other labs' behaviour. Shared-code changes are limited to the two generic engine hooks below, a new `render/texture_quad.rs`, and the home grid growing to five tiles. ## Decisions @@ -159,7 +159,7 @@ Numeric 8×8 grids, the KaTeX formula panel (KaTeX is already a dependency, lazi ### Stages -1. **Color.** Point cloud of the image's pixels in the colour cube; a slider turns the axes from RGB to YCbCr (the cube visibly rotates so the grey diagonal becomes Y). The Y, Cb, Cr planes in a row below the cube; the per-plane toggles (the image rebuilt from Y only or chroma only) move to Plan 4. Formula: the static 3×3 matrix (amendment 8). *Notice:* Y looks like the photo; Cb and Cr are flat and soft. +1. **Color.** Point cloud of the image's pixels in the colour cube; a slider turns the axes from RGB to YCbCr (the cube visibly rotates so the grey diagonal becomes Y). The Y, Cb, Cr planes in a row below the cube; the per-plane toggles (the image rebuilt from Y only or chroma only) move to the next plan. Formula: the static 3×3 matrix (amendment 8). *Notice:* Y looks like the photo; Cb and Cr are flat and soft. 2. **Subsample.** Cb and Cr at full resolution beside the subsampled planes (nearest upsampling, so the blocks show); the image recomposed with the chosen mode; "samples kept: 100 % / 67 % / 50 %". *Notice:* half the data gone, almost invisible. 3. **Blocks.** Block grid and MCU outlines over the image; the selected block as 8×8 numbered cells with a −128 shift toggle; a component switch Y / Cb / Cr (chroma shows the block covering the selection). Formula: f(x, y) − 128. 4. **DCT.** The 64-pattern basis atlas; the selected block's coefficients as a diverging heatmap with numbers. Hover a coefficient → its basis pattern lights and the formula shows F(u, v) with the block's actual sum. A *k* scrubber (0–64) rebuilds the block from the first *k* zigzag terms beside its shrinking error. An energy-compaction curve (cumulative energy by zigzag index). **Why cosines?** side panel: one row of the block, its periodic extension (DFT, a jump at the seam) vs its mirrored extension (DCT, smooth), and the number of terms each needs for 95 % of the energy. Formula: the 2D DCT-II. @@ -190,7 +190,7 @@ Numeric 8×8 grids, the KaTeX formula panel (KaTeX is already a dependency, lazi - end to end: PSNR non-decreasing in quality across q ∈ {10, 30, 50, 70, 90}; q = 100 with 4:4:4 gives PSNR > 45 dB; `parse_segments(encode(..))` lists SOI, APP0, DQT, SOF0, DHT, SOS, EOI in order; decode(encode(img)) for 1 and 3 components, every subsampling, odd sizes; malformed inputs (truncated, bad marker, progressive SOF2) return the right `JpegError`. - randomized blocks and images use the repo's deterministic `splitmix64` approach; no new test dependencies. - **WASM browser (`crates/viz-core/tests/wasm.rs`):** the **browser-decoder oracle**: for the three generated built-ins × every subsampling × q ∈ {10, 50, 90}, decode our bytes with `createImageBitmap` and compare with our `decode`: max \|Δ\| ≤ 3 and mean \|Δ\| ≤ 0.5 per channel in every mode (measured on Chrome 155: max 3, mean ≤ 0.37; the 128×128 and 99×61 sizes are both checked). Plus: lab construction, `update_rule_config_with_image`, actions keep `n`, `rule_inspect` keys, `rule_bytes`, rendering every stage without GL errors. -- **Web (Vitest):** URL params, `layout.ts`, summary/inspect parsing, upload downscale maths, and a `JpegLab` component test against `FakeEngine` (stage switching, rail keyboard, bezel controls dispatch the right actions); `App`, `Home` and router tests updated for five labs (Plan 4). A `kit.ts` wrapper test lands with the wrapper (amendment 9). +- **Web (Vitest):** URL params, `layout.ts`, summary/inspect parsing, upload downscale maths, and a `JpegLab` component test against `FakeEngine` (stage switching, rail keyboard, bezel controls dispatch the right actions); `App`, `Home` and router tests updated for five labs (the next plan). A `kit.ts` wrapper test lands with the wrapper (amendment 9). - **Browser:** every stage in light and dark at 1440 and 375 px via the capture script, plus the home tile. ## Delivery diff --git a/docs/testing.md b/docs/testing.md index 84b10d0..968b87f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -9,8 +9,8 @@ npm test npm run coverage ``` -The Rust suite currently contains 400 passing tests: 85 in the dependency-free `jpeg` crate (every stage's round trip and invariants, the published Wikipedia DCT and quantization values, the Annex K Huffman codes, and encoder/decoder end-to-end cases including corrupt and hostile input) and 315 in `viz-core`. It exercises playback reduction, configuration schemas, deterministic seeded rules, geometric invariants, 2D and 3D camera behavior, serialization, type-erased dispatch, input handling, and visualization state. The separate browser-targeted WASM smoke suite currently contains 48 passing tests (33 engine smoke tests, 6 JPEG kit tests, 8 JPEG lab engine tests, and the JPEG browser-decoder oracle, which checks that Chrome decodes our encoder's files to within 3 levels per channel of our own decoder) and is run with `wasm-pack test --chrome --headless crates/viz-core` as documented in the root README. +The Rust suite currently contains 400 passing tests: 85 in the dependency-free `jpeg` crate (every stage's round trip and invariants, the published Wikipedia DCT and quantization values, the Annex K Huffman codes, and encoder/decoder end-to-end cases including corrupt and hostile input) and 315 in `viz-core`. It exercises playback reduction, configuration schemas, deterministic seeded rules, geometric invariants, 2D and 3D camera behavior, serialization, type-erased dispatch, input handling, and visualization state. The separate browser-targeted WASM smoke suite currently contains 49 passing tests (33 engine smoke tests, 6 JPEG kit tests, 9 JPEG lab engine tests, and the JPEG browser-decoder oracle, which checks that Chrome decodes our encoder's files to within 3 levels per channel of our own decoder) and is run with `wasm-pack test --chrome --headless crates/viz-core` as documented in the root README. -As measured on October 4, 2026, the 375 web tests (across 30 test files) cover 91.03% of statements, 89.29% of branches, 89.55% of functions, and 91.03% of lines. They exercise the single-flight WASM loader, the shared engine lifecycle helper, the theme and token contracts, the sorting lab's summary parsing and sonification planner (against a stub AudioContext), the notes lab's theory table, log speed mapping, layout, summary parsing and voice planner (against the same stub), the gallery home's four live tiles, and representative Svelte mount and interaction paths across all four labs. Since then the web suite has grown to 562 tests in 40 files, adding the JPEG lab's stages, share-link, summary, layout, maths and formulas modules, the R–D cache, the Huffman view data, the byte-statistics (`bytes`) and picture-catalog (`images`) modules behind the overview and gallery, and the five new stages, plus its component test against the fake engine. +As measured on October 4, 2026, the 375 web tests (across 30 test files) cover 91.03% of statements, 89.29% of branches, 89.55% of functions, and 91.03% of lines. They exercise the single-flight WASM loader, the shared engine lifecycle helper, the theme and token contracts, the sorting lab's summary parsing and sonification planner (against a stub AudioContext), the notes lab's theory table, log speed mapping, layout, summary parsing and voice planner (against the same stub), the gallery home's four live tiles, and representative Svelte mount and interaction paths across all four labs. Since then the web suite has grown to 574 tests in 40 files, adding the JPEG lab's stages, share-link, summary, layout, maths and formulas modules, the R–D cache, the Huffman view data, the byte-statistics (`bytes`) and picture-catalog (`images`) modules behind the overview and gallery, and the five new stages, plus its component test against the fake engine. Instrumentation-based Rust source coverage is currently 0% because the Cargo test gate does not configure a Rust coverage reporter. The web report's principal gaps are the Svelte components' pointer, zoom and speed-ramp paths, the pre-paint theme script in index.html, and the browser entry point. Real WebGL behavior remains outside the DOM-based unit-test environment. diff --git a/scripts/jpeg-gallery/README.md b/scripts/jpeg-gallery/README.md index 79a8653..53a71f1 100644 --- a/scripts/jpeg-gallery/README.md +++ b/scripts/jpeg-gallery/README.md @@ -19,7 +19,9 @@ Save these four files, under the same names, into one folder (``). Each python3 make_gallery.py ``` -This writes `spring.png`, `shuttle.png`, `doge.png` and `honest-work.png` to ``. spring and shuttle are cropped and resized to 256 × 256; doge and honest-work are resized so the long side is 320 px. The script also writes a raw `.rgba` file beside each PNG. The app does not use these, so delete them rather than commit them. +This writes `spring.png`, `shuttle.png`, `doge.png` and `honest-work.png` to ``. spring and shuttle are cropped and resized to 256 × 256; doge and honest-work are resized so the long side is 320 px. + +The originals' sizes are 1999 × 1277 (spring), 4015 × 2672 (shuttle), 2175 × 2563 (doge) and 3008 × 1911 (honest-work); the crop boxes in the script are in those pixels. Pillow 12.3.0 reproduces the committed PNGs byte for byte. ## 3. Draw the cartoon diff --git a/scripts/jpeg-gallery/make_gallery.py b/scripts/jpeg-gallery/make_gallery.py index 3cb7946..22f9e4c 100644 --- a/scripts/jpeg-gallery/make_gallery.py +++ b/scripts/jpeg-gallery/make_gallery.py @@ -17,6 +17,4 @@ size = (round(im.size[0] * s), round(im.size[1] * s)) im = im.resize(size, Image.LANCZOS) im.save(f"{out}/{id_}.png", optimize=True) - rgba = im.convert("RGBA").tobytes() - open(f"{out}/{id_}.rgba", "wb").write(rgba) print(id_, im.size) diff --git a/web/src/lib/components/__tests__/JpegLab.test.ts b/web/src/lib/components/__tests__/JpegLab.test.ts index c8f1fc0..4c9c0f9 100644 --- a/web/src/lib/components/__tests__/JpegLab.test.ts +++ b/web/src/lib/components/__tests__/JpegLab.test.ts @@ -10,7 +10,7 @@ import { jpegSummaryFixture, } from '../../test/fakeViz'; import { navigate, route } from '../../router.svelte'; -import { loadPicture } from '../../jpeg/images'; +import { decodeUpload, loadPicture } from '../../jpeg/images'; installRafPolyfill(); @@ -28,6 +28,7 @@ vi.mock('../../jpeg/images', async (importOriginal) => { const { w, h } = real.IMAGES.find((im) => im.id === id)?.file ?? { w: 256, h: 256 }; return { w, h, rgba: new Uint8Array(w * h * 4).fill(128) }; }), + decodeUpload: vi.fn(real.decodeUpload), thumbnail: vi.fn(async (id: string) => `data:image/png;base64,${id}`), }; }); @@ -162,13 +163,97 @@ describe('JpegLab.svelte', () => { expect(lastPush()[0].image_id).toBe('gradient'); expect((await findByRole('alert')).textContent).toContain('the server answered 404'); expect((getByLabelText('Picture') as HTMLSelectElement).value).toBe('gradient'); - // The shared link now names what is on screen, and the block that belonged to the lost picture is not carried over. - expect(route.query).toBe('img=gradient'); // On the overview the alert is the first row of its stats, where it hides nothing. expect(container.querySelector('.overview [role="alert"]')).toBeTruthy(); expect(container.querySelector('.error')).toBeNull(); }); + it('keeps the link the reader opened when the first picture falls back to the gradient', async () => { + vi.mocked(loadPicture).mockImplementationOnce(() => Promise.reject(new Error('offline'))); + await renderLab('img=shuttle&q=30&b=3,4'); + expect(lastPush()[0].image_id).toBe('gradient'); + await new Promise((r) => setTimeout(r, 30)); + // The lost picture stays named in the address, so a reload (or a forward) can try it again. + expect(route.query).toBe('img=shuttle&q=30&b=3,4'); + }); + + it('writes img=gradient into the link for an uploaded picture', async () => { + const { container, getByLabelText } = await renderLab('img=shuttle'); + vi.mocked(decodeUpload).mockImplementationOnce(async () => ({ w: 64, h: 48, rgba: new Uint8Array(64 * 48 * 4).fill(90) })); + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + await fireEvent.change(input, { target: { files: [new File(['x'], 'x.png', { type: 'image/png' })] } }); + await vi.waitFor(() => expect(lastPush()[0].image_id).toBe('upload')); + expect(lastPush().slice(1, 3)).toEqual([64, 48]); + expect((getByLabelText('Picture') as HTMLSelectElement).value).toBe('upload'); + // The link cannot carry the pixels; it names the gradient, not the default spring a bare link would open. + expect(route.query).toBe('img=gradient'); + await fireEvent.change(getByLabelText('Quality')); + expect(route.query).toBe('img=gradient'); + }); + + it('shows the picture credit in the overview story, whatever the stage has room for', async () => { + Object.defineProperty(HTMLElement.prototype, 'clientWidth', size(343, 0)); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', size(0, 300)); + try { + const { container } = await renderLab(); + await vi.waitFor(() => expect(container.querySelector('.stage-card')).toBeTruthy()); + const credit = container.querySelector('.stage-card .credit')!; + expect(credit.textContent).toContain('Grand Prismatic Spring, Yellowstone'); + expect(credit.textContent).toContain('Jim Peaco, National Park Service'); + expect(credit.textContent).toContain('Public domain'); + expect(credit.querySelector('a')?.getAttribute('href')).toContain('commons.wikimedia.org'); + } finally { + Object.defineProperty(HTMLElement.prototype, 'clientWidth', size(1134, 0)); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', size(0, 585)); + } + }); + + it('shows no credit for a generated picture, and none off the overview', async () => { + const { container } = await renderLab('img=text'); + await vi.waitFor(() => expect(container.querySelector('.stage-card')).toBeTruthy()); + expect(container.querySelector('.stage-card .credit')).toBeNull(); + const spring = await renderLab('img=spring&stage=color'); + expect(spring.container.querySelector('.stage-card .credit')).toBeNull(); + }); + + it('draws nothing and shows no readout until the first picture is in', async () => { + let release!: () => void; + vi.mocked(loadPicture).mockImplementationOnce( + () => new Promise((resolve) => (release = () => resolve({ w: 256, h: 256, rgba: new Uint8Array(256 * 256 * 4).fill(128) }))), + ); + navigate('jpeg', ''); + const { container } = render(App); + // The engine is up but the picture is not: the viz must not fall back to its built-in gradient. + await vi.waitFor(() => expect(updateVizConfigSpy).toHaveBeenCalled()); + const panels = lastViz().panels as { kind: string; rect: number[] }[]; + expect(panels.length).toBeGreaterThan(0); // an empty list means "fit the image" to the viz + expect(panels.every((p) => p.rect[2] === 0 || p.rect[3] === 0)).toBe(true); + expect(pushes()).toHaveLength(0); + expect(container.querySelector('.stats')).toBeNull(); + release(); + await vi.waitFor(() => expect(container.querySelector('.stats')?.textContent).toContain('4.0 KB')); + const drawn = (lastViz().panels as { kind: string; rect: number[] }[]).filter((p) => p.rect[2] > 0 && p.rect[3] > 0).map((p) => p.kind); + expect(drawn).toEqual(expect.arrayContaining(['original', 'decoded'])); + }); + + it('starts the first picture before the engine is ready', async () => { + const calls = vi.mocked(loadPicture).mock.calls.length; + navigate('jpeg', 'img=shuttle'); + render(App); + // Synchronously after mount: the fetch has begun, while the WASM module is still loading. + expect(vi.mocked(loadPicture).mock.calls.slice(calls)).toEqual([['shuttle']]); + await vi.waitFor(() => expect(pushes().length).toBeGreaterThan(0)); + expect(vi.mocked(loadPicture).mock.calls.slice(calls)).toEqual([['shuttle']]); + }); + + it('has no block-picking area on the overview, and the how-it-works tip says where clicking applies', async () => { + const { container, getByRole, queryByRole } = await renderLab(); + expect(queryByRole('button', { name: /Pick a block/ })).toBeNull(); + expect(container.textContent).toContain('On stages 1'); + await fireEvent.click(getByRole('button', { name: 'Step 1: Color' })); + expect(getByRole('button', { name: /Pick a block/ })).toBeTruthy(); + }); + it('picks a picture from the gallery', async () => { const { getByRole } = await renderLab(); await fireEvent.click(getByRole('button', { name: 'Made of cosines' })); @@ -323,14 +408,13 @@ describe('JpegLab.svelte', () => { }); it('moves the selected block with the arrow keys on the image, without changing stage', async () => { - const { getByRole } = await renderLab(); + const { getByRole } = await renderLab('stage=blocks'); const pick = getByRole('button', { name: /Pick a block/ }); ruleActionSpy.mockClear(); await fireEvent.keyDown(pick, { key: 'ArrowRight' }); // The summary's luma block is (21, 17); one block right. expect(ruleActionSpy).toHaveBeenLastCalledWith({ kind: 'select_block', component: 0, bx: 22, by: 17 }); - expect(route.query).toContain('b=22,17'); - expect(route.query).not.toContain('stage='); + expect(route.query).toBe('stage=blocks&b=22,17'); await fireEvent.keyDown(pick, { key: 'ArrowDown' }); expect(ruleActionSpy).toHaveBeenLastCalledWith({ kind: 'select_block', component: 0, bx: 22, by: 18 }); }); diff --git a/web/src/lib/components/__tests__/Overview.test.ts b/web/src/lib/components/__tests__/Overview.test.ts index b33f342..0ba2925 100644 --- a/web/src/lib/components/__tests__/Overview.test.ts +++ b/web/src/lib/components/__tests__/Overview.test.ts @@ -22,6 +22,16 @@ describe('Overview.svelte', () => { expect(c.querySelector('[role="group"]')?.getAttribute('aria-label')).toContain('File size'); }); + it('hedges the claim about the middle histogram, and says custom tables when they are in use', () => { + const plain = show(1102, 216).textContent!; + expect(plain).toContain('At quality 50 the file is'); + expect(plain).toContain('can peak at both edges'); + expect(plain).not.toContain('peaks at both edges'); + const custom = render(Overview, { rect: { x: 0, y: 0, w: 1102, h: 216 }, image, jpeg, quality: 50, custom: true, credit }).container.textContent!; + expect(custom).toContain('With custom tables the file is'); + expect(custom).not.toContain('At quality'); + }); + it('keeps the labels beside the size bars, never inside them', () => { const c = show(1102, 216); for (const bar of c.querySelectorAll('.bar')) expect(bar.textContent).toBe(''); diff --git a/web/src/lib/components/jpeg/Overview.svelte b/web/src/lib/components/jpeg/Overview.svelte index 111b415..9e6e45f 100644 --- a/web/src/lib/components/jpeg/Overview.svelte +++ b/web/src/lib/components/jpeg/Overview.svelte @@ -26,13 +26,15 @@ jpeg: Uint8Array | null; /** The quality setting the file was made at. */ quality: number; + /** Custom quantization tables are in use, so the quality setting no longer sizes the file. */ + custom?: boolean; /** Who made the picture, for a bundled one. */ credit?: Credit; /** Messages to show at the top (a picture that would not load, an encode that failed). */ errors?: string[]; } - let { rect, image, jpeg, quality, credit, errors = [] }: Props = $props(); + let { rect, image, jpeg, quality, custom = false, credit, errors = [] }: Props = $props(); /** Height of a histogram's drawing, in viewBox units; the bars scale to the tallest bin. */ const H = 100; @@ -56,8 +58,8 @@ ]); const caption = $derived( - `At quality ${quality} the file is ${jpeg ? `${((100 * jpegBytes) / rawBytes).toFixed(1)}%` : 'a fraction'} of the raw size. ` + - 'Small negative differences wrap round to 255, so the middle histogram peaks at both edges.', + `${custom ? 'With custom tables' : `At quality ${quality}`} the file is ${jpeg ? `${((100 * jpegBytes) / rawBytes).toFixed(1)}%` : 'a fraction'} of the raw size. ` + + 'Small negative differences wrap round to 255, so the middle histogram can peak at both edges.', ); const creditText = $derived(credit ? `${credit.title} · ${credit.author} · ${credit.licence}` : ''); diff --git a/web/src/lib/components/labs/JpegLab.svelte b/web/src/lib/components/labs/JpegLab.svelte index 3dc1e5d..eda62b1 100644 --- a/web/src/lib/components/labs/JpegLab.svelte +++ b/web/src/lib/components/labs/JpegLab.svelte @@ -34,7 +34,7 @@ type SubsamplingId, } from '../../jpeg/params'; import { GROUP_LABELS, IMAGES, decodeUpload, loadPicture, type PictureGroup, type RgbaImage } from '../../jpeg/images'; - import { CAPTION_MIN, blockAt, cellAt, jpegLayout, toDevicePanels, type Panel, type StageLayout } from '../../jpeg/layout'; + import { BLANK_PANELS, CAPTION_MIN, blockAt, cellAt, jpegLayout, toDevicePanels, type Panel, type StageLayout } from '../../jpeg/layout'; import { readSummary, readoutOf, type JpegSummary } from '../../jpeg/summary'; import { NOTICE, stageTex } from '../../jpeg/formulas'; import { readSegments, readTables, type HuffTableView, type SegmentView } from '../../jpeg/huffman'; @@ -44,6 +44,9 @@ const DEFAULT_SPEED = 64; const linked = parseParams(route.query); + // The first picture starts loading now, not when the engine is ready, so the fetch overlaps the WASM load. + const firstPicture = loadPicture(linked.img); + firstPicture.catch(() => {}); // the failure is handled where it is awaited (the gradient fallback) let imageId = $state(linked.img); let quality = $state(linked.quality); let subsampling = $state(linked.subsampling); @@ -94,7 +97,8 @@ let qualityFrame = 0; const stageInfo = $derived(STAGES.find((s) => s.id === stage)!); - const readout = $derived(summary ? readoutOf(summary) : null); + // Until the first picture is in, the summary is the engine's own stand-in gradient: show none of it. + const readout = $derived(image && summary ? readoutOf(summary) : null); /** Who made the picture in use, when it is a bundled one. */ const credit = $derived(IMAGES.find((im) => im.id === imageId)?.credit); const GROUPS = Object.keys(GROUP_LABELS) as PictureGroup[]; @@ -106,7 +110,8 @@ function currentQuery(): string { return buildParams({ - // A share link cannot carry an uploaded picture, so it names the gradient instead. + // A share link cannot carry an uploaded picture, so it names the generated gradient outright + // (written even though it is not the default: a link with no `img=` opens the spring). img: imageId === 'upload' ? 'gradient' : imageId, quality, subsampling, @@ -151,7 +156,7 @@ $effect(() => { void iteration; const a = api; - if (!a) return; + if (!a || !image) return; summary = readSummary(a.readSummary()); }); @@ -276,34 +281,38 @@ * current image in place and says why. `block` is the luma block to select * once it is in (a shared link names one); otherwise the encoder's default. * With no picture yet (the first load failed, say offline) the generated - * gradient stands in, so the lab still opens, with the error shown. + * gradient stands in, so the lab still opens, with the error shown; the + * address keeps naming the picture that was asked for, so reloading (or + * sharing the link on) tries it again. Resolves true for that fallback. */ - async function loadImage(load: () => Promise, id: ImageId | 'upload', block?: [number, number]) { + async function loadImage(load: () => Promise, id: ImageId | 'upload', block?: [number, number]): Promise { const ticket = ++loadTicket; let img: RgbaImage; let message: string | null = null; + let fellBack = false; try { img = await load(); } catch (err) { - if (ticket !== loadTicket) return; + if (ticket !== loadTicket) return false; message = err instanceof Error ? err.message : 'That picture could not be loaded.'; if (image) { loadError = message; // The select shows the option the reader chose; put it back on the picture in use. if (pictureSelect) pictureSelect.value = imageId; - return; + return false; } try { img = await loadPicture('gradient'); } catch { loadError = message; - return; + return false; } - if (ticket !== loadTicket) return; + if (ticket !== loadTicket) return false; id = 'gradient'; block = undefined; + fellBack = true; } - if (ticket !== loadTicket) return; + if (ticket !== loadTicket) return false; loadError = message; image = img; imageId = id; @@ -311,8 +320,9 @@ anchor = block ?? null; component = 0; first = true; - encode(); + encode(!fellBack); pushLayout(); + return fellBack; } const chooseImage = (id: ImageId, block?: [number, number]) => loadImage(() => loadPicture(id), id, block); @@ -509,9 +519,11 @@ } window.addEventListener('resize', pushLayout); window.addEventListener('keydown', onKey); - const start = imageId === 'upload' ? 'gradient' : imageId; - await loadImage(() => loadPicture(start), start, linked.block); - if (route.query !== currentQuery()) syncUrl(); + // Until the first picture is in, draw nothing (the viz would otherwise show its own stand-in gradient). + a.patchVizConfig({ panels: toDevicePanels(BLANK_PANELS, 1) }); + const fellBack = await loadImage(() => firstPicture, linked.img, linked.block); + // Rewrite junk in the link to what loaded, but not a link whose picture could not be had. + if (!fellBack && route.query !== currentQuery()) syncUrl(); } onDestroy(() => { @@ -608,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} @@ -800,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} @@ -901,6 +920,8 @@ 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; diff --git a/web/src/lib/jpeg/__tests__/images.test.ts b/web/src/lib/jpeg/__tests__/images.test.ts index 6338b7c..39a1fe9 100644 --- a/web/src/lib/jpeg/__tests__/images.test.ts +++ b/web/src/lib/jpeg/__tests__/images.test.ts @@ -141,6 +141,27 @@ describe('picture catalog', () => { 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']); diff --git a/web/src/lib/jpeg/__tests__/layout.test.ts b/web/src/lib/jpeg/__tests__/layout.test.ts index bf6dadd..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, @@ -188,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__/summary.test.ts b/web/src/lib/jpeg/__tests__/summary.test.ts index c148662..c2c33a4 100644 --- a/web/src/lib/jpeg/__tests__/summary.test.ts +++ b/web/src/lib/jpeg/__tests__/summary.test.ts @@ -127,6 +127,8 @@ describe('formulas', () => { 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/formulas.ts b/web/src/lib/jpeg/formulas.ts index 974e702..7a7e10c 100644 --- a/web/src/lib/jpeg/formulas.ts +++ b/web/src/lib/jpeg/formulas.ts @@ -126,7 +126,7 @@ export function stageTex(stage: StageId, s: JpegSummary | null, hover: [number, /** What to look for on each stage. */ export const NOTICE: Record = { overview: - '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.', + '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 8071871..f219362 100644 --- a/web/src/lib/jpeg/images.ts +++ b/web/src/lib/jpeg/images.ts @@ -132,7 +132,7 @@ export const IMAGES: readonly { group: 'maths', thumb: 'crop', }, - { id: 'noise', label: 'Noise', title: 'Random pixels: nothing to compress', 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' }, @@ -180,8 +180,24 @@ export async function loadPicture(id: ImageId): Promise { * 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 async function thumbnail(id: ImageId, side: number): Promise { - if (entry(id)?.file) return pictureUrl(id); +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; diff --git a/web/src/lib/jpeg/layout.ts b/web/src/lib/jpeg/layout.ts index 9c268cb..59bfa70 100644 --- a/web/src/lib/jpeg/layout.ts +++ b/web/src/lib/jpeg/layout.ts @@ -290,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[],