Skip to content

Commit 67ef8ec

Browse files
adulbrichclaude
andauthored
Port the pipeline to WebAssembly: a zero-dependency desktop app and a client-side web app (#240)
* feat(pipeline): port the orchestrator to TypeScript Ports `pipeline.rs::pipeline` / `process_image_set` and the `*_spec` argument builders to TypeScript, so the same pipeline can run in the Tauri webview and in a browser with no tools installed. Part of #231. Nothing imports this yet, and that is deliberate. The Rust pipeline remains the only one the app uses; wiring the TypeScript one in is #232/#233. Landing it un-wired is what keeps the risk near zero: the module is new, and the only pre-existing file touched is jest.setup.js. Unit tests, typecheck, lint and the static export all pass. The e2e suite was not run -- it needs a full Tauri build -- so "does not break the app" rests on the isolation rather than on having exercised the app end to end. Two rules hold for everything under src/lib/pipeline/, both greppable: - No @tauri-apps/* imports. Code that runs unchanged in both hosts is the whole point, and a Tauri import is the easiest way to lose that by accident. - No `path` or `fs`. next.config.js aliases `path` to path-browserify and stubs `fs` for tiff.js, so both resolve to something surprising. Paths are plain strings under a virtual /work prefix. Layering: pure argument builders, a ToolRunner interface replacing CommandSpec and run_with_io, and the orchestrator written against that interface. So all of this is testable with a fake runner, without wasm, binaries or a filesystem. Details carried over rather than rediscovered: - The status payload keys stay snake_case. pipeline-status-context.tsx validates with a zod schema built around serde's output (set_index, set_total); camelCase would be silently rejected. - `evalglare -V` exits 1 by design after printing the illuminance. Rust already treats a nonzero exit with non-empty stdout as success; so does this. An orchestrator that threw on a nonzero code would fail every run, and the value becomes COMPUTED_VERTICAL_ILLUMINANCE. - Header editing runs before evalglare, because evalglare reads the view geometry from the header. - falsecolor's -lw/-lh must be separate arguments; the substring matcher swallowed them as one non-numeric width and dropped the whole legend. - Batching and cancellation stay in the frontend. The Rust command handles one set too and never populates set_index/set_total, so moving the batch loop here would duplicate behaviour the app already has. Not yet ported, and tracked on #231: the Warning events from cal_check and validity, and output naming. The first two are pure and belong with this phase; output naming depends on where results are written, which is #232. 44 tests, ported from the Rust `mod tests` blocks where they existed. The fake runner models file dependencies rather than just recording calls: a mistyped intermediate filename passes every ordering assertion while producing a pipeline where one stage reads a file no stage ever wrote. That was found by mutation testing -- an earlier version of these tests did not catch it. jest.setup.js gains a TextEncoder/TextDecoder polyfill, in the same style as the ResizeObserver and crypto.subtle ones already there. jsdom omits them; browsers and workers both have them. Refs radiantlab/HDRICalibrationTool#231 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(pipeline): port the cal_check and validity warnings Ports `pipeline/cal_check.rs` and `pipeline/validity.rs` and wires both into the TypeScript orchestrator. Part of #231. These matter because `pipeline-status-context.tsx` already has a `warningTextFor` branch for `PipelineStatusKind::Warning`, so an orchestrator that never emits one would make the app quietly stop reporting results it reports today. Neither check can fail a run: a hardcoded .cal file may well match the resolution it is handed, and a failed validity check is information for the operator rather than grounds for discarding the picture. cal_check warns when a geometric .cal file cannot adapt to the resolution it is about to be applied at. Two details that are easy to get wrong and are now covered by tests: - The resolution reported is the one AFTER the resize, not the mask diameter, because that is what the correction is actually applied to. - Only the fisheye and vignetting files are checked. A photometric factor or a neutral density transmittance has no pixel coordinates to get wrong, and Rust checks exactly those two. validity compares the HDR-derived vertical illuminance against a measured one, using the Pierson et al. 2019 thresholds. A pass is emitted as a `step` rather than a `warning`, so a good result is not shown to the operator as a problem; only the two failing bands warn. The numeric-fragment scan matches Rust's behaviour rather than approximating it. Rust splits on any character that is not a digit or a dot and then calls `parse::<f64>()`, so a bare "." or a "1.2.3" is skipped; `/[0-9.]+/` plus `Number()` rejects the same fragments, and there is a test for it. While here, the corrections table became an array of named fields rather than a five-element tuple. Adding `checkResolution` to a positional tuple would have made the call sites unreadable. 28 more tests, 72 in the module. All four wiring mistakes I could think of are mutation-tested: dropping the post-resize resolution update, emitting a validity pass as a warning, and removing the resolution check from a file that needs it each fail at least one test. Refs radiantlab/HDRICalibrationTool#231 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(pipeline): add the WebAssembly ToolRunner Implements `ToolRunner` over Emscripten modules, completing the layering: argument builders, the runner interface, the orchestrator, and now the real adapter. Part of #231. Still nothing imports it. Every mechanism here was measured in #234 against the reference brackets in Chromium and WebKit, rather than inferred: - One fresh module instance per invocation. EXIT_RUNTIME=1 means one main() per instance, and discarding the instance is also what reclaims its heap -- which is why a 2.13 GB hdrgen merge does not carry into later stages. - FS stays usable after callMain returns despite EXIT_RUNTIME=1, and callMain returns the exit code rather than throwing. Both properties are needed at once: one to retrieve output, the other because evalglare -V exits 1 on success. - MEMFS is per-instance, so intermediates are copied between instances. That copy is a typed-array memcpy that never enters wasm linear memory, measured at about 1 ms per intermediate. - MEMFS keeps file bytes outside the wasm heap, so staged inputs cost JS heap rather than counting against the wasm32 ceiling. Files therefore live in the runner in plain JS memory and are staged into each instance on demand. Only the paths an invocation names are staged -- argv and stdin double as the dependency list -- because staging everything would re-copy every intermediate on every stage. Two things worth calling out: - Parent directories are created explicitly. Source images keep the caller's paths, which are not under /work, and MEMFS creates nothing implicitly, so FS.writeFile("/in/a.jpg") fails with ENOENT otherwise. Found by writing the integration test, not by reasoning about it. - Outputs are collected by scanning /work rather than by consulting the declared output, so tools that name their own output (hdrgen -o, dcraw_emu -Z, ra_xyze's trailing argument) need no special-casing in the runner. Where the .js/.wasm artifacts are served from is a deployment question (#232/#233), so the module loader is injected. `urlModuleLoader` covers the browser and the Tauri webview; tests supply their own. 25 new tests, 97 in the module. Seven of them are an integration suite running the real orchestrator over the real runner with only Emscripten faked -- the seam neither unit suite covers. Commenting out the output collection or the parent-directory creation fails all seven. Refs radiantlab/HDRICalibrationTool#231 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(pipeline): reimplement falsecolor in TypeScript Ports src/px/falsecolor.pl (Radiance 6.0) to TypeScript, driving pcomb, pcompos, psign and pextrem through the ToolRunner instead of a shell. Closes the last gate on the pipeline: falsecolor is Perl, so it has no wasm build, and without it the orchestrator's twelfth stage could never complete. #230. Verified byte-identical to the Perl original on a real 1000x1000 pipeline output, for both invocation shapes the app produces: falsecolor -e -i <picture> falsecolor -s 1000 -l cd/m2 -n 8 -e -i <picture> Both run against the same native Radiance binaries, so the comparison isolates this port's logic rather than tool versions or wasm effects. The reference picture is committed as a fixture rather than read from a developer's machine, and the test skips cleanly where Radiance is not installed. Two things worth knowing: - pextrem was missing from the wasm tool set, added in radiantlab/Radiance. The app always passes -e, which sets doextrem, and falsecolor.pl then shells out to `pextrem -o`. Easy to miss because falsecolor is Perl and so never appears in the tool list itself. - pc0.cal is generated from falsecolor.pl's heredoc rather than retyped: it carries the tbo palette as three 256-entry tables, and a single wrong digit would shift colours in a way no test would notice. The generator is kept alongside the fixtures, and a test compares the output against the same heredoc interpolated by Perl. Scope is deliberately narrow: contours, alternative palettes, logarithmic mapping and overlays are not implemented. There is no argv parsing to reject them through -- the function takes structured options, so an unsupported feature cannot be requested by accident. The Perl ends in a pipe (pcomb | pcompos | getinfo). With no shell, each stage writes a file the next one reads; every tool in the chain accepts a path where it accepted `-`, so the commands are otherwise unchanged. Refs radiantlab/HDRICalibrationTool#230 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(pipeline): call falsecolor from the orchestrator, and ship the artifacts Closes the loop left open when falsecolor landed: the orchestrator was still invoking a "falsecolor" tool through the runner, which cannot exist -- the original is Perl and has no wasm build. It now calls the TypeScript reimplementation, which drives pcomb, pcompos, psign and pextrem through the same runner. That changes the observable tool sequence, so the tests changed with it. The stage assertions now cover the sequence up to falsecolor and then check that its own calls follow, and the corrections test counts only the pcomb calls that write a correction output, since falsecolor uses pcomb as well. The fake runners gained pextrem output: falsecolor parses its two `x y r g b` lines to label the extrema and refuses to continue without them, so a fake that returns nothing fails every run. Also adds output naming (port of output_naming.rs), which the desktop cutover needs. Two implementations that name their outputs differently cannot be A/B compared, and that comparison is the whole point of running both. The set name is sanitised the same way -- every character outside [A-Za-z0-9-_] becomes an underscore, which is what stops "../" from steering a write out of the output directory. public/wasm/ carries the browser builds of all nine tools, under 4 MB. They are build outputs, which do not normally belong in a repository; they are here because the app must load them offline and the static export has no build step that could fetch them. The repo already carries 29 MB of native binaries for the same reason. README records where they come from, how to refresh them, and why a NODERAWFS build must not be substituted. falsecolor parity against the Perl still holds after the rewiring. Refs radiantlab/HDRICalibrationTool#231 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(home): add the WebAssembly pipeline adapter The bridge between the app and src/lib/pipeline/. It lives under app/ rather than in the library because the library must not import @tauri-apps/*: the same code has to run in a browser with no Tauri at all, and that boundary is the thing keeping the port portable. Nothing dispatches to it yet -- the call site still invokes the Rust pipeline. This commit adds the piece, wiring the switch comes next. Three details that make it a drop-in rather than a parallel universe: - Status goes out as a Tauri event, so pipeline-status-context.tsx needs no changes whatsoever. It cannot tell which pipeline produced the event, which is exactly what makes an A/B comparison meaningful. - Outputs are named by the ported output_stem, so both pipelines write <set>_<timestamp>.hdr and <set>_<timestamp>_fc.hdr into the same directory. Two implementations that named files differently could not be compared. - Each output is announced only after it is on disk, matching the Rust pipeline. Run history attributes outputs to a set by that ordering, so a set that failed must have announced none. Inputs are staged up front rather than lazily, so a missing file fails before any wasm module is instantiated rather than eight stages in. The runner is cleared afterwards; without it a batch would accumulate every set's images in JS memory. Tested against a fake host, with the runner and orchestrator injected rather than module-mocked: jest.mock does not hoist above imports under this project's SWC transform, and both already have their own tests. What is tested here is the adapter's contract with the app -- which files are staged, what the outputs are called, and that writes precede announcements. Refs radiantlab/HDRICalibrationTool#231 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(home): run the WebAssembly pipeline behind a setting Adds the dispatch. `useWasmPipeline` is off by default, so the app behaves exactly as before unless it is turned on: 61 lines across three files, and the only change to the run path is one if/else. The point of the setting is comparison, not choice. Both pipelines take the same params, emit the same status events and name their outputs identically, so nothing downstream of the dispatch can tell them apart -- which is what makes running them on the same image set worth anything. Both the setting and the Rust path go at #233 once the WebAssembly one is proven. The settings toggle says what it does and what it does not: the three tool paths stop being used, and RAW input has no wasm converter yet (#237), so JPEG and TIFF sets only. Typechecking the call site surfaced something worth keeping. The form's numeric fields are `number | null` until filled in -- diameter, both view angles, the target resolution, and the mask offsets. The Rust command takes f64 and would fail on a null just as surely, but silently at the IPC boundary. The adapter now checks all seven before anything is staged and names the field in the error, so the message says which box to go and fill rather than surfacing as a NaN in an argument list several stages later. Refs radiantlab/HDRICalibrationTool#231 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(settings): handle useWasmPipeline missing from stored settings Zustand shallow-merges at the top level, so a persisted `settings` object replaces the defaults wholesale. Anyone who saved settings before this field existed rehydrates with it undefined rather than false. Behaviour is already correct -- undefined is falsy, so the Rust pipeline runs -- but the checkbox rendered uncontrolled on first paint and would have warned when first clicked. Reads defensively instead. Recorded next to the persist config, because a setting whose default were true would need an actual migration rather than a fallback. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(pipeline): say which module failed to load, and why The browser's own error for a failed dynamic import is "Importing a module script failed" -- no module name, no URL, no cause. That is what surfaced when the WebAssembly pipeline was first run in the app, and it is not enough to act on. The modules are same-origin and fetchable, so a HEAD request first separates the common causes from each other: a wrong base URL, a build without the artifacts, or the app pointed at a different dev-server port all now produce a message naming the tool, the URL and the status. A genuine parse or policy failure is still reported as itself, with the note that the file was reachable so the fault is not a missing artifact. Also adds turbopackIgnore next to webpackIgnore. Turbopack happens to leave this import alone already -- verified in the emitted chunk -- but it is the directive that actually applies to the dev server this app runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(pipeline): argv[0], the announced output, and image filtering Three defects from the first real run of the WebAssembly pipeline in the app. **argv[0] was "this.program".** Emscripten defaults thisProgram to "./this.program", and Radiance tools record argv[0] in the picture header, so every stage was credited to it. It also broke falsecolor's cleanup: the final `getinfo -r "EXPOSURE" "pcompos " ...` strips falsecolor's own scaffolding by matching the recorded command prefix, so with the wrong name it silently removed nothing and the compositing arguments were left in the header. The runner now passes the tool name as thisProgram. **The viewer opened the false-colour image.** Rust writes <stem>_fc.hdr but emits pipeline-output only for the HDR picture; the adapter announced both, and the viewer opens the most recently announced output. Now only the picture is announced. Both files are still written. **Image filtering did not run.** filterImages was in the params the form builds and was dropped on the floor. Ported from filter_images and select_exposure_range in merge_exposures.rs: mask to the lens circle, count crushed and clipped pixels per frame, sort by brightness, and keep the run from the darkest frame with clean shadows to the first with clean highlights. Decoding is injected rather than imported, so src/lib/pipeline stays host-agnostic; the app supplies createImageBitmap plus OffscreenCanvas, and closes each bitmap because a 21-megapixel frame is ~84 MB of RGBA. Without a decoder the filter is skipped rather than failing -- merging every frame is correct, only slower. Verified against the real 18-frame JPEG bracket by reimplementing the same computation independently: it keeps 13 of 18, trimming four blown frames from the bright end and one crushed frame from the dark end. That check was worth running -- my first test expectations were wrong, because on flat synthetic frames a single frame is simultaneously the last with clean shadows and the first with clean highlights, which makes the selection look far narrower than it is on real data. Refs radiantlab/HDRICalibrationTool#231 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(presets): verify calibration files were copied, not just requested Preset calibration files were copied with copyFile and then hashed by reading the source again. Those two can disagree: a copy that landed short still recorded the hash of what the source should have contained, so the preset looked intact while the file behind it was not. That is not hypothetical. Calibration files kept on Google Drive copied as zero bytes, and an empty .cal turns its correction into a silent no-op, so successive runs with identical settings produced different results with nothing in the UI to explain it. It cost a long debugging session to find, because the recorded run parameters name the .cal *paths* and those were identical every time. Now the source is read once, checked for emptiness, written, and hashed from the same bytes. Content and hash cannot disagree, and an unreadable cloud placeholder fails at save time with a message saying what to do about it, rather than at run time as a wrong number. Worth noting the WebAssembly pipeline was already reporting this -- its cal_check warning said the file referenced no xres/yres and contained no pixel-scale constants, which is exactly what an empty file looks like. The Rust pipeline ran past it silently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * build: refresh hdrgen wasm with the integer JPEG IDCT Picks up radiantlab/hdrgen's PANLIB_JPEG_REPRODUCIBLE_DCT, so JPEG decoding is bit-identical across architectures rather than varying with the compiler's floating point code generation. Changes output: measured on the 18-frame bracket, the merged result moves about 0.11% relative to the native binary the app still ships. That is the accurate integer transform replacing a float approximation, not a regression, and it stops two users on different machines getting different numbers. Refs radiantlab/HDRICalibrationTool#235 * docs(licensing): record why LibRaw is taken under the LGPL, not the CDDL The WebAssembly port changes LibRaw from a separately-invoked binary (and on Windows a separate DLL) into statically linked code, which is a genuine change in the nature of the combination. It deserved a deliberate decision rather than being discovered at deploy time. Three things the analysis settled: LibRaw offers LGPL-2.1 or CDDL-1.0. The intuition for static linking points at the CDDL, since it is file-scoped and has no relinking provision -- but the CDDL is GPL-incompatible and this app is GPL-3, so the LGPL is the only usable option. Recorded because it is the question most likely to be reopened. LGPL-2.1 section 3 is the mechanism: it permits applying ordinary GPL terms, "version 2 ... or any later version", to a given copy. That brings the bundle inside GPL-3 with no mixed-licence question left. The fork is not relicensed; radiantlab/LibRaw keeps upstream's dual terms so it stays useful to others. The relinking obligation that motivated the concern turns out to be satisfied already, and would have been either way: LGPL section 6(a) wants the linking work available so a user can relink, and the whole app is public GPL-3 source with public forks and public CI. Also notes what serving .wasm from a static host implies (GPL-3 section 6(d) applies to a Vercel/Cloudflare deploy exactly as to a desktop release), and which optional LibRaw components stay off for licensing reasons. The README's licensing paragraph described the old separate-process arrangement, and named only LibRaw; it now covers all four components. Refs #237 * build(wasm): ship the dcraw_emu browser build, completing the RAW path The orchestrator has called dcraw_emu for RAW inputs since the port (orchestrator.ts:489); what was missing was the module itself, so a RAW bracket failed at module load. This adds it, 912 KB, bringing public/wasm to 4.7 MB. Verified in a browser on a real 5796x3870 CR2: exit 0 in 1.9 s, output byte-identical to the NODERAWFS build, heap peaking at 266 MiB or 6.5% of the wasm32 ceiling. Staging the 26 MB input grew the heap by nothing at all, since MEMFS keeps file bytes outside linear memory. Also noted in the README that the pipeline skips image filtering when input is RAW. That is not an omission in the port -- merge_exposures.rs:103 makes it an `else if` on the same branch, so the two implementations agree. Closes #237 * docs(pipeline): correct what pcomb -h actually does The comment said -h "suppresses the header pcomb would otherwise prepend". That is wrong, and inspecting real outputs shows it: pcomb's own command line is present in every finished picture. What -h does is toggle echoheader off (pcomb.c:118), which stops the *input's* header being copied through. The consequence is worth writing down because it is counterintuitive. A run with calibration files ends on this stage and keeps almost nothing: camera, hdrgen's record of which frames were merged, the original capture date, PRIMARIES, EXPOSURE, and the crop and resize lines all go. A run without calibration files runs no pcomb at all and keeps the lot. So more processing yields less provenance. Nothing numerical is lost. PRIMARIES is always Radiance's default here, and EXPOSURE is always 1 because nullify_exposure_value passes ra_xyze -o, which sets origexp = 1.0 (ra_xyze.c:105) -- that stage exists to force exactly this. No behaviour change: the argv still matches photometric_adjustment.rs. * perf(pipeline): release the merge's inputs once it has run The browser build's binding constraint is not the wasm32 ceiling, which is what #232 assumed. MEMFS keeps file bytes outside wasm linear memory (#234, and a dcraw_emu instance measured at 16.0 MiB both before and after staging a 26 MB CR2), so intermediates never count against it. They accumulate in WasmToolRunner's own Map, which nothing empties until clear() at the end of the set. The RAW path is where that bites. A 10-frame CR2 bracket stages ten source frames (~230 MB) and converts them to ten 67 MB TIFFs, and once hdrgen has merged them no later stage names either -- about 900 MB of a ~1.1 GB peak, held to the end for nothing. Sizes from the Rust pipeline's tmp/ after a real CR2 run, which writes the same intermediates. prepareInputs already knows exactly what it consumed, so this returns that list and releases it after the merge rather than introducing a general lifecycle scheme. The JPEG path releases its sources too, including frames the image filter dropped, which were staged before the run and never named again. release() is optional on ToolRunner: an implementation that ignores it is still correct, just heavier. Also adds retainedBytes(), because onHeapPeak measures the per-instance wasm heap and therefore says nothing about what the run is accumulating. Verified by mutation: with the release removed, the new test fails on the peak-versus-final assertion rather than passing quietly. * chore(tauri): delete four Rust files that were never compiled None of these has a `mod` declaration anywhere in the tree, so cargo has never built them: src/hdr_image_help.rs main.rs declares no `mod hdr_image_help` src/image_cache/cache.rs image_cache/mod.rs declares no submodules src/image_cache/dcraw_tiff.rs ditto src/image_cache/hdr_avif.rs ditto cache.rs and dcraw_tiff.rs are duplicates of code already living in image_cache/mod.rs -- dcraw_base_args, dcraw_context, get_cache_dir, compute_hash_for_file, ensure_tiff_for_raw are defined in both. It looks like a refactor that was abandoned partway. That duplication is a live hazard rather than just clutter: dcraw_tiff.rs contains the dcraw_emu flag list, so editing it to change how RAW files are converted would look entirely correct and change nothing at all. Found while tracing those flags for #242. `cargo check` is clean before and after, which is the point. * feat!: run the pipeline as WebAssembly only, and delete the Rust one The WebAssembly pipeline has been running beside the Rust one behind a setting since #231, so the two could be compared on real image sets rather than in principle. They have been, on both reference brackets: JPEG rust 885.768499 wasm 885.570826 0.0223% RAW rust 18.282153 wasm 18.282153 identical RAW agrees exactly because the difference on the JPEG path is the integer IDCT (#235) and a CR2 never goes through a JPEG decoder -- LibRaw decodes its lossless-JPEG payload itself. That is the whole discrepancy, accounted for. Removes 3,135 lines across 18 files: pipeline.rs, pipeline/ and command.rs. Tauri keeps only what serves the image viewer, which is convert_raw_img and read_hdr_metadata; those move to WebAssembly at #232, and until then the dcraw_emu sidecar, libraw.dll and dcrawEmuPath all have to stay. hdrgen's sidecar goes now, since nothing invokes it (27 MB). Settings lose radiancePath, hdrgenPath and useWasmPipeline. That is the change that actually answers the "dependencies are hard to set up" complaint: there is nothing left to point at. init.tsx no longer guesses where Radiance was installed either. useWasmPipeline is deleted rather than defaulted true, deliberately. zustand persist shallow-merges, so anyone who explicitly set it false still has false in localStorage, and dispatching on it would send them to a pipeline that no longer exists. Removed fields do linger in stored settings; that is inert and left alone, since rewriting users' settings is more risk than three dead strings. Noted in the store. RunRecord.toolPaths becomes optional and is no longer written -- there are no paths to capture, and nothing ever read it back. Old history still parses. buildPipelineParams and BuiltPipelineParams drop the tool paths outright rather than passing them to be ignored, so the next reader does not go looking for a consumer. BREAKING CHANGE: the `pipeline` Tauri command is gone. * feat!: convert RAW previews with WebAssembly, removing the last binary The viewer's RAW preview was the only thing still shelling out to a bundled executable. It now runs the same dcraw_emu.wasm the pipeline uses, so the app ships no native binaries at all and there is nothing left for a user to locate. Settings lose dcrawEmuPath, the last of the four tool paths. Removes convert_raw_img, raw_image_help.rs and image_cache/, the dcraw_emu sidecars, libraw.dll, and the whole externalBin/resources block from tauri.conf.json. Tauri keeps exactly one command, read_hdr_metadata, which moves at #232. Verified in Chromium against the reference CR2: exit 0, 67,293,432 bytes, sha256 8137c98a... -- byte-identical to both the pipeline's own conversion and a native build. That identity is the premise of #242, now measured rather than inferred, and it is why generic-image-metadata.ts shares raw-preview's cache instead of converting a second time to read the dimensions. Also confirmed SharedArrayBuffer is not defined in a page served without COOP/COEP, which is what makes narrowing MEMFS's Uint8Array to Uint8Array<ArrayBuffer> safe rather than optimistic -- callers can hand `.buffer` to the tiff worker instead of copying 67 MB to satisfy the type. useTiffPath becomes use-tiff-bytes: there is no longer a path to return, and every caller immediately read the file at that path anyway. One deliberate regression. The Rust implementation cached conversions on disk, so they survived a restart, and this cache is in memory and per session. A cache written through Tauri's filesystem is precisely what the browser build cannot use, and #242 has to choose a persistent backend regardless (OPFS, given 67 MB per frame). Re-converting costs ~2 s; carrying a throwaway implementation costs more. BREAKING CHANGE: the `convert_raw_img` Tauri command is gone. * feat(settings): report the app, Tauri and image-tool versions The header had room for the app and Tauri versions and nothing else, which left the three tools that actually do the work unreported. Now that they ship with the app rather than being installed, which build of each is in use is worth being able to see. Versions move to Settings, where there is room, and gain the tools plus a one-line note on what each one does -- a bare version number for software the user never chose is not informative on its own. The tool versions cannot be pulled at runtime. None of the Radiance tools in this build prints its version (only ranimove1.c and rpiece.c even reference VersionID, and neither is shipped), and dcraw_emu's usage banner carries no LibRaw version. So the build is the only place that knows: scripts/wasm-versions.mjs reads the three fork checkouts and writes public/wasm/versions.json next to the artifacts it describes. hdrgen 1.0.0-13-gad214f2 radiantlab/hdrgen @ ad214f25 Radiance 6.1a radiantlab/Radiance @ c9993272 LibRaw 0.22.0 radiantlab/LibRaw @ c9d6743a Emscripten 6.0.4 Commit as well as version, because all three are forks tracking upstreams that tag irregularly -- Radiance tags every commit, hdrgen and LibRaw sit well past their last tag -- so a version alone does not identify a build. `npm run wasm:versions:check` fails if the file is stale, so refreshing the .wasm without regenerating it can be caught rather than leaving the app confidently reporting the previous build. Documented alongside the existing build recipes in public/wasm/README.md. * chore: drop accidentally committed Playwright scratch output Three .playwright-mcp/ files went in with the previous commit via `git add -A`. They are per-run browser snapshots from verifying the wasm builds, of no value to anyone else. Gitignored so the next verification run does not repeat it. * docs: stop telling users to install Radiance and hdrgen The README's Getting Started section still instructed users to install Radiance and hdrgen and note their folder paths, and the Settings section still explained which paths to enter. Neither is possible now: those settings are gone and the tools ship with the app. Actively misleading rather than merely stale, since someone following it would install two dependencies they do not need and then look for fields that no longer exist. Also points the contributor list at the forks the artifacts are actually built from rather than at upstream, and marks PRD's "vendored binaries in progress (PR #207)" as superseded -- the tools run in-process as WebAssembly rather than being bundled as executables, which removes the install step and the tool paths together. * perf: convert each RAW once and share it between the viewer and the pipeline Closes #242. The duplication was worse than the issue assumed. image-set-preview.tsx renders a thumbnail for every file in a set, so uploading a 10-frame CR2 bracket already converted all ten; running the pipeline then converted them a second time. ~20 s and 673 MB of repetition on every run, not just when someone happened to preview a frame. Sharing is sound because both paths want the same bytes: dcrawArgs is one definition and both use it, verified in a browser as sha256 8137c98a... from the preview path, the pipeline and a native build alike. The pipeline gets an optional `convertRaw` injection, alongside the existing `decodeImage`. src/lib/pipeline/ stays host-agnostic and still runs dcraw_emu itself when nothing is injected, so nothing there depends on the host having a cache. Memory does not double. WasmToolRunner.writeFile stores the array it is given rather than copying, so a cached frame staged into the pipeline is the same buffer. The cache is bounded by an LRU byte budget so one bracket stays resident -- the preview-then-run case -- without a long session pinning several. Two things the in-memory cache had wrong and now does not: Keying on path alone served a stale conversion forever if the file behind it was replaced mid-session. The Rust implementation avoided that by hashing contents; size and mtime are far cheaper and catch the same case. A host that cannot stat still gets a working cache. The wasm loader was constructed internally, which made the whole module untestable. It is injectable now, and the seven tests that follow cover dedup-by-reference, argv parity with the pipeline, fingerprint invalidation, failures not being cached, and byte accounting. * feat!: move persistence to IndexedDB, and presets to storing their contents Closes #239. First half of #232. Settings, presets and run history were JSON files under Tauri's app config directory, which a browser build cannot reach. They are now in IndexedDB, and one implementation serves both hosts. IndexedDB rather than localStorage because calibration files are stored as bytes: localStorage holds strings only, so they would need base64 and grow by a third, its 5 MB quota is shared with the settings store, and its API is synchronous. The more important half is that a preset now stores its calibration files rather than copying them to disk beside a record pointing at the copy. That arrangement let the two disagree, and it did: files on a cloud drive copied as zero bytes while the preset still recorded the hash of what the source should have contained, so runs varied with nothing in the UI to explain it. Content and record are now the same object, and the desync class is gone rather than patched. That required deciding what a path means when there is no disk. src/lib/vfs.ts resolves synthetic ones, with two deliberately different lifetimes: /session/... images the user just picked, in memory, gone with the tab -- a browser cannot reopen last session's file anyway /presets/... a preset's calibration, from IndexedDB, derived from the preset id and slot so it is the same string every session That second property is the point. A preset whose .cal path stops resolving next session is the zero-byte bug wearing a different hat, so the key had to be stable by construction rather than by luck. Keeping paths as strings is also what protects the validation. PipelineParams .inputImages is string[], dcrawArgs and hdrgenArgs build argv from paths, and the wasm runner uses argv as its dependency list -- the contract the byte-identical results were measured against. Handing File objects into the pipeline would have invalidated all of it. Existing users are migrated once at startup, and the import deliberately skips zero-byte .cal files rather than carrying the corruption forward wearing a valid-looking record. It reports which, so they can be re-saved. presets.ts and raw-preview.ts now take their host file access by injection, following HostFilesystem and RawSourceIo. Two more narrow seams rather than one wide Host object, and the preset tests no longer mock Tauri at all. 297 tests, cargo check, next build, tsc and lint all pass. BREAKING CHANGE: presets and run history move to IndexedDB. Migrated automatically on first launch; the old files are left on disk untouched. * feat!: parse HDR headers in TypeScript, leaving Tauri with no commands `read_hdr_metadata` was the last one. With it ported, `main.rs` registers no `invoke_handler` at all and Tauri is purely a shell for native file access, dialogs and window management. The image viewer works in a browser unchanged. The port is also cheaper than what it replaces. The Rust command opened the picture a second time to read the same few hundred bytes the viewer had already loaded; this parses the bytes in hand. Only the first 64 KB is decoded, and as latin1 rather than utf-8, so a high byte from an RGBE payload cannot throw or shift where the blank line is found. The longest header this pipeline has produced is under 1.5 KB. Eight tests, against real headers from pipeline output rather than invented ones -- including the empty-key and empty-value cases the Rust version dropped, and the BTreeMap ordering the viewer's metadata panel relies on. Dropped the local `HdrMetadata` interface's required `FORMAT` field while wiring it up. Nothing reads it as a property; it appears only as a lookup key in illuminance-details.tsx, and the Rust command made no such guarantee either, so the type was asserting something untrue. cargo check, next build, tsc, 305 tests and lint all pass. BREAKING CHANGE: the `read_hdr_metadata` Tauri command is gone. No Tauri commands remain. * feat: run in a plain browser, Safari-first Completes the host abstraction. One build serves both: Tauri loads the same static export a web server does, and the difference is detected at runtime rather than compiled in. Importing @tauri-apps/* in a browser is harmless -- those modules only fail when called -- so the rule throughout is to gate the calls, not the imports. Built on <input type="file"> and downloads rather than File System Access. Safari implements none of File System Access: no showOpenFilePicker, no showDirectoryPicker, no showSaveFilePicker. So the input element is not a degraded fallback, it is what most non-Chromium users get, and making it the primary means the path everyone takes is the path that was built and tested. Six narrow seams under src/lib/host/ rather than one wide Host object, following HostFilesystem and RawSourceIo which were already proven here: env.ts isTauri, capabilities, app info, platform pick.ts files, directories, and grouping images into sets save.ts writing outputs, or downloading them events.ts the pipeline status channel image-src.ts a URL an <img> can load, for a path or for bytes file-info.ts size and mtime, virtual or real reveal.ts show in file manager, desktop only Three of these are decisions rather than translations, and are worth naming: Output cannot use showSaveFilePicker even where it exists, because it needs a user gesture per file and a batch produces two per set. A browser downloads; the browser decides where, which is why the output-path setting is hidden there rather than left looking meaningful. Directory picking returns the files inside rather than a directory path. A browser cannot produce one, and every caller enumerated it immediately anyway. Grouping into sets moved into pick.ts because it is the part that differs: a directory on the desktop, webkitRelativePath in a browser. Drag and drop branches rather than adapts. Tauri reports drops at the window level with OS paths and window-relative coordinates, which is why the hit test exists; a browser fires DOM events on the element and hands over File objects. Status events stop being Tauri events entirely. The pipeline runs in the page now, on the same side as the UI listening to it, so an EventTarget is what Tauri's API was standing in for. That also closed a real leak: unsubscribing is synchronous, where listen() returned a promise for an unsubscribe, and an unmount before it settled left the listener attached. Verified by driving the built static export in Chromium with no Tauri present: the 18-frame JPEG bracket uploads through the real file chooser, all 18 thumbnails render from object URLs, file sizes read from virtual paths, and clicking a thumbnail brings up the lens-mask fields. 305 tests, cargo check, next build, tsc and lint all pass. * fix: make the app work as a website, not just a webview Four things found by actually running the built export in a browser rather than reasoning about it. **The site root 404'd.** There was no `src/app/page.tsx`, so the export contained no index.html at all. It never showed on the desktop because Tauri opens /home-page directly and nothing ever asks for /. Added a root that replaces into /home-page -- client-side, since a static export has no server to redirect from. **Pages taller than the viewport were clipped, not scrolled.** The body is `h-screen overflow-hidden` so the generator page can drive its own resizable panels, and every other page inherited that with no scroll of its own. The layout now hands each page a bounded box and the pages that need it scroll inside it. `min-h-0` is the load-bearing part: without it a flex child refuses to shrink below its content and never overflows at all. Settings and the viewer's metadata card were both affected; the viewer's could not even be scrolled once bounded, because it was `pointer-events-none`. **Dark mode existed but was unreachable.** globals.css has had a full `.dark` token block all along and nothing ever put the class on <html>, so pages drifted into hardcoded greys instead -- 45 of them. Those are now semantic tokens, and a provider follows the system unless the user picks otherwise. Defaulting to "system" rather than "light" because a desktop app that ignores the OS looks broken beside everything else. The first render is always "system" and the stored choice is read in an effect: this is a static export, so the build-time HTML cannot know the preference and reading localStorage during render would break hydration. **Two controls that could not work were still on screen.** "Open folder" has no meaning without a file manager, and a downloaded picture has no path to reveal, so it is hidden rather than left to do nothing when clicked. And a download is invisible -- the browser chooses where it lands -- so the run now says which file went to the downloads folder instead of reporting success with nothing to show for it. Also corrected a capability check that lied. `canWriteToChosenDirectory` returned true for Chromium because `showDirectoryPicker` exists, while `save.ts` downloads in every browser, so the settings page offered an output path nothing honoured. It is desktop-only until directory handles are actually implemented. Outputs are now kept in the session filesystem as well as downloaded, so "view result" still works after a browser run. Without that the viewer had nothing to open: the download belongs to the browser and the app cannot read it back. Verified in Chromium against the built export: root redirects, dark mode applies through the shell and persists, settings scrolls, and an .hdr opened from disk renders with COMPUTED_VERTICAL_ILLUMINANCE 885.768499 read by the TypeScript header parser. * docs: deployment guide, and the GPL source offer the web build requires The Settings page now carries a link to the Corresponding Source. That is an obligation rather than a nicety: serving .wasm is conveying object code under GPL-3, and section 6(d) asks for "clear directions next to the object code". A link in the repository is not next to the object code; this is. DEPLOYMENT.md covers Vercel and any other static host, and states the two things worth knowing rather than discovering: no COOP/COEP headers are needed, because every wasm tool is deliberately single-threaded, and public/wasm must be served as application/wasm. It also records what the web build genuinely cannot do -- outputs are downloaded wherever the browser puts them, files picked in a previous session cannot be reopened, and Safari and mobile are unmeasured -- so those are known limits rather than surprises. Verified against the final built export in Chromium, end to end with no Tauri: the 18-frame JPEG bracket runs to completion, both outputs download with a message naming each one, and "open image" then renders the result from the session filesystem with its header parsed in TypeScript. * chore: drop a screenshot committed by accident A Playwright screenshot from verifying dark mode went in with the deployment docs. It is a scratch artifact, not documentation. * chore(deps): update 40 packages within their major versions Routine catch-up: React 19.1 to 19.2, Tailwind 4.1 to 4.3, three 0.175 to 0.185, the Radix set, the Tauri plugins, jest, biome. One source change was needed. react-error-boundary 6.1 widened the `error` it hands a fallback from `Error` to `unknown`, which is the more honest type -- `throw` accepts any value, and a rejected promise carrying a string is common enough that reading `.message` off it was only ever safe by convention. Both call sites go through a small helper now. tsc, 305 tests, next build and lint all pass. Major bumps are held back for separate changes, so a break can be attributed. * chore(lint): turn off noUnnecessaryConditions, which only fires falsely here The Biome 2.5.6 bump made this rule fire at seven sites. Every one is a case where the type is more confident than runtime reality, and removing the guard would introduce a crash rather than delete dead code: React refs, four times. `.current` is typed from its initial value, so `if (viewport)` on a DOM ref reads as always-falsy and `if (!dragStateRef .current)` as always-truthy. Both are wrong the moment the component mounts or a drag begins. Unvalidated reads from storage, twice. `readJson<T>` casts whatever came out of IndexedDB to T without checking its shape, so the type promises an array where a corrupt document may hold anything. The `?? []` guards reality. And once each on an overload it resolves wrongly, and on noUncheckedIndexedAccess -- that last already carried an inline suppression before the rule widened, which is its own evidence. Off globally with the reasoning recorded, matching how noBitwiseOperators is handled, rather than seven scattered suppressions for a rule that has caught nothing real. Worth revisiting if Biome learns that a ref's current value is not its initial one. * chore(deps)!: take five major bumps @types/node 20 -> 26 @testing-library/jest-dom 6 -> 7 lucide-react 0.547 -> 1.28 react-dropzone 14 -> 19 react-zoom-pan-pinch 3 -> 4 Only one needed a source change: react-zoom-pan-pinch renamed `onTransformed` to `onTransform`, with the same arguments and timing. lucide-react's move to 1.0 and react-dropzone's 14-to-19 jump both turned out to be no-ops here -- the icons used are all still exported under the same names, and the dropzone is only used through TauriDropzone, which drives DOM events directly rather than the hook. Taken as one commit because each verified clean on its own; Next and TypeScript are held back separately, being the two with real blast radius. * chore(deps)!: Next 15.3 to 16.2 No source changes were needed. Next migrated tsconfig.json itself, setting `jsx: react-jsx` for the automatic runtime and adding `.next/dev/types` to `include`; the rest of that diff is its reformatting. Verified rather than assumed, because a framework major is where a static export usually breaks quietly: `out/` still contains index.html and all six routes, the built page renders and hydrates in a browser with no Tauri, and the theme toggle and dropzone are live. 305 tests, tsc, cargo check and lint pass. * chore(deps)!: TypeScript 5.8 to 6, and drop the es5 target with it Not 7, deliberately. TypeScript 7 installs and typechecks this project clean, but Next 16 refuses it: "TypeScript 7.0.2 does not provide the compiler API required by Next.js. Enable experimental.useTypeScriptCli ... or install TypeScript 6 instead." Taking 6 rather than turning on an experimental flag in a build meant for production. 7 is a one-line change once Next supports it. TypeScript 6 removed `target: es5`, which this project still carried from create-next-app and which was never true of where it runs -- a Tauri webview and modern browsers. Now ES2022, and three workarounds it forced are gone: iterating a Map's values directly instead of forEach with a lint suppression, `sets.entries()` without an Array.from copy, and a stale comment on the standard-deviation reduce. Also excluded `out` and `.next` from tsconfig. `include` is `**/*.ts`, so it was sweeping build artifacts back in and typechecking a worker file Next had copied into out/. Latent all along; TypeScript 6 is just the first to trip over it. 305 tests, tsc, next build, cargo check and lint all pass. * fix(settings): stop the action bar covering the end of the page The Clear/Apply bar was `fixed bottom-0`, so it floated over the scroll container and sat on top of whatever the page ended with -- most visibly the "About this build" card, which was simply unreachable. Bottom margin cannot fix that reliably, because the bar's height is not the margin's business. It is a sibling of the scroll area now rather than floating over it: the page is a column, the content scrolls in the middle, the bar sits below at its natural height. Correct at any viewport and with any amount of content. Also tokenised three greys the bar still carried, which the earlier sweep missed because they were shades I had not listed. Adds four logo directions under public/logo/, drawn from the six-blade aperture already used beside "Projection type" and coloured from the palette in globals.css -- whose osu-luminance and osu-candela are photometric units, which is the right well for this. Nothing points at them yet. * feat: adopt the aperture marks, A for the app and D for the favicon The header and the bundled app icons take mark A, the exposure stack. The favicon takes mark D, the flat single-colour mark, which is the only one still unmistakably an aperture at 16px. Two things the specimen sheet did not catch, both found by measuring: A's darkest blade is #241a14, which is 1.11:1 against the dark ground -- not subtle, invisible, leaving a visible gap in the iris now that the app has a dark theme. There is a dark variant whose ramp starts at ember rather than ink, lowest blade 2.03:1, swapped on the theme class. It keeps the dark-to-bright reading that makes it an exposure bracket in the first place. The header logo's src was relative, so it resolved against the current directory and 404'd on /image-viewer/view. Absolute now. Pre-existing, and only reachable since the app started being served as a website. The favicon is theme-aware too: beaver orange, luminance yellow in a dark tab strip. A browser that ignores a media query inside an SVG favicon keeps the orange, which is the safe half. App icons regenerated with `tauri icon` from a padded 1024px source -- an edge-to-edge glyph reads as cropped in a dock. Dropped the android/ and ios/ sets it also emits; this app has no mobile targets. * fix(mask): keep the full-size editor at the image's aspect ratio The circle and its handle are positioned as percentages of the box the overlay sits in, which is only correct while that box is exactly the image's shape. The box was `h-full max-w-full` plus an aspect-ratio, which is height-driven: width derives from height, and on a tall window the derived width exceeds the dialog, so `max-w-full` clamps it. A clamped width against a fixed height means the aspect-ratio no longer holds, the picture letterboxes inside the box, and the circle keeps being drawn against the full box -- so it lands off the image. That is why it only went wrong on taller screens: that is when the clamp engages. Measured at 1100x1500 before and after, with the old rule reapplied in the page as a control: fixed overlay 940x627 aspect 1.500 (image is 1.5) old rule overlay 940x1208 aspect 0.778 `min(100%, 100cqh * ratio)` states the constraint directly instead: as wide as the viewport allows, never taller than it. Both axes are satisfied by construction, so nothing clamps and the ratio always holds. The parent gets `container-type: size` so cqh resolves against it. The inline preview was never affected: it is width-driven in a sidebar, so nothing ever clamped it. * perf: compile each wasm tool once instead of on every stage Reported as "much slower on the Vercel deployment than locally", which was the clue: whatever the difference was, it had to be network, because the work itself is identical client-side code. A fresh module instance is created for every stage -- EXIT_RUNTIME=1 allows one main() each -- and left to itself the Emscripten glue re-fetches and re-compiles the .wasm on every one of them. The runner cached the JS factory and nothing else, so the binary went over the wire per stage. Locally that comes from cache and only looks like overhead. On a host serving public/ with `must-revalidate` it is a round trip per stage, and hdrgen.wasm is 2.6 MB. Measured over six instantiations of hdrgen in Chromium: glue fetches + compiles each 6 requests 9.1 ms per instantiation compiled module reused 0 requests 1.2 ms per instantiation 7.6x on the instantiation itself, before counting the network. Note that `wasmBinary` does not work here: these builds declare the variable and never read it back off the module argument, so passing it is silently ignored -- that was the first thing I tried, and the request count did not move. `instantiateWasm` is the hook the glue actually honours, and handing it an already-compiled module skips fetch and compile together. Compiled modules are cached by URL rather than per runner, because a runner is created per pipeline run and another per RAW preview; caching inside one would still recompile dcraw_emu for every thumbnail. A compiled WebAssembly.Module is immutable and holds no instance state, so sharing it is safe. Verified with a full run of the built export: nine wasm requests for nine distinct tools across the whole pipeline, where it was previously one per stage. Also adds vercel.json setting immutable, year-long caching on /wasm. Vercel serves public/ with `max-age=0, must-revalidate` by default, so even the first fetch of each tool was revalidating on every reload. * fix: run the pipeline in a Web Worker so the page stays responsive Reported as the page not responding while merging. It was not a hang: the pipeline was running on the main thread. `callMain` is synchronous and blocks its thread for the whole of a tool, and hdrgen merging a bracket is tens of seconds of solid work. On the main thread that means no repaints, no clicks, no progress bar moving, and eventually the browser's "page is not responding" prompt -- while the run is in fact proceeding normally underneath. My mistake: #231 is titled "port the pipeline orchestrator from Rust to a TypeScript Web Worker" and I built it inline. Measured on the built export, a 6-frame JPEG set with a 100 ms heartbeat on the main thread: 207 beats over a 20.8 s run, worst gap 102 ms. The main thread is never blocked; the worst stall is 2 ms of jitter. The worker reads no files. The page stages the bytes and transfers them in -- transferred, not copied, since a bracket is hundreds of megabytes -- because only the page knows how to reach a file, Tauri's filesystem on the desktop and the virtual filesystem in a browser. That is what lets one worker serve both. The image filter comes along too: createImageBitmap and OffscreenCanvas both exist in a worker, so nothing had to stay behind. RAW conversions the page already holds are handed over rather than redone. They are peeked from the cache, never converted on the main thread, because converting there would put the ~2 s a frame back where this change just took it from. `prepareInputs` skips a conversion whose output is already staged, so the worker converts only what is missing. Keeps the #242 win without the freeze. Stopping now terminates the worker between status events. That is the same granularity as before -- callMain could not be interrupted mid-stage on the main thread either. The adapter's tests inject an in-process executor rather than a fake runner, since a worker is precisely what jsdom cannot provide. They still assert the staging order, output naming and announcement ordering they always did. Not verified on the desktop build: I have no way to exercise the Tauri webview here, and while `new Worker(new URL(...))` is standard and the app already ships a tiff worker the same way, that path deserves a run before release. * fix(deps): realign the Tauri crates with the npm plugins The desktop build was broken. `chore(deps)!: take five major bumps` moved the npm side forward and left Cargo.lock where it was, so `tauri build` refused to start: tauri-plugin-dialog (v2.4.0) : @tauri-apps/plugin-dialog (v2.7.2) tauri-plugin-fs (v2.4.2) : @tauri-apps/plugin-fs (v2.5.1) Found by running the desktop end-to-end suite, which is exactly the gap the new desktop CI workflow closes: nothing in CI compiled the Rust crate after that bump. While here, dropped five dependencies that nothing references any more. `main.rs` registers plugins and shows a window; the pipeline, the RAW converter and the image cache all moved to WebAssembly and IndexedDB in the frontend, and took `image`, `rayon`, `blake3`, `chrono` and `serde` with them. Also removed the empty `src-tauri/binaries/`, left behind when the sidecars went. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(nav): give the logo a width so the title sits flush left The mark carries `width={512} height={512}` and the class list set only `h-10`. CSS height overrides the height attribute; nothing overrode the width, so the box stayed 512px wide with `object-contain` letterboxing the 40px mark inside it. The logo looked correct and silently reserved half the header. Measured at 1440px wide: the `#logo` group started flush left at 32px as intended, but ran 760px wide and put the title at x=556 -- which is 512 (the width attribute) + 12 (`mr-3`) exactly. `w-10` fixes it. Also swapped the per-element margin for a `gap`, moved the tutorial button out of a wrapper div it did not need, and dropped `text-right`, which did nothing next to `justify-between`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(save): space downloads apart so WebKit does not drop one Safari users were losing the luminance map. A run saves two files back to back, the picture and then the false-colour map. WebKit drops a download outright if another starts in the same task, and it keeps the *later* one -- so Safari delivered the false-colour map and nothing else, while the run reported success for both. Measured directly, with the same anchor and blob URL `save.ts` uses and nothing else running: gap WebKit Chromium 0ms 1 event (the second only) 2 events 250ms 2 events 2 events 1000ms 2 events 2 events 300ms carries a margin over the smallest gap that worked without being long enough to notice. Downloads are queued through a promise chain rather than gated on a timestamp, so concurrent callers space out too instead of both reading the same clock and firing together. Caught by the new Playwright suite, which runs WebKit first for exactly this reason. The pipeline itself was never at fault: it completes in WebKit in about 40 seconds and always did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(web): drive the browser build with Playwright The desktop suite structurally cannot reach the half of the app that only exists in a browser: files arrive through a file dialog rather than as dropped paths, outputs leave as downloads rather than as writes to a chosen folder, and there is no filesystem behind the pipeline at all. None of that was covered. Two suites rather than one, and not by preference. Playwright cannot attach to a Tauri window: neither WKWebView nor WebKitGTK exposes a CDP endpoint for it to speak to. So WebdriverIO keeps the desktop and Playwright takes the web. They share `e2e-tests/test/inputs/` rather than copying it, so they cannot drift on to different brackets while both stay green. WebKit is listed first in the config and runs first in CI. Safari implements no part of the File System Access API, so it takes the plain file-input and download path -- which is what this application ships to everyone. That ordering has already earned itself: it found the download bug fixed in the previous commit, which Chromium cannot see. Sixteen tests, passing on both engines in 5.2 minutes, including a full pipeline run from bracket to two verified Radiance pictures. Three seams were harder than expected and are commented where they live: - `pick.ts` creates a file input, clicks it and discards it, so there is no element for `setInputFiles` to target. The `filechooser` event is the only handle on a picker opened that way. - Radix keeps a *collapsed* accordion section's content mounted, so a field inside one has a bounding box and reads as present while its own header paints over it. The picker is not hidden, it is covered, and clicking it waits forever. `elementFromPoint` is what shows this; a bounding box does not. - Two concurrent `waitForEvent("download")` calls are two one-shot listeners on one event, not a queue. Both resolve on the same first download, and the pair reads as one file delivered twice. Also stopped Jest from collecting either e2e suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(lint): ignore generated artifacts, and clear what was left `npm run check` reported 12,784 errors and had plainly stopped being run. A check nobody runs is not a check, and the new web CI workflow runs this one on every push, so it had to actually mean something. About 12,700 of those were generated files: Emscripten glue emitted beside each `.wasm`, marks exported as single-line SVG, and schemas written by the Tauri CLI. Formatting them produces enormous diffs that say nothing and would be undone by the next regeneration, so they are excluded rather than reformatted. That left 53. Of those, 48 were formatting and are applied here. The remaining five were real, if small: - `compileFrom` discarded the streaming-compile error in its fallback path. On a host serving `.wasm` with the wrong MIME type the status code is the useful half; on a genuinely broken module the original compile error is. Now carried as `cause`. - `peekRawTiff` swallowed failures in a bare `catch {}`. Correct -- it only peeks, and undefined sends the caller down the cache-miss path where the worker converts and reports properly -- but worth saying. - `tauri-dropzone` nested a `.then` inside a `.then` and had crept over the complexity limit. Flattened to sequential awaits and the hit test extracted; behavio…
1 parent 2a9ef12 commit 67ef8ec

209 files changed

Lines changed: 19911 additions & 11219 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci-desktop.yml

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# Checks the Tauri shell on all three platforms it ships to.
2+
#
3+
# The application logic is covered by the web CI, because it is the same code:
4+
# the pipeline is WebAssembly and the frontend is a static export, and Tauri
5+
# only wraps them in a native window. What is left for this workflow is
6+
# everything that is *not* shared -- that the Rust crate compiles on each
7+
# target, that its dependencies still agree with the npm side, and that the
8+
# app actually runs in a native webview rather than only in Chromium.
9+
#
10+
# That last point earns its cost. Two failures this workflow exists to catch
11+
# have already happened: a dependency bump moved the Tauri npm plugins forward
12+
# while Cargo.lock stayed put, which fails the build outright, and the pipeline
13+
# moved into a Web Worker, which was verified in a browser but not in
14+
# WKWebView or WebKitGTK.
15+
16+
name: CI (desktop)
17+
18+
on:
19+
pull_request:
20+
branches: [main]
21+
push:
22+
branches: [main]
23+
workflow_dispatch:
24+
25+
concurrency:
26+
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
27+
group: ci-desktop-${{ github.ref }}
28+
29+
permissions:
30+
contents: read
31+
32+
jobs:
33+
rust:
34+
name: Rust checks
35+
runs-on: ubuntu-22.04
36+
steps:
37+
- uses: actions/checkout@v7
38+
39+
- name: Install system dependencies
40+
run: |
41+
sudo apt-get update
42+
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
43+
44+
- uses: dtolnay/rust-toolchain@stable
45+
with:
46+
components: clippy, rustfmt
47+
48+
- uses: Swatinem/rust-cache@v2
49+
with:
50+
workspaces: src-tauri
51+
52+
- name: Format
53+
run: cargo fmt --manifest-path src-tauri/Cargo.toml --check
54+
55+
- name: Clippy
56+
run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings
57+
58+
build:
59+
name: Build (${{ matrix.platform }})
60+
runs-on: ${{ matrix.platform }}
61+
strategy:
62+
fail-fast: false
63+
matrix:
64+
include:
65+
- platform: macos-latest
66+
args: --target universal-apple-darwin
67+
- platform: ubuntu-22.04
68+
args: ""
69+
- platform: windows-latest
70+
args: ""
71+
steps:
72+
- uses: actions/checkout@v7
73+
74+
- uses: actions/setup-node@v7
75+
with:
76+
cache: npm
77+
node-version: lts/*
78+
79+
- uses: dtolnay/rust-toolchain@stable
80+
with:
81+
# Only the macOS runner builds a universal binary, so only it needs
82+
# both architectures installed.
83+
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
84+
85+
- uses: Swatinem/rust-cache@v2
86+
with:
87+
key: ${{ matrix.platform }}
88+
workspaces: src-tauri
89+
90+
- name: Install system dependencies (Ubuntu)
91+
if: matrix.platform == 'ubuntu-22.04'
92+
# webkit2gtk 4.1 is the Tauri v2 one. The 4.0 package that used to be
93+
# installed alongside it was for Tauri v1 and does nothing here.
94+
run: |
95+
sudo apt-get update
96+
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
97+
98+
- run: npm ci
99+
100+
- name: Build
101+
# No bundling. Producing installers is the release workflow's job, and
102+
# skipping it here saves several minutes per platform on every push
103+
# without weakening what this job actually checks: that it compiles.
104+
run: npm run tauri build -- --no-bundle ${{ matrix.args }}
105+
106+
e2e:
107+
name: End-to-end (${{ matrix.platform }})
108+
runs-on: ${{ matrix.platform }}
109+
strategy:
110+
fail-fast: false
111+
matrix:
112+
platform: [ubuntu-22.04, windows-latest]
113+
steps:
114+
- uses: actions/checkout@v7
115+
116+
- uses: actions/setup-node@v7
117+
with:
118+
cache: npm
119+
node-version: lts/*
120+
121+
- uses: dtolnay/rust-toolchain@stable
122+
123+
- uses: Swatinem/rust-cache@v2
124+
with:
125+
key: e2e-${{ matrix.platform }}
126+
workspaces: src-tauri
127+
128+
- name: Install system dependencies (Ubuntu)
129+
if: matrix.platform == 'ubuntu-22.04'
130+
run: |
131+
sudo apt-get update
132+
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xvfb
133+
134+
- run: npm ci
135+
136+
- run: npm ci --prefix e2e-tests
137+
138+
# No Radiance or hdrgen is downloaded any more. Both are WebAssembly
139+
# shipped in the repository, so the run that generates an HDR image --
140+
# which never worked reliably in CI without real binaries -- now runs
141+
# unconditionally, on every platform, with nothing installed.
142+
- name: Run (Linux, headless)
143+
if: matrix.platform == 'ubuntu-22.04'
144+
run: xvfb-run --auto-servernum npm run test:e2e:desktop
145+
146+
- name: Run (Windows)
147+
if: matrix.platform == 'windows-latest'
148+
run: npm run test:e2e:desktop

.github/workflows/ci-web.yml

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# Checks the web build: lint, types, unit tests, the static export, and the
2+
# Playwright suite that drives it in a real browser.
3+
#
4+
# This is the one that runs on every push and pull request, because it is the
5+
# one that covers the whole application. The pipeline is WebAssembly and the
6+
# frontend is a static export, so a browser exercises the same code the
7+
# desktop app runs -- the desktop job exists to prove the Tauri shell around it
8+
# still builds, not to test the pipeline a second time.
9+
10+
name: CI (web)
11+
12+
on:
13+
pull_request:
14+
branches: [main]
15+
push:
16+
branches: [main]
17+
workflow_dispatch:
18+
19+
concurrency:
20+
# A newer push makes an in-flight run irrelevant. Not applied to `main`,
21+
# where every commit's result is worth keeping.
22+
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
23+
group: ci-web-${{ github.ref }}
24+
25+
permissions:
26+
contents: read
27+
28+
jobs:
29+
check:
30+
name: Lint, types and unit tests
31+
runs-on: ubuntu-latest
32+
steps:
33+
- uses: actions/checkout@v7
34+
35+
- uses: actions/setup-node@v7
36+
with:
37+
cache: npm
38+
# Both lockfiles. Keyed on the root one alone, the Playwright
39+
# install in the e2e job is uncached on every run.
40+
cache-dependency-path: |
41+
package-lock.json
42+
e2e-web/package-lock.json
43+
node-version: lts/*
44+
45+
- run: npm ci
46+
47+
- name: Lint
48+
run: npm run check
49+
50+
- name: Typecheck
51+
run: npx tsc --noEmit
52+
53+
- name: Unit tests
54+
run: npm test -- --ci
55+
56+
- name: Check versions.json is present and well formed
57+
# Not `npm run wasm:versions:check`, which was here and wrong.
58+
# That regenerates the file from sibling *fork checkouts*
59+
# (`../LibRaw`, `../Radiance`, `../hdrgen`) and compares -- a tool for
60+
# whoever rebuilt the artifacts, run from the tree they were built in.
61+
# CI has no such checkouts and should not: cloning three forks to
62+
# re-derive a committed file would be checking the clone, not the
63+
# build. The real check belongs with the rebuild job in #244, which
64+
# has those sources by construction.
65+
#
66+
# What is worth checking here is that the file the Settings page reads
67+
# actually shipped and parses, since an export missing it renders fine
68+
# and simply stops saying which Radiance produced your luminance map.
69+
run: |
70+
node -e "
71+
const v = require('./public/wasm/versions.json');
72+
const tools = Object.keys(v.tools ?? {});
73+
if (tools.length === 0) { throw new Error('versions.json lists no tools'); }
74+
for (const [name, tool] of Object.entries(v.tools)) {
75+
if (!tool.version) { throw new Error(name + ' has no version'); }
76+
}
77+
console.log('versions.json describes ' + tools.length + ' tools:', tools.join(', '));
78+
"
79+
80+
build:
81+
name: Static export
82+
runs-on: ubuntu-latest
83+
steps:
84+
- uses: actions/checkout@v7
85+
86+
- uses: actions/setup-node@v7
87+
with:
88+
cache: npm
89+
# Both lockfiles. Keyed on the root one alone, the Playwright
90+
# install in the e2e job is uncached on every run.
91+
cache-dependency-path: |
92+
package-lock.json
93+
e2e-web/package-lock.json
94+
node-version: lts/*
95+
96+
- run: npm ci
97+
98+
- run: npm run build
99+
100+
- name: Check the export is actually servable
101+
# A static export with no index.html 404s at the root, which is the
102+
# first thing a visitor sees and the last thing a build log mentions.
103+
run: |
104+
test -f out/index.html
105+
test -f out/home-page.html
106+
test -f out/wasm/versions.json
107+
test -f out/wasm/hdrgen.wasm
108+
109+
- uses: actions/upload-artifact@v7
110+
with:
111+
name: web-build
112+
path: out
113+
retention-days: 7
114+
115+
e2e:
116+
name: End-to-end (${{ matrix.project }})
117+
needs: build
118+
runs-on: ubuntu-latest
119+
strategy:
120+
fail-fast: false
121+
matrix:
122+
# WebKit first in the list and first in the config, deliberately.
123+
# Safari implements no part of the File System Access API, so it takes
124+
# the plain file-input and download path -- which is the path this
125+
# application ships to everyone.
126+
project: [webkit, chromium]
127+
steps:
128+
- uses: actions/checkout@v7
129+
130+
- uses: actions/setup-node@v7
131+
with:
132+
cache: npm
133+
# Both lockfiles. Keyed on the root one alone, the Playwright
134+
# install in the e2e job is uncached on every run.
135+
cache-dependency-path: |
136+
package-lock.json
137+
e2e-web/package-lock.json
138+
node-version: lts/*
139+
140+
- name: Reuse the export from the build job
141+
# Rather than rebuilding per browser. It is the same bytes either way,
142+
# and this is the artifact that would actually be deployed.
143+
uses: actions/download-artifact@v8
144+
with:
145+
name: web-build
146+
path: out
147+
148+
# `working-directory` rather than `npm --prefix`. `--prefix` moves where
149+
# npm resolves packages, not the working directory, so Playwright looked
150+
# for its config at the repository root, found none, and reported
151+
# `Available projects: ""`.
152+
- name: Install
153+
run: npm ci
154+
working-directory: e2e-web
155+
156+
- name: Install browsers
157+
run: npx playwright install --with-deps ${{ matrix.project }}
158+
working-directory: e2e-web
159+
160+
- name: Run
161+
run: npx playwright test --project=${{ matrix.project }}
162+
working-directory: e2e-web
163+
164+
- if: ${{ !cancelled() }}
165+
uses: actions/upload-artifact@v7
166+
with:
167+
name: playwright-report-${{ matrix.project }}
168+
path: |
169+
e2e-web/playwright-report
170+
e2e-web/test-results
171+
retention-days: 7

0 commit comments

Comments
 (0)