diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9048f31..b1d402a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -110,7 +110,9 @@ jobs:
- name: Setup Emscripten
uses: mymindstorm/setup-emsdk@ab889da2abbcbb280f91ec4c215d3bb4f3a8f775
with:
- version: 'latest'
+ # Pinned: the version that built the checked-in web/public/codec.wasm.
+ # 'latest' moved to 6.x and broke the build; bump deliberately.
+ version: '5.0.1'
- name: Build WASM
# We use 'emmake' to ensure the Makefile sees the Emscripten environment
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..0ac3aa1
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,77 @@
+# Codec Explorer
+
+An interactive lab for transform image compression (DCT vs Haar vs CDF 9/7 wavelets). A C++17 codec core is compiled to WebAssembly and driven by a Svelte 5 + TypeScript web app. Everything runs in the browser.
+
+## Layout
+
+- `core/` — the codec, plain C++17 with no dependencies. `inc/` headers, `src/` sources, `tests/` GoogleTest.
+ - `ImageCodec` — colour conversion, chroma subsampling, 8×8 DCT or whole-image wavelet, quantization, bit estimate.
+ - `wavelet`, `transform`, `entropy`, `colorspace`, `CodecAnalysis` (PSNR, SSIM, artifact maps).
+- `web/cpp/codec_web.cpp` — the WASM binding layer: one global session, C functions exported to JS.
+- `web/public/codec.{js,wasm}` — the **checked-in** WASM build. Edit C++, then rebuild it (below), or the app keeps running the old code.
+- `web/src/` — the app.
+ - `App.svelte` switches between three full-page modes: `ViewerMode`, `InspectorMode` (one 8×8 block through the pipeline), `ArtifactInspectorMode` (error, edge and blocking maps).
+ - `lib/state.svelte.ts` — shared reactive state (`appState`, a Svelte 5 `$state` class).
+ - `lib/wasm-bridge.ts` — typed wrappers over `Module._*` calls; `types/wasm.d.ts` declares them.
+ - `lib/inspection.ts`, `lib/grid-renderer.ts`, `lib/basis-popover.ts` — the Block Inspector's imperative DOM rendering (see "Inspector DOM" below).
+ - `lib/components/` — shared UI: `QualityControl`, `CodecSettings` (transform + chroma), `ResultSummary`, `ImageViewer`.
+ - `styles/` — global CSS bundled through `app.css`. Colours are tokens in `variables.css`; dark mode is `[data-theme="dark"]`.
+- `apps/native/` — an OpenCV desktop app for poking at the core (needs OpenCV).
+
+## Commands
+
+Web, from `web/`:
+
+```bash
+npm run dev # Vite dev server
+npm run check # svelte-check (types)
+npm run test # unit tests (Vitest + jsdom, fast)
+npm run test:browser # integration tests in Playwright Chromium
+npm run test:all # both
+```
+
+From the repo root:
+
+```bash
+make web # rebuild web/public/codec.{js,wasm} (needs Emscripten 5.0.1, as pinned in CI)
+make test # C++ GoogleTest suite (CMake; configures apps/native too, so needs OpenCV)
+make sanitize # C++ tests under ASan + UBSan (falls back to UBSan where ASan can't start, e.g. macOS 26)
+```
+
+Before calling a change done, run `npm run check` and `npm run test:all` (plus `make test` for C++ changes).
+
+## Contracts across the WASM boundary
+
+Nothing links C++ and TypeScript at compile time, so these pairs are kept in sync by hand and **guarded by tests**:
+
+| What | C++ side | TS side | Test |
+|---|---|---|---|
+| Exported functions | `WEB_EXPORTS` in `Makefile`, or `EMSCRIPTEN_KEEPALIVE` | `web/src/types/wasm.d.ts` | `tests/unit/wasm-exports.test.js` |
+| View mode numbers | `enum ViewMode` in `codec_web.cpp` | `ViewMode` in `lib/state.svelte.ts` | `tests/unit/wasm-contracts.test.js` |
+| Transform numbers | `map_transform_mode` in `codec_web.cpp` | `Transform` in `lib/transforms.ts` | same |
+| Block buffer layout | `BlockDebugData` in `core/inc/ImageCodec.h` | `BLOCK_GRID` in `lib/inspection.ts` | same |
+| Histogram bins | `CoefficientStats::NUM_BINS` | `COEFF_BINS` in `lib/wasm-bridge.ts` | same |
+
+Change both sides together. Buffers returned by `malloc` in C++ are freed by the TS caller (see each binding's comment).
+
+## Inspector DOM
+
+The Block Inspector renders its grids imperatively and finds elements by ID. Those lookups skip missing elements **silently**, so:
+
+- Refer to inspector element IDs only through `INSPECTOR_IDS` in `lib/inspector-dom.ts`, never as string literals.
+- To add one: give the element an `id` in `InspectorMode.svelte` and add it to `INSPECTOR_IDS`.
+- `tests/unit/inspector-dom.test.js` fails if an ID isn't rendered, or if lib code bypasses the list.
+
+## Conventions
+
+- Svelte 5 runes (`$state`, `$derived`, `$effect`, `$props`); mount with `mount()`. Use `untrack` when an effect calls code that reads state it shouldn't depend on.
+- Hover help: put explanations in plain `title` attributes (SVG: a `
` child). `lib/hover-hint.ts` shows them quickly and styled, app-wide. Add the `has-hint` class to a text label to give it the dotted underline.
+- Reuse the shared components (`QualityControl`, `CodecSettings`, `ResultSummary`) instead of copying controls into a mode.
+- Tests are `.js` files that import `.ts` sources with a `.js` extension; a small Vite plugin in both Vitest configs resolves them. The WASM `Module` global is mocked in `tests/setup/`.
+- `@sveltejs/vite-plugin-svelte` is pinned to `^4` because Vitest 2.1 needs Vite 5. Don't bump it alone.
+- Comments explain why, not what. Match the density of the surrounding code.
+- Commits: conventional style with a scope, e.g. `feat(inspector): …`, `fix(core): …`, `refactor: …`, `docs: …`. Work on a feature branch and open a PR to `main`; Vercel deploys `main`.
+
+## Related work
+
+Motion estimation lives on the paused `feature/motion-estimation` branch. Its bindings (`*_me_*`, `MotionEstimator.*`) are on `main` too; leave them alone unless working on that feature.
diff --git a/Makefile b/Makefile
index e074abb..68849f3 100644
--- a/Makefile
+++ b/Makefile
@@ -1,5 +1,7 @@
# --- CONFIGURATION ---
-WEB_COMPILER = emcc
+# em++ (not emcc): newer Emscripten releases only link the C++ standard library
+# when invoked as the C++ driver.
+WEB_COMPILER = em++
# C functions exported to JavaScript (keep in sync with web/src/types/wasm.d.ts)
WEB_EXPORTS = _init_session _process_image _preview_image _rd_encode _get_rd_bit_estimate \
_get_view_ptr _set_view_tint _set_artifact_gain \
diff --git a/core/inc/ImageCodec.h b/core/inc/ImageCodec.h
index 947e050..d343fc2 100644
--- a/core/inc/ImageCodec.h
+++ b/core/inc/ImageCodec.h
@@ -74,7 +74,7 @@ class ImageCodec {
* zeroFraction — fraction that quantize to zero at this codec's quality
*/
struct CoefficientStats {
- static constexpr int NUM_BINS = 12;
+ static constexpr int NUM_BINS = 12; // mirrored by COEFF_BINS in web/src/lib/wasm-bridge.ts
double histogram[NUM_BINS] = {};
double zeroFraction = 0.0;
};
@@ -107,6 +107,9 @@ class ImageCodec {
Image upsampleChannel(const Image& channel, int targetWidth, int targetHeight, ChromaSubsampling cs) const;
public:
+ // Returned to JS by inspect_block_data as one flat buffer; the field order
+ // is mirrored by BLOCK_GRID in web/src/lib/inspection.ts (checked by
+ // web/tests/unit/wasm-contracts.test.js).
struct BlockDebugData {
double original[8][8];
double coefficients[8][8]; // Transform coefficients (DCT or DWT)
diff --git a/web/cpp/codec_web.cpp b/web/cpp/codec_web.cpp
index 514a9d8..1135ce3 100644
--- a/web/cpp/codec_web.cpp
+++ b/web/cpp/codec_web.cpp
@@ -36,7 +36,8 @@ static MotionEstimator g_me;
static std::vector g_mvs;
static std::vector> g_search_steps;
-// Enum to match view modes in JavaScript.
+// View modes for get_view_ptr. Mirrors ViewMode in web/src/lib/state.svelte.ts;
+// web/tests/unit/wasm-contracts.test.js checks the two agree.
enum ViewMode {
RGB = 0,
Artifacts = 1,
@@ -57,6 +58,8 @@ static ImageCodec::ChromaSubsampling map_cs_mode(int mode) {
}
}
+// `transform_mode` arguments: mirrors Transform in web/src/lib/transforms.ts
+// (checked by web/tests/unit/wasm-contracts.test.js).
static ImageCodec::TransformType map_transform_mode(int mode) {
switch (mode) {
case 1: return ImageCodec::TransformType::DWT_HAAR;
diff --git a/web/src/ArtifactInspectorMode.svelte b/web/src/ArtifactInspectorMode.svelte
index 62a77c6..3740339 100644
--- a/web/src/ArtifactInspectorMode.svelte
+++ b/web/src/ArtifactInspectorMode.svelte
@@ -2,9 +2,11 @@
import { onMount, onDestroy } from 'svelte';
import { fade } from 'svelte/transition';
import { appState, ViewMode, openBlockInspector } from './lib/state.svelte.js';
- import { TRANSFORMS, isWavelet } from './lib/transforms.js';
+ import { isWavelet } from './lib/transforms.js';
import { processImage, previewImage, getViewPtr, getStats, free, setArtifactGain } from './lib/wasm-bridge.js';
import QualityControl from './lib/components/QualityControl.svelte';
+ import CodecSettings from './lib/components/CodecSettings.svelte';
+ import ResultSummary from './lib/components/ResultSummary.svelte';
import ImageViewer from './lib/components/ImageViewer.svelte';
let artifactCanvas: HTMLCanvasElement | undefined = $state();
@@ -34,12 +36,6 @@
{ val: ViewMode.BlockingMap, label: 'Blocking Map', desc: 'Detects discontinuities at 8×8 block edges, showing where the macroblock structure is visible.' },
];
- const CS_MODES = [
- { id: 'art_cs_444', val: 444, label: '4:4:4' },
- { id: 'art_cs_422', val: 422, label: '4:2:2' },
- { id: 'art_cs_420', val: 420, label: '4:2:0' },
- ];
-
// Wavelets have no 8×8 grid, so the blocking map should stay dark — say so.
const artifactModes = $derived(ARTIFACT_MODES.map(m =>
m.val === ViewMode.BlockingMap && isWavelet(appState.transformType)
@@ -50,10 +46,6 @@
ARTIFACT_MODES.find(m => m.val === appState.currentViewMode) ?? ARTIFACT_MODES[0]
);
- type MetricState = 'good' | 'moderate' | 'poor';
- function getPsnrState(v: number): MetricState { return v >= 40 ? 'good' : v >= 30 ? 'moderate' : 'poor'; }
- function getSsimState(v: number): MetricState { return v >= 0.95 ? 'good' : v >= 0.9 ? 'moderate' : 'poor'; }
-
// --- Viridis false-colour LUT ---
// Stops: [intensity 0-255, R, G, B]
// Black → purple → blue → teal → yellow-green → yellow → white
@@ -385,8 +377,15 @@
+
+
+
+
+