Skip to content

v2: Pure-Rust rewrite (egui, no Swift) — do not merge until M6 gate - #6

Draft
Kathir-D wants to merge 115 commits into
mainfrom
v2
Draft

Kathir-D wants to merge 115 commits into
mainfrom
v2

Conversation

@Kathir-D

@Kathir-D Kathir-D commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner

Draft PR for the pure-Rust rewrite of v1 on the v2 branch — the contract is docs/v2-plan.md; this stays draft until the owner moves it (agents never merge into main), and testing is tiered per plan §9.

Status (2026-10-20) — M1 vertical slice complete pending the owner's README pixels:

  • Workspace ladder + lint regime in place (cargo xtask layers, never-crash lints, the LibRaw unsafe carve-out enforced by CI).
  • Engine core ported and tested: session/scan/batch, model, settings (shapes checked against the real v1 settings file), rating modes, undo/redo.
  • M1 slice end-to-end on every surface: window (filmstrip + loupe + keyboard, egui 0.37/wgpu), firstcut-cli run (headless chain, one JSON line per command), firstcut --control <port> (engine.* + ui.widgets/ui.clickWidget/ui.screenshot), firstcut-cli mcp (registry as MCP tools over stdio).
  • Headless UI snapshots: apps/firstcut/tests/snapshots/*.png via kittest (green on all three OSes).
  • Opt-in real-photo tests over the owner's corpus: 475 passed, 0 failed (scripts/test-with-photos.sh).
  • Local milestone gate green: cargo xtask ci → pass (fmt, clippy -D warnings, layers, assets, full workspace tests); CI matrix green on the tip.
  • Showcase: docs/showcase/m1-screenshot.sh drives the real app through the control channel and saves the screenshot outside the repo (pixels are the owner's; README image needs the owner's word).
  • Parity status row-by-row: docs/parity-checklist.md (the merge gate reads that file). Known M2 finding recorded there: decoded pixels ignore EXIF orientation.
  • The real-photo crash found by the showcase run (6000×4000 texture over egui's max texture side) is fixed with regression tests.

Next: M2 (decode priority window, cancellation, Exact RAW via LibRaw, memory budget, watcher/histogram, bench rows).

…ing→catalog→decode→engine→ui, apps, libraw carve-out crate)
…s), ci (full gate writing report.json+summary.md), smoke (window proof)
… + carve-out, layers, tiered tests), CLAUDE.md points at it, todo §0.5 handoff
… cfg'd, nix unix-only, gated tests, platform-graded u64 widenings)
…lback); platform lint fixes; xtask echoes 250 lines of a failing step
…ugh the registry; the app drives decodes and settings
Prefetch the display tier 32 ahead/12 behind, plus eight-frame heads of the neighbouring batches, bounded by the display budget. Four workers measure 54-59 display decodes/s against the 20/s a 20 Hz hold needs, while the 6-8 ms preview covers the lead-in and any batch boundary.\n\nParallelise the 24 MP display downsample (about 10 ms of the single-frame cost) and add a four-worker throughput row to bench --decode so the 20 Hz claim stays measured.
Decode JPEGs into a 4-byte-aligned RgbaBuffer and turn it into egui's Color32 with bytemuck::try_cast_vec in the decode worker, so the paint thread no longer converts 4.1 ms of pixels and clones 0.6 ms of texture data per 3240x2160 frame. Texture uploads share the decoder's Arc when the frame fits max_texture_side.

Raise the automatic decode bank from four workers to one per core up to eight, and benchmark 48 decodes so the sustained row is not dominated by worker start-up. Add 5K/6K fitted-target rows.

Measured on the M1 Pro, Game1JENKS/Game3KC: 85.2/79.1 fps at 2048 px, 548/441 at the 1350 PRVW fit, 76.1/65.1 at the 3240 4K fit, 65.3/55.1 at 4320, 52.9/35.9 at 5076. Exact RAW stays at 0.343-0.346 s.
The Exact RAW develop spent four full-frame passes on things that can be one:
rawler's CropDefault copy (96 MB, 22 ms), its sRGB gamma step (a powf per
channel, 44 ms), the f32 to RGBA8 pack (12 ms), and the orientation rotate
(32 ms). The crop rectangle is now read in place (LinearFrame), the sRGB curve
is a 16-bit-indexed table built from rawler's own srgb_apply_gamma, and encode
+ pack + rotate happen once, destination-row parallel, tiled 64x64 for the
transposing orientations because a full-height column walk measured 54 ms.

Warm Exact RAW on a 24 MP CR3: 0.343 s -> 0.286 s on Game1JENKS and Game3KC.
The opt-in real-photo test asserts the fused output stays within one 8-bit
level of rawler's stock pipeline (measured: max 1, 1% of pixels).

bench --decode --stages is new: it times rawler's passes on one file in one
process so the next optimisation can be aimed (decode 0.119, demosaic 0.099,
tail 0.055 of the current 0.286).
v2's packaging carried a different icon — a crop bracket around an F — that
was drawn for the port. The owner's mark is the v1 one, so `assets/branding/`
now holds v1's own appiconset exports byte-for-byte:

* `firstcut-128/256/512/1024.png` are `icon_128x128`, `icon_128x128@2x`,
  `icon_256x256@2x` and `icon_512x512@2x` from v1's asset catalogue, so the
  simplified three-frame art is kept at small sizes instead of being a
  downscale of the large one;
* `firstcut.icns` is those ten exports through `iconutil` (verified: the
  round-tripped 16, 256 and 1024 px images are pixel-identical to v1's);
* `firstcut.ico` is 16/32/64/128/256 from the same exports plus a 48 px
  LANCZOS scale of the 128 px art (v1 shipped macOS only, so no 48 px export
  exists).

`cargo xtask package` rebuilt the macOS app and its `AppIcon.icns` is
byte-identical to the new source. Attribution, the plan's icon note, the
decisions table and the changelog say so explicitly, so the mark cannot drift
again without a doc change.
`apply_orientation` named the source pixel for every destination pixel with a
match and two checked multiplications, which made the transposing EXIF
orientations the most expensive single thing after the demosaic. Every EXIF
transform is affine along a destination row, so the origin and the step are
hoisted out of the loop: the inner loop is one add, one checked 4-byte load and
one 4-byte store.

The orientation row of `bench --decode --stages` on a 24 MP CR3 (orientation 8)
drops from 0.033 s to 0.015 s. The corner/orientation test still checks all
eight transforms and that each is a bijection over the frame's pixels.
rawler's develop expands the CFA into a 288 MB interleaved f32 RGB buffer and
then walks it four times. The fused plan replaces that with three row-parallel
plane passes and one output pass:

* green, which is the pixel's own sample at a green pixel and PPG's gradient
  choice at the rest, with rawler's 3-pixel bilinear ring at the edges;
* the pixel's own colour, plus red and blue at the green pixels by hue transit;
* the channel missing at every red/blue pixel, whose diagonal neighbours are
  the *other* colour's own samples, so it reads only the CFA and green planes.

Every pass fetches the five rows it reads once and then walks its half of the
row with a stride of two, so the inner loop has no index arithmetic and no
per-pixel CFA lookup. The output pass reads three complete rows, applies white
balance and the camera matrix, and encodes/packs in the same loop.

`fused::Plan` accepts a file only when it has been proven on it (single-
component Bayer, no Fuji rotation, a matrix it can size, a crop inside the
active area); anything else returns `None` and `develop_fast` runs rawler's
pipeline exactly as before, so a refusal is never an error.

Measured on a 24 MP CR3 at 6000x4000, warm, 8 threads, `bench --decode --stages`:

  decode        0.1200 s    (unchanged; rawler's CRX decode)
  demosaic      0.0936 s    (rawler's PPG: 0.099 s, over a 288 MB buffer)
  pack          0.0237 s
  orientation   0.0152 s
  sum           0.2566 s    (was 0.286 s; measured decode_raw 0.2562 s)

The opt-in real-photo gate still holds: the fused output is within one 8-bit
level of rawler's stock pipeline (max difference 1, 238 463 of 24 M pixels),
and the unit tests compare the demosaic against rawler's PPG pixel for pixel
for all four Bayer orders and an offset active area.
Exact RAW is 0.286 s -> 0.257 s on both games, and every §5 row was
re-measured on this build (the display rows had not been updated since the
zero-copy handoff landed): 89.4 fps at 2048 px, 638 at the 1350 px PRVW fit,
82.4 at the 4K fit, 74.9/62.4 at the 5K/6K fits on Game1JENKS, and
87.7/581/82.3/73.6/53.2 on Game3KC. The cold first-touch row keeps its cold
reading, because it is the one row that depends on the page cache.

The stage table now names what the fused plan actually runs, and the "what is
left" paragraph changes with it: the 0.120 s CRX decode is the largest single
stage, the demosaic is at parity with rawler's algorithm and division-bound,
and the floor with this algorithm is ~0.24 s. The owner decision therefore
narrows to accepting 0.257 s, funding a custom CRX decoder, or redefining the
gate as first-frame latency.
`apply_orientation` is only called from `develop_libraw`, which is compiled on
macOS and Linux. Importing it made Windows' clippy see an unused import and
fail `cargo xtask ci` with `-D warnings` (run 41036436148). The call is now
qualified, which is what the fused path already does.

Checked with `cargo clippy --target x86_64-pc-windows-msvc -p
firstcut-decode-libraw --all-targets -- -D warnings`.
The scanner accepts them as photographs, but only a RAW's embedded JPEG and a
plain JPEG had a display source, so a HEIC-only folder opened and painted
nothing (v1 read them through ImageIO; todo.md:978 requires those folders to
cull like RAW folders).

PNG and TIFF decode in-process with the image crate (pure Rust, no unsafe).
HEIF is converted by the platform's own tool - sips on macOS, WIC through a
static PowerShell script on Windows, heif-convert/ffmpeg/ImageMagick on Linux -
and the PNG that comes back is decoded in-process. Orientation is applied
exactly once: a converter that bakes the rotation in is detected by the frame's
aspect against the stored sensor dimensions. A machine with no converter gets a
typed error naming what to install.

decode_display_at and decode_thumbnail route a file with no embedded JPEG to
the still path, and the exact tier treats a non-RAW's own pixels as exact
instead of asking the RAW backend for sensor data a HEIF does not have.
…e identity

Move to Trash failed per file on Windows: trash_for was unix-only and returned
Unsupported. The file now goes to the Recycle Bin through the trash crate, a
safe wrapper over the shell API, so the no-unsafe rule holds; the bin hands back
no path, so undo reports the file as 'in the Recycle Bin: restore it from there'
instead of skipping it silently.

free_space was None off unix, so Finish never warned about a full destination.
It is fs4::available_space now, which asks statvfs on unix and
GetDiskFreeSpaceExW on Windows.

crosses_volume and the session's volume identity answered 'unknown' on Windows.
Both now read the volume serial number through file-id, and the scanner records
the (volume serial, file index) pair as the (device, inode) identity, so a
rename is recognised on Windows the way it is on unix.
The strip uploads a photo's thumbnail and the loupe uploads the same photo's
display bitmap, in the same frame. Keyed on the photo id alone, the second
upload replaced the first, so the current photo's filmstrip cell painted a
freed texture - a blank cell, reproducible on any RAW shoot.

TextureKey is (photo id, [width, height]). The size in the key also keeps the
existing zoomed-redecode behaviour: a size change is a new texture rather than
an in-place replace. one_photo_at_two_sizes_keeps_two_textures is the
regression test.
The two Windows rename tests failed because the session test helper recorded
(0, 0) as device and inode while the scanner had already moved to real Windows
file ids, so a rescan could not recognise the renamed file. `stat_identity` is
now the scanner's exported definition; the helper calls it, so what it ingests
is exactly what a rescan produces. A meta test pins the rename stability the
engine relies on, on whichever platform runs it.
The parity checklist's Windows volume-identity bullet now names the shared
`stat_identity` definition and the test that pins it; todo.md §0.5 records why
run 41222385725 was red on Windows only and what closed it.
`free_space` has asked the OS on every platform since the M5 work
(`fs4::available_space`), so Windows returns a real number, not `None`. The
test kept asserting the old "cannot be checked" answer and the 3-OS run on
91bbec4 failed on that alone.
The free-space test's Windows branch was the next failure after the rename
tests, hidden behind cargo's stop-at-first-failure. The handoff says so.
Six calls need a person: the Exact RAW gate (0.257 s against 0.119 s), the
default viewer tier, the Game1JENKS re-press, HEIF on Linux, the Windows owner
checks, and where to start on the boundary ground truth. `docs/v2-decisions.html`
is one self-contained page — no network, no build step — with the measured
numbers behind each option, an optional note per decision, and answers that
stay in the browser and download as firstcut-v2-decisions.json.

todo.md §0.2 points at it next to the photo review page, and §0.5 records that
it landed with the matrix green at 744f031.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant