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<MotionVector> g_mvs; static std::vector<std::pair<int,int>> 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 @@ <summary class="sidebar-section-title"><span>Controls</span></summary> <div class="sidebar-section-body"> <QualityControl idPrefix="artifactQuality" onPreview={previewQuality} /> + <CodecSettings idPrefix="art" /> + </div> + </details> + + <details class="sidebar-section visual-tools-section" open> + <summary class="sidebar-section-title"><span>Display</span></summary> + <div class="sidebar-section-body"> {#if appState.currentViewMode === ViewMode.Artifacts} - <div class="control-group" transition:fade={{duration: 150}}> + <div class="control-group gain-group" transition:fade={{duration: 150}}> <label class="gain-label"> Error Gain <span class="gain-value-display">{gainValue.toFixed(1)}×</span> @@ -399,43 +398,8 @@ <p class="gain-hint">Amplifies pixel error before clamping to 255. Higher = faint errors more visible.</p> </div> {/if} - - <div class="control-group"> - <div class="group-label">Chroma Subsampling</div> - <div class="toggle-group"> - {#each CS_MODES as cs} - <input type="radio" id={cs.id} name="art_chroma" - checked={appState.currentCsMode === cs.val} - onchange={() => appState.currentCsMode = cs.val}> - <label for={cs.id}>{cs.label}</label> - {/each} - </div> - </div> - - <div class="control-group"> - <div class="group-label">Transform</div> - <div class="toggle-group"> - {#each TRANSFORMS as t} - <input type="radio" id="art_transform_{t.slug}" name="art_transform_type" - checked={appState.transformType === t.value} - onchange={() => appState.transformType = t.value}> - <label for="art_transform_{t.slug}" title={t.fullName}>{t.label}</label> - {/each} - </div> - </div> - </div> - </details> - - <details class="sidebar-section visual-tools-section" open> - <summary class="sidebar-section-title"> - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> - <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/> - </svg> - <span>Visual Tools</span> - </summary> - <div class="sidebar-section-body"> <div class="tools-grid"> - <button class="tool-btn" class:active={isBlinking} aria-pressed={isBlinking} onclick={toggleBlink} title="Blink Test"> + <button class="tool-btn" class:active={isBlinking} aria-pressed={isBlinking} onclick={toggleBlink} title="Blink test: flip repeatedly between the original and the map, so the changes jump out"> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/> @@ -444,7 +408,7 @@ <span>Blink</span> </button> - <button class="tool-btn" class:active={showHeatmap} aria-pressed={showHeatmap} onclick={() => showHeatmap = !showHeatmap} title="Toggle Heat Map"> + <button class="tool-btn" class:active={showHeatmap} aria-pressed={showHeatmap} onclick={() => showHeatmap = !showHeatmap} title="Colour the map from dark (no change) to yellow and white (large change), instead of grayscale"> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/> @@ -452,7 +416,7 @@ <span>Heat Map</span> </button> - <button class="tool-btn" class:active={showBlockGrid} aria-pressed={showBlockGrid} onclick={() => showBlockGrid = !showBlockGrid} title="8×8 Block Grid"> + <button class="tool-btn" class:active={showBlockGrid} aria-pressed={showBlockGrid} onclick={() => showBlockGrid = !showBlockGrid} title="Overlay the 8×8 block grid the DCT works on, to line artifacts up with block edges"> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <rect x="3" y="3" width="7" height="7"/> @@ -489,34 +453,9 @@ </details> <details class="sidebar-section" open> - <summary class="sidebar-section-title"><span>Metrics</span></summary> + <summary class="sidebar-section-title"><span>Result</span></summary> <div class="sidebar-section-body"> - <div class="compact-metrics-grid"> - <div class="metric-header-row"> - <div class="metric-col-label">Channel</div> - <div class="metric-col-label">PSNR (dB)</div> - <div class="metric-col-label">SSIM</div> - </div> - {#each [ - { channel: 'Y', psnr: appState.psnr.y, ssim: appState.ssim.y }, - { channel: 'Cr', psnr: appState.psnr.cr, ssim: appState.ssim.cr }, - { channel: 'Cb', psnr: appState.psnr.cb, ssim: appState.ssim.cb }, - ] as row} - {@const ps = getPsnrState(row.psnr)} - {@const ss = getSsimState(row.ssim)} - <div class="metric-row"> - <div class="metric-channel-name">{row.channel}</div> - <div class="metric-cell psnr-cell" - class:stat-good={ps==='good'} class:stat-moderate={ps==='moderate'} class:stat-poor={ps==='poor'}> - {row.psnr.toFixed(2)} - </div> - <div class="metric-cell" - class:stat-good={ss==='good'} class:stat-moderate={ss==='moderate'} class:stat-poor={ss==='poor'}> - {row.ssim.toFixed(4)} - </div> - </div> - {/each} - </div> + <ResultSummary showSize={false} /> </div> </details> @@ -774,6 +713,9 @@ } /* ===== Error Gain slider ===== */ + .gain-group { + margin-bottom: 14px; + } .gain-label { display: flex; diff --git a/web/src/InspectorMode.svelte b/web/src/InspectorMode.svelte index 0523120..6322e9f 100644 --- a/web/src/InspectorMode.svelte +++ b/web/src/InspectorMode.svelte @@ -45,6 +45,15 @@ } }); + // Opened without a block (from the viewer): start on the top suggestion + // rather than an empty page. + $effect(() => { + const first = appState.suggestedBlocks[0]; + if (first && !untrack(() => appState.inspectedBlock)) { + appState.inspectedBlock = { x: first.x, y: first.y }; + } + }); + // Reactive: re-process when quality/CS/transform changes in inspector $effect(() => { const q = appState.quality; @@ -301,23 +310,20 @@ Block Inspector </h2> <div class="inspector-topbar-badges"> - <span class="inspector-badge badge-coords"> + <span class="inspector-badge badge-coords" title="Top-left pixel of the block, then its block column and row"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> <path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path> <circle cx="12" cy="10" r="3"></circle> </svg> <span id="blockCoords">{appState.inspectedBlock ? `${appState.inspectedBlock.x * 8}, ${appState.inspectedBlock.y * 8} (Block ${appState.inspectedBlock.x},${appState.inspectedBlock.y})` : '—'}</span> </span> - <span class="inspector-badge badge-qtable" id="qTableType"> - {appState.currentViewMode === ViewMode.Cr || appState.currentViewMode === ViewMode.Cb ? 'Chroma' : 'Luma'} - </span> </div> <div class="inspector-topbar-legend"> - <span class="legend-item"><span class="legend-dot dot-positive"></span>Positive</span> - <span class="legend-item"><span class="legend-dot dot-negative"></span>Negative</span> - <span class="legend-item"><span class="legend-dot dot-zero"></span>Near Zero</span> - <span class="legend-item"><span class="legend-dot dot-intensity"></span>Pixel Intensity</span> - <span class="legend-item"><span class="run-dot" style="margin-right: 4px; opacity: 1;"></span>Zero Run</span> + <span class="legend-item" title="Coefficient or error above zero"><span class="legend-dot dot-positive"></span>Positive</span> + <span class="legend-item" title="Coefficient or error below zero"><span class="legend-dot dot-negative"></span>Negative</span> + <span class="legend-item" title="Close to zero: what quantization turns into nothing"><span class="legend-dot dot-zero"></span>Near Zero</span> + <span class="legend-item" title="Grayscale cells show pixel brightness, 0 to 255"><span class="legend-dot dot-intensity"></span>Pixel Intensity</span> + <span class="legend-item" title="A run of zeros in zig-zag order, stored as one short code"><span class="run-dot" style="margin-right: 4px; opacity: 1;"></span>Zero Run</span> </div> </div> @@ -329,7 +335,7 @@ <!-- Thumbnail Navigator --> <details class="sidebar-section" open> - <summary class="sidebar-section-title"><span>Image Navigator</span></summary> + <summary class="sidebar-section-title"><span title="Click anywhere on the thumbnail to inspect that block">Image Navigator</span></summary> <div class="sidebar-section-body"> <div class="thumbnail-container" id="thumbnailContainer"> <canvas id="thumbnailCanvas" bind:this={thumbnailCanvas} @@ -342,7 +348,7 @@ <!-- Suggested Blocks --> <details class="sidebar-section" open> - <summary class="sidebar-section-title"><span>Suggested Blocks</span></summary> + <summary class="sidebar-section-title"><span title="Blocks picked because they show compression clearly: edges ring, textures lose detail, smooth areas show blocking">Suggested Blocks</span></summary> <div class="sidebar-section-body"> <div class="suggested-blocks-list" id="suggestedBlocksList"> {#if appState.suggestedBlocks.length === 0} @@ -353,7 +359,7 @@ {#each ['edge', 'texture', 'smooth'] as cat} {#if appState.suggestedBlocks.some(b => b.category === cat)} <div class="suggested-category-header"> - {cat === 'edge' ? '🔷 Edges' : cat === 'texture' ? '🔶 Textures' : '🟢 Smooth'} + {cat === 'edge' ? 'Edges' : cat === 'texture' ? 'Textures' : 'Smooth'} </div> {#each appState.suggestedBlocks.filter(b => b.category === cat) as block} {@const isActive = appState.inspectedBlock?.x === block.x && appState.inspectedBlock?.y === block.y} @@ -385,7 +391,7 @@ <QualityControl idPrefix="inspQuality" onPreview={(q) => { appState.quality = q; }} /> <div class="control-group"> - <div class="group-label">View Mode</div> + <div class="group-label"><span class="has-hint" title="Which channel to follow through the pipeline: Y is brightness; Cr and Cb are colour differences">Channel</span></div> <div class="toggle-group"> {#each inspViewModes as mode} <input type="radio" id={mode.id} name="insp_view_mode" @@ -397,7 +403,7 @@ </div> <div class="control-group"> - <div class="group-label">Chroma Subsampling</div> + <div class="group-label"><span class="has-hint" title="How much colour resolution is kept before the transform. Disabled for Y, which is never subsampled.">Chroma Subsampling</span></div> <div class="toggle-group"> {#each chromaModes as cs} <input type="radio" id={cs.id} name="insp_chroma_subsampling" @@ -409,14 +415,6 @@ </div> </div> - <div class="control-group"> - <div class="group-label">Transform</div> - <p class="control-hint"> - The block inspector follows the 8×8 DCT used by JPEG. Wavelet - transforms work on the whole image — switch to Haar or CDF 9/7 - in the viewer and open the Subbands view to explore them. - </p> - </div> </div> </details> @@ -447,29 +445,33 @@ <!-- Pipeline Content --> <div id="inspectorContent" style={appState.inspectedBlock ? '' : 'display: none'}> - <!-- Context Crop --> - <div class="context-crop-container"> - <div class="context-crop-label">Block Context</div> - <div class="context-crop-canvas-wrap"> + <!-- Which block, and what this page follows --> + <div class="block-header"> + <div class="context-crop-canvas-wrap" title="The block (red) and its neighbourhood in the image"> <canvas id="contextCropCanvas" bind:this={contextCropCanvas} width="64" height="64"></canvas> </div> - <div class="context-crop-caption" id="contextCropCaption"> - {appState.inspectedBlock - ? `Block (${appState.inspectedBlock.x}, ${appState.inspectedBlock.y}) — pixel (${appState.inspectedBlock.x * 8}, ${appState.inspectedBlock.y * 8})` - : 'Select a block to see its surrounding context'} + <div class="block-header-text"> + <div class="block-header-title" id="contextCropCaption"> + {#if appState.inspectedBlock} + Block ({appState.inspectedBlock.x}, {appState.inspectedBlock.y}) + <span>pixel ({appState.inspectedBlock.x * 8}, {appState.inspectedBlock.y * 8})</span> + {/if} + </div> + <p> + Follow these 64 pixels into frequencies, through the rounding that loses + information, and back. + <span class="kbd-only"><kbd>←</kbd><kbd>↑</kbd><kbd>↓</kbd><kbd>→</kbd> move to a neighbouring block.</span> + </p> </div> </div> <!-- Inspector Pipeline --> <div class="inspector-pipeline"> - <div class="pipeline-row-label"> - Encoding Pipeline - <span class="nav-hint"><kbd>←</kbd><kbd>→</kbd><kbd>↑</kbd><kbd>↓</kbd> navigate blocks</span> - </div> + <div class="pipeline-row-label">Encode <span class="row-sub">pixels → stored integers</span></div> <div class="pipeline-row"> <div class="pipeline-block"> - <div class="pipeline-block-header"><h3>Original</h3></div> + <div class="pipeline-block-header"><h3 class="has-hint" title="The block's pixel values (0 to 255) for the selected channel">Original</h3></div> <div id="gridOriginal" class="block-grid"></div> <div class="block-label">AxB Pixels</div> </div> @@ -478,26 +480,26 @@ <div class="arrow-line"></div><div class="arrow-label">{transformName}</div><div class="arrow-icon">→</div> </div> <div class="pipeline-block"> - <div class="pipeline-block-header"><h3>{transformGridLabel}</h3></div> + <div class="pipeline-block-header"><h3 class="has-hint" title="How much of each of the 64 cosine patterns the block contains: smooth patterns top-left, finest detail bottom-right. Most of the energy sits top-left.">{transformGridLabel}</h3></div> <div id="gridDCT" class="block-grid" data-target="gridOriginal"></div> <div class="block-label">Frequencies</div> </div> <div class="pipeline-arrow"> - <div class="arrow-tooltip"><strong>Quantization</strong> Divides frequencies by the quantization table to reduce data size.</div> + <div class="arrow-tooltip"><strong>Quantization</strong> Divides frequencies by the quantization table and rounds. This is the only lossy step.</div> <div class="arrow-line"></div><div class="arrow-label">÷ Quant</div><div class="arrow-icon">→</div> </div> <div class="pipeline-block"> - <div class="pipeline-block-header"><h3>Quantized</h3></div> + <div class="pipeline-block-header"><h3 class="has-hint" title="Each coefficient divided by its quant table entry and rounded. These integers are what gets stored; zeros cost almost nothing. With Edit coefficients on, click one to drop it.">Quantized</h3></div> <div id="gridQuantized" class="block-grid" data-target="gridOriginal"></div> - <div class="block-label">Integers</div> + <div class="block-label">Stored Data</div> </div> </div> - <div class="pipeline-row-label">Decoding Pipeline</div> + <div class="pipeline-row-label">Decode <span class="row-sub">stored integers → pixels</span></div> <div class="coeff-edit-bar" role="group" aria-label="Coefficient experiments"> <button class="tool-btn coeff-edit-toggle" class:active={coeffEdit.editing} aria-pressed={coeffEdit.editing} onclick={() => coeffEdit.editing = !coeffEdit.editing} - title="Click coefficients in the decoder grids to drop or restore them"> + title="Click coefficients in Quantized or Dequantized to drop or restore them"> ✎ Edit coefficients </button> <label class="coeff-keep"> @@ -518,16 +520,7 @@ </div> <div class="pipeline-row"> <div class="pipeline-block"> - <div class="pipeline-block-header"><h3>Quantized</h3></div> - <div id="gridQuantized2" class="block-grid" data-target="gridReconstructed"></div> - <div class="block-label">Stored Data</div> - </div> - <div class="pipeline-arrow"> - <div class="arrow-tooltip"><strong>Dequantization</strong> Multiplies stored integers by the quantization table.</div> - <div class="arrow-line"></div><div class="arrow-label">× Quant</div><div class="arrow-icon">→</div> - </div> - <div class="pipeline-block"> - <div class="pipeline-block-header"><h3>Dequantized</h3></div> + <div class="pipeline-block-header"><h3 class="has-hint" title="The stored integers multiplied back by the quant table: the original coefficients plus rounding error. The rounding can't be undone.">Dequantized</h3></div> <div id="gridDequantized" class="block-grid" data-target="gridReconstructed"></div> <div class="block-label">Approx Freqs</div> </div> @@ -537,7 +530,7 @@ </div> <div class="pipeline-block"> <div class="pipeline-block-header"> - <h3>Reconstructed</h3> + <h3 class="has-hint" title="The decoded block: what the viewer shows">Reconstructed</h3> <span id="reconstructionProgress" class="reconstruction-progress" style="display:none"></span> <button class="pipeline-play-btn tooltip-container" onclick={() => window.dispatchEvent(new CustomEvent('animate-reconstruction'))}> @@ -550,6 +543,23 @@ <div id="gridReconstructed" class="block-grid"></div> <div class="block-label">Final Image</div> </div> + <div class="pipeline-arrow"> + <div class="arrow-tooltip"><strong>Compare</strong> Subtract the reconstruction from the original to see what was lost.</div> + <div class="arrow-line"></div><div class="arrow-label">vs Original</div><div class="arrow-icon">→</div> + </div> + <div class="pipeline-block error-block"> + <div class="pipeline-block-header"><h3 class="has-hint" title="Original minus reconstructed, per pixel: the detail compression lost">Error Residual</h3></div> + <div class="error-visualizer"> + <div id="gridError" class="block-grid"></div> + <div class="error-legend-vertical"> + <div class="legend-scale"> + <span>+30</span> + <div class="scale-bar"></div> + <span>-30</span> + </div> + </div> + </div> + </div> </div> <!-- Reconstruction animation info banner (shown only during animation) --> @@ -568,54 +578,30 @@ </div> </div> - <div class="pipeline-row-label">Analysis & Statistics</div> - <div class="analysis-row"> - <div class="pipeline-block error-block"> - <div class="pipeline-block-header"><h3>Error Residual</h3></div> - <div class="error-visualizer"> - <div id="gridError" class="block-grid"></div> - <div class="error-legend-vertical"> - <div class="legend-scale"> - <span>+30</span> - <div class="scale-bar"></div> - <span>-30</span> - </div> - </div> - </div> - </div> - + <div class="pipeline-row-label">What it costs <span class="row-sub">and why</span></div> + <div class="analysis-row analysis-row-two"> <div class="analysis-card stats-card"> - <div class="analysis-header"><h3>Block Statistics</h3></div> + <div class="analysis-header"><h3>This Block</h3></div> <div class="stats-grid"> <div class="stat-box tooltip-container"> - <div class="stat-value" id="statMSE">—</div> - <div class="stat-label">MSE</div> - <div class="tooltip-content-small"><strong>Mean Squared Error</strong><br><code>(1/64) × Σ(orig − recon)²</code><br>Average squared pixel error across all 64 coefficients.</div> - </div> - <div class="stat-box tooltip-container"> - <div class="stat-value" id="statPeakError">—</div> - <div class="stat-label">Peak Error</div> - <div class="tooltip-content-small"><strong>Peak Absolute Error</strong><br><code>max |orig − recon|</code><br>Worst-case pixel difference in the block.</div> + <div class="stat-value" id="statEstBits">—</div> + <div class="stat-label">Bits · <span id="statBpp">—</span> per pixel</div> + <div class="tooltip-content-small"><strong>Estimated bits (JPEG-style)</strong><br>A simplified JPEG Huffman simulation for this block: zig-zag runs and magnitudes, with DC coded as the difference from the previous block. Per pixel is that divided by 64. The viewer's file size uses a whole-image entropy estimate instead, so the two don't add up exactly.</div> </div> <div class="stat-box tooltip-container"> <div class="stat-value" id="statZeros">—</div> - <div class="stat-label">Zeros (Count)</div> - <div class="tooltip-content-small"><strong>Zero Coefficients</strong><br><code>count(|quant[i]| < 0.5)</code><br>Number of quantized transform coefficients rounded to zero.</div> + <div class="stat-label">Zeros · <span id="statCompression">—</span></div> + <div class="tooltip-content-small"><strong>Zero coefficients</strong><br><code>count(|quant[i]| < 0.5)</code><br>Quantized coefficients rounded to zero. Zeros are nearly free to store, so more zeros means fewer bits.</div> </div> <div class="stat-box tooltip-container"> - <div class="stat-value" id="statCompression">—</div> - <div class="stat-label">Compression</div> - <div class="tooltip-content-small"><strong>Zero Coefficient Ratio</strong><br><code>(zeros / 64) × 100%</code><br>Percentage of quantized transform coefficients equal to zero.</div> - </div> - <div class="stat-box tooltip-container"> - <div class="stat-value" id="statEstBits">—</div> - <div class="stat-label">JPEG Bits (est.)</div> - <div class="tooltip-content-small"><strong>Estimated Bits (JPEG-style)</strong><br>A simplified JPEG Huffman simulation for this block: zig-zag runs and magnitudes, with DC coded as the difference from the previous block. The viewer's file size uses a whole-image entropy estimate instead, so the two don't add up exactly.</div> + <div class="stat-value" id="statMSE">—</div> + <div class="stat-label">MSE</div> + <div class="tooltip-content-small"><strong>Mean squared error</strong><br><code>(1/64) × Σ(orig − recon)²</code><br>Average squared pixel error across the block.</div> </div> <div class="stat-box tooltip-container"> - <div class="stat-value" id="statBpp">—</div> - <div class="stat-label">Bits / Pixel</div> - <div class="tooltip-content-small"><strong>Bits Per Pixel</strong><br><code>(Est. Bits) / 64</code><br>Average number of bits required to store each pixel in this block.</div> + <div class="stat-value" id="statPeakError">—</div> + <div class="stat-label">Peak Error</div> + <div class="tooltip-content-small"><strong>Peak absolute error</strong><br><code>max |orig − recon|</code><br>Worst-case pixel difference in the block.</div> </div> </div> <div id="lossMeterContainer" class="mt-auto"></div> @@ -623,8 +609,8 @@ <div class="pipeline-block qtable-block"> <div class="pipeline-block-header"> - <h3>Quant Table</h3> - <span class="inspector-badge badge-qtable" id="qTableType2"> + <h3 class="has-hint" title="The divisor for each frequency at the current quality. Bigger divisors (usually bottom-right) throw away fine detail the eye notices least.">Quant Table</h3> + <span class="inspector-badge badge-qtable" id="qTableType"> {appState.currentViewMode === ViewMode.Cr || appState.currentViewMode === ViewMode.Cb ? 'Chroma' : 'Luma'} </span> </div> @@ -666,7 +652,7 @@ <div class="zigzag-visualizer-row"> <div class="pipeline-block" style="flex: 0 0 auto; margin-right: 16px;"> - <div class="pipeline-block-header"><h3>Quantized</h3></div> + <div class="pipeline-block-header"><h3 class="has-hint" title="What the decoder reads back from the file">Quantized</h3></div> <div id="gridQuantizedAdvanced" class="block-grid" data-target="gridOriginal"></div> </div> <div style="flex: 1; min-width: 0;"> diff --git a/web/src/ViewerMode.svelte b/web/src/ViewerMode.svelte index 907f53a..69777cb 100644 --- a/web/src/ViewerMode.svelte +++ b/web/src/ViewerMode.svelte @@ -1,23 +1,26 @@ <script lang="ts"> - import { onDestroy, onMount, untrack } from 'svelte'; + import { onDestroy, onMount } from 'svelte'; import { appState, ViewMode, type ViewModeValue } from './lib/state.svelte.js'; import { processImage, previewImage, rdEncode, getViewPtr, getStats, free, setViewTint, getLastBitEstimate } from './lib/wasm-bridge.js'; - import { TRANSFORMS, Transform, isWavelet, nextTransform, transformInfo } from './lib/transforms.js'; + import { TRANSFORMS, isWavelet, nextTransform, transformInfo } from './lib/transforms.js'; import { parseUrlState, writeUrlState } from './lib/url-state.js'; import { handleFileSelect, loadImageFromUrl } from './lib/image-manager.js'; import { formatBytes } from './lib/format.js'; import ImageViewer from './lib/components/ImageViewer.svelte'; + import QualityControl from './lib/components/QualityControl.svelte'; + import CodecSettings from './lib/components/CodecSettings.svelte'; + import ResultSummary from './lib/components/ResultSummary.svelte'; import AppHeader from './lib/components/viewer/AppHeader.svelte'; - import RdChart, { type RdPoint } from './lib/components/viewer/RdChart.svelte'; - import CoeffHistogram from './lib/components/viewer/CoeffHistogram.svelte'; + import CompareCard from './lib/components/viewer/CompareCard.svelte'; + import type { RdPoint } from './lib/components/viewer/RdChart.svelte'; import ImageSourcePanel from './lib/components/viewer/ImageSourcePanel.svelte'; - import CodecControls from './lib/components/viewer/CodecControls.svelte'; - import MetricsPanel from './lib/components/viewer/MetricsPanel.svelte'; - import ToolsPanel from './lib/components/viewer/ToolsPanel.svelte'; + + /** Kodak image shown on first visit, so the app opens on a result rather than an empty frame. */ + const DEFAULT_TEST_IMAGE = 3; let originalCanvas: HTMLCanvasElement | undefined = $state(); let processedCanvas: HTMLCanvasElement | undefined = $state(); - let dropZoneVisible = $state(!appState.originalImageData); + let isDraggingFile = $state(false); let rdSeries = $state<Record<number, RdPoint[]>>({}); let rdLoading = $state(false); let rdError = $state(''); @@ -25,49 +28,42 @@ const RD_QUALITIES = [1, 5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]; const RD_REBUILD_DEFER_MS = 250; - type BlockCoord = { x: number; y: number } | null; - let originalCtx: CanvasRenderingContext2D | null = null; let processedCtx: CanvasRenderingContext2D | null = null; let processedImageData: ImageData | null = null; - let renderedOriginalImageRef: ImageData | null = null; - let lastOverlayBlock: BlockCoord = null; - let overlayRafId = 0; - let pendingOverlayBlock: BlockCoord = null; + let renderedOriginal: ImageData | null = null; // what originalCanvas currently shows let rdDeferTimer: ReturnType<typeof setTimeout> | null = null; let rdIdleHandle: number | null = null; + const VIEW_MODES: { val: ViewModeValue; label: string; title: string }[] = [ + { val: ViewMode.RGB, label: 'Image', title: 'The decoded image' }, + { val: ViewMode.Y, label: 'Y', title: 'Luma: brightness, kept at full resolution' }, + { val: ViewMode.Cr, label: 'Cr', title: 'Red-difference chroma' }, + { val: ViewMode.Cb, label: 'Cb', title: 'Blue-difference chroma' } + ]; + const SUBBAND_MODE = { val: ViewMode.Subbands, label: 'Subbands', title: 'Wavelet coefficient pyramid' }; + + // The subband pyramid only exists for wavelet transforms. + const wavelet = $derived(isWavelet(appState.transformType)); + const viewModes = $derived(wavelet ? [...VIEW_MODES, SUBBAND_MODE] : VIEW_MODES); + const isChroma = $derived(appState.currentViewMode === ViewMode.Cr || appState.currentViewMode === ViewMode.Cb); + + const processedCaption = $derived([ + appState.currentViewMode === ViewMode.RGB ? null + : `${viewModes.find(m => m.val === appState.currentViewMode)?.label} channel`, + transformInfo(appState.transformType).label, + `Q${appState.quality}` + ].filter(Boolean).join(' · ')); + + // The pyramid isn't pixel-aligned with the image, so a comparison split + // would just hide half of it: show it full-frame. + const viewType = $derived(appState.currentViewMode === ViewMode.Subbands ? 'processed' : 'comparison'); + // ===== Zoom ===== const ZOOM_LEVEL = 3; let isZoomMode = $state(false); let zoom = $state(1); let zoomOriginX = $state(50); // % of canvas let zoomOriginY = $state(50); - let viewerToggle = $state<'comparison' | 'original' | 'processed'>('comparison'); - - // Keep comparisonPercent in sync with toggle - $effect(() => { - if (viewerToggle === 'original') { - appState.comparisonPercent = 100; - } else if (viewerToggle === 'processed') { - appState.comparisonPercent = 0; - } else if (viewerToggle === 'comparison') { - // Restore center split by default when switching back to comparison - // or we could keep its last dragged value. User said "Set default view to comparison mode" - // so we set it to 50 when first entering or switching. - if (appState.comparisonPercent === 100 || appState.comparisonPercent === 0) { - appState.comparisonPercent = 50; - } - } - }); - - // Handle view toggle dragging vs clicking - // If user drags the slider, we should switch to 'comparison' mode automatically - $effect(() => { - const p = appState.comparisonPercent; - if (p > 0 && p < 100 && viewerToggle !== 'comparison') { - viewerToggle = 'comparison'; - } - }); function toggleZoomMode() { isZoomMode = !isZoomMode; @@ -79,59 +75,41 @@ } onMount(() => { - // Ensure we're in a view mode supported by the viewer UI - const viewerModes: ViewModeValue[] = [ViewMode.RGB, ViewMode.Artifacts, ViewMode.Y, ViewMode.Cr, ViewMode.Cb, ViewMode.Subbands]; + // Ensure we're in a view mode the viewer offers + const viewerModes: ViewModeValue[] = [ViewMode.RGB, ViewMode.Y, ViewMode.Cr, ViewMode.Cb, ViewMode.Subbands]; if (!viewerModes.includes(appState.currentViewMode)) { appState.currentViewMode = ViewMode.RGB; } if (appState.wasmReady && appState.originalImageData) { - // Ensure drop zone is hidden - dropZoneVisible = false; - - // Reset toggle when returning - viewerToggle = 'comparison'; - - // Render to the new canvas elements render(); - - // Update UI state updateFileSizeEstimate(); } }); onDestroy(() => { rdJobId++; - cancelOverlayDraw(); clearRdScheduling(); }); // ===== Render ===== - function isSameBlock(a: BlockCoord, b: BlockCoord): boolean { - return !!a && !!b && a.x === b.x && a.y === b.y; - } - function ensureCanvasResources(): boolean { if (!originalCanvas || !processedCanvas || !appState.originalImageData) return false; const width = appState.imgWidth; const height = appState.imgHeight; - const resized = processedCanvas.width !== width || processedCanvas.height !== height || - originalCanvas.width !== width || originalCanvas.height !== height; - - if (resized) { + if (processedCanvas.width !== width || processedCanvas.height !== height || + originalCanvas.width !== width || originalCanvas.height !== height) { processedCanvas.width = width; processedCanvas.height = height; originalCanvas.width = width; originalCanvas.height = height; - renderedOriginalImageRef = null; - lastOverlayBlock = null; + renderedOriginal = null; } - if (!originalCtx) originalCtx = originalCanvas.getContext('2d'); if (!processedCtx) processedCtx = processedCanvas.getContext('2d', { willReadFrequently: true }); - if (!originalCtx || !processedCtx) return false; + if (!processedCtx) return false; if (!processedImageData || processedImageData.width !== width || processedImageData.height !== height) { processedImageData = new ImageData(width, height); @@ -139,49 +117,6 @@ return true; } - function drawOriginalBaseImage(force = false): void { - if (!originalCtx || !appState.originalImageData) return; - if (!force && renderedOriginalImageRef === appState.originalImageData) return; - originalCtx.putImageData(appState.originalImageData, 0, 0); - renderedOriginalImageRef = appState.originalImageData; - lastOverlayBlock = null; - } - - function restoreBlock(block: BlockCoord): void { - if (!block || !originalCtx || !appState.originalImageData) return; - originalCtx.putImageData(appState.originalImageData, 0, 0, block.x * 8, block.y * 8, 8, 8); - } - - function applyOverlayBlock(block: BlockCoord): void { - if (!originalCtx || !appState.originalImageData) return; - - if (isSameBlock(block, lastOverlayBlock)) return; - if (lastOverlayBlock) restoreBlock(lastOverlayBlock); - if (block) { - restoreBlock(block); - originalCtx.strokeStyle = '#ff0000'; - originalCtx.lineWidth = 1; - originalCtx.strokeRect(block.x * 8, block.y * 8, 8, 8); - } - lastOverlayBlock = block ? { ...block } : null; - } - - function cancelOverlayDraw(): void { - if (overlayRafId) cancelAnimationFrame(overlayRafId); - overlayRafId = 0; - pendingOverlayBlock = null; - } - - function scheduleOverlayDraw(block: BlockCoord): void { - pendingOverlayBlock = block ? { ...block } : null; - if (overlayRafId) return; - overlayRafId = requestAnimationFrame(() => { - overlayRafId = 0; - if (!appState.isInspectMode) return; - applyOverlayBlock(pendingOverlayBlock); - }); - } - function render() { if (!appState.wasmReady || !appState.originalImageData || !processedCanvas) return; if (!ensureCanvasResources() || !processedCtx || !processedImageData) return; @@ -192,8 +127,7 @@ if (!outputPtr) throw new Error('WASM get_view_ptr returned null'); const targetBuffer = processedImageData.data; - const rgbaSize = targetBuffer.length; - const sourceView = new Uint8ClampedArray(Module.HEAPU8.buffer, outputPtr, rgbaSize); + const sourceView = new Uint8ClampedArray(Module.HEAPU8.buffer, outputPtr, targetBuffer.length); targetBuffer.set(sourceView); processedCtx.putImageData(processedImageData, 0, 0); @@ -201,8 +135,10 @@ appState.psnr = { y: stats.psnr.y, cr: stats.psnr.cr, cb: stats.psnr.cb }; appState.ssim = { y: stats.ssim.y, cr: stats.ssim.cr, cb: stats.ssim.cb }; - drawOriginalBaseImage(); - if (appState.isInspectMode) applyOverlayBlock(appState.highlightBlock); + if (renderedOriginal !== appState.originalImageData) { + originalCanvas?.getContext('2d')?.putImageData(appState.originalImageData, 0, 0); + renderedOriginal = appState.originalImageData; + } } catch (err) { console.error('Render error:', err); } finally { @@ -249,39 +185,43 @@ } }); - // ===== File loading ===== + // ===== Image loading ===== + + function onImageLoaded(testImage: number | null) { + appState.activeTestImage = testImage; + processImage(appState.quality, appState.currentCsMode); + appState.comparisonPercent = 50; + render(); + updateFileSizeEstimate(); + } function loadFile(file: File) { if (!file || !file.type.startsWith('image/')) return; if (!originalCanvas || !processedCanvas) return; - handleFileSelect(file, originalCanvas, processedCanvas, () => { - appState.activeTestImage = null; - processImage(appState.quality, appState.currentCsMode); - if (viewerToggle === 'comparison') appState.comparisonPercent = 50; - render(); - updateFileSizeEstimate(); - dropZoneVisible = false; - }); + handleFileSelect(file, originalCanvas, processedCanvas, () => onImageLoaded(null)); } function loadTestImage(index: number) { if (!appState.wasmReady || !originalCanvas || !processedCanvas) return; - loadImageFromUrl(`/test-images/${index}.png`, originalCanvas, processedCanvas, () => { - appState.activeTestImage = index; - processImage(appState.quality, appState.currentCsMode); - if (viewerToggle === 'comparison') appState.comparisonPercent = 50; - render(); - updateFileSizeEstimate(); - dropZoneVisible = false; - }); + loadImageFromUrl(`/test-images/${index}.png`, originalCanvas, processedCanvas, () => onImageLoaded(index)); } - // ===== Comparison slider ===== + // Drop an image file anywhere on the viewer. + function onViewerDragOver(e: DragEvent) { + if (!e.dataTransfer?.types.includes('Files')) return; + e.preventDefault(); + isDraggingFile = true; + } - function updateComparisonView(percent: number) { - appState.comparisonPercent = Math.max(0, Math.min(100, percent)); + function onViewerDrop(e: DragEvent) { + e.preventDefault(); + isDraggingFile = false; + const file = e.dataTransfer?.files[0]; + if (file) loadFile(file); } + // ===== Comparison slider ===== + let isDraggingComparison = false; // Track whether the pointer moved enough to count as a drag vs. a click let mouseDownClientX = 0; @@ -290,7 +230,7 @@ function onViewerMouseDown(e: MouseEvent) { mouseDownClientX = e.clientX; hasDraggedSinceDown = false; - if (appState.isInspectMode || isZoomMode) return; + if (isZoomMode || viewType !== 'comparison') return; isDraggingComparison = true; handleViewerInteraction(e.clientX); } @@ -299,28 +239,12 @@ if (Math.abs(e.clientX - mouseDownClientX) > 4) hasDraggedSinceDown = true; // In zoom mode, start comparison drag as soon as the threshold is met if (isZoomMode && !isDraggingComparison && hasDraggedSinceDown && - !appState.isInspectMode && e.buttons === 1) { + viewType === 'comparison' && e.buttons === 1) { isDraggingComparison = true; } if (isDraggingComparison) { handleViewerInteraction(e.clientX); } - if (appState.isInspectMode && appState.originalImageData && processedCanvas) { - const rect = processedCanvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - const imgX = Math.floor(x * (appState.imgWidth / rect.width)); - const imgY = Math.floor(y * (appState.imgHeight / rect.height)); - let nextBlock: BlockCoord = null; - if (imgX < 0 || imgX >= appState.imgWidth || imgY < 0 || imgY >= appState.imgHeight) { - nextBlock = null; - } else { - nextBlock = { x: Math.floor(imgX / 8), y: Math.floor(imgY / 8) }; - } - if (isSameBlock(nextBlock, appState.highlightBlock)) return; - appState.highlightBlock = nextBlock ? { ...nextBlock } : null; - scheduleOverlayDraw(nextBlock); - } } function onDocumentMouseUp() { @@ -336,39 +260,24 @@ const refEl = (zoom > 1 && viewportEl) ? viewportEl : processedCanvas; const rect = refEl.getBoundingClientRect(); const x = clientX - rect.left; - updateComparisonView((x / rect.width) * 100); + appState.comparisonPercent = Math.max(0, Math.min(100, (x / rect.width) * 100)); } function onViewerClick(e: MouseEvent) { - if (isZoomMode && !hasDraggedSinceDown) { - if (zoom > 1) { - resetZoom(); - } else if (originalCanvas) { - const rect = originalCanvas.getBoundingClientRect(); - zoomOriginX = ((e.clientX - rect.left) / rect.width) * 100; - zoomOriginY = ((e.clientY - rect.top) / rect.height) * 100; - zoom = ZOOM_LEVEL; - } - return; - } - if (appState.isInspectMode && appState.highlightBlock && appState.wasmReady) { - appState.inspectedBlock = { ...appState.highlightBlock }; - appState.appMode = 'inspector'; - appState.isInspectMode = true; - appState.transformType = Transform.DCT; + if (!isZoomMode || hasDraggedSinceDown) return; + if (zoom > 1) { + resetZoom(); + } else if (originalCanvas) { + const rect = originalCanvas.getBoundingClientRect(); + zoomOriginX = ((e.clientX - rect.left) / rect.width) * 100; + zoomOriginY = ((e.clientY - rect.top) / rect.height) * 100; + zoom = ZOOM_LEVEL; } } function enterInspectorMode() { appState.appMode = 'inspector'; appState.isInspectMode = true; - appState.transformType = Transform.DCT; - } - - function onViewerMouseLeave() { - if (!appState.highlightBlock) return; - appState.highlightBlock = null; - if (appState.isInspectMode) scheduleOverlayDraw(null); } // ===== File size estimate ===== @@ -376,11 +285,8 @@ function updateFileSizeEstimate() { if (!appState.wasmReady || !appState.originalImageData) return; - const w = appState.imgWidth; - const h = appState.imgHeight; - const totalPixels = w * h; - const originalBytes = totalPixels * 3; - + const originalBytes = appState.imgWidth * appState.imgHeight * 3; + // Use the actual bit estimate from the last encode const bits = getLastBitEstimate(); const estimatedBytes = Math.max(64, Math.round(bits / 8)); @@ -399,6 +305,8 @@ }; } + // ===== Rate–distortion curves ===== + async function rebuildRdCurve() { if (!appState.wasmReady || !appState.originalImageData) return; @@ -464,15 +372,17 @@ }, RD_REBUILD_DEFER_MS); } - // Reset RD curve when image changes (but don't auto-generate — user must click) + // The curves depend on the image and the chroma mode (not on quality or + // transform, which only move the marker): rebuild them in the background. $effect(() => { - const _ready = appState.wasmReady; - const _image = appState.originalImageData; + const _cs = appState.currentCsMode; + const ready = appState.wasmReady && !!appState.originalImageData; clearRdScheduling(); rdJobId++; rdSeries = {}; rdLoading = false; rdError = ''; + if (ready) scheduleRdCurveRebuild(); }); // ===== Keyboard shortcuts ===== @@ -524,37 +434,21 @@ // ===== Wavelet-only views ===== - // The subband pyramid doesn't exist for DCT; fall back to RGB when switching away. + // The subband pyramid doesn't exist for DCT; fall back to the image when switching away. $effect(() => { - if (!isWavelet(appState.transformType) && appState.currentViewMode === ViewMode.Subbands) { + if (!wavelet && appState.currentViewMode === ViewMode.Subbands) { appState.currentViewMode = ViewMode.RGB; } }); - // The pyramid isn't pixel-aligned with the image, so a comparison split - // would just hide half of it: show it full-frame, and restore the split after. - let previousToggle: typeof viewerToggle | null = null; - $effect(() => { - const showingSubbands = appState.currentViewMode === ViewMode.Subbands; - untrack(() => { - if (showingSubbands && previousToggle === null) { - previousToggle = viewerToggle; - viewerToggle = 'processed'; - } else if (!showingSubbands && previousToggle !== null) { - viewerToggle = previousToggle; - previousToggle = null; - } - }); - }); - // ===== Shareable URL state ===== - // Load the test image named in the URL (if any) once WASM is ready. - let pendingUrlImage = parseUrlState(location.hash).testImage ?? null; + // Load the test image named in the URL, or a default one, once WASM is ready. + let pendingTestImage: number | null = parseUrlState(location.hash).testImage ?? DEFAULT_TEST_IMAGE; $effect(() => { - if (pendingUrlImage === null || !appState.wasmReady || !originalCanvas || !processedCanvas) return; - if (!appState.originalImageData) loadTestImage(pendingUrlImage); - pendingUrlImage = null; + if (pendingTestImage === null || !appState.wasmReady || !originalCanvas || !processedCanvas) return; + if (!appState.originalImageData) loadTestImage(pendingTestImage); + pendingTestImage = null; }); // Mirror codec settings into the URL hash (debounced) so they can be shared. @@ -581,110 +475,143 @@ <div class="container"> <div class="main-column"> - <div class="card viewer-card"> + <div class="card viewer-card" class:drag-over={isDraggingFile} + role="region" aria-label="Image viewer. Drop an image file here to load it." + ondragover={onViewerDragOver} + ondragleave={() => isDraggingFile = false} + ondrop={onViewerDrop}> <div class="canvas-wrapper" id="viewer-container"> - {#if appState.originalImageData && !appState.isInspectMode} <div class="viewer-canvas-header"> - <div class="canvas-header-left"> - <div class="img-dim-info"> - <span class="dim-val">{appState.imgWidth}</span> - <span class="dim-sep">×</span> - <span class="dim-val">{appState.imgHeight}</span> - <span class="dim-unit">px</span> - </div> + <div class="view-mode-toggle" role="group" aria-label="What to show"> + {#each viewModes as mode} + <button class:active={appState.currentViewMode === mode.val} aria-pressed={appState.currentViewMode === mode.val} + title={mode.title} + disabled={!appState.originalImageData} + onclick={() => appState.currentViewMode = mode.val}> + {mode.label} + </button> + {/each} </div> - - <div class="canvas-header-center"> - <div class="view-mode-toggle"> - <button class:active={viewerToggle === 'comparison'} aria-pressed={viewerToggle === 'comparison'} onclick={() => viewerToggle = 'comparison'}> - Comparison - </button> - <button class:active={viewerToggle === 'original'} aria-pressed={viewerToggle === 'original'} onclick={() => viewerToggle = 'original'}> - Original - </button> - <button class:active={viewerToggle === 'processed'} aria-pressed={viewerToggle === 'processed'} onclick={() => viewerToggle = 'processed'}> - Processed - </button> - </div> - </div> - - <div class="canvas-header-right"> - <div class="viewer-zoom-controls"> - {#if zoom > 1} - <span class="zoom-level-chip">{zoom.toFixed(0)}×</span> - <button class="zoom-ctrl-btn zoom-out-btn" onclick={resetZoom} title="Reset zoom (Esc)"> - <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> - <circle cx="11" cy="11" r="8"/> - <line x1="21" y1="21" x2="16.65" y2="16.65"/> - <line x1="8" y1="11" x2="14" y2="11"/> - </svg> - Zoom out - </button> - {:else} - <button class="zoom-ctrl-btn" class:active={isZoomMode} aria-pressed={isZoomMode} - onclick={toggleZoomMode} - title={isZoomMode ? 'Exit zoom mode (Esc)' : 'Zoom mode — click image to zoom in'}> - <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> - <circle cx="11" cy="11" r="8"/> - <line x1="21" y1="21" x2="16.65" y2="16.65"/> - {#if !isZoomMode}<line x1="11" y1="8" x2="11" y2="14"/>{/if} - <line x1="8" y1="11" x2="14" y2="11"/> - </svg> - {isZoomMode ? 'Zoom ON' : 'Zoom'} - </button> - {/if} - </div> + {#if isChroma} + <button class="zoom-ctrl-btn" class:active={appState.tintEnabled} aria-pressed={appState.tintEnabled} + title="Colour the chroma channel. Off shows it in grayscale, where fine detail is easier to see." + onclick={() => appState.tintEnabled = !appState.tintEnabled}> + Tint + </button> + {/if} + + <div class="viewer-zoom-controls"> + {#if zoom > 1} + <button class="zoom-ctrl-btn zoom-out-btn" onclick={resetZoom} title="Reset zoom (Esc)"> + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> + <circle cx="11" cy="11" r="8"/> + <line x1="21" y1="21" x2="16.65" y2="16.65"/> + <line x1="8" y1="11" x2="14" y2="11"/> + </svg> + {zoom.toFixed(0)}× + </button> + {:else} + <button class="zoom-ctrl-btn" class:active={isZoomMode} aria-pressed={isZoomMode} + onclick={toggleZoomMode} + disabled={!appState.originalImageData} + title={isZoomMode ? 'Exit zoom mode (Esc)' : 'Zoom: click the image to zoom in'}> + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> + <circle cx="11" cy="11" r="8"/> + <line x1="21" y1="21" x2="16.65" y2="16.65"/> + {#if !isZoomMode}<line x1="11" y1="8" x2="11" y2="14"/>{/if} + <line x1="8" y1="11" x2="14" y2="11"/> + </svg> + Zoom + </button> + {/if} + <button class="zoom-ctrl-btn" onclick={downloadProcessed} + disabled={!appState.originalImageData} + title="Download the compressed image as PNG" aria-label="Download PNG"> + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> + <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path> + <polyline points="7 10 12 15 17 10"></polyline> + <line x1="12" y1="15" x2="12" y2="3"></line> + </svg> + </button> </div> </div> - {/if} - <ImageViewer id="viewer" style="width: 100%" bind:originalCanvas bind:processedCanvas + originalLabel="Original image" processedLabel="Compressed image, {transformInfo(appState.transformType).label} at quality {appState.quality}" - viewType={viewerToggle} + {viewType} bind:comparisonPercent={appState.comparisonPercent} bind:isZoomMode bind:zoom bind:zoomOriginX bind:zoomOriginY - isInspectMode={appState.isInspectMode} - bind:highlightBlock={appState.highlightBlock} - showInspectorBanner={true} - showQuickstart={!isWavelet(appState.transformType)} zoomHint="Click image to zoom in  ·  drag to use comparison slider  ·  Esc to exit" onViewerMouseDown={onViewerMouseDown} onViewerClick={onViewerClick} - onViewerMouseLeave={onViewerMouseLeave} - onEnterInspector={enterInspectorMode} /> + + {#if appState.originalImageData} + {#if viewType === 'comparison'} + <div class="split-labels" aria-hidden="true"> + <span>Original</span> + <span>{processedCaption}</span> + </div> + {:else} + <p class="viewer-note"> + Wavelet pyramid of the luma channel: the top-left thumbnail is the coarse + approximation; each ring of quadrants holds detail at one scale. Black was quantized to zero. + </p> + {/if} + {/if} + + <div class="go-deeper"> + {#if !wavelet} + <button class="go-deeper-btn" onclick={enterInspectorMode} disabled={!appState.originalImageData}> + <strong>Inspect a block</strong> + <span>Follow one 8×8 block through DCT, quantization and back</span> + </button> + {/if} + <button class="go-deeper-btn" onclick={() => appState.appMode = 'artifact_inspector'} disabled={!appState.originalImageData}> + <strong>Analyze artifacts</strong> + <span>Map where detail was lost: error, ringing, blocking</span> + </button> + </div> </div> </div> - <RdChart series={rdSeries} {rdLoading} {rdError} - sampleCount={RD_QUALITIES.length} - onGenerate={() => void rebuildRdCurve()} /> - - <CoeffHistogram /> + <CompareCard series={rdSeries} {rdLoading} {rdError} sampleCount={RD_QUALITIES.length} /> </div> - <div class="card controls controls-card"> - <!-- Drop Zone / Onboarding --> - <details class="viewer-section" open> - <summary class="viewer-section-title">Basic</summary> + + <aside class="card controls controls-card"> + <section class="viewer-section"> + <h2 class="viewer-section-title">Image</h2> <div class="viewer-section-body"> - <ImageSourcePanel bind:dropZoneVisible activeTestImageIndex={appState.activeTestImage} + <ImageSourcePanel activeTestImageIndex={appState.activeTestImage} onFile={loadFile} onTestImage={loadTestImage} /> - <CodecControls onQualityPreview={previewQuality} /> </div> - </details> + </section> - <MetricsPanel /> + <section class="viewer-section"> + <h2 class="viewer-section-title">Compression</h2> + <div class="viewer-section-body"> + <QualityControl idPrefix="quality" onPreview={previewQuality} /> + <CodecSettings idPrefix="v" /> + </div> + </section> - <ToolsPanel onEnterInspector={enterInspectorMode} onDownload={downloadProcessed} /> - </div> + <section class="viewer-section"> + <h2 class="viewer-section-title">Result</h2> + <div class="viewer-section-body"> + <ResultSummary /> + </div> + </section> + + <p class="kbd-hint"><kbd>[</kbd> <kbd>]</kbd> quality ±5 · <kbd>T</kbd> next transform</p> + </aside> </div> </div> diff --git a/web/src/app.css b/web/src/app.css index 166a6a2..17ed053 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -12,4 +12,3 @@ @import './styles/inspector-popover.css'; @import './styles/inspector-grid.css'; @import './styles/inspector-responsive.css'; -@import './styles/onboarding.css'; diff --git a/web/src/lib/basis-popover.ts b/web/src/lib/basis-popover.ts index fd1d837..bf71a2a 100644 --- a/web/src/lib/basis-popover.ts +++ b/web/src/lib/basis-popover.ts @@ -1,6 +1,7 @@ import { hideTooltip } from './tooltip.js'; import { computeBasisPattern, computeHaarBasisPattern, getFreqLabel, getHaarFreqLabel } from './dct-utils.js'; import { appState } from './state.svelte.js'; +import { INSPECTOR_IDS } from './inspector-dom.js'; interface CachedGridData { coeffData?: Float64Array; @@ -101,7 +102,7 @@ function positionPopover(popover: HTMLElement, e: Pick<MouseEvent, 'clientX' | ' export function showBasisPopover(e: Pick<MouseEvent, 'clientX' | 'clientY'>, row: number, col: number): void { hideTooltip(); - const popover = document.getElementById('basisPopover'); + const popover = document.getElementById(INSPECTOR_IDS.basisPopover); if (!popover || !cachedGridData.coeffData) return; const idx = row * 8 + col; @@ -114,14 +115,14 @@ export function showBasisPopover(e: Pick<MouseEvent, 'clientX' | 'clientY'>, row if (el) el.innerText = text; }; - setEl('basisCoord', `(${row}, ${col})`); - setEl('basisFreqLabel', getTransformFreqLabel(row, col)); - setEl('basisValue', coeffVal.toFixed(2)); - setEl('basisQuantized', Math.round(quantVal).toString()); - setEl('basisDivisor', Math.round(qtVal).toString()); + setEl(INSPECTOR_IDS.basisCoord, `(${row}, ${col})`); + setEl(INSPECTOR_IDS.basisFreqLabel, getTransformFreqLabel(row, col)); + setEl(INSPECTOR_IDS.basisValue, coeffVal.toFixed(2)); + setEl(INSPECTOR_IDS.basisQuantized, Math.round(quantVal).toString()); + setEl(INSPECTOR_IDS.basisDivisor, Math.round(qtVal).toString()); // Update the static label next to the coefficient value - const coeffLabelEl = document.getElementById('basisValueLabel'); + const coeffLabelEl = document.getElementById(INSPECTOR_IDS.basisValueLabel); if (coeffLabelEl) coeffLabelEl.innerText = appState.transformType === 1 ? 'DWT:' : 'DCT:'; const basisPattern = getBasisPattern(col, row); @@ -130,8 +131,8 @@ export function showBasisPopover(e: Pick<MouseEvent, 'clientX' | 'clientY'>, row contribution[i] = coeffVal * basisPattern[i]; } - drawPatternOnCanvas('basisCanvas', Array.from(basisPattern), 'diverging'); - drawPatternOnCanvas('contributionCanvas', Array.from(contribution), 'diverging'); + drawPatternOnCanvas(INSPECTOR_IDS.basisCanvas, Array.from(basisPattern), 'diverging'); + drawPatternOnCanvas(INSPECTOR_IDS.contributionCanvas, Array.from(contribution), 'diverging'); positionPopover(popover, e); popover.style.display = 'block'; @@ -140,7 +141,7 @@ export function showBasisPopover(e: Pick<MouseEvent, 'clientX' | 'clientY'>, row } export function hideBasisPopover(): void { - const popover = document.getElementById('basisPopover'); + const popover = document.getElementById(INSPECTOR_IDS.basisPopover); if (popover) { popover.classList.remove('visible'); setTimeout(() => { @@ -154,14 +155,14 @@ let animationId: number | null = null; let originalColors: string[] = []; let currentTargetGridId: string | null = null; -export function startBasisAnimation(row: number, col: number, targetGridId: string = 'gridOriginal'): void { +export function startBasisAnimation(row: number, col: number, targetGridId: string = INSPECTOR_IDS.gridOriginal): void { if (animationId !== null) { cancelAnimationFrame(animationId); animationId = null; restoreOriginalColors(); } - if (!targetGridId) targetGridId = 'gridOriginal'; + if (!targetGridId) targetGridId = INSPECTOR_IDS.gridOriginal; currentTargetGridId = targetGridId; const targetGrid = document.getElementById(targetGridId); diff --git a/web/src/lib/components/CodecSettings.svelte b/web/src/lib/components/CodecSettings.svelte new file mode 100644 index 0000000..e904ec6 --- /dev/null +++ b/web/src/lib/components/CodecSettings.svelte @@ -0,0 +1,44 @@ +<script lang="ts"> + import { appState } from '../state.svelte.js'; + import { TRANSFORMS } from '../transforms.js'; + + // Transform and chroma subsampling: the settings every mode shares + // besides quality (see QualityControl). + + interface Props { + /** Prefix for input ids/names, so several instances never collide. */ + idPrefix: string; + } + + let { idPrefix }: Props = $props(); + + const CS_MODES = [ + { val: 444, label: '4:4:4', title: 'Full colour resolution' }, + { val: 422, label: '4:2:2', title: 'Colour at half horizontal resolution' }, + { val: 420, label: '4:2:0', title: 'Colour at quarter resolution (JPEG default)' } + ]; +</script> + +<div class="control-group"> + <div class="group-label"><span class="has-hint" title="How the image is turned into frequencies before quantization. DCT works on 8×8 blocks (JPEG); Haar and CDF 9/7 are wavelets over the whole image (JPEG 2000 uses CDF 9/7).">Transform</span></div> + <div class="toggle-group"> + {#each TRANSFORMS as t} + <input type="radio" id="{idPrefix}_t_{t.slug}" name="{idPrefix}_transform" + checked={appState.transformType === t.value} + onchange={() => appState.transformType = t.value}> + <label for="{idPrefix}_t_{t.slug}" title={t.fullName}>{t.label}</label> + {/each} + </div> +</div> + +<div class="control-group"> + <div class="group-label"><span class="has-hint" title="How much colour resolution is kept. The eye is far less sensitive to colour than to brightness, so JPEG usually stores colour at a lower resolution.">Chroma subsampling</span></div> + <div class="toggle-group"> + {#each CS_MODES as cs} + <input type="radio" id="{idPrefix}_cs_{cs.val}" name="{idPrefix}_chroma" + checked={appState.currentCsMode === cs.val} + onchange={() => appState.currentCsMode = cs.val}> + <label for="{idPrefix}_cs_{cs.val}" title={cs.title}>{cs.label}</label> + {/each} + </div> +</div> diff --git a/web/src/lib/components/ImageViewer.svelte b/web/src/lib/components/ImageViewer.svelte index 89160c8..b0c51d5 100644 --- a/web/src/lib/components/ImageViewer.svelte +++ b/web/src/lib/components/ImageViewer.svelte @@ -12,25 +12,18 @@ zoom = $bindable(1), zoomOriginX = $bindable(50), zoomOriginY = $bindable(50), - isInspectMode = false, - highlightBlock = $bindable(null), - showInspectorBanner = false, - showQuickstart = false, showBlockGrid = false, zoomHint = null, onViewerClick = null, onViewerMouseDown = null, onViewerMouseLeave = null, onViewerMouseMove = null, - onEnterInspector = null, /** 8×8 block to outline (e.g. the block a click would open), or null. */ blockOutline = null as { x: number; y: number } | null, originalLabel = 'Original image', processedLabel = 'Compressed image' } = $props(); - type BlockCoord = { x: number; y: number } | null; - function handleMouseDown(e: MouseEvent) { if (onViewerMouseDown) onViewerMouseDown(e); } @@ -47,11 +40,12 @@ if (onViewerMouseMove) onViewerMouseMove(e); } + // Arrow keys move the comparison split (Shift for bigger steps). function handleKeyDown(e: KeyboardEvent) { - if (isInspectMode && (e.key === 'Enter' || e.key === ' ')) { - e.preventDefault(); - if (onViewerClick) onViewerClick(e as unknown as MouseEvent); - } + if (viewType !== 'comparison' || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return; + e.preventDefault(); + const step = (e.shiftKey ? 10 : 2) * (e.key === 'ArrowLeft' ? -1 : 1); + comparisonPercent = Math.max(0, Math.min(100, comparisonPercent + step)); } // comparisonPercent is in viewport-space %, but clip-path is applied inside the scaled @@ -66,18 +60,16 @@ style:--img-w={appState.imgWidth} style:--img-h={appState.imgHeight}> <div class="comparison-viewer" - style={`transform: scale(${zoom}); transform-origin: ${zoomOriginX}% ${zoomOriginY}%; cursor: ${isZoomMode && zoom === 1 ? 'zoom-in' : isInspectMode ? 'crosshair' : blockOutline ? 'pointer' : viewType === 'comparison' ? 'col-resize' : 'default'}`} + style={`transform: scale(${zoom}); transform-origin: ${zoomOriginX}% ${zoomOriginY}%; cursor: ${isZoomMode && zoom === 1 ? 'zoom-in' : blockOutline ? 'pointer' : viewType === 'comparison' ? 'col-resize' : 'default'}`} role="button" tabindex="0" - aria-label={isInspectMode - ? 'Inspector viewer. Press Enter to inspect highlighted block.' - : viewType === 'original' ? originalLabel + aria-label={viewType === 'original' ? originalLabel : viewType === 'processed' ? processedLabel - : `${originalLabel} compared with ${processedLabel}. Drag to adjust split.`} + : `${originalLabel} compared with ${processedLabel}. Drag or use arrow keys to move the split.`} + onkeydown={handleKeyDown} onmousedown={handleMouseDown} onclick={handleClick} onmousemove={handleMouseMove} - onkeydown={handleKeyDown} onmouseleave={handleMouseLeave}> <div class="canvas-container"> @@ -114,15 +106,8 @@ <!-- Overlays outside comparison-viewer so they don't scale with zoom transform --> - {#if appState.originalImageData && isInspectMode && showInspectorBanner} - <div class="inspector-active-banner"> - <span class="banner-dot"></span> - <strong>Inspector mode ON</strong> - <span>Click any 8×8 block to open the full pipeline.</span> - </div> - {/if} - {#if appState.originalImageData && !isInspectMode && viewType === 'comparison'} + {#if appState.originalImageData && viewType === 'comparison'} <div class="comparison-divider" style={`left: ${comparisonPercent}%`} aria-hidden="true"> <div class="comparison-handle"> <span></span> @@ -149,15 +134,6 @@ {/if} -{#if showQuickstart && appState.originalImageData} -<div class="inspector-quickstart"> - <div class="quickstart-copy"> - <strong>Block Inspector</strong> - <span>Click any 8×8 region to inspect DCT coefficients and quantization.</span> - </div> - <button class="inspector-quickstart-btn" onclick={onEnterInspector}>Enter Block Inspector</button> -</div> -{/if} <style> .zoom-viewport { diff --git a/web/src/lib/components/QualityControl.svelte b/web/src/lib/components/QualityControl.svelte index b8fe6e5..09217fe 100644 --- a/web/src/lib/components/QualityControl.svelte +++ b/web/src/lib/components/QualityControl.svelte @@ -57,7 +57,7 @@ <div class="control-group"> <label for="{idPrefix}Slider"> - Quality <span id="{idPrefix}Value">{sliderValue}</span> + <span class="has-hint" title="Scales the quantization table. Lower quality means bigger divisors, so more coefficients round to zero: a smaller file and more visible loss.">Quality</span> <span id="{idPrefix}Value">{sliderValue}</span> </label> <div class="preset-group"> {#each PRESETS as preset} diff --git a/web/src/lib/components/ResultSummary.svelte b/web/src/lib/components/ResultSummary.svelte new file mode 100644 index 0000000..8b5bd6b --- /dev/null +++ b/web/src/lib/components/ResultSummary.svelte @@ -0,0 +1,143 @@ +<script lang="ts"> + import { appState } from '../state.svelte.js'; + + // What the current settings cost and buy: estimated size, then luma + // quality. Per-channel numbers stay one click away. + + interface Props { + /** Show the estimated file size (only the viewer keeps it up to date). */ + showSize?: boolean; + } + + let { showSize = true }: Props = $props(); + + type MetricState = 'good' | 'moderate' | 'poor'; + const psnrState = (v: number): MetricState => v >= 40 ? 'good' : v >= 30 ? 'moderate' : 'poor'; + const ssimState = (v: number): MetricState => v >= 0.95 ? 'good' : v >= 0.9 ? 'moderate' : 'poor'; + + const channels = $derived([ + { channel: 'Y', psnr: appState.psnr.y, ssim: appState.ssim.y }, + { channel: 'Cr', psnr: appState.psnr.cr, ssim: appState.ssim.cr }, + { channel: 'Cb', psnr: appState.psnr.cb, ssim: appState.ssim.cb } + ]); +</script> + +{#if !appState.originalImageData} +<div class="analysis-placeholder">Load an image to see what compression costs.</div> +{:else} +<div class="result-summary"> + {#if showSize && appState.fileSizeInfo} + {@const size = appState.fileSizeInfo} + <div class="result-size"> + <span class="result-size-value has-hint" title="Estimated from the entropy of the quantized coefficients. A real encoder's file would be close, but not identical.">~{size.estimated}</span> + <span class="result-size-note">{size.ratio}× smaller than {size.original} raw</span> + </div> + {/if} + <div class="result-quality"> + <div class="result-stat" title="Peak signal-to-noise ratio of the luma (brightness) channel. Above 40 dB is near-transparent; below 30 dB is visibly degraded."> + <span class="result-stat-label">PSNR</span> + <span class="result-stat-value stat-{psnrState(appState.psnr.y)}">{appState.psnr.y.toFixed(1)} <small>dB</small></span> + </div> + <div class="result-stat" title="Structural similarity of the luma channel, from 0 to 1. Above 0.95 looks near-identical."> + <span class="result-stat-label">SSIM</span> + <span class="result-stat-value stat-{ssimState(appState.ssim.y)}">{appState.ssim.y.toFixed(3)}</span> + </div> + </div> + <details class="result-details"> + <summary title="PSNR and SSIM for brightness (Y) and both colour-difference channels (Cr, Cb)">Per channel</summary> + <div class="compact-metrics-grid"> + <div class="metric-header-row"> + <div class="metric-col-label">Channel</div> + <div class="metric-col-label">PSNR (dB)</div> + <div class="metric-col-label">SSIM</div> + </div> + {#each channels as metric} + <div class="metric-row"> + <div class="metric-channel-name">{metric.channel}</div> + <div class="metric-cell psnr-cell stat-{psnrState(metric.psnr)}">{metric.psnr.toFixed(2)}</div> + <div class="metric-cell ssim-cell stat-{ssimState(metric.ssim)}">{metric.ssim.toFixed(4)}</div> + </div> + {/each} + </div> + </details> +</div> +{/if} + +<style> + .result-summary { + display: flex; + flex-direction: column; + gap: 12px; + } + + .result-size { + display: flex; + flex-direction: column; + gap: 2px; + } + + .result-size-value { + font-size: 1.6rem; + font-weight: 700; + line-height: 1.1; + color: var(--text); + font-variant-numeric: tabular-nums; + } + + .result-size-note { + font-size: 0.75rem; + color: var(--text-muted); + } + + .result-quality { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + } + + .result-stat { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 10px; + border-radius: 8px; + background: var(--bg-secondary); + border: 1px solid var(--border-subtle, var(--border)); + cursor: help; + } + + .result-stat-label { + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.06em; + color: var(--text-muted); + } + + .result-stat-value { + font-size: 1rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + } + + .result-stat-value small { + font-size: 0.7rem; + font-weight: 600; + opacity: 0.7; + } + + .result-details summary { + cursor: pointer; + font-size: 0.75rem; + font-weight: 600; + color: var(--text-muted); + user-select: none; + } + + .result-details summary:hover { + color: var(--text); + } + + .result-details[open] summary { + margin-bottom: 8px; + } +</style> diff --git a/web/src/lib/components/viewer/AppHeader.svelte b/web/src/lib/components/viewer/AppHeader.svelte index d27b0a5..4fae33b 100644 --- a/web/src/lib/components/viewer/AppHeader.svelte +++ b/web/src/lib/components/viewer/AppHeader.svelte @@ -17,19 +17,19 @@ </script> <header> - <h1>WASM Codec Explorer</h1> + <h1>Codec Explorer</h1> <div class="header-badges"> - <div class="privacy-badge"> - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" - stroke-linecap="round" stroke-linejoin="round"> + <span class="privacy-note" title="Everything runs in your browser via WebAssembly; images never leave your device."> + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" + stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect> <path d="M7 11V7a5 5 0 0 1 10 0v4"></path> </svg> - <span>Processed locally on your device</span> - </div> - <a href="https://github.com/thundermage117/codec" class="github-link" target="_blank" rel="noopener noreferrer"> - <img src="/assets/GitHub_Invertocat_Black.svg" alt="GitHub Logo" width="16" height="16"> - <span>View Source on GitHub</span> + Runs locally + </span> + <a href="https://github.com/thundermage117/codec" class="github-link" target="_blank" rel="noopener noreferrer" + aria-label="Source on GitHub" title="Source on GitHub"> + <img src="/assets/GitHub_Invertocat_Black.svg" alt="" width="16" height="16"> </a> <button class="theme-toggle" id="themeToggle" aria-label="Toggle dark mode" onclick={toggleTheme}> <svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" diff --git a/web/src/lib/components/viewer/CodecControls.svelte b/web/src/lib/components/viewer/CodecControls.svelte deleted file mode 100644 index 65850b3..0000000 --- a/web/src/lib/components/viewer/CodecControls.svelte +++ /dev/null @@ -1,98 +0,0 @@ -<script lang="ts"> - import { appState, ViewMode } from '../../state.svelte.js'; - import { TRANSFORMS, isWavelet } from '../../transforms.js'; - import QualityControl from '../QualityControl.svelte'; - - const VIEW_MODES = [ - { id: 'view_rgb', val: ViewMode.RGB, label: 'RGB' }, - { id: 'view_artifacts', val: ViewMode.Artifacts, label: 'Error' }, - { id: 'view_y', val: ViewMode.Y, label: 'Y' }, - { id: 'view_cr', val: ViewMode.Cr, label: 'Cr' }, - { id: 'view_cb', val: ViewMode.Cb, label: 'Cb' } - ]; - const SUBBAND_MODE = { id: 'view_subbands', val: ViewMode.Subbands, label: 'Subbands' }; - - // The subband pyramid only exists for wavelet transforms. - const viewModes = $derived(isWavelet(appState.transformType) ? [...VIEW_MODES, SUBBAND_MODE] : VIEW_MODES); - - interface Props { - /** Live preview while the quality slider is dragged. */ - onQualityPreview?: (quality: number) => void; - } - - let { onQualityPreview }: Props = $props(); -</script> - -<QualityControl idPrefix="quality" onPreview={onQualityPreview} /> - -<div class="control-group"> - <div class="group-label">View Mode</div> - <div class="toggle-group"> - {#each viewModes as mode} - <input type="radio" id={mode.id} name="view_mode" - checked={appState.currentViewMode === mode.val} - onchange={() => appState.currentViewMode = mode.val}> - <label for={mode.id}>{mode.label}</label> - {/each} - </div> - {#if appState.currentViewMode === ViewMode.Subbands} - <p class="control-hint"> - Wavelet pyramid of the luma channel: the top-left thumbnail is the LL - approximation; each ring of quadrants holds horizontal, vertical and - diagonal detail at one scale. Black areas were quantized to zero. - </p> - {/if} - {#if appState.currentViewMode === ViewMode.Cr || appState.currentViewMode === ViewMode.Cb} - <div style="display: flex; align-items: center; gap: 6px;"> - <div class="toggle-group tint-toggle-group" style="flex: 1;"> - <input type="radio" id="tint_color" name="tint_mode" - checked={appState.tintEnabled} - onchange={() => appState.tintEnabled = true}> - <label for="tint_color">Color</label> - <input type="radio" id="tint_gray" name="tint_mode" - checked={!appState.tintEnabled} - onchange={() => appState.tintEnabled = false}> - <label for="tint_gray">Grayscale</label> - </div> - <div class="tooltip-container"> - <span class="info-icon"> - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> - <circle cx="12" cy="12" r="10"></circle> - <line x1="12" y1="16" x2="12" y2="12"></line> - <line x1="12" y1="8" x2="12.01" y2="8"></line> - </svg> - </span> - <div class="tooltip-content"> - <strong>Why remove the tint?</strong><br> - The human eye is biologically wired to detect changes in brightness (Luminance) far - better than changes in color (Chrominance). Viewing these channels in grayscale - helps you see fine details and artifacts that get lost in the color tint. - </div> - </div> - </div> - {/if} -</div> - -<div class="control-group"> - <div class="group-label">Chroma Subsampling</div> - <div class="toggle-group"> - {#each [{ id: 'cs_444', val: 444, label: '4:4:4' }, { id: 'cs_422', val: 422, label: '4:2:2' }, { id: 'cs_420', val: 420, label: '4:2:0' }] as cs} - <input type="radio" id={cs.id} name="chroma_subsampling" - checked={appState.currentCsMode === cs.val} - onchange={() => appState.currentCsMode = cs.val}> - <label for={cs.id}>{cs.label}</label> - {/each} - </div> -</div> - -<div class="control-group"> - <div class="group-label">Transform</div> - <div class="toggle-group"> - {#each TRANSFORMS as t} - <input type="radio" id="v_transform_{t.slug}" name="v_transform_type" - checked={appState.transformType === t.value} - onchange={() => appState.transformType = t.value}> - <label for="v_transform_{t.slug}" title={t.fullName}>{t.label}</label> - {/each} - </div> -</div> diff --git a/web/src/lib/components/viewer/CoeffHistogram.svelte b/web/src/lib/components/viewer/CoeffHistogram.svelte index 15549f0..b74a3b4 100644 --- a/web/src/lib/components/viewer/CoeffHistogram.svelte +++ b/web/src/lib/components/viewer/CoeffHistogram.svelte @@ -1,7 +1,8 @@ <script lang="ts"> + import { untrack } from 'svelte'; import { appState } from '../../state.svelte.js'; import { getCoeffStats, COEFF_BINS, type CoeffStats } from '../../wasm-bridge.js'; - import { TRANSFORMS, transformInfo } from '../../transforms.js'; + import { TRANSFORMS } from '../../transforms.js'; // Bin b holds |c| in [2^(b-1), 2^b); bin 0 is |c| < 1; the last bin is open-ended. function binLabel(b: number): string { @@ -14,7 +15,6 @@ let statsQuality = $state(0); let loading = $state(false); let jobId = 0; - let wanted = false; // user has asked for the chart at least once for this image function compute(): void { if (!appState.wasmReady || !appState.originalImageData) return; @@ -33,116 +33,96 @@ }, 0); } - function generate(): void { - wanted = true; - compute(); - } - - // Reset when the image changes. + // Mounted only while its tab is open, so compute straight away and + // start over whenever the image changes. $effect(() => { const _ready = appState.wasmReady; const _image = appState.originalImageData; - jobId++; - wanted = false; - stats = null; - loading = false; + untrack(() => { + jobId++; + stats = null; + compute(); + }); }); - // Once shown, follow the quality slider (the histogram is fixed per image; - // only the zeroed fractions move). + // Follow the quality slider (the histogram is fixed per image; only the + // zeroed fractions move). $effect(() => { const _q = appState.quality; - if (!wanted) return; + if (untrack(() => statsQuality) === 0) return; const timer = setTimeout(compute, 120); return () => clearTimeout(timer); }); </script> -<div class="card hist-card"> - <section class="rd-chart-panel" aria-live="polite"> - <div class="rd-chart-header"> - <div> - <h3>Coefficient Sparsity</h3> - <p>Where each transform puts the luma channel's detail energy. Coefficients that quantize to zero are nearly free to code.</p> - </div> - {#if loading} - <span class="rd-chart-status">Computing…</span> - {/if} - {#if appState.originalImageData && !loading && !stats} - <button class="tool-btn rd-generate-btn" onclick={generate} style="margin-left: auto;">Generate</button> - {/if} - </div> - - {#if !appState.originalImageData} - <div class="rd-chart-empty">Load an image to compare coefficient sparsity.</div> - {:else if loading} - <div class="rd-chart-empty">Transforming the image with DCT, Haar and CDF 9/7…</div> - {:else if !stats} - <div class="rd-chart-empty">Click Generate to compare how sparse each transform makes the image.</div> - {:else} - {@const series = TRANSFORMS.map(t => ({ info: t, data: stats![t.value] }))} - {@const svgW = 700} - {@const svgH = 240} - {@const padL = 52} - {@const padR = 16} - {@const padT = 30} - {@const padB = 48} - {@const chartW = svgW - padL - padR} - {@const chartH = svgH - padT - padB} - {@const maxY = Math.max(...series.flatMap(s => s.data.histogram)) * 1.1 || 1} - {@const groupW = chartW / COEFF_BINS} - {@const barW = (groupW - 6) / series.length} - <div class="rd-chart-svg-wrap"> - <svg viewBox={`0 0 ${svgW} ${svgH}`} role="img" - aria-label="Histogram of AC coefficient magnitudes for DCT, Haar and CDF 9/7"> - {#each [0.25, 0.5, 0.75, 1.0] as frac} - {@const gy = padT + chartH * (1 - frac)} - <line x1={padL} y1={gy} x2={svgW - padR} y2={gy} class="rd-grid-line"/> - <text x={padL - 5} y={gy + 4} class="rd-axis-text rd-axis-text-end">{(maxY * frac * 100).toFixed(0)}%</text> - {/each} - <line x1={padL} y1={padT} x2={padL} y2={padT + chartH} class="rd-axis-line"/> - <line x1={padL} y1={padT + chartH} x2={svgW - padR} y2={padT + chartH} class="rd-axis-line"/> - - {#each series as s, k} - {#each s.data.histogram as val, b} - {@const barH = Math.max(0, (val / maxY) * chartH)} - <rect x={padL + b * groupW + 3 + k * barW} y={padT + chartH - barH} - width={Math.max(1, barW - 1)} height={barH} style="fill: {s.info.color}" opacity="0.85"> - <title>{s.info.label}: {(val * 100).toFixed(1)}% of coefficients with |c| in bin {binLabel(b)} - - {/each} - {/each} +
+ {#if !appState.originalImageData} +
Load an image to compare coefficient sparsity.
+ {:else if loading} +
Transforming the image with DCT, Haar and CDF 9/7…
+ {:else if stats} + {@const series = TRANSFORMS.map(t => ({ info: t, data: stats![t.value] }))} + {@const svgW = 700} + {@const svgH = 240} + {@const padL = 52} + {@const padR = 16} + {@const padT = 30} + {@const padB = 48} + {@const chartW = svgW - padL - padR} + {@const chartH = svgH - padT - padB} + {@const maxY = Math.max(...series.flatMap(s => s.data.histogram)) * 1.1 || 1} + {@const groupW = chartW / COEFF_BINS} + {@const barW = (groupW - 6) / series.length} +
+ + {#each [0.25, 0.5, 0.75, 1.0] as frac} + {@const gy = padT + chartH * (1 - frac)} + + {(maxY * frac * 100).toFixed(0)}% + {/each} + + - {#each { length: COEFF_BINS } as _, b} - {binLabel(b)} - {/each} + {#each series as s, k} + {#each s.data.histogram as val, b} + {@const barH = Math.max(0, (val / maxY) * chartH)} + + {s.info.label}: {(val * 100).toFixed(1)}% of coefficients with |c| in bin {binLabel(b)} + + {/each} + {/each} - |AC coefficient| (doubling-width bins) - Share + {#each { length: COEFF_BINS } as _, b} + {binLabel(b)} + {/each} - {#each series as s, k} - - {s.info.label} - {/each} - -
+ Size of each coefficient, ignoring sign. AC means every coefficient except the DC / LL average. Each bin is twice as wide as the one before.|AC coefficient| (doubling-width bins) + Fraction of the transform's coefficients that fall in each binShare -
{#each series as s, k} - {#if k > 0}
{/if} -
- {s.info.label} zeroed at Q{statsQuality} - {(s.data.zeroFraction * 100).toFixed(1)}% -
+ + {s.info.label} {/each} + +
+ +
+ {#each series as s, k} + {#if k > 0}
{/if} +
+ {s.info.label} zeroed at Q{statsQuality} + {(s.data.zeroFraction * 100).toFixed(1)}%
-

- Current transform: {transformInfo(appState.transformType).label}. - DC / LL coefficients are excluded. The zeroed share follows the quality slider. -

- {/if} -
- + {/each} + +

+ DC / LL coefficients are excluded. The zeroed share follows the quality slider. +

+ {/if} + diff --git a/web/src/lib/components/viewer/ImageSourcePanel.svelte b/web/src/lib/components/viewer/ImageSourcePanel.svelte index bbfc5b9..fc9a4e1 100644 --- a/web/src/lib/components/viewer/ImageSourcePanel.svelte +++ b/web/src/lib/components/viewer/ImageSourcePanel.svelte @@ -2,13 +2,12 @@ import { appState } from '../../state.svelte.js'; interface Props { - dropZoneVisible: boolean; activeTestImageIndex: number | null; onFile: (file: File) => void; onTestImage: (index: number) => void; } - let { dropZoneVisible = $bindable(), activeTestImageIndex, onFile, onTestImage }: Props = $props(); + let { activeTestImageIndex, onFile, onTestImage }: Props = $props(); const TEST_IMAGE_COUNT = 20; // Kodak dataset let testImagesOpen = $state(false); @@ -19,65 +18,31 @@ if (file) onFile(file); } - function onDrop(e: DragEvent) { - e.preventDefault(); - dropZoneVisible = false; - const file = e.dataTransfer?.files[0]; - if (file) onFile(file); - } - function openFilePicker() { fileInput?.click(); } - - function onDropZoneKeyDown(e: KeyboardEvent) { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - openFilePicker(); - } - } - + {#if !appState.wasmReady}
{appState.status}
{/if} -{#if dropZoneVisible} -
{ e.preventDefault(); (e.currentTarget as HTMLElement).classList.add('drag-over'); }} - ondragleave={(e) => (e.currentTarget as HTMLElement).classList.remove('drag-over')} - ondrop={onDrop}> - - - - - -
Drop an image here
-
or browse to upload
-
-{:else} - -{/if}
@@ -93,7 +58,7 @@ {/each}
- + Sample Images {#if activeTestImageIndex !== null} diff --git a/web/src/lib/components/viewer/MetricsPanel.svelte b/web/src/lib/components/viewer/MetricsPanel.svelte deleted file mode 100644 index 3aa3dca..0000000 --- a/web/src/lib/components/viewer/MetricsPanel.svelte +++ /dev/null @@ -1,74 +0,0 @@ - - -
- Analysis -
- {#if appState.originalImageData} -
-
-
Channel
-
PSNR (dB)
-
SSIM
-
- {#each [ - { channel: 'Y', psnr: appState.psnr.y, ssim: appState.ssim.y }, - { channel: 'Cr', psnr: appState.psnr.cr, ssim: appState.ssim.cr }, - { channel: 'Cb', psnr: appState.psnr.cb, ssim: appState.ssim.cb } - ] as metric} - {@const psnrState = getPsnrState(metric.psnr)} - {@const ssimState = getSsimState(metric.ssim)} -
-
{metric.channel}
-
- {metric.psnr.toFixed(2)} -
-
- {metric.ssim.toFixed(4)} -
-
- {/each} -
- - {:else} -
Load an image to see quality metrics and compression analysis.
- {/if} - - {#if appState.fileSizeInfo} -
-
- File Size Comparison - -{appState.fileSizeInfo.reduction}% -
-
-
- {appState.fileSizeInfo.original} - → - ~{appState.fileSizeInfo.estimated} -
-
-
-
-
- -
- {/if} -
-
diff --git a/web/src/lib/components/viewer/RdChart.svelte b/web/src/lib/components/viewer/RdChart.svelte index ddc4808..4b8e685 100644 --- a/web/src/lib/components/viewer/RdChart.svelte +++ b/web/src/lib/components/viewer/RdChart.svelte @@ -1,5 +1,3 @@ - - -
-
-
-
-

Rate-Distortion Curve

-

Quality tradeoff: higher bitrate usually improves PSNR.

-
- {#if rdLoading} - Sampling… - {:else if allPoints.length > 0} - {allPoints.length} points - {/if} - {#if appState.originalImageData && !rdLoading} - - {/if} -
- - {#if !appState.originalImageData} -
Load an image to generate an RD plot.
- {:else if rdError} -
{rdError}
- {:else if rdLoading && allPoints.length === 0} -
Sampling {sampleCount} quality levels × {TRANSFORMS.length} transforms…
- {:else if allPoints.length === 0} -
Click Generate to compute the rate-distortion curve.
- {:else if plotted.some(s => s.points.length >= 2)} - {@const svgW = 700} - {@const svgH = 280} - {@const padL = 64} - {@const padR = 20} - {@const padT = 18} - {@const padB = 52} - {@const xMinRaw = Math.min(...allPoints.map((p) => p.bitrate))} - {@const xMaxRaw = Math.max(...allPoints.map((p) => p.bitrate))} - {@const yMinRaw = Math.min(...allPoints.map((p) => p.psnr))} - {@const yMaxRaw = Math.max(...allPoints.map((p) => p.psnr))} - {@const xPad = Math.max(0.05, (xMaxRaw - xMinRaw) * 0.08)} - {@const yPad = Math.max(0.5, (yMaxRaw - yMinRaw) * 0.12)} - {@const xMin = xMinRaw - xPad} - {@const xMax = xMaxRaw + xPad} - {@const yMin = yMinRaw - yPad} - {@const yMax = yMaxRaw + yPad} - {@const chartW = svgW - padL - padR} - {@const chartH = svgH - padT - padB} - {@const mapX = (v: number) => padL + ((v - xMin) / (xMax - xMin || 1)) * chartW} - {@const mapY = (v: number) => svgH - padB - ((v - yMin) / (yMax - yMin || 1)) * chartH} - {@const xTicks = Array.from({length: 5}, (_, i) => xMinRaw + i * (xMaxRaw - xMinRaw) / 4)} - {@const yTicks = Array.from({length: 5}, (_, i) => yMinRaw + i * (yMaxRaw - yMinRaw) / 4)} - - {@const curves = plotted.map(({ info, points }) => { - const pts = points.map(p => ({ x: mapX(p.bitrate), y: mapY(p.psnr) })); - const path = smoothSvgPath(pts); - const fill = pts.length > 0 ? path + ` L ${pts[pts.length - 1].x},${svgH - padB} L ${pts[0].x},${svgH - padB} Z` : ''; - return { info, points, path, fill }; - })} +
+ {#if !appState.originalImageData} +
Load an image to generate an RD plot.
+ {:else if rdError} +
{rdError}
+ {:else if rdLoading && allPoints.length === 0} +
Sampling {sampleCount} quality levels × {TRANSFORMS.length} transforms…
+ {:else if allPoints.length === 0} +
Preparing the curve…
+ {:else if plotted.some(s => s.points.length >= 2)} + {@const svgW = 700} + {@const svgH = 280} + {@const padL = 64} + {@const padR = 20} + {@const padT = 18} + {@const padB = 52} + {@const xMinRaw = Math.min(...allPoints.map((p) => p.bitrate))} + {@const xMaxRaw = Math.max(...allPoints.map((p) => p.bitrate))} + {@const yMinRaw = Math.min(...allPoints.map((p) => p.psnr))} + {@const yMaxRaw = Math.max(...allPoints.map((p) => p.psnr))} + {@const xPad = Math.max(0.05, (xMaxRaw - xMinRaw) * 0.08)} + {@const yPad = Math.max(0.5, (yMaxRaw - yMinRaw) * 0.12)} + {@const xMin = xMinRaw - xPad} + {@const xMax = xMaxRaw + xPad} + {@const yMin = yMinRaw - yPad} + {@const yMax = yMaxRaw + yPad} + {@const chartW = svgW - padL - padR} + {@const chartH = svgH - padT - padB} + {@const mapX = (v: number) => padL + ((v - xMin) / (xMax - xMin || 1)) * chartW} + {@const mapY = (v: number) => svgH - padB - ((v - yMin) / (yMax - yMin || 1)) * chartH} + {@const xTicks = Array.from({length: 5}, (_, i) => xMinRaw + i * (xMaxRaw - xMinRaw) / 4)} + {@const yTicks = Array.from({length: 5}, (_, i) => yMinRaw + i * (yMaxRaw - yMinRaw) / 4)} + + {@const curves = plotted.map(({ info, points }) => { + const pts = points.map(p => ({ x: mapX(p.bitrate), y: mapY(p.psnr) })); + const path = smoothSvgPath(pts); + const fill = pts.length > 0 ? path + ` L ${pts[pts.length - 1].x},${svgH - padB} L ${pts[0].x},${svgH - padB} Z` : ''; + return { info, points, path, fill }; + })} - {@const targetPoints = series[appState.transformType] ?? []} - {@const candidates = targetPoints.length > 0 ? targetPoints : allPoints} - {@const currentPoint = candidates.reduce((best, p) => Math.abs(p.quality - appState.quality) < Math.abs(best.quality - appState.quality) ? p : best)} - {@const cpx = mapX(currentPoint.bitrate)} - {@const cpy = mapY(currentPoint.psnr)} -
- - - {#each curves as curve} - - - - - {/each} - - - - - {#each yTicks as tick} - - {/each} - {#each xTicks as tick} - - {/each} - - - + {@const targetPoints = series[appState.transformType] ?? []} + {@const candidates = targetPoints.length > 0 ? targetPoints : allPoints} + {@const currentPoint = candidates.reduce((best, p) => Math.abs(p.quality - appState.quality) < Math.abs(best.quality - appState.quality) ? p : best)} + {@const cpx = mapX(currentPoint.bitrate)} + {@const cpy = mapY(currentPoint.psnr)} +
+ + {#each curves as curve} - - + + + + {/each} + + + + + {#each yTicks as tick} + + {/each} + {#each xTicks as tick} + + {/each} + + + + {#each curves as curve} + + + {/each} - - + + - {#each curves as curve} - {#each curve.points as point} - {@const isCurrent = point.quality === appState.quality && appState.transformType === curve.info.value} - selectPoint(curve.info.value, point.quality)} - onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') selectPoint(curve.info.value, point.quality); }}> - {curve.info.label}: Q{point.quality} · {point.psnr.toFixed(2)} dB · {point.bitrate.toFixed(2)} bpp · ~{formatBytes(point.estimatedBytes)} - - {/each} - {/each} + {#each curves as curve} + {#each curve.points as point} + {@const isCurrent = point.quality === appState.quality && appState.transformType === curve.info.value} + selectPoint(curve.info.value, point.quality)} + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') selectPoint(curve.info.value, point.quality); }}> + {curve.info.label}: Q{point.quality} · {point.psnr.toFixed(2)} dB · {point.bitrate.toFixed(2)} bpp · ~{formatBytes(point.estimatedBytes)} + + {/each} + {/each} - Q{currentPoint.quality} + Q{currentPoint.quality} - - {#each curves as curve, i} - - {curve.info.label} - {/each} - {#each xTicks as tick} - {tick.toFixed(2)} - {/each} - {#each yTicks as tick} - {tick.toFixed(1)} - {/each} - Bitrate (bits per pixel) - PSNR Y (dB) - -
+ + {#each curves as curve, i} + + {curve.info.label} + {/each} + {#each xTicks as tick} + {tick.toFixed(2)} + {/each} + {#each yTicks as tick} + {tick.toFixed(1)} + {/each} + Estimated stored bits divided by the number of pixels. Lower means a smaller file.Bitrate (bits per pixel) + Peak signal-to-noise ratio of the brightness channel. Higher is closer to the original; above about 40 dB differences are hard to see.PSNR Y (dB) + +
-
-
- Transform - {transformInfo(appState.transformType).label} -
-
-
- Quality - Q{currentPoint.quality} -
-
-
- Bitrate - {currentPoint.bitrate.toFixed(2)} bpp -
-
-
- PSNR Y - {currentPoint.psnr.toFixed(2)} dB -
-
-
- Est. size - ~{formatBytes(currentPoint.estimatedBytes)} -
-
- {:else} -
Need more samples to render an RD curve.
- {/if} -
-
+ {:else} +
Need more samples to render an RD curve.
+ {/if} + diff --git a/web/src/lib/components/viewer/ToolsPanel.svelte b/web/src/lib/components/viewer/ToolsPanel.svelte deleted file mode 100644 index d7f2df2..0000000 --- a/web/src/lib/components/viewer/ToolsPanel.svelte +++ /dev/null @@ -1,73 +0,0 @@ - - -
- Advanced -
-
-
Tools
- {#if wavelet} - - - {:else} - - {/if} - - - {#if wavelet} -

The block inspector is specific to the 8×8 DCT. Wavelets have no blocks — explore their subband pyramid instead.

- {:else} -

Dive deep into compression artifacts, error heatmaps, and edge distortion analysis.

- {/if} -

[ ] quality ±5 · T cycle transform

-
-
-
diff --git a/web/src/lib/grid-renderer.ts b/web/src/lib/grid-renderer.ts index 5042772..4cef84f 100644 --- a/web/src/lib/grid-renderer.ts +++ b/web/src/lib/grid-renderer.ts @@ -3,6 +3,7 @@ import { showBasisPopover, hideBasisPopover, getCachedGridData } from './basis-p import { coeffEdit } from './coeff-edit.svelte.js'; import { ZIGZAG_INDICES, computeBasisPattern, computeHaarBasisPattern, getFreqLabel, getHaarFreqLabel } from './dct-utils.js'; import { appState } from './state.svelte.js'; +import { INSPECTOR_IDS } from './inspector-dom.js'; function getBasisPattern(u: number, v: number): Float64Array { return appState.transformType === 1 @@ -17,15 +18,15 @@ function getTransformFreqLabel(row: number, col: number): string { } const ALL_GRID_IDS = [ - 'gridOriginal', 'gridDCT', 'gridQuantized', 'gridQuantized2', - 'gridQuantizedAdvanced', 'gridDequantized', 'gridReconstructed', - 'gridQuantTable', 'gridError' + INSPECTOR_IDS.gridOriginal, INSPECTOR_IDS.gridDCT, INSPECTOR_IDS.gridQuantized, + INSPECTOR_IDS.gridQuantizedAdvanced, INSPECTOR_IDS.gridDequantized, INSPECTOR_IDS.gridReconstructed, + INSPECTOR_IDS.gridQuantTable, INSPECTOR_IDS.gridError ]; let lastHighlightedKey: string | null = null; -/** Decoder-side grids whose coefficients can be dropped in edit mode. */ -const EDITABLE_GRID_IDS = ['gridQuantized2', 'gridDequantized']; +/** Grids whose coefficients can be dropped in edit mode: the stored data and what the decoder makes of it. */ +const EDITABLE_GRID_IDS: string[] = [INSPECTOR_IDS.gridQuantized, INSPECTOR_IDS.gridDequantized]; export function clearAllHighlights(): void { lastHighlightedKey = null; @@ -44,7 +45,7 @@ export function highlightAcrossGrids(row: number, col: number): void { if (cell && cell.classList) cell.classList.add('cell-highlight'); }); - const zzContainer = document.getElementById('gridZigzag'); + const zzContainer = document.getElementById(INSPECTOR_IDS.gridZigzag); if (zzContainer) { const zzCell = (zzContainer.querySelector(`.zz-cell[data-idx="${idx}"]`) || zzContainer.querySelector(`.zz-run[data-indices~="${idx}"]`)) as HTMLElement | null; @@ -63,7 +64,7 @@ export function highlightRunAcrossGrids(indices: number[]): void { }); }); - const zzContainer = document.getElementById('gridZigzag'); + const zzContainer = document.getElementById(INSPECTOR_IDS.gridZigzag); if (zzContainer) { indices.forEach(idx => { const zzCell = zzContainer.querySelector(`.zz-cell[data-idx="${idx}"]`) as HTMLElement | null; @@ -85,7 +86,7 @@ function getCellDescription(row: number, col: number, gridType: string): string } export function renderLossMeter(mse: number, _peakError: number): void { - const container = document.getElementById('lossMeterContainer'); + const container = document.getElementById(INSPECTOR_IDS.lossMeterContainer); if (!container) return; let meter = container.querySelector('.loss-meter') as HTMLDivElement | null; @@ -95,9 +96,9 @@ export function renderLossMeter(mse: number, _peakError: number): void { meter.className = 'loss-meter'; meter.innerHTML = `
- Quality + Fidelity
- PSNR-based Quality
+ Block fidelity (from this block's PSNR)
clamp((10 log10(255²/MSE) − 20) / 30, 0, 1)
Maps the 20–50 dB PSNR range to 0–100%. MSE = 0 is treated as lossless.
@@ -166,7 +167,7 @@ export function renderGrid( const row = Number.parseInt(cell.dataset.row || '0'); const col = Number.parseInt(cell.dataset.col || '0'); - // Edit mode: decoder-side coefficients toggle instead of animating. + // Edit mode: stored coefficients toggle instead of animating. if (coeffEdit.editing && EDITABLE_GRID_IDS.includes(el.id)) { coeffEdit.toggle(row * 8 + col); return; @@ -177,7 +178,7 @@ export function renderGrid( detail: { row, col, targetGridId } })); showBannerForCell(row, col); - if (el.id === 'gridDequantized') { + if (el.id === INSPECTOR_IDS.gridDequantized) { applyPartialReconToGrid(row, col); } }; @@ -228,7 +229,7 @@ export function renderGrid( if (e.key === 'Escape') { // First Escape closes the popover; the next one reaches the // inspector's own handler and exits. - const popoverOpen = document.getElementById('basisPopover')?.classList.contains('visible'); + const popoverOpen = document.getElementById(INSPECTOR_IDS.basisPopover)?.classList.contains('visible'); const tooltipOpen = document.querySelector('.grid-cell-tooltip.visible'); if (popoverOpen || tooltipOpen) { e.stopPropagation(); @@ -402,7 +403,7 @@ function focusGridCell(grid: HTMLElement, idx: number): void { } export function renderZigzagArray(data: Float64Array): void { - const el = document.getElementById('gridZigzag'); + const el = document.getElementById(INSPECTOR_IDS.gridZigzag); if (!el) return; el.innerHTML = ''; @@ -535,7 +536,7 @@ export function startZigzagAnimation(): void { highlightAcrossGrids(row, col); // Scroll the active zz-cell or zz-run into view within the container - const zzContainer = document.getElementById('gridZigzag'); + const zzContainer = document.getElementById(INSPECTOR_IDS.gridZigzag); if (zzContainer) { const activeZz = (zzContainer.querySelector(`.zz-cell[data-idx="${idx}"]`) || zzContainer.querySelector(`.zz-run[data-indices~="${idx}"]`)) as HTMLElement | null; @@ -581,7 +582,7 @@ function applyPartialReconToGrid(row: number, col: number): void { } if (zzPos < 0) return; - const targetGrid = document.getElementById('gridReconstructed'); + const targetGrid = document.getElementById(INSPECTOR_IDS.gridReconstructed); if (!targetGrid) return; const cells = Array.from(targetGrid.querySelectorAll('.grid-cell')) as HTMLElement[]; if (cells.length !== 64) return; @@ -684,7 +685,7 @@ export function startReconstructionAnimation(): void { const data = getCachedGridData(); if (!data || !data.dequantizedData) return; - const targetGrid = document.getElementById('gridReconstructed'); + const targetGrid = document.getElementById(INSPECTOR_IDS.gridReconstructed); if (!targetGrid) return; const cells = Array.from(targetGrid.querySelectorAll('.grid-cell')) as HTMLElement[]; @@ -795,7 +796,7 @@ function setReconstructionButtonIcon(playing: boolean): void { } function updateReconstructionProgress(index: number): void { - const el = document.getElementById('reconstructionProgress'); + const el = document.getElementById(INSPECTOR_IDS.reconstructionProgress); if (!el) return; if (index < 0) { el.style.display = 'none'; return; } @@ -815,7 +816,7 @@ function updateReconstructionProgress(index: number): void { } function hideReconAnimBanner(): void { - const banner = document.getElementById('reconAnimBanner'); + const banner = document.getElementById(INSPECTOR_IDS.reconAnimBanner); if (banner) banner.style.display = 'none'; } @@ -857,23 +858,23 @@ function updateReconAnimBanner( coeff: number, basisPattern: Float64Array | null ): void { - const banner = document.getElementById('reconAnimBanner'); + const banner = document.getElementById(INSPECTOR_IDS.reconAnimBanner); if (!banner) return; if (processedCount >= 64) { // Done state - const stepEl = document.getElementById('reconBannerStep'); - const fillEl = document.getElementById('reconBannerFill') as HTMLElement | null; - const freqEl = document.getElementById('reconBannerFreq'); - const coeffEl = document.getElementById('reconBannerCoeff'); - const descEl = document.getElementById('reconBannerDesc'); + const stepEl = document.getElementById(INSPECTOR_IDS.reconBannerStep); + const fillEl = document.getElementById(INSPECTOR_IDS.reconBannerFill) as HTMLElement | null; + const freqEl = document.getElementById(INSPECTOR_IDS.reconBannerFreq); + const coeffEl = document.getElementById(INSPECTOR_IDS.reconBannerCoeff); + const descEl = document.getElementById(INSPECTOR_IDS.reconBannerDesc); if (stepEl) stepEl.textContent = '64'; if (fillEl) fillEl.style.width = '100%'; if (freqEl) { freqEl.textContent = 'Complete'; freqEl.style.background = '#10b981'; freqEl.style.color = 'white'; } if (coeffEl) { coeffEl.textContent = ''; coeffEl.style.color = ''; } if (descEl) descEl.textContent = 'All 64 coefficients applied — reconstruction complete'; // Clear the canvas - const canvas = document.getElementById('reconBasisCanvas') as HTMLCanvasElement | null; + const canvas = document.getElementById(INSPECTOR_IDS.reconBasisCanvas) as HTMLCanvasElement | null; if (canvas) { const ctx = canvas.getContext('2d'); if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height); } banner.style.display = ''; return; @@ -886,11 +887,11 @@ function updateReconAnimBanner( const label = getTransformFreqLabel(row, col); const isZero = Math.abs(coeff) < 0.0001; - const stepEl = document.getElementById('reconBannerStep'); - const fillEl = document.getElementById('reconBannerFill') as HTMLElement | null; - const freqEl = document.getElementById('reconBannerFreq'); - const coeffEl = document.getElementById('reconBannerCoeff'); - const descEl = document.getElementById('reconBannerDesc'); + const stepEl = document.getElementById(INSPECTOR_IDS.reconBannerStep); + const fillEl = document.getElementById(INSPECTOR_IDS.reconBannerFill) as HTMLElement | null; + const freqEl = document.getElementById(INSPECTOR_IDS.reconBannerFreq); + const coeffEl = document.getElementById(INSPECTOR_IDS.reconBannerCoeff); + const descEl = document.getElementById(INSPECTOR_IDS.reconBannerDesc); if (stepEl) stepEl.textContent = String(processedCount); if (fillEl) fillEl.style.width = `${(processedCount / 64) * 100}%`; @@ -918,7 +919,7 @@ function updateReconAnimBanner( : FREQ_DESCRIPTIONS[label] ?? ''; } - const canvas = document.getElementById('reconBasisCanvas') as HTMLCanvasElement | null; + const canvas = document.getElementById(INSPECTOR_IDS.reconBasisCanvas) as HTMLCanvasElement | null; if (canvas && basisPattern && !isZero) { drawContributionOnCanvas(canvas, basisPattern, coeff); } else if (canvas && isZero) { @@ -978,7 +979,7 @@ if (typeof window !== 'undefined') { } export function renderEntropySummary(symbols: any[]): void { - const container = document.getElementById('entropySummary'); + const container = document.getElementById(INSPECTOR_IDS.entropySummary); if (!container) return; let dcBits = 0; diff --git a/web/src/lib/hover-hint.ts b/web/src/lib/hover-hint.ts new file mode 100644 index 0000000..dc1c84a --- /dev/null +++ b/web/src/lib/hover-hint.ts @@ -0,0 +1,139 @@ +// Fast hover hints for every `title` in the app. +// +// The browser's native title tooltip waits about a second and can't be +// sped up, so this shows the same text in a styled tooltip after a short +// delay. Markup keeps using plain `title` attributes (and SVG +// children); while a hint is showing, the native source is set aside so +// both don't appear, and it is put back when the pointer leaves. + +const SHOW_DELAY_MS = 150; +const GAP_PX = 8; +/** Moving to another hint this soon after one was visible shows it at once. */ +const SWITCH_WINDOW_MS = 300; + +let tip: HTMLDivElement | null = null; +let showTimer: ReturnType<typeof setTimeout> | null = null; +let active: { el: Element; restore: () => void } | null = null; +let lastVisibleAt = -Infinity; +let installed = false; + +function ensureTip(): HTMLDivElement { + if (!tip) { + tip = document.createElement('div'); + tip.className = 'hover-hint'; + tip.setAttribute('role', 'tooltip'); + tip.id = 'hover-hint'; + document.body.appendChild(tip); + } + return tip; +} + +/** The nearest element (from `start` up) that carries hint text, and that text. */ +function findHint(start: Element | null): { el: Element; text: string } | null { + for (let el = start; el && el !== document.body; el = el.parentElement) { + const title = el.getAttribute('title'); + if (title) return { el, text: title }; + // SVG elements describe themselves with a <title> child. + if (el instanceof SVGElement) { + const child = Array.from(el.children).find(c => c.tagName.toLowerCase() === 'title'); + if (child?.textContent) return { el, text: child.textContent }; + } + } + return null; +} + +/** Hide the native tooltip source; returns a function that puts it back. */ +function suppressNative(el: Element): () => void { + const title = el.getAttribute('title'); + if (title !== null) { + el.removeAttribute('title'); + // Svelte may have set a new title meanwhile; only restore if none. + return () => { if (!el.hasAttribute('title')) el.setAttribute('title', title); }; + } + const child = Array.from(el.children).find(c => c.tagName.toLowerCase() === 'title'); + if (child) { + const next = child.nextSibling; + child.remove(); + return () => { if (el.isConnected) el.insertBefore(child, next?.parentNode === el ? next : el.firstChild); }; + } + return () => {}; +} + +function place(el: Element): void { + if (!tip) return; + const r = el.getBoundingClientRect(); + const t = tip.getBoundingClientRect(); + let top = r.top - t.height - GAP_PX; + if (top < 4) top = r.bottom + GAP_PX; // no room above: go below + const left = Math.max(4, Math.min(r.left + r.width / 2 - t.width / 2, window.innerWidth - t.width - 4)); + tip.style.top = `${top}px`; + tip.style.left = `${left}px`; +} + +function hide(): void { + if (showTimer) clearTimeout(showTimer); + showTimer = null; + if (active) { + active.restore(); + active.el.removeAttribute('aria-describedby'); + active = null; + } + if (tip?.classList.contains('visible')) lastVisibleAt = Date.now(); + tip?.classList.remove('visible'); +} + +function show(el: Element, text: string, immediate: boolean, suppress = true): void { + hide(); + // Keyboard focus never shows the native tooltip, and the title may be + // the element's accessible name, so leave it in place then. + active = { el, restore: suppress ? suppressNative(el) : () => {} }; + el.setAttribute('aria-describedby', 'hover-hint'); + const reveal = () => { + showTimer = null; + const t = ensureTip(); + t.textContent = text; + t.classList.add('visible'); + place(el); + }; + if (immediate) reveal(); + else showTimer = setTimeout(reveal, SHOW_DELAY_MS); +} + +export function installHoverHints(): void { + if (installed) return; + installed = true; + document.addEventListener('pointerover', (e) => { + if (e.pointerType === 'touch') return; + // Still inside the hinted element (its title is set aside meanwhile). + if (active && active.el.contains(e.target as Node)) return; + const found = findHint(e.target as Element); + if (!found) { + if (active) hide(); + return; + } + // Moving between hinted elements: switch without waiting again. + const switching = !!tip?.classList.contains('visible') || Date.now() - lastVisibleAt < SWITCH_WINDOW_MS; + show(found.el, found.text, switching); + }); + + document.addEventListener('pointerout', (e) => { + if (!active) return; + const to = e.relatedTarget as Node | null; + if (to && active.el.contains(to)) return; + hide(); + }); + + // Keyboard users get the same hint on focus. + document.addEventListener('focusin', (e) => { + const target = e.target as Element; + if (!(target instanceof HTMLElement) || !target.matches(':focus-visible')) return; + const found = findHint(target); + if (found && found.el === target) show(found.el, found.text, false, false); + }); + document.addEventListener('focusout', hide); + + // Clicks, scrolling and Escape all dismiss it. + document.addEventListener('pointerdown', hide); + document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); }); + window.addEventListener('scroll', hide, true); +} diff --git a/web/src/lib/inspection.ts b/web/src/lib/inspection.ts index cecec0e..678a3d4 100644 --- a/web/src/lib/inspection.ts +++ b/web/src/lib/inspection.ts @@ -5,10 +5,20 @@ import { renderGrid, renderLossMeter, renderZigzagArray, renderEntropySummary, s import { estimateBlockBits, getEntropySymbols, reconstructBlock } from './dct-utils.js'; import { coeffEdit } from './coeff-edit.svelte.js'; import { untrack } from 'svelte'; +import { INSPECTOR_IDS } from './inspector-dom.js'; -// Index of the quantized grid within BlockDebugData (original, coefficients, -// quantTable, quantized, reconstructed — 64 doubles each). -const QUANTIZED_GRID = 3; +/** + * Layout of the buffer inspect_block_data returns: ImageCodec::BlockDebugData + * (core/inc/ImageCodec.h), five 8×8 grids of doubles in this order. Must match + * the struct's field order; tests/unit/wasm-contracts.test.js checks it. + */ +export const BLOCK_GRID = { + original: 0, + coefficients: 1, + quantTable: 2, + quantized: 3, + reconstructed: 4 +} as const; /** * The block coded just before (blockX, blockY) in a non-interleaved raster @@ -49,9 +59,8 @@ export function inspectBlock(blockX: number, blockY: number): void { hideBasisPopover(); stopReconstructionAnimation(); - const coordsSpan = document.getElementById('blockCoords'); - const qTableType = document.getElementById('qTableType'); - const qTableType2 = document.getElementById('qTableType2'); + const coordsSpan = document.getElementById(INSPECTOR_IDS.blockCoords); + const qTableType = document.getElementById(INSPECTOR_IDS.qTableType); if (coordsSpan) coordsSpan.innerText = `${blockX * 8}, ${blockY * 8} (Block ${blockX},${blockY})`; @@ -61,7 +70,6 @@ export function inspectBlock(blockX: number, blockY: number): void { const tableLabel = (channelIndex === 0) ? 'Luma' : 'Chroma'; if (qTableType) qTableType.innerText = tableLabel; - if (qTableType2) qTableType2.innerText = tableLabel; // Read the previous block's quantized DC first: inspectBlockData returns a // pointer to one shared buffer that the next call overwrites. @@ -69,7 +77,7 @@ export function inspectBlock(blockX: number, blockY: number): void { let prevDc = 0; if (prev) { const prevPtr = inspectBlockData(prev.x, prev.y, channelIndex, appState.quality); - if (prevPtr) prevDc = new Float64Array(Module.HEAPU8.buffer, prevPtr + QUANTIZED_GRID * 64 * 8, 1)[0]; + if (prevPtr) prevDc = new Float64Array(Module.HEAPU8.buffer, prevPtr + BLOCK_GRID.quantized * 64 * 8, 1)[0]; } const ptr = inspectBlockData(blockX, blockY, channelIndex, appState.quality); @@ -95,11 +103,11 @@ export function inspectBlock(blockX: number, blockY: number): void { } current = { - original: readGrid(0), - coefficients: readGrid(1), - quantTable: readGrid(2), - quantized: readGrid(QUANTIZED_GRID), - reconstructed: readGrid(4), + original: readGrid(BLOCK_GRID.original), + coefficients: readGrid(BLOCK_GRID.coefficients), + quantTable: readGrid(BLOCK_GRID.quantTable), + quantized: readGrid(BLOCK_GRID.quantized), + reconstructed: readGrid(BLOCK_GRID.reconstructed), prevDc, originalRGB, reconstructedRGB, @@ -168,29 +176,28 @@ function renderNow(): void { } }; - setStatEl('statMSE', mse >= 100 ? mse.toFixed(0) : mse.toFixed(2), mseClass, mseClass); - setStatEl('statPeakError', peakError >= 100 ? peakError.toFixed(0) : peakError.toFixed(1), peakClass, peakClass); - setStatEl('statZeros', `${zeroCount}/64`, null, null); - setStatEl('statCompression', `${zeroPercent}%`, compClass, compClass); + setStatEl(INSPECTOR_IDS.statMSE, mse >= 100 ? mse.toFixed(0) : mse.toFixed(2), mseClass, mseClass); + setStatEl(INSPECTOR_IDS.statPeakError, peakError >= 100 ? peakError.toFixed(0) : peakError.toFixed(1), peakClass, peakClass); + setStatEl(INSPECTOR_IDS.statZeros, `${zeroCount}/64`, null, null); + setStatEl(INSPECTOR_IDS.statCompression, `${zeroPercent}%`, compClass, compClass); const estBits = estimateBlockBits(quantized, prevDc); const bpp = estBits / 64; // Good bpp is strictly less than 1.5, moderate is < 3.0, otherwise poor. const bppClass = bpp < 1.5 ? 'good' : bpp < 3.0 ? 'moderate' : 'poor'; - setStatEl('statEstBits', `${estBits}`, null, null); - setStatEl('statBpp', bpp.toFixed(2), bppClass, bppClass); - - renderGrid('gridOriginal', (isRGB ? originalRGB : null) ?? original, 'intensity', 'original', isRGB && !!originalRGB); - renderGrid('gridDCT', coefficients, 'frequency', 'transform'); - renderGrid('gridQuantized', current.quantized, 'frequency', 'quantized'); - renderGrid('gridQuantized2', quantized, 'frequency', 'quantized'); - renderGrid('gridQuantizedAdvanced', quantized, 'frequency', 'quantized'); - renderGrid('gridDequantized', dequantized, 'frequency', 'dequantized'); - renderGrid('gridReconstructed', reconstructedRGB ?? reconstructed, 'intensity', 'reconstructed', !!reconstructedRGB); - renderGrid('gridQuantTable', quantTable, 'qtable', 'qtable'); - renderGrid('gridError', Array.from(errorData), 'error', 'error'); - markDroppedCells(['gridQuantized2', 'gridDequantized']); + setStatEl(INSPECTOR_IDS.statEstBits, `${estBits}`, null, null); + setStatEl(INSPECTOR_IDS.statBpp, bpp.toFixed(2), bppClass, bppClass); + + renderGrid(INSPECTOR_IDS.gridOriginal, (isRGB ? originalRGB : null) ?? original, 'intensity', 'original', isRGB && !!originalRGB); + renderGrid(INSPECTOR_IDS.gridDCT, coefficients, 'frequency', 'transform'); + renderGrid(INSPECTOR_IDS.gridQuantized, quantized, 'frequency', 'quantized'); + renderGrid(INSPECTOR_IDS.gridQuantizedAdvanced, quantized, 'frequency', 'quantized'); + renderGrid(INSPECTOR_IDS.gridDequantized, dequantized, 'frequency', 'dequantized'); + renderGrid(INSPECTOR_IDS.gridReconstructed, reconstructedRGB ?? reconstructed, 'intensity', 'reconstructed', !!reconstructedRGB); + renderGrid(INSPECTOR_IDS.gridQuantTable, quantTable, 'qtable', 'qtable'); + renderGrid(INSPECTOR_IDS.gridError, Array.from(errorData), 'error', 'error'); + markDroppedCells([INSPECTOR_IDS.gridQuantized, INSPECTOR_IDS.gridDequantized]); renderLossMeter(mse, peakError); renderZigzagArray(quantized); diff --git a/web/src/lib/inspector-dom.ts b/web/src/lib/inspector-dom.ts new file mode 100644 index 0000000..77bbef4 --- /dev/null +++ b/web/src/lib/inspector-dom.ts @@ -0,0 +1,63 @@ +// Element IDs that the Block Inspector's imperative code (inspection.ts, +// grid-renderer.ts, basis-popover.ts) looks up in InspectorMode.svelte. +// +// Those lookups skip missing elements silently, so a renamed or removed +// element would just stop updating with no error. Always refer to these IDs +// through INSPECTOR_IDS (never a string literal): tests/unit/inspector-dom.test.js +// checks that every one is rendered by a component, and that lib code doesn't +// look up IDs that bypass this list. +// +// To add an element the TS code updates: give it an id in the markup and add +// it here. + +export const INSPECTOR_IDS = { + // Top bar and block header + blockCoords: 'blockCoords', + qTableType: 'qTableType', + + // Pipeline grids + gridOriginal: 'gridOriginal', + gridDCT: 'gridDCT', + gridQuantized: 'gridQuantized', + gridDequantized: 'gridDequantized', + gridReconstructed: 'gridReconstructed', + gridError: 'gridError', + gridQuantTable: 'gridQuantTable', + + // Block statistics + statEstBits: 'statEstBits', + statBpp: 'statBpp', + statZeros: 'statZeros', + statCompression: 'statCompression', + statMSE: 'statMSE', + statPeakError: 'statPeakError', + lossMeterContainer: 'lossMeterContainer', + + // Reconstruction animation + reconstructionProgress: 'reconstructionProgress', + reconAnimBanner: 'reconAnimBanner', + reconBasisCanvas: 'reconBasisCanvas', + reconBannerStep: 'reconBannerStep', + reconBannerFreq: 'reconBannerFreq', + reconBannerCoeff: 'reconBannerCoeff', + reconBannerDesc: 'reconBannerDesc', + reconBannerFill: 'reconBannerFill', + + // Entropy encoding (advanced section) + gridQuantizedAdvanced: 'gridQuantizedAdvanced', + gridZigzag: 'gridZigzag', + entropySummary: 'entropySummary', + + // Basis-function popover + basisPopover: 'basisPopover', + basisCoord: 'basisCoord', + basisFreqLabel: 'basisFreqLabel', + basisValue: 'basisValue', + basisValueLabel: 'basisValueLabel', + basisQuantized: 'basisQuantized', + basisDivisor: 'basisDivisor', + basisCanvas: 'basisCanvas', + contributionCanvas: 'contributionCanvas' +} as const; + +export type InspectorId = typeof INSPECTOR_IDS[keyof typeof INSPECTOR_IDS]; diff --git a/web/src/lib/state.svelte.ts b/web/src/lib/state.svelte.ts index 945e04b..aedd883 100644 --- a/web/src/lib/state.svelte.ts +++ b/web/src/lib/state.svelte.ts @@ -1,5 +1,7 @@ import { Transform, type TransformValue } from './transforms.js'; +// View modes understood by the WASM get_view_ptr. Mirrors the ViewMode enum in +// web/cpp/codec_web.cpp; tests/unit/wasm-contracts.test.js checks they agree. export const ViewMode = { RGB: 0, Artifacts: 1, diff --git a/web/src/lib/transforms.ts b/web/src/lib/transforms.ts index 8b437c3..1581a84 100644 --- a/web/src/lib/transforms.ts +++ b/web/src/lib/transforms.ts @@ -1,4 +1,6 @@ // Transform types understood by the WASM codec (`transform_mode` argument). +// Mirrors map_transform_mode in web/cpp/codec_web.cpp (checked by +// tests/unit/wasm-contracts.test.js). export const Transform = { DCT: 0, Haar: 1, diff --git a/web/src/lib/wasm-bridge.ts b/web/src/lib/wasm-bridge.ts index bb7c7de..d954583 100644 --- a/web/src/lib/wasm-bridge.ts +++ b/web/src/lib/wasm-bridge.ts @@ -98,7 +98,7 @@ export function inspectBlockData(blockX: number, blockY: number, channelIndex: n return Module._inspect_block_data(blockX, blockY, channelIndex, quality, appState.currentCsMode, t); } -// Must match ImageCodec::CoefficientStats::NUM_BINS. +// Must match ImageCodec::CoefficientStats::NUM_BINS (checked by tests/unit/wasm-contracts.test.js). export const COEFF_BINS = 12; export interface CoeffStats { diff --git a/web/src/main.ts b/web/src/main.ts index 43332a7..d250c79 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,5 +1,7 @@ import { mount } from 'svelte'; import App from './App.svelte'; +import { installHoverHints } from './lib/hover-hint.js'; import './app.css'; mount(App, { target: document.body }); +installHoverHints(); diff --git a/web/src/styles/base.css b/web/src/styles/base.css index 24c94a0..9cdc341 100644 --- a/web/src/styles/base.css +++ b/web/src/styles/base.css @@ -17,7 +17,6 @@ body { code, .stat-value, -.file-size-values, .stats { font-family: var(--font-mono); } diff --git a/web/src/styles/components.css b/web/src/styles/components.css index 0c88551..6e2df4f 100644 --- a/web/src/styles/components.css +++ b/web/src/styles/components.css @@ -10,63 +10,16 @@ border-radius: 12px; background: var(--bg-secondary); overflow: hidden; - opacity: 0; - transform: translateY(10px); - animation: viewerSectionStaggerIn 0.35s cubic-bezier(0.2, 0.8, 0.2, 1) forwards; - transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; -} - -.controls .viewer-section:nth-child(2) { - animation-delay: 0.08s; -} - -.controls .viewer-section:nth-child(3) { - animation-delay: 0.14s; -} - -.controls .viewer-section:nth-child(4) { - animation-delay: 0.2s; -} - -.viewer-section:hover { - transform: translateY(-1px); - box-shadow: 0 10px 24px -18px var(--shadow-color); - border-color: color-mix(in srgb, var(--primary) 25%, var(--border-subtle, var(--border))); } .viewer-section-title { - list-style: none; - cursor: pointer; + margin: 0; padding: 12px 14px; font-size: var(--text-sm); font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); - display: flex; - align-items: center; - gap: 8px; - user-select: none; -} - -.viewer-section-title::-webkit-details-marker { - display: none; -} - -.viewer-section-title::after { - content: ''; - margin-left: auto; - width: 6px; - height: 6px; - border-right: 1.5px solid var(--text-muted); - border-bottom: 1.5px solid var(--text-muted); - transform: rotate(-45deg); - transition: transform 0.2s ease; - opacity: 0.7; -} - -.viewer-section[open]>.viewer-section-title::after { - transform: rotate(45deg); } .viewer-section-body { @@ -75,19 +28,6 @@ display: flex; flex-direction: column; gap: 14px; - animation: viewerSectionIn 0.2s ease; -} - -@keyframes viewerSectionIn { - from { - opacity: 0; - transform: translateY(-3px); - } - - to { - opacity: 1; - transform: translateY(0); - } } .control-group { @@ -96,64 +36,6 @@ gap: 8px; } -.viewer-section .control-group { - opacity: 0; - transform: translateY(4px); - animation: controlGroupIn 0.26s ease forwards; -} - -.viewer-section[open] .control-group:nth-child(1) { - animation-delay: 0.04s; -} - -.viewer-section[open] .control-group:nth-child(2) { - animation-delay: 0.09s; -} - -.viewer-section[open] .control-group:nth-child(3) { - animation-delay: 0.14s; -} - -.viewer-section[open] .control-group:nth-child(4) { - animation-delay: 0.19s; -} - -@keyframes viewerSectionStaggerIn { - from { - opacity: 0; - transform: translateY(10px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes controlGroupIn { - from { - opacity: 0; - transform: translateY(4px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -@media (prefers-reduced-motion: reduce) { - - .viewer-section, - .viewer-section .control-group, - .viewer-section-body { - animation: none; - transition: none; - opacity: 1; - transform: none; - } -} - label { font-weight: 600; font-size: var(--text-md); @@ -407,7 +289,6 @@ input[type="checkbox"] { color: var(--color-poor); } - .analysis-placeholder { font-size: var(--text-base); color: var(--text-muted); @@ -417,12 +298,6 @@ input[type="checkbox"] { text-align: center; } -.advanced-hint { - margin: 0; - font-size: var(--text-base); - color: var(--text-muted); -} - .control-hint { margin: 6px 0 0; font-size: var(--text-base-sm); @@ -500,61 +375,6 @@ input[type="checkbox"] { letter-spacing: 0.02em; } -/* File Size Estimation Bar */ -.file-size-bar-container { - margin-top: 6px; - padding: 14px; - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: var(--radius-xl); - display: flex; - flex-direction: column; - gap: 10px; - transition: all 0.2s ease; -} - -.file-size-bar-container:hover { - border-color: var(--primary-subtle); - background: color-mix(in srgb, var(--primary) 3%, var(--bg-secondary)); -} - -.file-size-header { - display: flex; - justify-content: space-between; - align-items: center; -} - -.file-size-title { - font-size: var(--text-xs); - font-weight: 800; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.12em; -} - -.reduction-badge { - background: var(--color-good-bg); - color: var(--color-good); - border: 1px solid var(--color-good-border); - font-size: var(--text-sm); - font-weight: 800; - padding: 3px 10px; - border-radius: var(--radius-full); -} - -.file-size-main { - display: flex; - flex-direction: column; - padding: 2px 0; -} - -.size-comparison-flow { - display: flex; - align-items: center; - gap: 8px; - font-family: var(--font-mono); -} - .size-val { font-weight: 700; white-space: nowrap; @@ -570,39 +390,10 @@ input[type="checkbox"] { opacity: 0.7; } -.size-arrow { - color: var(--text-muted); - opacity: 0.5; - font-size: 1.1rem; - font-weight: 400; -} - .size-val.highlight { color: var(--primary); } -.file-size-track { - width: 100%; - height: 8px; - background: var(--bg-tertiary); - border-radius: var(--radius-full); - overflow: hidden; - margin: 2px 0; - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05); -} - -.file-size-fill { - height: 100%; - background: linear-gradient(90deg, var(--color-good), var(--primary)); - border-radius: var(--radius-full); - transition: width 0.8s cubic-bezier(0.34, 1.56, 0.64, 1); -} - -.file-size-footer { - display: flex; - justify-content: center; -} - .compression-ratio-label { font-size: var(--text-sm); font-weight: 700; @@ -610,15 +401,6 @@ input[type="checkbox"] { opacity: 0.9; } -.tint-toggle-group { - margin-top: 6px; -} - -.tint-toggle-group label { - padding: 5px 4px; - font-size: var(--text-base-sm); -} - /* Beta Badge */ .beta-badge { font-size: 0.6rem; @@ -871,12 +653,4 @@ input[type="checkbox"] { color: var(--text-muted); text-align: center; opacity: 0.7; -} - -/* Specific button for RD/Histogram generation to ensure consistency */ -.rd-generate-btn { - width: 120px !important; - padding-left: 0 !important; - padding-right: 0 !important; - flex-shrink: 0; } \ No newline at end of file diff --git a/web/src/styles/header.css b/web/src/styles/header.css index 77d3b57..c485dc5 100644 --- a/web/src/styles/header.css +++ b/web/src/styles/header.css @@ -47,15 +47,12 @@ h1 { header { top: 8px; padding: 10px 12px; - flex-direction: column; - align-items: flex-start; backdrop-filter: none; -webkit-backdrop-filter: none; } .header-badges { - width: 100%; - justify-content: flex-start; + flex-wrap: nowrap; } h1 { @@ -63,30 +60,25 @@ h1 { } } -.privacy-badge { +.privacy-note { display: inline-flex; align-items: center; - gap: 8px; - background-color: var(--success-bg); - color: var(--success-text); - padding: 6px 12px; - border-radius: var(--radius-full); - font-size: var(--text-base); + gap: 5px; + color: var(--text-muted); + font-size: var(--text-sm); font-weight: 600; - box-shadow: var(--shadow-sm); + cursor: help; } .github-link { display: inline-flex; align-items: center; - gap: 8px; + justify-content: center; + width: 36px; + height: 36px; + border: 1px solid var(--border); background-color: var(--bg-secondary); - color: var(--text-muted); - padding: 6px 12px; border-radius: var(--radius-full); - font-size: var(--text-base); - font-weight: 600; - text-decoration: none; box-shadow: var(--shadow-sm); transition: all 0.2s ease; } diff --git a/web/src/styles/inspector-layout.css b/web/src/styles/inspector-layout.css index 9cd5784..e92ca5b 100644 --- a/web/src/styles/inspector-layout.css +++ b/web/src/styles/inspector-layout.css @@ -145,29 +145,21 @@ border-radius: 3px; } -/* ===== Context Crop ===== */ -.context-crop-container { +/* ===== Block header: which block, and what the page follows ===== */ +.block-header { display: flex; - flex-direction: column; align-items: center; - gap: 6px; + gap: 16px; margin-bottom: 20px; - padding: 16px; + padding: 12px 16px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); } -.context-crop-label { - font-size: var(--text-sm); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-muted); -} - .context-crop-canvas-wrap { + flex-shrink: 0; position: relative; border: 2px solid var(--border); border-radius: 6px; @@ -177,17 +169,43 @@ .context-crop-canvas-wrap canvas { display: block; - width: 256px; - height: 256px; + width: 112px; + height: 112px; image-rendering: pixelated; image-rendering: crisp-edges; } -.context-crop-caption { - font-size: 0.64rem; +.block-header-text { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.block-header-title { + font-size: 1rem; + font-weight: 700; + color: var(--text); +} + +.block-header-title span { + margin-left: 6px; + font-size: 0.75rem; + font-weight: 500; color: var(--text-muted); - text-align: center; - max-width: 280px; +} + +.block-header-text p { + margin: 0; + font-size: 0.8rem; + line-height: 1.5; + color: var(--text-muted); +} + +.block-header-text kbd { + font-size: 0.65rem; + padding: 0 4px; + margin: 0 1px; } /* ===== Inspector Placeholder ===== */ diff --git a/web/src/styles/inspector-pipeline.css b/web/src/styles/inspector-pipeline.css index 4536b61..2db1b36 100644 --- a/web/src/styles/inspector-pipeline.css +++ b/web/src/styles/inspector-pipeline.css @@ -20,37 +20,16 @@ opacity: 0.6; padding-left: 4px; display: flex; - justify-content: space-between; - align-items: center; + align-items: baseline; + gap: 8px; } -.nav-hint { - display: flex; - align-items: center; - gap: 3px; - font-size: 0.62rem; - font-weight: 400; +.pipeline-row-label .row-sub { + font-weight: 500; text-transform: none; letter-spacing: 0; } -.nav-hint kbd { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 16px; - height: 16px; - padding: 0 3px; - border: 1px solid var(--border); - border-radius: 3px; - background: var(--bg-secondary); - font-family: var(--font-mono); - font-size: 0.55rem; - font-weight: 600; - color: var(--text-muted); - box-shadow: 0 1px 0 var(--border); -} - .pipeline-row { display: flex; align-items: center; @@ -344,6 +323,16 @@ gap: 12px; } +/* Stats take the room; the quant table stays the size of the pipeline grids. */ +.analysis-row-two { + grid-template-columns: 1fr clamp(140px, 18vw, 260px); +} + +/* Cards in a pipeline row share a top edge even when their heights differ. */ +.pipeline-row > .pipeline-block { + align-self: stretch; +} + /* Pipeline blocks in the analysis row fill their grid cell */ .analysis-row .pipeline-block { width: 100%; diff --git a/web/src/styles/inspector-responsive.css b/web/src/styles/inspector-responsive.css index 50d31b4..d940d0c 100644 --- a/web/src/styles/inspector-responsive.css +++ b/web/src/styles/inspector-responsive.css @@ -98,13 +98,23 @@ transform: translateX(-50%) translateY(-4px); } - .analysis-row { + .analysis-row, + .analysis-row-two { grid-template-columns: 1fr; } + /* No arrow keys on phones. */ + .block-header .kbd-only { + display: none; + } + + .pipeline-row > .pipeline-block { + align-self: center; + } + .context-crop-canvas-wrap canvas { - width: 200px; - height: 200px; + width: 80px; + height: 80px; } .sidebar-shortcuts { diff --git a/web/src/styles/layout.css b/web/src/styles/layout.css index ae7ad4a..009baf1 100644 --- a/web/src/styles/layout.css +++ b/web/src/styles/layout.css @@ -39,70 +39,19 @@ } } -#viewerMode .main-column > .card, -#viewerMode .container > .controls-card { - opacity: 0; - transform: translateY(14px); - animation: viewerCardIn 0.45s cubic-bezier(0.16, 1, 0.3, 1) forwards; -} - -#viewerMode .viewer-card { - animation-delay: 0.05s; -} - -#viewerMode .rd-card { - animation-delay: 0.12s; -} - -#viewerMode .hist-card { - animation-delay: 0.19s; -} - -#viewerMode .controls-card { - animation-delay: 0.15s; -} - -#viewerMode .main-column > .card:hover, -#viewerMode .container > .controls-card:hover { - transform: translateY(-2px); - box-shadow: 0 12px 30px -14px var(--shadow-color), 0 5px 12px -6px var(--shadow-color); - border-color: color-mix(in srgb, var(--primary) 22%, var(--border-subtle, var(--border))); -} - -@keyframes viewerCardIn { - from { - opacity: 0; - transform: translateY(14px) scale(0.995); - } - - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -@media (prefers-reduced-motion: reduce) { - #viewerMode .main-column > .card, - #viewerMode .container > .controls-card { - opacity: 1; - transform: none; - animation: none; - } - - #viewerMode .main-column > .card:hover, - #viewerMode .container > .controls-card:hover { - transform: none; - } - - .card { - transition: none; - } -} - @media (max-width: 768px) { .container { grid-template-columns: 1fr; } + + /* Phones: image, then the controls that change it, then the charts. */ + #viewerMode .main-column { + display: contents; + } + + #viewerMode .viewer-card { order: 1; } + #viewerMode .controls-card { order: 2; } + #viewerMode .compare-card { order: 3; } } @media (max-width: 1024px) { diff --git a/web/src/styles/onboarding.css b/web/src/styles/onboarding.css deleted file mode 100644 index b86822e..0000000 --- a/web/src/styles/onboarding.css +++ /dev/null @@ -1,64 +0,0 @@ -/* ===== Onboarding / Drop Zone ===== */ -.drop-zone { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 16px; - padding: 60px 32px; - border: 2px dashed var(--border); - border-radius: 16px; - background: var(--bg-secondary); - cursor: pointer; - transition: all 0.3s ease; - text-align: center; - min-height: 280px; -} - -.drop-zone:hover, -.drop-zone.drag-over { - border-color: var(--primary); - background: var(--primary-subtle); -} - -.drop-zone.drag-over { - transform: scale(1.01); - box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.1); -} - -.drop-zone-icon { - width: 64px; - height: 64px; - color: var(--text-muted); - opacity: 0.6; - transition: all 0.3s ease; -} - -.drop-zone:hover .drop-zone-icon, -.drop-zone.drag-over .drop-zone-icon { - color: var(--primary); - opacity: 1; - transform: translateY(-4px); -} - -.drop-zone-text { - font-size: 1rem; - font-weight: 600; - color: var(--text); -} - -.drop-zone-hint { - font-size: 0.8rem; - color: var(--text-muted); -} - -.drop-zone-hint .browse-link { - color: var(--primary); - font-weight: 600; - text-decoration: underline; - text-underline-offset: 2px; -} - -.drop-zone input[type="file"] { - display: none; -} \ No newline at end of file diff --git a/web/src/styles/utilities.css b/web/src/styles/utilities.css index 9783e40..5bf6729 100644 --- a/web/src/styles/utilities.css +++ b/web/src/styles/utilities.css @@ -69,4 +69,33 @@ .tooltip-content::after { left: 90%; } -} \ No newline at end of file +} +/* A label that explains itself on hover (via its title attribute). */ +.has-hint { + cursor: help; + text-decoration: underline dotted; + text-decoration-color: color-mix(in srgb, currentColor 45%, transparent); + text-underline-offset: 3px; +} + +/* Fast hover hints for title attributes (see lib/hover-hint.ts). */ +.hover-hint { + position: fixed; + z-index: 4000; + max-width: 280px; + padding: 7px 10px; + border-radius: 6px; + background: var(--tooltip-bg); + color: var(--tooltip-text); + font-size: 0.75rem; + font-weight: 500; + line-height: 1.4; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.25); + pointer-events: none; + opacity: 0; + transition: opacity 0.1s ease; +} + +.hover-hint.visible { + opacity: 1; +} diff --git a/web/src/styles/viewer.css b/web/src/styles/viewer.css index 230c69b..b91a106 100644 --- a/web/src/styles/viewer.css +++ b/web/src/styles/viewer.css @@ -54,7 +54,6 @@ canvas { background: rgba(30, 41, 59, 0.9); } - .comparison-viewer { position: relative; width: 100%; @@ -98,43 +97,6 @@ canvas { box-shadow: 0 0 0 2px rgba(5, 150, 105, 0.35), 0 8px 24px rgba(5, 150, 105, 0.28); } -.inspector-active-banner { - position: absolute; - left: 12px; - top: 12px; - z-index: 4; - display: inline-flex; - align-items: center; - gap: 7px; - background: rgba(15, 23, 42, 0.82); - border: 1px solid rgba(52, 211, 153, 0.45); - color: rgba(241, 245, 249, 0.98); - border-radius: 999px; - font-size: 0.7rem; - padding: 6px 10px; - max-width: calc(100% - 24px); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); -} - -.inspector-active-banner strong { - font-size: 0.72rem; - white-space: nowrap; -} - -.inspector-active-banner span:last-child { - opacity: 0.9; -} - -.banner-dot { - width: 7px; - height: 7px; - border-radius: 999px; - background: #34d399; - box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.25); - flex-shrink: 0; -} - .comparison-divider { position: absolute; top: 0; @@ -182,7 +144,6 @@ canvas { @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { - .inspector-active-banner, .comparison-handle { backdrop-filter: none; -webkit-backdrop-filter: none; @@ -213,33 +174,14 @@ canvas { height: auto; } - @media (max-width: 860px) { .viewer-canvas-header { padding: 0 4px 6px; } - - .comparison-label { - font-size: 0.65rem; - } - - .inspector-active-banner span:last-child { - display: none; - } - - .inspector-quickstart { - flex-direction: column; - align-items: flex-start; - } - - .inspector-quickstart-btn { - width: 100%; - } } @media (max-width: 1024px) { - .inspector-active-banner, .comparison-handle { backdrop-filter: none; -webkit-backdrop-filter: none; @@ -247,57 +189,10 @@ canvas { } .viewer-canvas-header { - display: grid; - grid-template-columns: 1fr auto 1fr; - align-items: center; - padding: 0 4px 12px; -} - -.canvas-header-left { - display: flex; - align-items: center; -} - -.canvas-header-center { - display: flex; - align-items: center; - justify-content: center; -} - -.canvas-header-right { display: flex; align-items: center; - justify-content: flex-end; -} - -.img-dim-info { - font-size: 0.72rem; - font-weight: 600; - color: var(--text-muted); - background: rgba(255, 255, 255, 0.05); - padding: 5px 10px; - border-radius: 999px; - display: inline-flex; - align-items: center; - gap: 3px; - border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.1)); -} - -.dim-sep { - color: var(--text-muted); - opacity: 0.4; - font-size: 0.65rem; - padding: 0 1px; -} - -.dim-unit { - font-size: 0.65rem; - opacity: 0.6; - margin-left: 1px; -} - -.dim-val { - font-variant-numeric: tabular-nums; + gap: 8px; + padding: 0 4px 12px; } /* View Mode Toggle (Segmented Control) within Viewer Card */ @@ -321,7 +216,7 @@ canvas { cursor: pointer; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); white-space: nowrap; - min-width: 90px; + min-width: 56px; } .view-mode-toggle button:hover { @@ -335,16 +230,10 @@ canvas { box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); } -/* Phones: dimensions + zoom share the first row, the view toggle spans the second. */ +/* Phones: the view toggle gets its own row above the zoom controls. */ @media (max-width: 600px) { .viewer-canvas-header { - grid-template-columns: 1fr auto; - row-gap: 8px; - } - - .canvas-header-center { - grid-column: 1 / -1; - grid-row: 2; + flex-wrap: wrap; } .view-mode-toggle { @@ -359,6 +248,7 @@ canvas { } .viewer-zoom-controls { + margin-left: auto; display: flex; align-items: center; gap: 6px; @@ -409,20 +299,6 @@ canvas { letter-spacing: 0.02em; } -/* Old labels and mini info removed — moved to global header */ - - -.inspector-quickstart { - border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.2)); - border-radius: 10px; - padding: 10px 12px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - background: rgba(15, 23, 42, 0.4); -} - .rd-chart-panel { padding: 4px; display: flex; @@ -452,13 +328,6 @@ canvas { opacity: 0.85; } -.rd-chart-status { - font-size: 0.72rem; - font-weight: 700; - color: var(--primary); - white-space: nowrap; -} - .rd-chart-svg-wrap { width: 100%; border: 1px solid var(--border-subtle, var(--border)); @@ -603,39 +472,83 @@ canvas { min-width: 40%; } } +.view-mode-toggle button:disabled, +.zoom-ctrl-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Dropping an image file onto the viewer */ +.viewer-card.drag-over { + outline: 2px dashed var(--primary); + outline-offset: -6px; +} -.quickstart-copy { +.split-labels { + display: flex; + justify-content: space-between; + padding: 8px 4px 0; + font-size: 0.72rem; + font-weight: 600; + color: rgba(203, 213, 225, 0.8); +} + +.viewer-note { + margin: 8px 4px 0; + font-size: 0.72rem; + line-height: 1.5; + color: rgba(203, 213, 225, 0.8); +} + +/* Ways into the two deep-dive modes, under the image */ +.go-deeper { + display: flex; + gap: 10px; + margin-top: 12px; +} + +.go-deeper-btn { + flex: 1; display: flex; flex-direction: column; + align-items: flex-start; gap: 2px; - min-width: 0; + text-align: left; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); + color: rgba(248, 250, 252, 0.98); + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease; } -.quickstart-copy strong { +.go-deeper-btn strong { font-size: 0.8rem; - color: rgba(248, 250, 252, 0.98); - letter-spacing: 0.02em; } -.quickstart-copy span { +.go-deeper-btn strong::after { + content: ' →'; + color: var(--primary); +} + +.go-deeper-btn span { font-size: 0.72rem; - color: rgba(203, 213, 225, 0.92); + color: rgba(203, 213, 225, 0.85); } -.inspector-quickstart-btn { - flex-shrink: 0; - border: none; - background: var(--primary); - color: white; - border-radius: 8px; - padding: 8px 10px; - font-size: 0.74rem; - font-weight: 700; - cursor: pointer; - transition: background-color 0.2s ease, transform 0.15s ease; +.go-deeper-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); + border-color: var(--primary); } -.inspector-quickstart-btn:hover { - background: var(--primary-hover); - transform: translateY(-1px); -} \ No newline at end of file +.go-deeper-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +@media (max-width: 600px) { + .go-deeper { + flex-direction: column; + } +} diff --git a/web/src/types/wasm.d.ts b/web/src/types/wasm.d.ts index d883c9b..2a3e064 100644 --- a/web/src/types/wasm.d.ts +++ b/web/src/types/wasm.d.ts @@ -1,3 +1,6 @@ +// The C functions the WASM build exports. Each must be listed in the Makefile's +// WEB_EXPORTS or marked EMSCRIPTEN_KEEPALIVE in web/cpp/codec_web.cpp +// (checked by tests/unit/wasm-exports.test.js). declare global { var Module: WasmModule; } diff --git a/web/tests/browser/inspection.browser.test.js b/web/tests/browser/inspection.browser.test.js index 530fdc8..6ef7ed8 100644 --- a/web/tests/browser/inspection.browser.test.js +++ b/web/tests/browser/inspection.browser.test.js @@ -11,8 +11,8 @@ import { inspectBlock } from '../../src/lib/inspection.js'; const REQUIRED_DIVS = [ 'blockCoords', - 'qTableType', 'qTableType2', - 'gridOriginal', 'gridDCT', 'gridQuantized', 'gridQuantized2', + 'qTableType', + 'gridOriginal', 'gridDCT', 'gridQuantized', 'gridDequantized', 'gridReconstructed', 'gridQuantTable', 'gridError', 'statMSE', 'statPeakError', 'statZeros', 'statCompression', 'lossMeterContainer', 'basisPopover', @@ -143,7 +143,6 @@ describe('inspectBlock - qTableType label', () => { appState.currentViewMode = ViewMode.Y; // channelIndex = 0 inspectBlock(0, 0); expect(document.getElementById('qTableType').innerText).toBe('Luma'); - expect(document.getElementById('qTableType2').innerText).toBe('Luma'); }); it('shows "Chroma" when currentViewMode is Cr', () => { diff --git a/web/tests/unit/hover-hint.test.js b/web/tests/unit/hover-hint.test.js new file mode 100644 index 0000000..ae01bec --- /dev/null +++ b/web/tests/unit/hover-hint.test.js @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; +import { installHoverHints } from '../../src/lib/hover-hint.js'; + +function hover(el, related = null) { + el.dispatchEvent(new MouseEvent('pointerover', { bubbles: true, relatedTarget: related })); +} + +function leave(el, to = document.body) { + el.dispatchEvent(new MouseEvent('pointerout', { bubbles: true, relatedTarget: to })); +} + +const tip = () => document.querySelector('.hover-hint'); + +describe('hover-hint', () => { + beforeAll(() => installHoverHints()); + + beforeEach(() => { + vi.useFakeTimers(); + document.body.querySelectorAll(':not(.hover-hint)').forEach(n => n.remove()); + }); + + afterEach(() => { + document.dispatchEvent(new MouseEvent('pointerdown')); + vi.advanceTimersByTime(1000); // let the quick-switch window lapse + vi.useRealTimers(); + }); + + it('shows the title text after a short delay, not the native tooltip', () => { + document.body.insertAdjacentHTML('beforeend', '<button id="b" title="Explains b">b</button>'); + const b = document.getElementById('b'); + + hover(b); + expect(b.hasAttribute('title')).toBe(false); // native tooltip suppressed + expect(tip()?.classList.contains('visible') ?? false).toBe(false); + + vi.advanceTimersByTime(200); + expect(tip().classList.contains('visible')).toBe(true); + expect(tip().textContent).toBe('Explains b'); + expect(b.getAttribute('aria-describedby')).toBe('hover-hint'); + }); + + it('restores the title when the pointer leaves', () => { + document.body.insertAdjacentHTML('beforeend', '<span id="s" title="Explains s">s</span>'); + const s = document.getElementById('s'); + + hover(s); + vi.advanceTimersByTime(200); + leave(s); + + expect(s.getAttribute('title')).toBe('Explains s'); + expect(s.hasAttribute('aria-describedby')).toBe(false); + expect(tip().classList.contains('visible')).toBe(false); + }); + + it('keeps the hint while moving over the element\'s children', () => { + document.body.insertAdjacentHTML('beforeend', '<div id="d" title="Explains d"><strong id="c">child</strong></div>'); + const d = document.getElementById('d'); + const c = document.getElementById('c'); + + hover(d); + vi.advanceTimersByTime(200); + leave(d, c); + hover(c); + + expect(tip().classList.contains('visible')).toBe(true); + expect(tip().textContent).toBe('Explains d'); + }); + + it('switches straight to the next hint without waiting again', () => { + document.body.insertAdjacentHTML('beforeend', '<i id="a" title="A">a</i><i id="z" title="Z">z</i>'); + const a = document.getElementById('a'); + const z = document.getElementById('z'); + + hover(a); + vi.advanceTimersByTime(200); + leave(a, z); + hover(z); + + expect(tip().classList.contains('visible')).toBe(true); + expect(tip().textContent).toBe('Z'); + expect(a.getAttribute('title')).toBe('A'); + }); + + it('reads SVG <title> children and puts them back', () => { + document.body.insertAdjacentHTML('beforeend', '<svg><text id="t"><title>Axis hintAxis'); + const t = document.getElementById('t'); + + hover(t); + vi.advanceTimersByTime(200); + expect(tip().textContent).toBe('Axis hint'); + expect(t.querySelector('title')).toBeNull(); + + leave(t); + expect(t.querySelector('title')?.textContent).toBe('Axis hint'); + }); + + it('ignores elements without hints', () => { + document.body.insertAdjacentHTML('beforeend', '

plain

'); + hover(document.getElementById('p')); + vi.advanceTimersByTime(200); + expect(tip()?.classList.contains('visible') ?? false).toBe(false); + }); +}); diff --git a/web/tests/unit/inspection.test.js b/web/tests/unit/inspection.test.js index ef67a2a..6bf380b 100644 --- a/web/tests/unit/inspection.test.js +++ b/web/tests/unit/inspection.test.js @@ -32,7 +32,6 @@ describe('inspection.js - inspectBlock', () => { document.body.innerHTML = ` -
@@ -66,7 +65,6 @@ describe('inspection.js - inspectBlock', () => { appState.currentViewMode = ViewMode.Y; inspectBlock(0, 0); expect(document.getElementById('qTableType').innerText).toBe('Luma'); - expect(document.getElementById('qTableType2').innerText).toBe('Luma'); }); it('sets Chroma label for Cb view mode', () => { @@ -85,7 +83,7 @@ describe('inspection.js - inspectBlock', () => { inspectBlock(0, 0); expect(hideBasisPopover).toHaveBeenCalled(); expect(setCachedGridData).toHaveBeenCalled(); - expect(renderGrid).toHaveBeenCalledTimes(9); // 9 grids updated + expect(renderGrid).toHaveBeenCalledTimes(8); // 8 grids updated expect(renderLossMeter).toHaveBeenCalled(); expect(renderZigzagArray).toHaveBeenCalled(); expect(renderEntropySummary).toHaveBeenCalled(); diff --git a/web/tests/unit/inspector-dom.test.js b/web/tests/unit/inspector-dom.test.js new file mode 100644 index 0000000..2269db9 --- /dev/null +++ b/web/tests/unit/inspector-dom.test.js @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { INSPECTOR_IDS } from '../../src/lib/inspector-dom.js'; + +// The Block Inspector's imperative code finds its elements by ID and skips +// missing ones silently, so a renamed element would just stop updating. +// These checks turn that into a test failure (see src/lib/inspector-dom.ts). +// Vitest runs from web/. +const src = join(process.cwd(), 'src'); + +function filesUnder(dir, ext) { + return readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + if (statSync(path).isDirectory()) return filesUnder(path, ext); + return path.endsWith(ext) ? [path] : []; + }); +} + +const renderedIds = new Set( + filesUnder(src, '.svelte').flatMap((f) => [...readFileSync(f, 'utf8').matchAll(/\bid="([A-Za-z][\w-]*)"/g)].map((m) => m[1])) +); + +const ids = Object.values(INSPECTOR_IDS); + +// IDs that lib code creates itself (in generated HTML) and then looks up. +const CREATED_BY_LIB = ['expandAllCosts', 'collapseAllCosts']; + +const libSources = filesUnder(join(src, 'lib'), '.ts') + .filter((f) => !f.endsWith('inspector-dom.ts')) + .map((f) => ({ file: f.slice(src.length + 1), text: readFileSync(f, 'utf8') })); + +describe('inspector element IDs', () => { + it.each(ids)('#%s is rendered by a component', (id) => { + expect(renderedIds.has(id)).toBe(true); + }); + + it('lib code refers to inspector IDs only through INSPECTOR_IDS', () => { + const literals = libSources.flatMap(({ file, text }) => + ids.filter((id) => new RegExp(`['"\`]${id}['"\`]`).test(text)).map((id) => `${file}: '${id}'`)); + expect(literals).toEqual([]); + }); + + it('lib code looks up no element IDs outside INSPECTOR_IDS', () => { + const lookup = /getElementById\(\s*['"`]([^'"`]+)['"`]|querySelector(?:All)?\(\s*['"`]#([\w-]+)/g; + const strays = libSources.flatMap(({ file, text }) => + [...text.matchAll(lookup)] + .map((m) => m[1] ?? m[2]) + .filter((id) => !CREATED_BY_LIB.includes(id)) + .map((id) => `${file}: #${id}`)); + expect(strays).toEqual([]); + }); +}); diff --git a/web/tests/unit/wasm-contracts.test.js b/web/tests/unit/wasm-contracts.test.js new file mode 100644 index 0000000..9f985a1 --- /dev/null +++ b/web/tests/unit/wasm-contracts.test.js @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { ViewMode } from '../../src/lib/state.svelte.js'; +import { Transform } from '../../src/lib/transforms.js'; +import { COEFF_BINS } from '../../src/lib/wasm-bridge.js'; +import { BLOCK_GRID } from '../../src/lib/inspection.js'; + +// Numbers and memory layouts shared across the WASM boundary are defined +// once in C++ and once in TypeScript. Nothing links them at compile time, so +// these tests read the C++ sources and compare. (Exported function names are +// checked in wasm-exports.test.js.) Vitest runs from web/. +const root = resolve(process.cwd(), '..') + '/'; +const binding = readFileSync(root + 'web/cpp/codec_web.cpp', 'utf8'); +const codecHeader = readFileSync(root + 'core/inc/ImageCodec.h', 'utf8'); + +describe('WASM contracts', () => { + it('view mode numbers match codec_web.cpp ViewMode', () => { + const body = binding.match(/enum ViewMode\s*\{([^}]*)\}/)[1]; + const cpp = Object.fromEntries([...body.matchAll(/(\w+)\s*=\s*(\d+)/g)].map((m) => [m[1], Number(m[2])])); + expect(cpp).toEqual({ ...ViewMode }); + }); + + it('transform numbers match codec_web.cpp map_transform_mode', () => { + const body = binding.match(/map_transform_mode\(int mode\)\s*\{([\s\S]*?)\n\}/)[1]; + const cases = Object.fromEntries([...body.matchAll(/case (\d+):\s*return ImageCodec::TransformType::(\w+)/g)] + .map((m) => [m[2], Number(m[1])])); + expect(body).toMatch(/default:\s*return ImageCodec::TransformType::DCT/); + expect(cases).toEqual({ DWT_HAAR: Transform.Haar, DWT_97: Transform.CDF97 }); + expect(Transform.DCT).toBe(0); + }); + + it('chroma subsampling modes map to the matching codec setting', () => { + const body = binding.match(/map_cs_mode\(int mode\)\s*\{([\s\S]*?)\n\}/)[1]; + for (const [, mode, cs] of body.matchAll(/case (\d+):\s*return ImageCodec::ChromaSubsampling::CS_(\d+)/g)) { + expect(cs).toBe(mode); + } + expect(body).toMatch(/case 422:/); + expect(body).toMatch(/case 420:/); + }); + + it('COEFF_BINS matches CoefficientStats::NUM_BINS', () => { + expect(Number(codecHeader.match(/NUM_BINS\s*=\s*(\d+)/)[1])).toBe(COEFF_BINS); + }); + + it('BLOCK_GRID follows the field order of BlockDebugData', () => { + const body = codecHeader.match(/struct BlockDebugData\s*\{([^}]*)\}/)[1]; + const fields = [...body.matchAll(/double\s+(\w+)\[8\]\[8\]/g)].map((m) => m[1]); + expect(fields).toEqual(Object.entries(BLOCK_GRID).sort((a, b) => a[1] - b[1]).map(([name]) => name)); + }); +});