Skip to content

Review fixes - #2

Merged
lovelaced merged 44 commits into
masterfrom
review-fixes
Jul 21, 2026
Merged

Review fixes#2
lovelaced merged 44 commits into
masterfrom
review-fixes

Conversation

@lovelaced

Copy link
Copy Markdown
Owner

No description provided.

lovelaced and others added 30 commits July 20, 2026 17:35
- [workspace.dependencies] for image/serde/serde_json shared with pixfix
- [profile.release]: thin LTO, strip, codegen-units=1 for the shipped CLI
- shellexpand moved behind the tui feature (its only consumer)
- rand 0.8 -> 0.9 (rng()/random() renames; seeded determinism comes later)
- f32::total_cmp replaces partial_cmp().unwrap() in score/distance sorts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
NormalizeError (thiserror) now covers I/O, image load/encode, grid, palette,
Lospec, and config failures with paths attached where relevant, and every lib
module returns it instead of anyhow or Result<_, String>. anyhow remains only
in main.rs glue. Adds the documented CLI exit-code policy (2 usage, 3 input
I/O, 4 processing, 5 output exists, 6 partial batch) with exit_code(); main
adopts it in a later commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All pipeline defaults and validation now live in one place:
PipelineOptions (all-Option plain data) + overlay() precedence merge +
into_config(). ConfigFile and PipelineFlags lower into it; pixfix follows
later. Wires the previously parsed-but-ignored grid.phase_x/phase_y config
keys, moves palette-file/Lospec I/O ahead of image loading, and validates
grid size/phase (fixing the --grid-size 0 panic path). Precedence unit
tests cover CLI > file > defaults, including the max_grid_candidate
inversion regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… path

- PipelineFlags carry no clap defaults; hard defaults live only in
  PipelineOptions::into_config, so an explicit CLI value now always beats
  the config file (fixes the inverted max_grid_candidate precedence)
- DownscaleMode is a clap ValueEnum; --palette and --bg-color validate at
  parse time; palette sources form a mutually exclusive group that
  conflicts with --no-quantize
- requires=grid_size on --grid-phase and --no-grid-detect, and run_pipeline
  applies overrides even when detection is skipped (fixes the silent no-op)
- bool pairs (--remove-bg/--no-remove-bg, --flood-fill/--no-flood-fill,
  --overwrite/--no-overwrite) so config-file booleans can be turned off
- --no-config, and an explicit --config path that is missing now errors
  instead of silently using defaults; loaded config path is logged
- config assembly happens before image I/O in every command;
  build_pipeline_config is gone

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New pipeline::grid::Grid carries f32 pitch and canonical phase per axis;
PipelineState's grid_size/grid_phase collapse into grid: Option<Grid>. All
block iteration goes through Grid::edges_x/edges_y, which clamp and count
partial blocks, so pre-phase margins and trailing remainders are now
processed instead of skipped, and the width-minus-phase u32 underflow class
is gone. --grid-size accepts fractional values (e.g. 10.667). Detection
itself still scans integer candidates; the fractional search lands next.
grid_variance_scores becomes grid_scores: Vec<(f32, f32)> and diagnostics
gain grid_best_guess; TUI and pixfix updated for the new types.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Detection is rebuilt around two 1-D alpha-aware OKLAB gradient profiles
(one O(W*H) pass) scored per (pitch, phase) in O(len/pitch), making an
exhaustive fractional pitch search affordable — AI upscales routinely use
non-integer pitch (512px / 48 cells = 10.667) that integer-only detection
could never find, which is how the old code locked onto 2px upscaler
ringing. Scoring is energy concentration (captured fraction minus coverage
fraction) under dual window policies, so no comb can win vacuously by
coverage; harmonics lose on missed edges and sub-pitches on doubled
coverage, with a refined integer-multiple comparison as a safety net.
Phase is scanned exhaustively per axis (the 8/12px caps are gone), pitch
refines by shrinking-step local search with parabolic sub-stepping, near
integer estimates snap when the integer scores comparably (clean upscales
stay exact), and near-equal axes constrain to a square grid.

Confidence becomes sqrt(quality x separation) with the runner-up drawn
from outside the winner's harmonic family; below a 0.35 floor the pipeline
declines to snap and reports the best guess instead of inventing a grid —
noise and heavily AA'd images now warn instead of producing pitch-2
garbage. Transparent pixels contribute silhouette-edge evidence through
alpha but their undefined RGB never does. max_candidate now defaults to
scaling with image size.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
oklab_hyab (|dL| + chroma distance) lands for the palette matcher; the
triangle-inequality is_between test moves from aa_removal to oklab.rs so
background de-fringing can share it; ColorHistogram gains from_pixels,
sorted_entries (deterministic count-desc/RGBA-asc order for clustering
inputs), and opaque_rgb_counts (alpha-stripped, alpha-0 excluded).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lettes, hyAB matcher

kmeans_weighted replaces kmeans_oklab/subsample: clustering runs over the
color histogram's unique colors weighted by count (exact, no 10k random
subsample), so rare structural colors like 1px outlines can no longer be
dropped, and all randomness flows from a ChaCha8 seed — identical input
now produces identical bytes on every run and platform, which unlocks
golden tests. Empty clusters reseed at the farthest weighted point, the
k-means++ init keeps a running min-distance array (O(nk)), and pinned
centroids (for upcoming dither pairs) join assignment but never move.

Auto-extracted palettes default to weighted medoids — real image colors,
never invented averages (Mean stays available); palette snapping goes
through PaletteMatcher with hyAB distance (city-block lightness tracks
perception better at large deltas: dark blue now snaps to navy, not
black) memoized per unique RGB instead of a full conversion and palette
scan per pixel. palette extract emits sorted medoid colors. Quantization
records unique-colors before/after in diagnostics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Neighbors below 50% alpha no longer vote or become snap targets — their
RGB is blend residue or garbage, and letting them in painted dark halos
around sprites on transparent backgrounds. AA removal now iterates up to
--aa-passes passes (default 3, early-stopped) so the 2-3px ramps on 10x
AI upscales fully collapse instead of leaving residue. A dither guard
(on by default) skips any pixel whose exact color appears at least twice
among its opaque neighbors: checkerboard partners always have exact
diagonal twins, so ordered dithering survives multi-pass removal while
lone blend pixels still collapse. The neighborhood scan also returns
pixel indices so the between-test reuses precomputed OKLAB conversions,
OFFSETS is shared from image_util::neighbors, and pixels-changed/passes
land in diagnostics. Threshold docs now match the code: higher = more
aggressive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, block confidence

Block votes are no longer keyed on exact RGBA: colors bucket by exact RGB,
then agglomerate in OKLAB within about one JND, so the per-pixel chroma
noise of AI upscales pools its weight instead of letting five identical
fringe pixels outvote a hundred nearly-identical block pixels. The winner
paints its weighted medoid — always a real input color. Near-transparent
pixels never vote color (no more black fringes from RGBA(0,0,0,0)
winning), and each block binarizes to fully opaque or fully transparent
by weighted alpha vote; --keep-alpha preserves original per-pixel alpha.
All modes route through the same vote; CenterPixel samples the fractional
block center with a vote fallback. Per-block winner shares land in
diagnostics as BlockShareMask, the CLI reports contested blocks, and
background-masked pixels (wired next) are excluded from votes with the
mask re-blockified after normalization.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nging

Border-color detection clusters border pixels in OKLAB (two-pass,
mean-refined, four seed attempts) instead of exact-RGBA counting, so
gradient and vignette backgrounds — where every border row is a unique
value and the old histogram silently gave up — now detect with proper
coverage, and the fill tolerance stretches up to 2x for spread-out
clusters. Detection and mask construction moved BEFORE grid
normalization, at full resolution where the AA fringe is genuinely one
pixel wide: masked pixels are excluded from block votes, the mask is
re-blockified through normalization, and clearing happens afterwards —
the fringe can no longer be baked into solid halo blocks that flood fill
then refuses to remove. A defringe dilation additionally absorbs pixels
that interpolate between background and sprite. Quantization now runs
after background removal so background colors never eat palette slots.
Flood fill is shared from image_util::flood (sprite-sheet autosplit
adopts it next); detected color/coverage/pixels-removed land in
diagnostics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New pipeline::dither detects ordered dithering (checkerboard and row/
column alternation) between block-color pairs on the logical grid,
post-normalization: block colors group within an OKLAB tolerance, each
block checks its 4-neighborhood for alternation, and a pair qualifies
only past both an absolute floor (8 blocks — stray specks can't reach
it) and half the pair's total footprint (two adjacent solid regions
don't count). Detected pairs are pinned as fixed k-means centroids so
quantization cannot merge the partners and flatten the shading;
fixed-palette modes warn when a pair collapses onto one entry. On by
default per the dithering-looks-nicer policy, with --flatten-dither
disabling detection, pinning, and the AA dither guard in one switch.
Also lands --seed and --keep-alpha/--logical-size plumbing through
options. Pairs are reported in diagnostics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-size

Reduced-resolution modes no longer Nearest-stretch the logical image back
to the original dimensions — when the pitch doesn't divide evenly that
recreated uneven 4px/5px logical pixels, the exact artifact this tool
removes. The default output is now an exact pixel-repeat re-upscale by
the rounded pitch (48 cells at 10.667px -> 528px, crisp), --logical-size
emits the true logical resolution, and explicit --target-width/height
still win as a final Nearest resize. Snap mode is unchanged at original
resolution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tiles record their sheet-coordinate origin, and the sheet-detected grid
is rebased ((phase - origin) mod pitch) into each tile's local frame
instead of being copied verbatim — with margins or spacing that aren't a
multiple of the pitch, every tile previously snapped against a shifted
phase and produced mixels. Tiles no longer re-detect; they receive an
explicit per-tile override, and reassembly pads to the max processed
tile size since rebased phases can shift logical block counts by one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All CLI output now flows through a Reporter: --quiet actually silences
the report lines (they were raw eprintln! before), and --json emits
exactly one machine-readable document on stdout — grid pitch/phase/
confidence, logical size, palette, color counts, AA/background/dither
stats, contested-block counts, warnings, duration — with a stable
always-present-fields schema and NDJSON progress reserved for stderr.
main() maps typed errors to documented exit codes (2 usage/config, 3
input I/O, 4 processing, 5 output exists, 6 partial batch; clap usage
errors are already 2), and the new analyze subcommand runs grid and
background detection and color counting with zero writes so agents can
inspect before committing to a pipeline run. palette list/extract/fetch
gained JSON documents too; anyhow is gone from the binary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rmat

One derive_output_path replaces the copy-pasted naming logic, and the
derived extension always comes from the OUTPUT format (png by default) —
JPEG inputs used to run the whole pipeline, fail encoding RGBA8 to JPEG,
and leave a zero-byte output behind. Saves now encode to a temp file in
the destination directory and rename into place, so no failure mode
leaves partial files. `-` works as input (stdin) and output (stdout PNG,
rejected with --json which owns stdout), --output-format selects
png/webp/bmp, and a single check_writable enforces the no-clobber policy
everywhere — including sheet --output-dir sprites and palette
extract/fetch -o, which previously overwrote unconditionally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e-dirs

run_batch no longer constructs a progress bar inside the library: it
takes a progress sink (the CLI wires indicatif, hidden under --quiet and
--json; JSON mode gets NDJSON per-file events on stderr), so pixfix can
reuse the parallel implementation instead of its serial copy. All output
paths are planned up front with case-insensitive collision detection —
recursive globs that flatten a/x.png and b/x.png onto one output used to
race two rayon workers over the same file — and --preserve-dirs
recreates the input tree instead. --suffix (empty allowed) replaces the
hardcoded naming rule agents had to reverse-engineer, extensions match
case-insensitively (.PNG works), every file outcome is itemized in the
result and the JSON report, already-existing outputs are counted as
skipped rather than failed, and partial failure exits 6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A 10s global timeout keeps a stalled connection from hanging the CLI (or
a desktop-app worker); slugs are validated to lowercase/digits/hyphens
before they reach either the URL or the cache path — '../../x' could
previously escape the cache directory on both read and write; palette
fetch --refresh bypasses the cache; and the doc comment now names the
real platform cache location instead of the Linux-only path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…od fill

The fixed-grid split/assemble path and the auto-split detector are two
unrelated systems; they now live in their own submodules. The hand-rolled
border BFS in cell extraction is replaced by the shared
image_util::flood::flood_fill_from_border, the dead RowEnvelope
max_bottom_gap field (written with .min() despite its name, never read)
is gone, and the two library-level eprintln calls became tracing events
so the TUI and desktop app cannot get stderr-corrupted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The desktop app now lowers its ProcessConfig into the library's
PipelineOptions and resolves through the same single assembly path as
the CLI — the duplicated builder had already drifted (bad downscale
modes silently became Snap; now they error). Previews travel as raw IPC
bytes instead of JSON number arrays (about 4x smaller per slider drag),
auto-split uses the library's background detection instead of hardcoded
white, the hand-rolled palette parser (with its operator-precedence bug)
is gone in favor of the library's, the app state uses parking_lot (no
poisoning), a generation counter stops stale pipeline runs from
overwriting newer results while scrubbing, and pipeline work runs under
spawn_blocking so the UI stays responsive. Batch goes through the
library's parallel run_batch. Fractional grid pitches display properly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
assert_cmd tests covering the agent-facing contract: distinct exit codes
(2 usage/config, 3 input, 5 output-exists, 6 partial batch), the --json
document schema for process/analyze/batch, --quiet emitting nothing on
success, JPEG inputs deriving .png outputs with no partial files, stdin/
stdout roundtrips, seeded byte-identical determinism, batch collision
rejection with --preserve-dirs resolution and case-insensitive extension
pickup, fractional-pitch detection end to end, and noise declining to
snap while still reporting a best guess. Synthetic images are generated
in-test (tests/common), so no fixtures are checked in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The test job now gates on cargo fmt and clippy -D warnings (and runs the
test suite with the tui feature compiled). A new check-pixfix job runs
clippy on the desktop app for every PR — the root test job never built
it, so PRs could not catch pixfix breakage. The duplicated manual
release path is gone from ci.yml: tag pushes release automatically here,
manual re-releases from an existing run stay in release.yml. The built
frontend bundle pixfix/ui/app.js is no longer tracked (hooks and CI
rebuild it), and the tauri.conf build hooks use pixfix-relative paths so
tauri build works from the app directory instead of only the repo root.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…claims

Documents fractional grid detection and the low-confidence best-guess
behavior, the analyze subcommand, the scripting/agents section (--json
document contract, NDJSON progress, exit-code table, determinism via
--seed), dither preservation and --flatten-dither, alpha binarization
and --keep-alpha, output sizing (--logical-size, integer re-upscale
default), --output-format/stdin/stdout piping, batch --suffix and
--preserve-dirs with collision behavior, and config precedence with
--no-config. Fixes stale claims: the tui feature is opt-in (not
default), batch does not take --target-width/height, --aa-threshold now
lives under anti-aliasing with --aa-passes, and palette caching is
described accurately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mechanical rustfmt pass so the new CI fmt gate starts green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…paths

Verified by building the app bundle: Tauri v2 executes
beforeBuildCommand from the cargo workspace root, not the config
directory, so the repo-root-relative hook paths are the correct ones.
The CI simplification stands (no override or separate frontend step
needed); only the comment changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
--chroma-key <HEX> (repeatable) removes the given colors everywhere in
the image within --chroma-tolerance (default 0.05, OKLAB) — no border
detection, no flood fill, so interior regions a border fill could never
reach are keyed too. Keys build their own mask through the same pre-snap
flow as background removal (keyed pixels never vote in block snapping,
de-fringing absorbs the AA halo around keyed regions) and combine freely
with --remove-bg. Config files take background.chroma_keys = ["FF00FF"]
and chroma_tolerance; pixfix gets a Chroma Key setting in the background
section wired through the shared options path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A hand-built single page in the desktop app's Catppuccin Mocha +
monospace styling: the hero animates scattered, blurred pixels settling
onto the grid (the tool's whole job, as a visual), sections reveal
gently on scroll, and the nav underlines the active section. All motion
is eased and plays once; prefers-reduced-motion and no-JS both degrade
to fully static. Content covers the pipeline stages, quick-start
recipes, a condensed flag reference, the agent contract (JSON document,
exit codes), and the desktop app. llms.txt (index + key facts) and
llms-full.txt (the complete manual in plain text) ship alongside for
language models. A Pages workflow deploys docs/ on pushes to master.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A coverage job runs cargo-llvm-cov over the CLI crate and uploads lcov
to Codecov (non-gating; needs the CODECOV_TOKEN repo secret). The README
gains the codecov badge and a link to the docs site, documents chroma
keying with an example, points agents at llms.txt/llms-full.txt, and
adds an analyze example to the quick start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pixel map's right lobe was shifted a column and a stray outline ran
down the middle — the heart on the page about fixing wonky pixels was
itself wonky. Now left-right symmetric.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…erence

The product has been pixfix all along; only the plumbing said otherwise.
The root crate and binary are now pixfix (cargo install pixfix, pixfix
process ...), the Tauri crate becomes pixfix-desktop in desktop/ so the
names can coexist in one workspace, and every reference follows: CLI
command name, config file (.pixfix.toml), Lospec cache directory, CI
package/artifact/binary names, README, docs site, llms.txt manuals, and
the tauri.conf hook paths. Repo links point at lovelaced/pixfix, which
GitHub already redirects to. Verified end to end: full test suite,
clippy, fmt, and a fresh pixfix.app bundle from the renamed crate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lovelaced and others added 14 commits July 20, 2026 20:38
…artistic resolution

Detection finds the grid the generator physically painted on (often ~2px
at 1024), but the intended pixel-art resolution usually sits at a
multiple of it — a 512x512 logical narrowboat is no SNES scene. --coarsen
N multiplies the final pitch (auto-detected or --grid-size, fractional
included: 2.714 x 2 = 5.428) before snapping. Phase is preserved so
coarse blocks stay aligned with the fine grid, it does nothing when
detection declines, programmatic per-tile sprite-sheet grids are never
re-coarsened, and reported values show the multiplied pitch while
grid_best_guess diagnostics keep the raw detection. Documented with
exact semantics in the README, docs site, and llms-full.txt; config key
[grid] coarsen; unit + CLI tests cover multiplication, phase
preservation, override interaction, and rejection of 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… chroma keys

The detection confidence floor (default 0.35) is now settable from the
CLI, config file ([grid] min_confidence), and desktop app — heavily
anti-aliased images that score just under the default can be accepted
without hand-forcing a pitch, and 0 accepts anything. The desktop app
gains Coarsen and Min Confidence setting rows and its chroma key field
now takes a comma-separated list, closing the parity gap with the CLI.
Documented in README and llms-full.txt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When one axis's pitch sits at an integer multiple (2-4x) of the other's,
the coarser axis has usually locked onto a harmonic of the true pitch —
dense dither makes the multiple score within the prefer-larger rule's
tie factor on one axis but not the other, which is exactly what happened
on a real 16:9 Midjourney test image (x locked 5.41 while y found the
true 2.71). The coarser axis is now re-refined at the finer axis's pitch
and adopts it when competitive; genuinely anisotropic grids keep their
ratio because a sub-pitch scores poorly there (same energy, double
coverage). On the field-test image the best guess goes from 5.41x2.71 at
0% confidence to a square 2.712x2.712 at 20%. Unit-tested with a
pair-correlated synthetic that reproduces the lock mechanism.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When analyze accepts a grid it also snaps at 1x/2x/4x of the detected
pitch and publishes each factor contested-block rate: the detected pitch
is the render quantum, and a multiple whose rate stays near the 1x
baseline means the content also reads cleanly at that coarser artistic
resolution. Field calibration drove the thresholds — a genuinely
pixel-perfect image cliffs +60pp when over-coarsened (no suggestion),
while the Midjourney narrowboat rises gently (30/38/43%) and gets one.
suggested_coarsen picks the largest factor within 15pp of baseline and
under 50% absolute; the rates are all in the JSON so humans and agents
can apply their own taste between qualifying factors. Documented in
README and llms-full.txt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Writes a diagnostic PNG next to the output: the source image dimmed,
each block tinted red in proportion to how contested its color vote was.
A wrong pitch or phase tints the entire frame; tint confined to noisy
regions (stars, dither curtains, AA edges) means the grid is right and
the noise is content. On the field-test telescope the overlay lights
exactly the aurora, the starfield, and the truss edges while flat sky
stays dark — instant visual confirmation the reconciled 2.712 pitch is
correct. Documented in README and llms-full.txt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Package metadata (repository, homepage, readme, keywords, categories)
and an exclude list keep the published crate slim; the desktop crate is
publish = false. A publish-crate job runs cargo publish on version tags
after tests pass, and skips quietly when the CARGO_REGISTRY_TOKEN secret
is absent. The pixfix name is unclaimed on crates.io.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Synthetic golden tests cannot catch perceptual regressions on the inputs
this tool exists for. The corpus pins known detection behavior on two
genuine Midjourney renders: the dusk narrowboat must keep detecting its
2px render quantum inside its known confidence band, and the aurora
telescope must keep DECLINING (its noise is real) while cross-axis
reconciliation holds the best guess square at ~2.71px — the exact
regression that existed before the reconciliation fix. Assertions are on
detection results within tolerance, not output bytes, since parallel
float reduction can differ in the last bits across thread schedules.
Adds ~3.4MB of tracked images under tests/corpus/ (exempted from the
global image gitignore, excluded from the published crate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The library unconditionally pulled CLI-only machinery (clap, indicatif,
glob, tempfile, tracing-subscriber) and rayon, which blocked any
wasm32-unknown-unknown build. Split them out:

- New `cli` feature (in defaults) gates the cli, batch, and paths
  modules, the stdin/stdout/atomic-write halves of image_util::io, and
  the clap::ValueEnum derives (OutputFormat, DownscaleMode).
- New `parallel` feature (in defaults) gates rayon; src/parallel.rs
  provides serial drop-ins for the exact adapter surface used, so call
  sites are identical either way.
- rand loses default features: every RNG is caller-seeded ChaCha8, so
  os_rng/getrandom (which doesn't build on wasm32) was dead weight.
- The pixfix binary now declares required-features = ["cli"].

Default build is unchanged: same deps, same rayon paths, same behavior.
Verified: cargo build; cargo test --workspace --features tui (167 tests
green); cargo clippy --workspace --all-targets --features tui -D
warnings; cargo check -p pixfix --no-default-features (native and
--target wasm32-unknown-unknown) both pass; cli-without-parallel combo
also checks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New workspace member wasm/ (pixfix-wasm, cdylib) exposes one
wasm-bindgen function: normalize(bytes, colors, coarsen) — decode, run
the default snap pipeline (seeded, quantize optional, 2048px cap),
return the PNG plus grid stats as a plain JS object.

The docs page gets a "try it" section between quick start and the
reference: a drop zone, colors/coarsen controls, side-by-side
original/normalized panels (image-rendering: pixelated, checkerboard
backing), and a one-line stats readout. The module lazy-loads on first
approach to the drop zone (?autoload=1 forces eager init for smoke
tests); if it can't load, the demo body hides behind a note pointing at
the CLI. Everything is client-side — no image leaves the page.

The built pkg (878 KB wasm + JS glue) is committed under docs/demo/pkg
since Pages deploys docs/ statically; the workflow comment records the
rebuild command. wasm-opt runs with post-MVP feature flags spelled out
(the wasm-pack-cached binary predates bulk-memory-by-default) and -Os.

Verified: wasm-pack build; Chrome headless over http logs the init
success line and renders the section in-place; file:// (import blocked)
degrades to the fallback note with no uncaught rejections; Node smoke
test drives normalize() end to end on a corpus sprite (grid accepted at
2px, coarsen 2 doubles the pitch, corrupt input rejected cleanly).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An input GIF with more than one frame now takes an animated pipeline
(src/anim.rs, core lib, wasm-clean) instead of being flattened to its
first frame. Everything global is decided once and shared, because
per-frame decisions would shimmer: the grid is detected on the first
frame and applied to all (detection declining means no frame snaps),
the background color is resolved once on the first post-AA frame with
the mask rebuilt per frame, dither pairs come from the first post-snap
frame, and quantization runs on ONE histogram merged across all frames
with one matcher snapping every frame — so the union of output colors
fits the budget and nothing flickers. Per-frame stages (AA removal,
masking, snap, output sizing) mirror run_pipeline's order and mask
semantics exactly; quantize grows resolve_palette/warn_collapsed_
dither_pairs so both paths share the mode logic.

Frames re-encode with their original delays, looping forever. Output
is always an animated GIF: derived names get .gif, --output-format
gif is accepted (also for stills, new OutputFormat variant), anything
else exits 2. Stdin/stdout piping works; --debug-overlay renders from
the first frame. process/sheet and analyze reports gain an
always-present frames field (1 for stills); analyze inspects the
first frame and reports the count. Static GIFs keep the still path.

Verified: unit tests (shared palette/grid, delay+pixel roundtrip,
deterministic encode), CLI tests (frame count preserved, byte-identical
reruns, webp rejection, frames in --json), and a PIL-generated GIF
end to end — durations [80,120,80,200] survive both the file and
piped paths bit-exactly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A visitor landing directly on #demo (or any fragment) jumps past the
scroll-reveal choreography; sections must never wait invisible for an
IntersectionObserver that already scrolled by. With a fragment present
everything reveals instantly. Verified via DOM dump: all reveal marks
applied on anchored loads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
-h now shows the 16 flags that change results (grid size, coarsen,
palette, background, chroma key); detection-tuning flags move behind
--help via hide_short_help. The desktop app mirrors this: phase,
confidence floor, detection limits, bg thresholds, and exact output
size collapse into an Advanced section with a show/hide toggle that
reports how many hidden settings are tuned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xUQfmEhKfYNEHg639XXGs
The CSP had no connect-src, so the webview blocked fetch() to the ipc:
custom protocol and Tauri silently fell back to postMessage transport.
On macOS that fallback delivers raw ipc::Response bytes as a JSON
number array, which Blob stringifies into text instead of PNG data —
every preview showed the broken-image glyph.

Two-layer fix: allow connect-src ipc: http://ipc.localhost so the fast
binary path works, and normalize raw invoke results (ArrayBuffer or
number array) through ipcBytes() so a transport fallback can never
break previews again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xUQfmEhKfYNEHg639XXGs
@lovelaced
lovelaced merged commit 8324fa7 into master Jul 21, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant