Commit 67ef8ec
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
- .github/workflows
- __tests__
- e2e-tests
- test/specs
- e2e-web
- tests
- licenses
- public
- logo
- wasm
- scripts
- src-tauri
- binaries
- gen/schemas
- icons
- src
- image_cache
- pipeline
- src
- app
- home-page
- image-viewer
- view
- runs
- settings-page
- stores
- components/ui
- (image)
- (tiff-image)
- lib
- host
- pipeline
- __fixtures__
- storage
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
0 commit comments