Compare commits

..
Author SHA1 Message Date
Claude 12188161f9 Merge remote-tracking branch 'origin/main' into integ-b13 2026-10-09 00:46:05 +00:00
BFlat de79310ae0 Merge pull request #419 from storytold/claude/integrate-batch-12
Integrate #351 (sharp zoomed Detail view, #323) with follow-ups
2026-10-09 07:45:11 +07:00
Claude 373efc280e Merge PR #416 2026-10-09 00:20:27 +00:00
Claude c75389dda1 Merge PR #415 2026-10-09 00:20:27 +00:00
Claude 8ee6d63d41 Merge PR #414 2026-10-09 00:20:27 +00:00
Claude f578b27273 Merge PR #412 2026-10-09 00:20:26 +00:00
Claude 43419dcfb8 Merge PR #411 2026-10-09 00:20:26 +00:00
Claude 83ea5e14df Merge PR #403 2026-10-09 00:20:26 +00:00
Claude eab88ea30c Follow-up to #351: drag drafts without a window stay canvas-sized; de/es/ru hint
- With no window (a mask or point-colour overlay, Visualize Spots, soft
  proofing, a window refused for a far read) the loupe renders the whole
  frame at the size it is drawn. While a slider drags that meant the
  photo's every pixel each draft frame at 1:1 and beyond (24 MP per tick
  where 2.5 MP was rendered before #351; mask overlays are on by
  default). A drag now drafts at most the canvas size, as the windowed
  path does; at rest the zoomed frame is rendered as before.
- The Preview size hint in German, Spanish and Russian; the replaced
  'Largest long edge…' text removed from those catalogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-09 00:16:01 +00:00
Claude 3e88a60dd6 Merge PR #351 2026-10-09 00:14:03 +00:00
ragtuxandClaude Fable 5.1 0cf3ceb438 Part of #260: Help ▸ Open Log Folder shows the log file in the file manager
The desktop app keeps logs/lightcraft.log in its settings folder (#286)
and a failed start names it (#370), but a user still had to find the
folder by hand. The host now hands the UI its log file (Services
.log_file) and Help ▸ Open Log Folder (app.openLogFolder) reveals it
through the existing reveal service: Finder and Explorer select the
file, Linux opens the folder. Off where no log is kept (the web,
--memory, LIGHTCRAFT_NO_PREFS). Translated in de, es, ja, pt-br, ru,
zh-hans and zh-hant; parity row MENU-HELP-LOGFOLDER; What's New and
README note it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 19:13:35 -05:00
BFlat bba4172f83 Merge pull request #413 from storytold/claude/integrate-batch-11
Integrate #407 (file dialogs off the UI thread) and #408 (macOS top-bar drag and zoom)
2026-10-09 07:06:20 +07:00
midasdf 93b900f8d7 M16: keep grapheme clusters intact in vertical watermarks 2026-10-09 09:02:24 +09:00
midasdf 0cb78071de M16: use paintable separators in localized UI text 2026-10-09 08:55:21 +09:00
Claude fec379db46 Russian for two strings the catalog lacked (colour label cleared, slideshow hint)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 23:47:41 +00:00
Claude 4d2f34fb35 Merge PR #408 2026-10-08 23:47:24 +00:00
Claude b306305472 Merge PR #407 2026-10-08 23:47:24 +00:00
BFlat 832b8ada51 Merge pull request #409 from storytold/claude/docs-374-nvidia-cache
Fix #374: troubleshooting for the NVIDIA shader-cache startup hang
2026-10-09 06:46:22 +07:00
Claude bf97105cf7 Fix #374: troubleshooting for the NVIDIA shader-cache startup hang
With Shader Cache Size disabled in NVIDIA Control Panel, the driver walks
the whole system drive inside D3D12CreateDevice (traced by the reporter
to NvMemMapStoragex.dll!TotalDiskUsage), before the window's first
frame; Driver Default fixes it (confirmed in #374, PhotoCraft too). The
launch-argument walk found on the way was fixed in #394.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 23:46:07 +00:00
Nathaniel LedfordandClaude Sonnet 5.5 eaaf859853 Parity: title-bar drag and zoom confirmed on a real macOS window
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 19:44:23 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 2bd13c1500 Parity: note the stale zoomed-state limitation of the title-bar toggle
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 19:42:52 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 cc8d617459 Top bar: a press that starts on a button or the search field never drags the window
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 19:42:52 -04:00
ragtuxandClaude Fable 5.1 d3ceaf0e79 Fix #191: commands' file dialogs run off the UI thread
The desktop showed rfd's synchronous dialogs on the UI thread, so the
window stopped answering the compositor while one was up: GNOME marked
LightCraft "not responding", the File menu stayed drawn and the control
channel went quiet. Services.picker lets the host start a dialog
elsewhere and answer through a channel; a command that needs a path
asks for one and returns, and runs again with the answer when the
dialog closes (pick.rs). Import Photos, Import from Folder, Open
Library, Import Lightroom Catalog, Find Missing Photos, the preset and
curve-preset imports and exports, Auto-Tag from Tracklog and Locate
Missing File use it; hosts without a picker (the web, tests) keep the
synchronous dialogs. ui.inspect lists the waiting commands as
fileDialogs. Same cause as storytold/photocraft#574.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 18:42:19 -05:00
BFlat 32cedb3153 Merge pull request #405 from storytold/claude/integrate-batch-10
Fix #310 (CR2/PEF starting look) and integrate #402 (menus stay below the menu bar)
2026-10-09 06:39:08 +07:00
Nathaniel LedfordandClaude Sonnet 5.5 86d88de2ee Parity: track title-bar drag and double-click zoom
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 19:37:55 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 d01ebd7953 Top bar: drag to move and double-click to zoom the window (integrated title bar)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 19:37:05 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 54b037a90a Headless host: record window commands and report the zoomed state back
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 19:37:05 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 1e27991cf1 Title bar: window-gesture policy (drag, double-click zoom, full-screen exempt)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 19:35:36 -04:00
Exalted5373andClaude Opus 5.5 7173242ab5 LR-IMP-FORMATS: no overflow deciding whether to scale a full-size JPEG decode
A full-size load asks the decoder for the largest box (`SourceLevel::Full` is
`usize::MAX`, cast to `u32::MAX`). `jpeg::decode` then computed `2 * u32::MAX` to
decide on DCT scaling: an overflow panic in debug builds (caught, so every
full-size JPEG load failed with "decoder panicked"), and a wrap that happened to
give the right answer in release. The comparison saturates now, and the engine
converts its `usize` edge with `u32::try_from` instead of a truncating `as`.

Found decoding camera JPEGs (no JFIF/EXIF header) at full size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:26:29 -07:00
Exalted5373andClaude Opus 5.5 31aa638383 LR-IMP-PROFILES: red / green / blue curves only with the master curve; neutral test values
Lightroom Classic reads `ToneCurvePV2012Red/Green/Blue` only together with
`ToneCurvePV2012`: a packet with a channel curve and no master curve leaves the
photo unchanged there (rendered on a chart: the channel curve alone has no
effect, with an identity master curve it applies). Imports now do the same.

The preset-packet test helper uses made-up as-shot values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:25:25 -07:00
Exalted5373andClaude Opus 5.5 09d5284e5a LR-EDIT-OPTICS-PROFILE: a preset's LensProfileEnable=0 keeps embedded lens corrections
XMP presets usually say LensProfileEnable=0, which turned optics.lens_profile
off and with it the DNG-embedded WarpRectilinear / FixVignetteRadial
corrections (iPhone ProRAW and other files that carry them). Lightroom
applies a file's built-in corrections whatever that switch says (our
inference), and LightCraft's profile corrections are only those embedded
ones, so crs import now carries over only an enabling switch. The Enable
Lens Corrections checkbox and the amount sliders keep working as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:25:25 -07:00
Exalted5373andClaude Opus 5.5 7e0250e11e LR-IMP-PROFILES: don't report preset bookkeeping fields as unmapped
XMP presets carry fields that describe the preset or the photo rather than
an adjustment (Cluster, SupportsAmount2, RequiresRGBTables, SortName,
Description, CropConstrainToWarp, OverrideLookVignette, AsShotTemperature /
AsShotTint). preset.import listed them as settings that couldn't be carried
over; so did an HDREditMode of 0 and PointColors slots that are all -1.
They are now treated as bookkeeping; an HDR edit, a used Point Color slot and
unknown adjustments are still reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:25:25 -07:00
Exalted5373andClaude Opus 5.5 9087d3ad7f PSD: as_chunks for the palette and 16/32-bit plane loops (clippy 1.98)
clippy 1.98's manual_chunks lint fails `cargo xtask ci` on main at psd.rs:170/191/201
(chunks_exact with a constant size). The fixed-size chunks also drop the per-sample
indexing. No behaviour change; the codecs tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:25:17 -07:00
Claude a1e99fad9e Follow-up to #402: the menu scroll arrows' accessibility labels are translated
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 23:13:39 +00:00
Claude ff9d74f4e5 Merge PR #402 2026-10-08 23:13:27 +00:00
Claude 55ecd900ae Fix #310: CR2 and PEF raws get the starting look fitted to their camera JPEG
CR2 and PEF decode without a camera colour matrix, like ARW, NEF, RW2,
RAF and CR3, but camera_preview::file_local_look (and the catalog's
relative white balance) left them out, so they rendered with the neutral
fallback matrix: the camera preview showed first, then a flat,
desaturated render replaced it. They now use the same guarded fit and
relative WB; a fit that fails the gates keeps the fallback, so a file can
only stay as it was or move closer to its camera JPEG. Not measured on
the corpus here (raw.pixls.us is blocked from this environment).
RENDER_CACHE_VERSION 15 re-renders cached CR2/PEF thumbnails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 23:13:16 +00:00
ragtuxandClaude Fable 5.1 2d47ba3207 Fix #189: in-window menus and submenus stay below the menu bar
egui keeps a popup inside the window, so a menu taller than the room
under the bar (Photo, on a short window) was slid up over the bar: the
titles vanished under it, and the pointer left on the title rested on
the first row and opened its submenu by itself. Every level now gets
the room it has below the bar (a submenu: from its row down, or up to
the bar when egui opens it upward) and scrolls inside it, with wheel,
drag and ▲/▼ strips that scroll while the pointer rests on them, as in
PhotoCraft's menu bar (storytold/photocraft#337). Menu levels and
submenu rows are registered for the control channel and tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 17:50:18 -05:00
BFlat 7b47ba7894 Merge pull request #401 from storytold/claude/integrate-batch-9
Header-only import probes (part of #367), PSD RLE allocation bounds, and #397 (CR3 C-RAW clipped highlights)
2026-10-09 05:48:39 +07:00
Nathaniel LedfordandClaude Sonnet 5.5 826ba91075 #323: docs: measured drag times, the Before/After memory, what stays canvas-sized
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:48:20 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 22b7ba09b9 #323: stage budget: the open photo's views are cut only in a runaway; the shared source is reported
A review measured 1 GB of GPU stages for a Before/After at 1:1 on a 2800 px
canvas against a 400 MB budget, with nothing bounding it. The open photo's window
and before caches are now cut (the before side first) past four times the budget,
the whole-frame view's never; idle caches still go at the budget. The one shared
device copy of a big original is reported (sharedSourceBytes in the renderer's
memory report) and a misleading comment about it is corrected.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:48:19 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 057a8d308e #323: the Before window waits for the original the After is decoding
At first open of Before/After the two window jobs were asked for together, each
decoded its own 24 MP copy of the original (a review saw two decodes and three
uploads for one photo) and the shared device copy was swapped between them. The
Before's window now waits until the After's job has handed the original to the
session (region::defer_before_window, Session media has_source); later windows
of both share the one copy.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:38:39 -04:00
Claude 1b5ba5eba7 Merge PR #397 2026-10-08 22:34:54 +00:00
Nathaniel LedfordandClaude Sonnet 5.5 ddaea580a1 #323: test: holding \ in a wipe is tested with the key held, and the mutation fails it
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:34:02 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 03249f4619 #323: holding \ in a wipe shows the Before window everywhere
The window of the Before view alone was clipped to the wipe's left half while the
picture drawn was the Before on the whole canvas, leaving the right half the
low-resolution stand-in. The clip follows the effective mode.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:32:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 495b1ac9fb #323: tests: the wipe layering and pane clipping are checked on the painted shapes
A review showed the wipe test asserted on a hand-written list of labels (deleting
the stand-in draw calls still passed) and nothing tested the clip at the call
site (replacing it with the canvas still passed). The tests now read the meshes
the frame painted: the Before window comes after the stand-in and stays inside
its side of the line, and side by side each window is painted inside its pane.
Both mutations of the code now fail them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:31:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 f1a513ffe2 #323: docs: drag timings on two canvases, what a fit view decodes, the shared source
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 a5b9dd603f #323: the drag profile covers a 2800 x 1700 canvas
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 a94b647dfd #323: the open photo's views are never trimmed from the stage budget
A review measured the drag at 100 % on a 2800 px canvas taking 87 ms a tick
against 24 ms with a bigger budget: the open photo's caches were over the budget
(a 5 MP window alone is 230 MB) and a "hard" budget cleared them every frame, so
every tick rebuilt them. The whole-frame, zoom window and before caches are what a
drag reuses; only idle caches (hover, compare tiles, second window) are trimmed
now, the least useful first, and the open photo's are counted and reported. The
drag test no longer waits on a tick count.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 f68cd69b1e #323: the windows of a big photo share one device copy of its source
Keeping a 288 MB upload with each view's stages made them thrash against the stage
budget; not keeping it made every new window (a pan crossing a snap, the first
window of the Before side, any control that invalidates the sampled pixels) upload
and convert the source again, about 64 ms against 6-12 ms. A source too big for a
view's stages is now kept once for all views of the photo (shared_source), outside
the views' stages, and given back when the app idles (trim_pool(0)).
source_uploads and shared_source_bytes expose it for tests and diagnostics.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 e9fb4b08e1 #323: the render plan: drags keep the window, modes without windows render full size, limits count
Review findings on region::plan:
- a drag at fit on a 4K or 5K display switched the window off (the whole-frame
  draft shrinks, the preview-source cap does not): the window decision now
  compares against the undrafted whole-frame render;
- overlays, soft proofing and windows refused for reading too far (a spot or Auto
  Mask stroke) left the 1:1 view a canvas-sized upscale: those modes render the
  whole frame at the size it is drawn, as before windows (a refusal is remembered
  per photo, look and frame, and retried when any changes);
- the 3840 and 5120 preview sizes had become no-ops: a limit above the preview
  source level is honoured;
- windows were limited to 8192 px frames: they now reach the photo's own pixels
  (65536 px at most), so a medium-format photo is sharp at 1:1;
- a pinch out from a deep zoom asked for an ever wider window every frame: windows
  already there are kept, magnified, until the gesture has been quiet.
The unused region::needed is gone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 8a5ecebc18 #323: wipe, pane clipping and identical-looks fixes for the Before/After windows
An adversarial review found that (1) in a wipe the Before window was drawn
before, so under, the low-resolution Before stand-in; (2) the windows of side by
side and stacked views were clipped to the canvas, so their margins spilled into
the neighbouring pane; (3) with identical Before and After looks (a fresh,
unedited photo) both windows had the same job key and the After tile replaced the
Before one in the tile table. Windows are now drawn over the stand-ins and under
the wipe line, clipped to their pane or their side of the line (region::window_clip,
pure and tested), and tiles are keyed by side and job key.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 eb06b65fdb #323: docs: the loupe's render plan, Before/After windows, drag timings
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 92196fddeb #323: tests: the drag keeps its original; a profile of slider drags (ignored)
profile_slider_drag prints the milliseconds from a slider tick to the loupe
showing it at fit, 100 % and 400 % of the demo library's 24 MP photos.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 ea33301e6e #323: a drag keeps the original its superseded drafts decoded; textures convert faster
A drag supersedes every request before it finishes, and the source a superseded
draft had decoded was thrown away with it, so a window cut from a full-size
original decoded it again for every tick. A view slot's superseded draft that is
newer than what the view shows now also hands its source to the session (late
results of other slots stay ignored, as before). Render results become textures
with a plain copy for the opaque case instead of egui's per-pixel premultiplying
conversion, on the UI thread.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 2f7d248d61 #323: a window does not keep the whole source uploaded with its stages
Profiling a slider drag at 100 % and 400 % found each tick of the window render
taking about 60 ms: the 24 MP source (288 MB) uploaded to the device was kept with
the view's stages, counted against the stage budget, and the stages cleared every
frame, so every tick uploaded and resampled again. A source over 96 MB is now
uploaded for the render only; the stages keep the window's own sampled pixels.
A tick costs 7-12 ms instead. equivalence.rs covers it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 35c9b67944 #323: test: no window over a picture that is not the frame (#280)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 6a07c35431 #323: hold the render sizes while a pinch or two-finger scroll runs
A continuous trackpad pinch changes the zoom every frame, and with it the sizes
the loupe plans, so it would have been a stream of re-renders of every size in
between. SizeHold (pure, tested) keeps the plan from before the gesture while it
runs and for a quarter of a second after; the view is drawn from the renders it
has, magnified by the GPU, and planned afresh when the gesture is quiet (a repaint
is asked for at that moment). Another photo resets the hold.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 72bab50c6e #323: Before/After at 1:1 compares like with like
The Before image was a stand-in capped at 2560 px beside an After at full
resolution, so a 1:1 comparison misled. Both sides now get the same treatment:
the same whole-frame size and, once zoomed, a window each (Slot::RegionBefore
beside Slot::Region) cut from the same frame, in side by side, top/bottom, the
\ original view and both wipes (the Before window is clipped to its side of the
line). Each side's windows and tiles are tracked apart, a side is freed when it
is no longer shown, and ui.inspect reports regionBefore.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 136b2851ee #323: engine: region_job_before renders a window of the photo without its edits
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 83d0304ce6 #323: the per-view stage caches are counted against the memory budget
The loupe's, the zoom window's and the before / hover renders' stage caches hold
decoded-image sized buffers that the engine's budget did not count. A quarter of
the memory budget is now theirs: each frame the renderer clears what exceeds it,
the least reusable first (hover, before, compare, second window, the zoom window)
and the open photo's own view last (stage_trim_order, pure and tested). The
inspector's memory report shows the budget and how often caches were trimmed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 3255cda67e #323: the loupe renders the canvas, plus the visible window at no more than 100 %
At 8:1 on a 24 MP photo every drag frame rendered the whole 24 MP frame, and a
Retina fit view (about 2800 px) decoded the original of every photo stepped to.
A new render plan (region::plan, pure and tested) makes the cost follow the
canvas, not the zoom:
- the whole-frame render is canvas-sized and never above the preview source
  level, the preview size limit or the texture side (a drag drafts that);
- once the view is zoomed past it (by more than 25 %) the visible window is
  rendered, cut from a frame that is at most the photo's own pixels, so beyond
  100 % the GPU magnifies it instead of a bigger render being made; this also
  ends the duplicated second render above 100 %;
- a window is drafted during a drag and a draft of an older value still draws;
- a user who capped the preview size keeps the whole-frame render at fit;
- no window where the picture on screen isn't the frame's own aspect (an
  unsupported raw's embedded JPEG cropped differently, #280).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 b7f632f5c0 #323: no loupe texture is larger than the GPU allows
The Automatic preview size let the loupe texture reach 8192 px on every
platform. The browser build draws with egui_glow, which panics on a texture
larger than the GPU takes, and many WebGL devices allow only 2048 or 4096.
Every render size the loupe asks for (loupe, neighbour prefetch, hover and
Before stand-ins, compare tiles, the second window) and every zoom window is now
clamped to the context's max_texture_side (2048 when the host reports none,
512 at the least). Headless drivers can pretend a small GPU for tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 63cfc6ab70 #323: parity, GPU and control docs: sharp zoomed views by window render
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 930d14aacd #323: fix the preview_limit doc comment (largest, not lowest)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 ff9c3fb2ec #323: windows: refuse what cannot fit, grow Auto Mask windows by the dabs near them
Review findings: window growth for spots was capped silently and then rendered
wrongly; Auto Mask growth took the whole stroke's bounding box; hidden masks grew
windows; the wasm window memory cap was not enforced. window_for_reads_checked
returns None when what a window reads would need more than 8192 px (4096 on
wasm) along an axis, Session::region_job returns None then (the loupe keeps the
whole-frame render), Auto Mask growth is per dab near the request (once, not
chained along the stroke) and ignores hidden masks. Docs say what the limits are.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 a1d907bf0f #323: a tight crop at its own pixels reads the source level its pixels need
An adversarial review found that sizing the loupe render from the cropped frame
(previous commit) left the source level chosen from the output edge alone: a
crop whose own long edge is under 2560 px was drawn from the 2560 px preview of
the whole photo, soft though sized right. The level now follows the pixels the
output needs along the source's long edge (output edge / crop fraction); grid
thumbnails keep their cheap level.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 05618b2ad4 #323: windows on the GPU; Auto Mask strokes grow the window; dab references at f32
The GPU renderer renders windows (no more CPU fallback): the vignette gets the
window's offset and the whole output's size (VIG_VIEW), the airlight and noise
reduction scale come from the whole frame, and a window grown for spots or
strokes is cut back after the readback. equivalence.rs renders every settings
case as a window plus a corner vignette and a spot reading from outside, within
the existing 1 LSB. Auto Mask strokes join spots in growing the window (they read
the pixel under each dab). An Auto Mask dab's reference pixel is chosen from its
position at f32 precision, like the GPU kernel: an f64 rounding error that
depended on the window's start could put a boundary dab on either pixel.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 4b83f8bb5e #323: windows cross the Web Worker boundary
WireJob carries the request's window (an old job without one has none), and the
browser build asks for window renders like the desktop; windows there are capped
at 3072 px a side to respect wasm memory.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 eebea486d5 #323: windows: spots render in a window; deep-zoom regression tests for the wide stages
Spot removal reads outside the spot and its automatic source depends on the
pixels at hand. A windowed render now fixes automatic sources from a fixed-size
render of the whole frame and grows the window it works on to hold every spot
that touches the requested one and the patch it reads (bounded at 8192 px),
cutting the result back to the request, so photos with spots get sharp windows
too. The wide stages (dehaze, clarity, highlights/shadows) are checked at depth
on hard-edged blocks: the cut kernels cost at most a few levels, so they are not
computed from a whole-frame proxy.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 dc5bff6947 #323: 100 % zoom is the photo's own pixels as shown (after crop and rotation)
fit_rect measured Percent zoom against the uncropped, unrotated photo, so a
cropped photo at 1:1 showed stretched and a rotated one used the wrong axis. The
loupe, its neighbour prefetch and the compare view now use the frame's
cropped-and-rotated size, which also stops a small crop being rendered above its
own pixels.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 f8f091436a #323: review fixes: no window renders during drags, current-look tiles only, margin by zoom
An adversarial review found that a slider drag at any zoom started a window
render (the 0.6-scale draft looked stretched), that a tile made for an earlier
look kept being drawn and that the tile table was cleared mid-drag, that the
fixed 256 px margin was far below the wide stages' kernels at depth, that spot
removal reads outside the window, and that the window texture stayed resident
after zooming out. Window renders now wait for the drag to end, a tile is drawn
only while photo, zoom and look match, the margin grows with the zoom (768 px
cap) and the window is capped at 6144 px, photos with spots keep the whole-frame
render, the texture and stages are freed when unneeded, and wasm never asks.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 244088498d #323: windows: noise reduction and mask edge refinement follow the whole frame's size
An adversarial review found local::denoise and Refine Edges scaled with the
window's own size, so their strength changed with pan and zoom. Frame::output_long
gives the whole output's long edge to both; tests on a noisy source cover them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 e0539db3f5 #323: the loupe renders the visible window at the zoom scale
When the whole-frame render is smaller than the zoomed view (a 1:1 or 8:1 view
of a large photo, or a capped Preview size) the loupe also requests a window
render (Slot::Region) of the pixels on screen plus a margin, and draws it over
the whole-frame render at its place of the frame. Stateless, grid-snapped
windows mean small pans reuse the render. Not used in the browser build, while
comparing, hovering a look, soft-proofing or showing an overlay. ui.inspect's
loupe reports the window.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 2079e8e08d #323: engine region_job and the window geometry for zoomed views
Session::region_job renders one PixelWindow of the loupe's frame from the source
level that frame size needs, with a key of its own and no cache writes.
ui-egui::region decides when a window is needed (the whole-frame render is
stretched) and which one: the visible pixels plus a margin, snapped to a 256 px
grid so small pans reuse the render, bounded in size, clamped into the frame.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 b358022f14 #323: pipeline: render a window of the output (RenderRequest::window)
A zoomed view needs the pixels it shows at the zoom scale, not a whole-frame
texture stretched over them. RenderRequest gains an optional PixelWindow of the
virtual full output that max_w x max_h describes; only the window is
materialized. It is Frame:🪟 the crop rectangle narrows to the window
(flips as drawn), so sampling, masks, spots, red eye and the lens warp need no
change. The post-crop vignette keeps the whole frame's geometry (Frame::view)
and dehaze uses the whole frame's airlight (estimated on a 384 px render), so a
window matches the same pixels of the full render. The GPU renderer declines
windowed requests (CPU for now).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 ca5c212d03 #323: review fixes: prefetch and stand-ins stay at preview level; render sizes move in 64 px steps
An adversarial review found that with Automatic the neighbour prefetch asked for
the full source level, whose single cache slot then evicted the open photo's
source, and that hover / Before renders and every window-resize pixel produced
full-size renders. Prefetch and stand-ins are now capped at the preview source
level and the loupe size is rounded up to a 64 px step.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 14570cfdf3 #323: Detail view renders at the size it is shown, not at a hidden 2560 px cap
The loupe capped every render at the "Preview size" setting (default 2560 px),
then stretched the texture over the on-screen rect, so 1:1 and Retina fit views
of larger photos looked soft or pixelated.

The setting is now a limit: Automatic (default) renders at the drawn size, up to
the photo's own pixels and an 8192 px ceiling; 1600–5120 px still cap it. The
domain rule is AppSettings::loupe_edge (NaN/zero/huge inputs are clamped). The
saved key changes from previewEdge to previewLimit so existing installs, whose
2560 was the old default, move to Automatic. Build Standard-Sized Previews keeps
a numeric edge via standard_preview_edge().

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00
Claude 8d79bf082e Batch fix: the header test's RLE PSD fixture uses PackBits runs of at most 128 bytes
The fixture wrote each row as one literal run of w bytes, which PackBits
can't express past 128 (a 160-pixel row's header byte read as a repeat
run). The old decoder clipped such rows silently; with the PSD fix in
this batch an overrun is an error, as it should be, and the fixture now
splits rows into valid runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 22:23:51 +00:00
Claude d316d97d80 Merge branch 'fix-psd-rle-alloc' into integ-b9
# Conflicts:
#	crates/codecs/src/psd.rs
2026-10-08 22:21:56 +00:00
Claude 2ab6e973c6 Merge branch 'fix-367-header-probe' into integ-b9 2026-10-08 22:21:37 +00:00
BFlat c395789026 Merge pull request #398 from storytold/claude/integrate-batch-8
Integrate #391 and #393 (undecodable raws import as preview only), with a work budget for the JPEG scan
2026-10-09 05:21:21 +07:00
Claude 6aa599f869 Fix: PSD RLE decode bounds its allocations by the input
A tiny hostile PSD could abort the process on out-of-memory in
`psd::decode` (allocation failure doesn't unwind, so catch_unwind
can't save it):

1. The RLE row byte-count table (channels x rows entries, from the
   header) was reserved with Vec::with_capacity before checking that
   the file holds it: 56 channels x 2^30 rows asked for ~480 GB from a
   ~40-byte file. The table is now taken as a slice of the input with
   checked size math and read lazily, so nothing is allocated from it.

2. RLE rows were zero-padded / run-expanded to the declared width with
   no tie to the input size, so all-zero row counts on a 1 Gpx (the
   DecodeOptions::max_pixels limit, already enforced by check_size)
   32-bit image expanded to 4 GiB per channel. A PackBits row expands
   at most 64x (2-byte repeat run -> 128 bytes), so each decoded row
   must now declare at least ceil(row_bytes / 128) * 2 encoded bytes,
   and the decoded channels' data must be present in the file, before
   any plane is allocated. Decoded planes are thus bounded by 64x the
   file size as well as by max_pixels. PackBits now decodes into a
   pre-sized, zeroed row and a run that would write past the declared
   width is an error instead of being silently clipped.

Also: size math (width x height x bpc, samples) is checked; image
buffers are allocated with try_reserve_exact (TooLarge on failure);
only the colour (+ alpha) planes are decoded, extra channels are
skipped; the PSB layer-section length no longer truncates on 32-bit
targets; interleaving uses iterators instead of indexing.

Valid PSDs (raw and RLE, 8/16/32-bit) decode identically; regression
tests cover a huge RLE header with a truncated table (aborted with a
481 GB allocation before), all-empty RLE rows, counts larger than the
file, a repeat run past the row width (was Ok before) and a 16-bit
gray RLE file with an extra channel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 22:19:42 +00:00
Claude f6f5e6f1b7 Part of #367: probes read image headers instead of decoding the pixels
The import probe of a non-raw image decoded it (at a 64 px fit, which for
JPEG still entropy-decodes every block and for PNG inflates everything)
only to learn its stored size and orientation: ~95 % of import CPU.

lightcraft-codecs gets `read_header(bytes) -> Header { format, width,
height, orientation }`: JPEG from its markers (EXIF orientation), PNG from
its chunks (IHDR, eXIf anywhere), TIFF from the first IFD (tiff crate,
no strips read), WebP from its RIFF chunks / bitstream header (image-webp),
PSD from its header and image resources. GIF, BMP and JPEG XL keep the
small decode. `files::probe_bytes` uses it; raw handling is unchanged.
The TIFF colour-layout check is shared with the decoder (`layout`).

Measured (release, 4 cores, throwaway benchmark, per file, bytes in
memory): 200 generated 4000x3000 JPEGs (3.6 MB): 72.7 ms -> 2.1 ms
(read_header alone 1.05 ms; the rest is the content hash and metadata);
20 PNGs (30.8 MB): 236-265 ms -> 9.0-9.6 ms.

Corrupt files (behaviour note): the header readers also refuse what a
decode refuses before the pixels (unsupported layout, compression, frame
type, bad IHDR, ...) and truncated files: a JPEG whose markers don't reach
its EOI, a PNG cut before the chunk after its IDAT run, TIFF strips/tiles
that start past the end, a WebP image chunk or PSD merged data past the
end. So truncated files (interrupted copies, partial downloads) are still
marked `error` in the import review, as before; tests check that whenever
the old decode accepted a (cut or byte-mutated) file the header agrees.
Changed: damage *inside* compressed data that is all present (flipped or
garbled bytes) was refused at scan and is now imported, and shows as
unreadable ("!" in the grid) when rendered. Also: tiny JPEGs (< 128 px)
were decoded leniently by the old probe, so a truncated one imported; it
is now refused. A TIFF truncated inside its last strip is not detected
(StripByteCounts are often overstated, so only strip starts are checked).

Tests: header readers vs decode on generated JPEG/PNG/TIFF/WebP/PSD/BMP
with and without EXIF/TIFF orientation; truncations and random mutations
agree with decode; hostile headers (zero/absurd sizes, bad lengths,
truncation, garbage) are errors; read_header added to the fuzz tests;
probe_bytes oriented dims match the decode-based values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 22:09:30 +00:00
radialmonster 7094ba1b6e LR-IMP-FORMATS: CR3 C-RAW samples that overshoot the bit depth are clamped
C-RAW is quantised wavelet coding: next to clipped highlights its
reconstruction overshoots the sensor range by a few quantisation steps.
On 17 CC0 CR3 files (SX70 HS, G5 X Mark II, M6 Mark II, EOS R, RP, 90D,
M200, R5 Mark II, R6 Mark II, R6 Mark III, R8) samples reached 16384 to
16419 at 14 bits, never below 0, and decoding failed with "decoded
sample outside its bit depth". Lossless files of the same bodies clip at
exactly 16383. Wavelet samples are now clamped to the CMP1 bit depth;
lossless samples outside it are still corrupt data.

The other 93 CR3 files have no sample outside the range and render
pixel-identically. Render cache version 13 -> 14.
2026-10-08 18:04:01 -04:00
78 changed files with 5481 additions and 313 deletions

No files matched your search

Generated
+1
View File
@@ -2899,6 +2899,7 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
"unicode-segmentation",
"web-time",
]
+2 -1
View File
@@ -359,7 +359,8 @@ Language**; the choice applies immediately and persists. See [docs/localization.
**Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder
(Linux `$XDG_CONFIG_HOME/lightcraft/logs/`, by default `~/.config/lightcraft/logs/`; macOS
`~/Library/Application Support/LightCraft/logs/`; Windows `%APPDATA%\LightCraft\logs\`), never in the library. A
launch from a desktop menu or the Dock has no terminal, so attach this file to a bug report. Each start moves the
launch from a desktop menu or the Dock has no terminal, so attach this file to a bug report (**Help ▸ Open Log
Folder** shows it in the file manager). Each start moves the
previous log to `lightcraft.1.log` and that one to `lightcraft.2.log`, so the log of a run that crashed survives the
next start; the file stops growing at 16 MiB, `--version` and `--help` write none, and runs with
`LIGHTCRAFT_NO_PREFS` log to standard error only. By default LightCraft's own crates log at `info` and everything else
+5 -4
View File
@@ -24,7 +24,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|---|---:|---|---|
| **Feature checklist** | 79.7% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF and CR3 have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
@@ -47,8 +47,9 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*.
1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use
matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2 and Fujifilm RAF have the file-local fit
(matrix + tone curve from their own JPEG); generalise it to the other makes' raws (PEF, ORF…), then validate fidelity.
matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2
and Pentax PEF have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF since issue #310, not yet
checked on the corpus); then ORF, then validate fidelity.
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): Remaining **unverified CR3 CRX** variants, compressed ORF, NEF
lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source,
no LGPL dependency); compressed NEF (#86) is the template.
@@ -186,7 +187,7 @@ Not decoded yet — preview only (no permissively licensed description; black-bo
alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis).
- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
**Camera colour matrices:** ARW, NEF, RW2, RAF and CR3 files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2 and PEF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our
own chart-based calibration (M11.4). Adobe matrices are never used.
+2
View File
@@ -168,6 +168,7 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
let (backup_backend, backup_files) = (backend.clone(), files);
let restore_backend = backend.clone();
Services {
picker: None,
backup_library: Some(Box::new(move |session: &mut Session| {
let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) };
// the photos' own names for the originals in the zip
@@ -218,6 +219,7 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
})),
reveal: None,
log_file: None,
open_with: None,
open_url: Some(Box::new(|url: &str| {
let w = web_sys::window().ok_or("no window")?;
+36
View File
@@ -53,6 +53,9 @@ pub struct WireJob {
/// The request's diagnostic overlay (`Overlay::to_parts`).
#[serde(default)]
pub overlay: (u8, f64),
/// Render only this window `[x, y, w, h]` of the output `max_w × max_h` describes (a zoomed view).
#[serde(default)]
pub window: Option<[usize; 4]>,
}
impl WireJob {
@@ -83,6 +86,7 @@ impl WireJob {
thumb_cached,
stages: job.stages.is_some().then(|| view.to_string()),
overlay: job.request.overlay.to_parts(),
window: job.request.window.map(|w| [w.x, w.y, w.w, w.h]),
}
}
@@ -108,6 +112,7 @@ impl WireJob {
space: lightcraft_engine::pipeline::OutputSpace::Srgb,
depth: lightcraft_engine::pipeline::OutputDepth::U8,
proof: None,
window: self.window.map(|[x, y, w, h]| lightcraft_engine::pipeline::PixelWindow { x, y, w, h }),
}
}
@@ -348,6 +353,36 @@ mod tests {
assert!(reopened.contains(&new));
}
// Issue #323: a worker given a zoomed view's window renders that window, not the whole frame
#[test]
fn a_window_job_crosses_the_wire_and_renders_the_same_window() {
use lightcraft_engine::pipeline::PixelWindow;
let mut s = Session::with_demo();
let id = s.visible_cloned()[0];
let win = PixelWindow { x: 300, y: 200, w: 160, h: 120 };
let job = s.region_job(id, 1920, 1280, win, true).unwrap();
let wire = WireJob::from_job(&job, false, "Region");
let wire: WireJob = serde_json::from_str(&serde_json::to_string(&wire).unwrap()).unwrap();
assert_eq!(wire.request().window, Some(win));
let mut core = WorkerCore::default();
let a = core.render(&wire, None).unwrap();
let b = job.run().rendered.unwrap();
assert_eq!((a.image.width, a.image.height), (160, 120));
assert_eq!(a.image.data, b.image.data);
}
// a job from before windows existed has none
#[test]
fn a_job_without_a_window_field_has_no_window() {
let mut s = Session::with_demo();
let id = s.visible_cloned()[0];
let wire = WireJob::from_job(&s.thumb_job(id, 128).unwrap(), false, "Thumb");
let mut v = serde_json::to_value(&wire).unwrap();
v.as_object_mut().unwrap().remove("window");
let old: WireJob = serde_json::from_value(v).unwrap();
assert_eq!(old.request().window, None);
}
#[test]
fn wire_job_renders_like_the_engine() {
let mut s = Session::with_demo();
@@ -393,6 +428,7 @@ mod tests {
thumb_cached: false,
stages: Some("Main".into()),
overlay: (0, 0.0),
window: None,
};
let mut core = WorkerCore::default();
assert_eq!(core.needs_original(&job).as_deref(), Some(hash.as_str()));
+40 -2
View File
@@ -281,8 +281,44 @@ fn windows_open_url_command(url: &str) -> std::process::Command {
c
}
fn services() -> Services {
/// The platform services; `ctx` repaints when a file dialog closes, and `log_file` is
/// for Help ▸ Open Log Folder.
fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services {
Services {
// Commands' file dialogs run on their own thread (#191): a dialog on the UI thread
// stopped the window answering the compositor, which then reported the app as hung.
picker: Some(Box::new(move |req: lightcraft_ui_egui::pick::PickRequest| {
use lightcraft_ui_egui::pick::PickKind;
let (tx, rx) = std::sync::mpsc::channel();
let repaint = ctx.clone();
std::thread::Builder::new()
.name("file-dialog".into())
.spawn(move || {
let mut d = rfd::FileDialog::new();
if let Some(t) = &req.title {
d = d.set_title(t);
}
if let Some((name, exts)) = &req.filter {
d = d.add_filter_nocase(name.clone(), exts);
}
if let Some(n) = &req.file_name {
d = d.set_file_name(n);
}
let s = |p: std::path::PathBuf| p.to_string_lossy().to_string();
let paths: Vec<String> = match req.kind {
PickKind::Files => d.pick_files().unwrap_or_default().into_iter().map(s).collect(),
PickKind::File => d.pick_file().map(s).into_iter().collect(),
PickKind::Folder => d.pick_folder().map(s).into_iter().collect(),
PickKind::Save => d.save_file().map(s).into_iter().collect(),
};
// the app may have quit meanwhile: nobody to tell
let _ = tx.send(paths);
repaint.request_repaint();
})
.map_err(|e| format!("could not start the file dialog: {e}"))?;
Ok(rx)
})),
log_file: log_file.map(|p| p.display().to_string()),
pick_lightroom_catalog: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Import Lightroom Catalog"))
@@ -603,6 +639,8 @@ fn main() -> eframe::Result {
wgpu_options: window_wgpu_options(),
..Default::default()
};
// the app's copy: `log_file` is still named in the message of a start that fails
let app_log_file = log_file.clone();
let started = eframe::run_native(
"LightCraft",
options,
@@ -614,7 +652,7 @@ fn main() -> eframe::Result {
std::env::var_os("LIGHTCRAFT_SAM3_DIR").map(std::path::PathBuf::from).or_else(|| config_dir().map(|d| d.join("models").join("sam3")));
// the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too)
session.segmenter.mirrors_file = config_dir().map(|d| d.join("models").join("sam3-mirrors.txt"));
let mut app = LightcraftApp::new(session, services());
let mut app = LightcraftApp::new(session, services(cc.egui_ctx.clone(), app_log_file.as_deref()));
if let Some(ui) = prefs {
app.ui = ui;
}
+3 -2
View File
@@ -336,12 +336,13 @@ impl Photo {
pub fn develops_raw(&self) -> bool {
self.kind == MediaKind::Raw && self.preview_only.is_none()
}
/// The current ARW, NEF, RW2, RAF and CR3 readers have vendor WB multipliers but no measured camera
/// The current ARW, NEF, RW2, RAF, CR3, CR2 and PEF readers have vendor WB multipliers but no measured camera
/// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered
/// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and
/// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files).
pub fn relative_wb(&self) -> bool {
self.develops_raw() && ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
self.develops_raw()
&& ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
}
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
/// default preset applied on top of them ([`Photo::import_look`]).
+1
View File
@@ -14,6 +14,7 @@ let d: Decoded = decode(&bytes, DecodeOptions::default())?;
let working: Rgb32f = to_working(&d); // linear Rec.2020 D65 (Bradford)
let fast = decode(&bytes, DecodeOptions::fit(2048, 2048))?; // JPEG: DCT-domain 1/2..1/8 scaling
let t: Thumbnail = decode_thumbnail(&bytes, 256)?; // embedded EXIF/MPF preview or scaled decode
let h: Header = read_header(&bytes)?; // stored width/height + orientation, no pixels decoded
let icc = icc::write_named(NamedSpace::DisplayP3); // or icc::write_matrix_trc(&space, &Trc::…)
let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some(&xmp) };
+92 -1
View File
@@ -108,13 +108,64 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
decode_with_fallback(bytes, opts, None)
}
/// Stored dimensions and EXIF orientation from the markers, without decoding the scan data. The
/// file is refused, as a decode would refuse it, when it has no frame header, an unsupported frame
/// type or component count, or when its markers don't run to an end-of-image marker after the
/// image data (a truncated file). Damage *inside* the entropy-coded data can't be seen this way.
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?;
if m.components == 0 {
return Err(Error::Malformed(F, "no frame header".into()));
}
check_size(F, m.width as u64, m.height as u64, &DecodeOptions::default())?;
if !matches!(m.sof, 0xC0..=0xC3) {
return Err(Error::Unsupported(F, "arithmetic-coded or hierarchical JPEG"));
}
if !matches!(m.components, 1 | 3 | 4) {
return Err(Error::Malformed(F, format!("{} colour components", m.components)));
}
if m.first_scan_components == 0 || !reaches_eoi(bytes) {
return Err(Error::Malformed(F, "truncated: the image data doesn't end with an end-of-image marker".into()));
}
let orientation = m.exif.as_deref().map(exif::summarize).unwrap_or_default().orientation.unwrap_or(1);
Ok((m.width, m.height, orientation))
}
/// Whether the markers after SOI, followed through every segment and scan, reach an EOI marker
/// (the first one: the primary image's, ahead of any MPF images appended to the file). Segments
/// are skipped by their length; in scan data `FF 00` (stuffing), `FF FF` (fill) and RSTn are not
/// markers that end it.
fn reaches_eoi(b: &[u8]) -> bool {
let mut p = 2usize;
loop {
// entropy-coded data (or garbage between segments, as `parse_markers` tolerates it)
let Some(skip) = b.get(p..).and_then(find_ff) else { return false };
p = p.saturating_add(skip);
let Some(&marker) = b.get(p.saturating_add(1)) else { return false };
p = match marker {
0xD9 => return true,
0xFF => p.saturating_add(1),
0x00 | 0x01 | 0xD0..=0xD8 => p.saturating_add(2),
_ => {
let Some(len) = b.get(p.saturating_add(2)..p.saturating_add(4)).map(|l| u16::from_be_bytes([l[0], l[1]]) as usize) else {
return false;
};
if len < 2 {
return false;
}
p.saturating_add(2).saturating_add(len)
}
};
}
}
pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback: Option<crate::NamedSpace>) -> Result<Decoded> {
let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?;
if m.components == 0 {
return Err(Error::Malformed(F, "no frame header".into()));
}
check_size(F, m.width as u64, m.height as u64, opts)?;
let scale_to = opts.max_size.filter(|&(mw, mh)| mw > 0 && mh > 0 && (m.width >= 2 * mw || m.height >= 2 * mh));
let scale_to = opts.max_size.filter(|&(mw, mh)| mw > 0 && mh > 0 && (m.width >= mw.saturating_mul(2) || m.height >= mh.saturating_mul(2)));
// zune-jpeg handles the common cases fastest; jpeg-decoder covers DCT scaling, CMYK/YCCK,
// 12-bit and non-interleaved sequential scans (which zune-jpeg 0.5 mis-decodes with subsampling).
let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components;
@@ -175,6 +226,21 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>)
Ok(Raw { width: w, height: h, model, alpha: false, premultiplied: false, buf, bit_depth: depth })
}
/// The first `0xFF` byte, eight bytes at a time (scan data has one every few hundred bytes: this
/// is most of [`header`]'s time).
fn find_ff(b: &[u8]) -> Option<usize> {
const ONES: u64 = 0x0101_0101_0101_0101;
let (words, tail) = b.as_chunks::<8>();
for (i, w) in words.iter().enumerate() {
// a zero byte in !w is an 0xFF byte in w
let x = !u64::from_ne_bytes(*w);
if x.wrapping_sub(ONES) & !x & (ONES << 7) != 0 {
return w.iter().position(|&v| v == 0xFF).map(|j| i * 8 + j);
}
}
tail.iter().position(|&v| v == 0xFF).map(|j| words.len() * 8 + j)
}
/// The box to request from `jpeg-decoder::scale` so the decoded image still covers `mw × mh` once
/// fitted with the source aspect ratio.
fn scaled_request(w: u32, h: u32, mw: u32, mh: u32) -> (u16, u16) {
@@ -297,6 +363,31 @@ mod tests {
assert_eq!(scaled_request(100, 100, 256, 256), (100, 100));
}
#[test]
fn find_ff_matches_a_plain_search() {
let mut b: Vec<u8> = (0..300u32).map(|i| (i * 7 % 255) as u8).collect();
for at in [None, Some(0), Some(7), Some(8), Some(15), Some(203), Some(296), Some(299)] {
if let Some(i) = at {
b[i] = 0xFF;
}
for start in [0, 1, 5, 9, 290, 299, 300] {
let s = &b[start..];
assert_eq!(find_ff(s), s.iter().position(|&v| v == 0xFF), "{at:?} from {start}");
}
}
}
#[test]
fn unbounded_fit_box_decodes_at_full_size() {
// a full-size load asks for the largest box (`usize::MAX` saturated to `u32::MAX`): no DCT scaling,
// and no overflow working out whether to scale
use crate::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
let pixels = vec![128u8; 32 * 24 * 3];
let jpeg = encode_jpeg(&EncodeImage::new(32, 24, 3, Samples::U8(&pixels)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
let d = crate::decode(&jpeg, DecodeOptions::fit(u32::MAX, u32::MAX)).unwrap();
assert_eq!((d.width, d.height), (32, 24));
}
#[test]
fn markers_on_garbage() {
assert!(parse_markers(b"").is_none());
+48
View File
@@ -6,6 +6,8 @@
//! profiles (matrix/TRC exactly, LUT/CMYK via the `moxcms` CMS), 16-bit and float precision are
//! preserved. EXIF/XMP/ICC blobs are passed through untouched; orientation is reported, never applied.
//! - [`to_working`] converts a decode result to linear Rec.2020 D65 (the pipeline working space).
//! - [`read_header`] reads a file's stored dimensions and orientation from its headers, without
//! decoding pixels (import probes).
//! - [`decode_thumbnail`] is the fast path for grid thumbnails (EXIF thumbnail or DCT-scaled decode).
//! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and (native, feature `avif`) AVIF, embedding
//! ICC/EXIF/XMP. [`icc::write_matrix_trc`] builds profiles for export.
@@ -156,6 +158,52 @@ pub fn decode(bytes: &[u8], opts: DecodeOptions) -> Result<Decoded> {
std::panic::catch_unwind(|| decode_unguarded(bytes, format, &opts)).unwrap_or_else(|_| Err(Error::Malformed(format, "decoder panicked".into())))
}
/// What a file's headers say about its image: enough to catalogue it without decoding pixels.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Header {
pub format: Format,
/// Full-resolution stored dimensions, orientation **not** applied ([`Decoded::source_width`]).
pub width: u32,
pub height: u32,
/// EXIF/TIFF orientation 1..=8, 1 when absent ([`Decoded::orientation`]).
pub orientation: u16,
}
/// Read the stored dimensions and orientation of any format [`decode`] supports, agreeing with
/// what a decode reports, without decoding pixels: JPEG, PNG, TIFF, WebP and PSD are read from
/// their headers (markers, chunks, IFD, resources). GIF, BMP and JPEG XL fall back to a small
/// decode.
///
/// The header readers also refuse what a decode would refuse before reaching the pixels (an
/// unsupported layout, compression or frame type) and files whose image data is cut short
/// (truncated: the data runs past the end of the file, or a JPEG's markers don't reach its
/// end-of-image marker). Damage *inside* compressed data that is all present is only found by a
/// decode. Malformed input yields an error, never a panic (as for [`decode`], a panic inside a
/// third-party parser is caught on unwinding targets).
pub fn read_header(bytes: &[u8]) -> Result<Header> {
let format = sniff(bytes).ok_or(Error::UnknownFormat)?;
std::panic::catch_unwind(|| read_header_unguarded(bytes, format))
.unwrap_or_else(|_| Err(Error::Malformed(format, "header reader panicked".into())))
}
/// [`read_header`] without the panic guard (for fuzzing our own code paths).
#[doc(hidden)]
pub fn read_header_unguarded(bytes: &[u8], format: Format) -> Result<Header> {
let (width, height, orientation) = match format {
Format::Jpeg => jpeg::header(bytes)?,
Format::Png => png_codec::header(bytes)?,
Format::Tiff => tiff_codec::header(bytes)?,
Format::WebP => webp::header(bytes)?,
Format::Psd => psd::header(bytes)?,
// rare here (or, for JPEG XL, without a header reader yet): a small decode, as before
_ => {
let d = decode_unguarded(bytes, format, &DecodeOptions::fit(64, 64))?;
(d.source_width, d.source_height, d.orientation)
}
};
Ok(Header { format, width, height, orientation })
}
/// Decode a JPEG preview with its enclosing container's colour space as a fallback.
/// A JPEG carrying ICC or EXIF metadata keeps its own interpretation. The fallback is applied
/// before transfer decoding and linear-light resizing, never to already-resampled pixels.
+69
View File
@@ -57,6 +57,75 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
finish(F, raw, meta, (w, h), opts)
}
/// Stored dimensions and EXIF orientation (`eXIf`, wherever it is) from the chunk headers, without
/// inflating the image data. Refused, as a decode would refuse it: an invalid IHDR, an indexed
/// image without a palette, no IDAT, or a file that ends before the chunk after the last IDAT
/// begins (truncated image data). Chunk CRCs and the deflate stream are not checked.
pub(crate) fn header(b: &[u8]) -> Result<(u32, u32, u16)> {
if !b.starts_with(b"\x89PNG\r\n\x1a\n") {
return Err(err("bad signature"));
}
let be32 = |p: usize| b.get(p..p.checked_add(4)?).map(|v| u32::from_be_bytes([v[0], v[1], v[2], v[3]]));
if be32(8) != Some(13) || b.get(12..16) != Some(b"IHDR") {
return Err(err("missing IHDR"));
}
let ihdr = b.get(16..29).ok_or_else(|| err("truncated IHDR"))?;
let (w, h) = (u32::from_be_bytes([ihdr[0], ihdr[1], ihdr[2], ihdr[3]]), u32::from_be_bytes([ihdr[4], ihdr[5], ihdr[6], ihdr[7]]));
let (depth, color, compression, filter, interlace) = (ihdr[8], ihdr[9], ihdr[10], ihdr[11], ihdr[12]);
if w > i32::MAX as u32 || h > i32::MAX as u32 {
return Err(err("dimensions out of range"));
}
check_size(F, w as u64, h as u64, &DecodeOptions::default())?;
let depth_ok = match color {
0 => matches!(depth, 1 | 2 | 4 | 8 | 16),
3 => matches!(depth, 1 | 2 | 4 | 8),
2 | 4 | 6 => matches!(depth, 8 | 16),
_ => false,
};
if !depth_ok || compression != 0 || filter != 0 || interlace > 1 {
return Err(err("invalid IHDR"));
}
let (mut p, mut palette, mut idat, mut after_idat, mut exif) = (8usize, false, false, false, None);
while let Some(len) = be32(p) {
let Some(kind) = p.checked_add(4).and_then(|k| b.get(k..k.checked_add(4)?)) else { break };
if idat && kind != b"IDAT" {
// a chunk follows the image data: the IDAT run is complete
after_idat = true;
}
if kind == b"IEND" {
break;
}
let start = p.saturating_add(8);
let data = (len <= i32::MAX as u32).then(|| start.checked_add(len as usize).and_then(|end| b.get(start..end))).flatten();
let Some(data) = data else {
if after_idat {
break; // a damaged chunk after the image data: a decode ignores it too
}
return Err(err("truncated"));
};
match kind {
b"PLTE" => palette = true,
b"IDAT" if after_idat => {} // a stray IDAT after other chunks: the first run is the image
b"IDAT" => {
if color == 3 && !palette {
return Err(err("indexed image without a palette"));
}
idat = true;
}
b"eXIf" if exif.is_none() => exif = Some(data.strip_prefix(b"Exif\0\0").unwrap_or(data)),
_ => {}
}
p = start.saturating_add(data.len()).saturating_add(4);
}
if !idat {
return Err(err("no image data"));
}
if !after_idat {
return Err(err("truncated image data"));
}
Ok((w, h, exif.map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1)))
}
fn container_hint(info: &png::Info) -> Option<SourceSpace> {
if let Some(c) = &info.coding_independent_code_points {
let named = match c.color_primaries {
+265 -72
View File
@@ -69,8 +69,8 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
let (icc, exif, xmp) = resources(res);
// Layer and mask information: only the layer count sign matters (merged transparency).
let lm_len = if psb { r.u64()? as usize } else { r.u32()? as usize };
let lm = r.take(lm_len)?;
let lm_len = if psb { r.u64()? } else { u64::from(r.u32()?) };
let lm = r.take(usize::try_from(lm_len).map_err(|_| err("truncated"))?)?;
let merged_transparency = if lm.len() >= if psb { 10 } else { 6 } {
let off = if psb { 8 } else { 4 };
let count = i16::from_be_bytes([lm[off], lm[off + 1]]);
@@ -88,33 +88,6 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
1 => return Err(Error::Unsupported(F, "1-bit bitmap PSD")),
_ => return Err(err("bad depth")),
};
let plane = width * height * bpc;
let row_bytes = width * bpc;
let mut planes: Vec<Vec<u8>> = Vec::with_capacity(channels);
match compression {
0 => {
for _ in 0..channels {
planes.push(r.take(plane)?.to_vec());
}
}
1 => {
let counts_len = channels * height;
let mut counts = Vec::with_capacity(counts_len);
for _ in 0..counts_len {
counts.push(if psb { r.u32()? as usize } else { r.u16()? as usize });
}
for c in 0..channels {
let mut out = Vec::with_capacity(plane);
for y in 0..height {
let src = r.take(counts[c * height + y])?;
unpackbits(src, row_bytes, &mut out);
}
planes.push(out);
}
}
_ => return Err(Error::Unsupported(F, "ZIP-compressed merged image data")),
}
let (model, n_color) = match mode {
1 | 8 => (Model::Gray, 1),
2 => (Model::Rgb, 1),
@@ -128,50 +101,106 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
return Err(err("too few channels"));
}
let alpha = merged_transparency && channels > n_color && mode != 2;
// Only the colour (+ alpha) planes are decoded; extra channels (spot colours, masks) are
// stored after them and never touched.
let used = n_color + alpha as usize;
let n = width * height;
let too_large = || Error::TooLarge(width as u64, height as u64);
let n = width.checked_mul(height).ok_or_else(too_large)?;
let row_bytes = width.checked_mul(bpc).ok_or_else(too_large)?;
let plane = n.checked_mul(bpc).ok_or_else(too_large)?;
let mut planes: Vec<Vec<u8>> = Vec::with_capacity(used);
match compression {
0 => {
// Raw planes are borrowed from the input, so their size is bounded by the file.
for _ in 0..used {
planes.push(r.take(plane)?.to_vec());
}
}
1 => {
// Row byte-count table: one entry per row per channel (all channels, since the data
// follows it). Taken from the input before anything is allocated from it.
let entry = if psb { 4 } else { 2 };
let table_len = channels.checked_mul(height).and_then(|c| c.checked_mul(entry)).ok_or_else(|| err("truncated"))?;
let table = r.take(table_len)?;
let count = |i: usize| -> usize {
let at = i.saturating_mul(entry);
match table.get(at..at.saturating_add(entry)) {
Some(&[a, b]) => usize::from(u16::from_be_bytes([a, b])),
Some(&[a, b, c, d]) => u32::from_be_bytes([a, b, c, d]) as usize,
_ => 0,
}
};
// PackBits expands at most 64× (a 2-byte repeat run yields 128 bytes), so a row needs
// at least this many encoded bytes. Requiring it (and the data being present) before
// allocating bounds the decoded planes by the file size: a tiny file can no longer
// declare gigabytes of zero-padded or repeat-run rows.
let min_row = row_bytes.div_ceil(128).saturating_mul(2);
let mut data_len = 0usize;
for i in 0..used.saturating_mul(height) {
let c = count(i);
if c < min_row {
return Err(err("RLE row too short for its width"));
}
data_len = data_len.saturating_add(c);
}
if data_len > bytes.len().saturating_sub(r.p) {
return Err(err("truncated"));
}
for c in 0..used {
let mut out = zeroed::<u8>(plane, too_large)?;
for (y, row) in out.chunks_exact_mut(row_bytes).enumerate() {
let src = r.take(count(c * height + y))?;
unpackbits(src, row)?;
}
planes.push(out);
}
}
_ => return Err(Error::Unsupported(F, "ZIP-compressed merged image data")),
}
let samples = n.checked_mul(used).ok_or_else(too_large)?;
// Interleave to device samples.
let buf = if mode == 2 {
// Indexed → 8-bit RGB via the 768-byte palette.
if color_mode_data.len() < 768 || depth != 8 {
let (Some(pal), true) = (color_mode_data.get(..768), depth == 8) else {
return Err(err("bad indexed palette"));
}
let mut v = Vec::with_capacity(n * 3);
for &i in &planes[0] {
};
let idx = planes.first().ok_or_else(|| err("missing plane"))?;
let mut v = zeroed::<u8>(n.checked_mul(3).ok_or_else(too_large)?, too_large)?;
for (o, &i) in v.as_chunks_mut::<3>().0.iter_mut().zip(idx) {
let i = i as usize;
v.extend_from_slice(&[color_mode_data[i], color_mode_data[256 + i], color_mode_data[512 + i]]);
*o = [pal[i], pal[256 + i], pal[512 + i]];
}
Buf::U8(v)
} else {
let cmyk = model == Model::Cmyk;
match bpc {
1 => {
let mut v = vec![0u8; n * used];
for (c, p) in planes.iter().take(used).enumerate() {
for i in 0..n {
let x = p[i];
let mut v = zeroed::<u8>(samples, too_large)?;
for (c, p) in planes.iter().enumerate() {
for (o, &x) in v.iter_mut().skip(c).step_by(used).zip(p) {
// PSD CMYK stores 255 = no ink; our CMYK convention is 0 = no ink.
v[i * used + c] = if model == Model::Cmyk && c < 4 { 255 - x } else { x };
*o = if cmyk && c < 4 { 255 - x } else { x };
}
}
Buf::U8(v)
}
2 => {
let mut v = vec![0u16; n * used];
for (c, p) in planes.iter().take(used).enumerate() {
for i in 0..n {
let x = u16::from_be_bytes([p[2 * i], p[2 * i + 1]]);
v[i * used + c] = if model == Model::Cmyk && c < 4 { 65535 - x } else { x };
let mut v = zeroed::<u16>(samples, too_large)?;
for (c, p) in planes.iter().enumerate() {
for (o, s) in v.iter_mut().skip(c).step_by(used).zip(p.as_chunks::<2>().0) {
let x = u16::from_be_bytes(*s);
*o = if cmyk && c < 4 { 65535 - x } else { x };
}
}
Buf::U16(v)
}
_ => {
let mut v = vec![0f32; n * used];
for (c, p) in planes.iter().take(used).enumerate() {
for i in 0..n {
let x = f32::from_be_bytes([p[4 * i], p[4 * i + 1], p[4 * i + 2], p[4 * i + 3]]);
v[i * used + c] = if model == Model::Cmyk && c < 4 { 1.0 - x } else { x };
let mut v = zeroed::<f32>(samples, too_large)?;
for (c, p) in planes.iter().enumerate() {
for (o, s) in v.iter_mut().skip(c).step_by(used).zip(p.as_chunks::<4>().0) {
let x = f32::from_be_bytes(*s);
*o = if cmyk && c < 4 { 1.0 - x } else { x };
}
}
Buf::F32(v)
@@ -182,25 +211,111 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (width as u32, height as u32), opts)
}
/// PackBits: append exactly `row_bytes` bytes (zero-padded if the run data is short).
fn unpackbits(mut src: &[u8], row_bytes: usize, out: &mut Vec<u8>) {
let target = out.len() + row_bytes;
while out.len() < target && !src.is_empty() {
let h = src[0] as i8;
src = &src[1..];
if h >= 0 {
let n = (h as usize + 1).min(src.len()).min(target - out.len());
out.extend_from_slice(&src[..n]);
src = &src[(h as usize + 1).min(src.len())..];
} else if h != -128 {
let n = ((1 - h as isize) as usize).min(target - out.len());
if let Some(&b) = src.first() {
out.extend(std::iter::repeat_n(b, n));
src = &src[1..];
/// Stored dimensions and EXIF orientation (resource 1058) from the header and image resources,
/// without reading the merged image data. Refused, as a decode would refuse it: an unsupported
/// depth, colour mode or compression, or merged image data that runs past the end of the file
/// (truncated). The run-length data itself is not unpacked.
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
let mut r = Rd { b: bytes, p: 0 };
if r.take(4)? != b"8BPS" {
return Err(err("bad signature"));
}
let psb = match r.u16()? {
1 => false,
2 => true,
_ => return Err(err("bad version")),
};
r.take(6)?;
let channels = r.u16()? as usize;
let height = r.u32()? as usize;
let width = r.u32()? as usize;
let depth = r.u16()?;
let mode = r.u16()?;
check_size(F, width as u64, height as u64, &DecodeOptions::default())?;
if channels == 0 || channels > 56 {
return Err(err("bad channel count"));
}
let cm_len = r.u32()? as usize;
let color_mode_data = r.take(cm_len)?;
let res_len = r.u32()? as usize;
let (_, exif, _) = resources(r.take(res_len)?);
let lm_len = if psb { r.u64()? as usize } else { r.u32()? as usize };
r.take(lm_len)?;
let compression = r.u16()?;
let bpc = match depth {
8 => 1usize,
16 => 2,
32 => 4,
1 => return Err(Error::Unsupported(F, "1-bit bitmap PSD")),
_ => return Err(err("bad depth")),
};
let n_color = match mode {
1 | 8 | 7 | 2 => 1,
3 => 3,
4 => 4,
9 => return Err(Error::Unsupported(F, "Lab PSD")),
_ => return Err(err("unknown colour mode")),
};
if channels < n_color {
return Err(err("too few channels"));
}
if mode == 2 && (color_mode_data.len() < 768 || depth != 8) {
return Err(err("bad indexed palette"));
}
let rest = bytes.len().saturating_sub(r.p);
let need = match compression {
0 => width.checked_mul(height).and_then(|n| n.checked_mul(bpc)).and_then(|n| n.checked_mul(channels)),
1 => {
// the row byte counts, then the rows they count
let rows = channels.checked_mul(height).ok_or_else(|| err("truncated"))?;
let table = rows.checked_mul(if psb { 4 } else { 2 }).filter(|&t| t <= rest).ok_or_else(|| err("truncated"))?;
let mut total = table;
for _ in 0..rows {
total = total.saturating_add(if psb { r.u32()? as usize } else { r.u16()? as usize });
}
Some(total)
}
_ => return Err(Error::Unsupported(F, "ZIP-compressed merged image data")),
};
if need.is_none_or(|n| n > rest) {
return Err(err("truncated"));
}
let orientation = exif.as_deref().map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1);
Ok((width as u32, height as u32, orientation))
}
/// A zero-filled buffer whose allocation failure is an error, not an abort.
fn zeroed<T: Clone + Default>(len: usize, too_large: impl Fn() -> Error) -> Result<Vec<T>> {
let mut v = Vec::new();
v.try_reserve_exact(len).map_err(|_| too_large())?;
v.resize(len, T::default());
Ok(v)
}
/// PackBits: decode one row into `row` (pre-zeroed, so short run data leaves zero padding).
/// A run that would write past the end of the row is an error; trailing bytes are ignored.
fn unpackbits(mut src: &[u8], row: &mut [u8]) -> Result<()> {
let mut at = 0;
while at < row.len() {
let Some((&h, rest)) = src.split_first() else { break };
src = rest;
let h = h as i8;
if h >= 0 {
let n = h as usize + 1;
let lit = src.get(..n).unwrap_or(src);
let dst = row.get_mut(at..at + lit.len()).ok_or_else(|| err("RLE row overruns its width"))?;
dst.copy_from_slice(lit);
at += lit.len();
src = src.get(n..).unwrap_or(&[]);
} else if h != -128 {
let n = (1 - h as isize) as usize;
let Some((&b, rest)) = src.split_first() else { break };
src = rest;
row.get_mut(at..at + n).ok_or_else(|| err("RLE row overruns its width"))?.fill(b);
at += n;
}
}
out.resize(target, 0);
Ok(())
}
fn resources(mut b: &[u8]) -> (Option<Vec<u8>>, Option<Vec<u8>>, Option<String>) {
@@ -234,11 +349,89 @@ mod tests {
#[test]
fn packbits() {
let mut out = Vec::new();
unpackbits(&[0xFE, 0xAA, 0x02, 0x80, 0x00, 0x2A], 6, &mut out);
assert_eq!(out, vec![0xAA, 0xAA, 0xAA, 0x80, 0x00, 0x2A]);
let mut out = Vec::new();
unpackbits(&[0x05, 1], 4, &mut out);
assert_eq!(out, vec![1, 0, 0, 0]);
let mut out = [0u8; 6];
unpackbits(&[0xFE, 0xAA, 0x02, 0x80, 0x00, 0x2A], &mut out).unwrap();
assert_eq!(out, [0xAA, 0xAA, 0xAA, 0x80, 0x00, 0x2A]);
// Short run data is zero-padded.
let mut out = [0u8; 4];
unpackbits(&[0x01, 1], &mut out).unwrap();
assert_eq!(out, [1, 0, 0, 0]);
// Truncated literal run: copies what is there.
let mut out = [0u8; 4];
unpackbits(&[0x03, 7, 8], &mut out).unwrap();
assert_eq!(out, [7, 8, 0, 0]);
}
#[test]
fn packbits_overrun_is_an_error() {
// Repeat run of 128 into a 4-byte row.
let mut out = [0u8; 4];
assert!(unpackbits(&[0x81, 0xAA], &mut out).is_err());
// Literal run of 6 into a 4-byte row.
assert!(unpackbits(&[0x05, 1, 2, 3, 4, 5, 6], &mut out).is_err());
}
/// PSD header + empty colour-mode/resources/layer sections + compression id.
fn header(channels: u16, w: u32, h: u32, depth: u16, mode: u16, compression: u16) -> Vec<u8> {
let mut b = b"8BPS".to_vec();
b.extend_from_slice(&1u16.to_be_bytes());
b.extend_from_slice(&[0; 6]);
b.extend_from_slice(&channels.to_be_bytes());
b.extend_from_slice(&h.to_be_bytes());
b.extend_from_slice(&w.to_be_bytes());
b.extend_from_slice(&depth.to_be_bytes());
b.extend_from_slice(&mode.to_be_bytes());
b.extend_from_slice(&[0; 12]); // colour mode data, resources, layer & mask: all empty
b.extend_from_slice(&compression.to_be_bytes());
b
}
#[test]
fn huge_rle_with_truncated_count_table_is_rejected_before_allocating() {
// 1 × 2^30 px, 56 channels: the row table alone would be 56 Gi entries; the file has 4 bytes.
let mut b = header(56, 1, 1 << 30, 32, 3, 1);
b.extend_from_slice(&[0, 0, 0, 0]);
let e = decode(&b, &DecodeOptions::default()).unwrap_err();
assert!(matches!(e, Error::Malformed(_, ref m) if m == "truncated"), "{e:?}");
}
#[test]
fn huge_rle_of_empty_rows_is_rejected_before_allocating() {
// 2^30 × 1 px RGB at 32 bits: every row count is 0, which used to zero-pad 3 × 4 GiB.
let mut b = header(3, 1 << 30, 1, 32, 3, 1);
b.extend_from_slice(&[0; 6]);
let e = decode(&b, &DecodeOptions::default()).unwrap_err();
assert!(matches!(e, Error::Malformed(_, ref m) if m.contains("too short")), "{e:?}");
// 1 Gpx RGB 8-bit whose (plausible) row counts claim more data than the file holds.
let mut b = header(3, 1 << 20, 1 << 10, 8, 3, 1);
b.extend_from_slice(&[0xFF; 3 * 1024 * 2]);
let e = decode(&b, &DecodeOptions::default()).unwrap_err();
assert!(matches!(e, Error::Malformed(_, ref m) if m == "truncated"), "{e:?}");
}
#[test]
fn rle_repeat_run_past_row_width_is_an_error() {
// 4 × 1 gray 8-bit; the single row is a 128-byte repeat run.
let mut b = header(1, 4, 1, 8, 1, 1);
b.extend_from_slice(&2u16.to_be_bytes());
b.extend_from_slice(&[0x81, 0x55]);
let e = decode(&b, &DecodeOptions::default()).unwrap_err();
assert!(matches!(e, Error::Malformed(_, ref m) if m.contains("overruns")), "{e:?}");
}
#[test]
fn rle_gray16_and_extra_channels_decode() {
// 2 × 2 gray 16-bit with an extra (unused) channel whose data is absent: only the used
// plane is decoded. Row: repeat run of 4 bytes 0x80 -> samples 0x8080.
let mut b = header(2, 2, 2, 16, 1, 1);
for _ in 0..4 {
b.extend_from_slice(&2u16.to_be_bytes());
}
b.extend_from_slice(&[0xFD, 0x80, 0xFD, 0x80]);
let d = decode(&b, &DecodeOptions::default()).unwrap();
assert_eq!((d.width, d.height), (2, 2));
let p = d.image.get(1, 1);
let q = d.image.get(0, 0);
assert!(p[0] > 0.0 && (p[0] - q[0]).abs() < 1e-6, "{p:?}");
}
}
+63 -21
View File
@@ -15,10 +15,7 @@ fn err(e: impl std::fmt::Display) -> Error {
}
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
let mut limits = Limits::default();
limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize;
limits.ifd_value_size = 32 << 20;
let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits);
let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits(opts));
let (w, h) = d.dimensions().map_err(err)?;
check_size(F, w as u64, h as u64, opts)?;
let ct = d.colortype().map_err(err)?;
@@ -31,23 +28,7 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
let colormap = d.get_tag_u16_vec(Tag::ColorMap).ok();
let compression = d.get_tag_u32(Tag::Compression).ok();
let (model, n_color, n_samples, bits) = match ct {
ColorType::Gray(b) => (Model::Gray, 1, 1, b),
ColorType::GrayA(b) => (Model::Gray, 1, 2, b),
ColorType::RGB(b) => (Model::Rgb, 3, 3, b),
ColorType::RGBA(b) => (Model::Rgb, 3, 4, b),
ColorType::CMYK(b) => (Model::Cmyk, 4, 4, b),
ColorType::CMYKA(b) => (Model::Cmyk, 4, 5, b),
ColorType::Palette(b) => (Model::Rgb, 1, 1, b),
// JPEG-compressed YCbCr is converted to RGB by the JPEG decoder.
ColorType::YCbCr(b) if compression == Some(7) => (Model::Rgb, 3, 3, b),
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 3 => (Model::Rgb, 3, num_samples as usize, bit_depth),
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 1 => (Model::Gray, 1, num_samples as usize, bit_depth),
other => return Err(Error::Unsupported(F, unsupported_name(other))),
};
if bits < 8 && planar && n_samples > 1 {
return Err(Error::Unsupported(F, "planar TIFF with samples narrower than a byte"));
}
let (model, n_color, n_samples, bits) = layout(ct, compression, planar)?;
let has_alpha = n_samples > n_color && !matches!(ct, ColorType::Palette(_));
let premultiplied = has_alpha && extra.first() == Some(&1);
@@ -111,6 +92,67 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
finish(F, raw, meta, (w, h), opts)
}
fn limits(opts: &DecodeOptions) -> Limits {
let mut limits = Limits::default();
limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize;
limits.ifd_value_size = 32 << 20;
limits
}
/// The sample layout of a colour type we can decode: (model, colour samples, samples per pixel,
/// bits per sample).
fn layout(ct: ColorType, compression: Option<u32>, planar: bool) -> Result<(Model, usize, usize, u8)> {
let l = match ct {
ColorType::Gray(b) => (Model::Gray, 1, 1, b),
ColorType::GrayA(b) => (Model::Gray, 1, 2, b),
ColorType::RGB(b) => (Model::Rgb, 3, 3, b),
ColorType::RGBA(b) => (Model::Rgb, 3, 4, b),
ColorType::CMYK(b) => (Model::Cmyk, 4, 4, b),
ColorType::CMYKA(b) => (Model::Cmyk, 4, 5, b),
ColorType::Palette(b) => (Model::Rgb, 1, 1, b),
// JPEG-compressed YCbCr is converted to RGB by the JPEG decoder.
ColorType::YCbCr(b) if compression == Some(7) => (Model::Rgb, 3, 3, b),
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 3 => (Model::Rgb, 3, num_samples as usize, bit_depth),
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 1 => (Model::Gray, 1, num_samples as usize, bit_depth),
other => return Err(Error::Unsupported(F, unsupported_name(other))),
};
if l.3 < 8 && planar && l.2 > 1 {
return Err(Error::Unsupported(F, "planar TIFF with samples narrower than a byte"));
}
Ok(l)
}
/// Stored dimensions and orientation from the first IFD, without reading the image data. Refused,
/// as a decode would refuse it: a layout or compression we can't decode, or strips/tiles that
/// start past the end of the file (truncated). The compressed data itself is not checked.
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits(&DecodeOptions::default()));
let (w, h) = d.dimensions().map_err(err)?;
check_size(F, w as u64, h as u64, &DecodeOptions::default())?;
let ct = d.colortype().map_err(err)?;
let compression = d.get_tag_u32(Tag::Compression).ok();
let planar = d.get_tag_u32(Tag::PlanarConfiguration).ok() == Some(2);
layout(ct, compression, planar)?;
// what this build of the `tiff` crate decompresses: none, CCITT G4, LZW, JPEG, Deflate, PackBits
if !matches!(compression.unwrap_or(1), 1 | 4 | 5 | 7 | 8 | 32773 | 32946) {
return Err(Error::Unsupported(F, "compression method"));
}
let chunks = |offsets: Tag, counts: Tag, d: &mut Decoder<std::io::Cursor<&[u8]>>| {
Some((d.get_tag_u64_vec(offsets).ok()?, d.get_tag_u64_vec(counts).ok()?))
};
let (offsets, counts) = chunks(Tag::StripOffsets, Tag::StripByteCounts, &mut d)
.or_else(|| chunks(Tag::TileOffsets, Tag::TileByteCounts, &mut d))
.ok_or_else(|| err("no image data"))?;
// Only where each chunk starts: some writers overstate StripByteCounts, and the `tiff` crate
// decodes such files (it stops once it has the pixels), so a chunk's end proves nothing.
let len = bytes.len() as u64;
if offsets.is_empty() || offsets.iter().zip(&counts).any(|(&o, &c)| c > 0 && o >= len) {
return Err(err("truncated: image data past the end of the file"));
}
let orientation = d.get_tag_u32(Tag::Orientation).ok().map(|v| v as u16).filter(|o| (1..=8).contains(o)).unwrap_or(1);
Ok((w, h, orientation))
}
/// A BYTE/UNDEFINED tag as bytes (the decoder may surface unknown byte tags as unsigned values).
fn tag_bytes<R: std::io::Read + std::io::Seek>(d: &mut Decoder<R>, tag: Tag) -> Option<Vec<u8>> {
use tiff::decoder::ifd::Value;
+27
View File
@@ -27,3 +27,30 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
let raw = Raw { width: w as usize, height: h as usize, model: Model::Rgb, alpha, premultiplied: false, buf: Buf::U8(buf), bit_depth: 8 };
finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (w, h), opts)
}
/// Stored dimensions and EXIF orientation from the RIFF chunks and the bitstream header, without
/// decoding the image. Refused, as a decode would refuse it: no image chunk (`VP8 `, `VP8L` or an
/// animation frame), or one that runs past the end of the file (truncated). The compressed data
/// itself is not checked.
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
let mut p = 12usize;
loop {
let Some(h) = bytes.get(p..p.saturating_add(8)) else { return Err(err("no image data")) };
let size = u32::from_le_bytes([h[4], h[5], h[6], h[7]]) as usize;
let end = p.saturating_add(8).saturating_add(size);
if matches!(&h[0..4], b"VP8 " | b"VP8L" | b"ANMF") {
if end > bytes.len() {
return Err(err("truncated image data"));
}
break;
}
p = end.saturating_add(size & 1);
}
let mut d = image_webp::WebPDecoder::new(std::io::Cursor::new(bytes)).map_err(err)?;
d.set_memory_limit(bytes.len());
let (w, h) = d.dimensions();
check_size(F, w as u64, h as u64, &DecodeOptions::default())?;
let exif = d.exif_metadata().ok().flatten();
let exif = exif.as_deref().map(|e| e.strip_prefix(b"Exif\0\0").unwrap_or(e));
Ok((w, h, exif.map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1)))
}
+2
View File
@@ -30,8 +30,10 @@ fn try_all(bytes: &[u8]) {
if let Some(f) = sniff(bytes) {
let _ = decode_unguarded(bytes, f, &small_opts());
let _ = decode_unguarded(bytes, f, &DecodeOptions { max_size: Some((8, 8)), max_pixels: 1 << 22 });
let _ = read_header_unguarded(bytes, f);
}
let _ = decode(bytes, small_opts());
let _ = read_header(bytes);
let _ = decode_thumbnail_with(bytes, &ThumbnailOptions { max_pixels: small_opts().max_pixels, ..ThumbnailOptions::new(16) });
let _ = lightcraft_codecs::icc::parse(bytes);
}
+366
View File
@@ -0,0 +1,366 @@
//! `read_header` (import probes) agrees with a decode on dimensions and orientation, refuses what a
//! decode refuses (truncated files included), and returns errors — never panics — on hostile input.
use lightcraft_codecs::exif::minimal_exif;
use lightcraft_codecs::*;
use lightcraft_raster::Rgba8;
fn pixels(w: usize, h: usize) -> Rgba8 {
Rgba8::from_fn(w, h, |x, y| [(x * 37 % 256) as u8, (y * 53 % 256) as u8, ((x ^ y) * 9 % 256) as u8, (128 + x % 100) as u8])
}
fn meta(exif: &Option<Vec<u8>>) -> EncodeMeta<'_> {
EncodeMeta { exif: exif.as_deref(), xmp: Some("<x/>"), ..Default::default() }
}
/// A TIFF with an Orientation tag (`encode_tiff` writes none), via the `tiff` crate's encoder.
fn tiff_oriented(img: &Rgba8, orientation: u16) -> Vec<u8> {
use tiff::encoder::{TiffEncoder, colortype};
let rgb: Vec<u8> = img.data.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
let mut cur = std::io::Cursor::new(Vec::new());
let mut enc = TiffEncoder::new(&mut cur).unwrap();
let mut im = enc.new_image::<colortype::RGB8>(img.width as u32, img.height as u32).unwrap();
im.encoder().write_tag(tiff::tags::Tag::Orientation, orientation).unwrap();
im.write_data(&rgb).unwrap();
cur.into_inner()
}
/// A minimal 8-bit RGB PSD (raw or PackBits-literal merged data) with an EXIF resource.
fn psd(w: usize, h: usize, orientation: Option<u16>, rle: bool) -> Vec<u8> {
let mut b = Vec::new();
b.extend_from_slice(b"8BPS");
b.extend_from_slice(&1u16.to_be_bytes());
b.extend_from_slice(&[0; 6]);
b.extend_from_slice(&3u16.to_be_bytes());
b.extend_from_slice(&(h as u32).to_be_bytes());
b.extend_from_slice(&(w as u32).to_be_bytes());
b.extend_from_slice(&8u16.to_be_bytes());
b.extend_from_slice(&3u16.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes()); // colour mode data
let mut res = Vec::new();
if let Some(o) = orientation {
let exif = minimal_exif(o);
res.extend_from_slice(b"8BIM");
res.extend_from_slice(&1058u16.to_be_bytes());
res.extend_from_slice(&[0, 0]); // empty pascal name, padded
res.extend_from_slice(&(exif.len() as u32).to_be_bytes());
res.extend_from_slice(&exif);
if exif.len() % 2 == 1 {
res.push(0);
}
}
b.extend_from_slice(&(res.len() as u32).to_be_bytes());
b.extend_from_slice(&res);
b.extend_from_slice(&0u32.to_be_bytes()); // layer and mask info
b.extend_from_slice(&(rle as u16).to_be_bytes());
let plane = |c: usize| (0..w * h).map(move |i| ((i * (c + 3) * 11) % 256) as u8);
if rle {
// every row as literal runs of at most 128 bytes (PackBits' longest)
let row_len = w + w.div_ceil(128);
for _ in 0..3 * h {
b.extend_from_slice(&(row_len as u16).to_be_bytes());
}
for c in 0..3 {
let p: Vec<u8> = plane(c).collect();
for row in p.chunks(w) {
for run in row.chunks(128) {
b.push(run.len() as u8 - 1);
b.extend_from_slice(run);
}
}
}
} else {
for c in 0..3 {
b.extend(plane(c));
}
}
b
}
/// Generated files of every format with a header reader, plus a fallback one (BMP via `image`).
fn fixtures(w: usize, h: usize) -> Vec<(String, Vec<u8>)> {
let img = pixels(w, h);
let e = EncodeImage::rgba8(&img);
let rgb: Vec<u8> = img.data.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
let rgb_e = EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&rgb));
let u16s: Vec<u16> = (0..w * h * 3).map(|i| (i * 331) as u16).collect();
let mut out = Vec::new();
for o in [None, Some(1), Some(6), Some(8)] {
let exif = o.map(minimal_exif);
let tag = format!("o{}", o.unwrap_or(0));
out.push((format!("jpeg 420 {tag}"), encode_jpeg(&rgb_e, 85, ChromaSubsampling::S420, &meta(&exif)).unwrap()));
out.push((format!("jpeg 444 {tag}"), encode_jpeg(&rgb_e, 85, ChromaSubsampling::S444, &meta(&exif)).unwrap()));
out.push((format!("png rgba {tag}"), encode_png(&e, &meta(&exif)).unwrap()));
out.push((format!("png 16-bit {tag}"), encode_png(&EncodeImage::new(w as u32, h as u32, 3, Samples::U16(&u16s)), &meta(&exif)).unwrap()));
out.push((format!("webp {tag}"), encode_webp_lossless(&e, &meta(&exif)).unwrap()));
out.push((format!("psd {tag}"), psd(w, h, o, false)));
out.push((format!("psd rle {tag}"), psd(w, h, o, true)));
out.push((format!("tiff {tag}"), tiff_oriented(&img, o.unwrap_or(1))));
}
out.push(("tiff lzw".into(), encode_tiff(&e, TiffCompression::Lzw, &EncodeMeta::default()).unwrap()));
out.push(("tiff packbits".into(), encode_tiff(&e, TiffCompression::PackBits, &EncodeMeta::default()).unwrap()));
let mut bmp = Vec::new();
image::codecs::bmp::BmpEncoder::new(&mut bmp).encode(&rgb, w as u32, h as u32, image::ExtendedColorType::Rgb8).unwrap();
out.push(("bmp".into(), bmp));
out
}
/// What the import probe learnt before it read headers: a small decode.
fn decoded(bytes: &[u8]) -> Result<Header> {
let d = decode(bytes, DecodeOptions::fit(64, 64))?;
Ok(Header { format: d.format, width: d.source_width, height: d.source_height, orientation: d.orientation })
}
#[test]
fn header_matches_decode() {
for (w, h) in [(160, 96), (96, 160), (7, 5)] {
for (name, bytes) in fixtures(w, h) {
let header = read_header(&bytes).unwrap_or_else(|e| panic!("{name} {w}×{h}: {e}"));
assert_eq!(header, decoded(&bytes).unwrap(), "{name} {w}×{h}");
assert_eq!((header.width, header.height), (w as u32, h as u32), "{name}");
}
}
let o = |name: &str| fixtures(16, 8).into_iter().find(|f| f.0 == name).map(|f| read_header(&f.1).unwrap().orientation).unwrap();
assert_eq!((o("jpeg 420 o6"), o("png rgba o8"), o("webp o6"), o("psd o8"), o("tiff o6"), o("jpeg 444 o0")), (6, 8, 6, 8, 6, 1));
}
/// Whenever the decode the probe used to run accepts a file (here: every cut of every fixture),
/// the header agrees with it: reading headers never refuses a file that decodes. The reverse
/// is checked for truncation, which the header readers detect.
#[test]
fn truncated_files_agree_with_decode() {
// 160 px wide: large JPEGs take the DCT-scaled (strict) decode path the probe took for photos
for (name, bytes) in fixtures(160, 96) {
let n = bytes.len();
let mut cuts: Vec<usize> = (0..100).map(|i| n * i / 100).collect();
cuts.extend(n.saturating_sub(64)..n);
for cut in cuts {
let part = &bytes[..cut];
if let Ok(d) = decoded(part) {
assert_eq!(read_header(part).ok(), Some(d), "{name} cut at {cut}/{n}");
}
}
for cut in [n / 4, n / 2, n * 9 / 10] {
assert!(read_header(&bytes[..cut]).is_err(), "{name}: cut at {cut}/{n} accepted");
}
}
}
#[test]
fn truncation_details() {
let f = fixtures(160, 96);
let get = |name: &str| f.iter().find(|x| x.0 == name).map(|x| x.1.clone()).unwrap();
let jpeg = get("jpeg 420 o6");
assert!(read_header(&jpeg[..jpeg.len() - 2]).is_err(), "a JPEG without its EOI is truncated");
let mut trailing = jpeg.clone();
trailing.extend_from_slice(&[0; 100]);
assert_eq!(read_header(&trailing).unwrap().orientation, 6, "bytes after the EOI are fine");
let png = get("png rgba o6");
assert!(read_header(&png[..png.len() - 2]).is_ok(), "a damaged IEND is ignored, as by a decode");
assert!(read_header(&png[..png.len() - 12]).is_err(), "nothing after the image data: truncated");
}
/// eXIf after the image data counts, as it does for a decode (which reads the whole file).
#[test]
fn png_exif_after_idat() {
let img = pixels(9, 4);
let plain = encode_png(&EncodeImage::rgba8(&img), &EncodeMeta::default()).unwrap();
let exif = minimal_exif(6);
let mut chunk = (exif.len() as u32).to_be_bytes().to_vec();
chunk.extend_from_slice(b"eXIf");
chunk.extend_from_slice(&exif);
chunk.extend_from_slice(&[0; 4]); // CRC: not checked by either reader
let iend = plain.len() - 12;
let mut b = plain[..iend].to_vec();
b.extend_from_slice(&chunk);
b.extend_from_slice(&plain[iend..]);
assert_eq!(read_header(&b).unwrap().orientation, 6);
}
fn jpeg_segments(sof: u8, w: u16, h: u16, comps: u8) -> Vec<u8> {
let mut b = vec![0xFF, 0xD8, 0xFF, sof, 0, 8 + 3 * comps as u16 as u8, 8];
b.extend_from_slice(&h.to_be_bytes());
b.extend_from_slice(&w.to_be_bytes());
b.push(comps);
for c in 0..comps {
b.extend_from_slice(&[c + 1, 0x11, 0]);
}
b.extend_from_slice(&[0xFF, 0xDA, 0, 8, 1, 1, 0, 0, 63, 0]);
b.extend_from_slice(&[0x12, 0xFF, 0x00, 0x34, 0xFF, 0xD0, 0x56]);
b.extend_from_slice(&[0xFF, 0xD9]);
b
}
fn png_ihdr(w: u32, h: u32, depth: u8, color: u8) -> Vec<u8> {
let mut b = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec();
b.extend_from_slice(&w.to_be_bytes());
b.extend_from_slice(&h.to_be_bytes());
b.extend_from_slice(&[depth, color, 0, 0, 0, 0, 0, 0, 0]);
b
}
fn png_chunk(b: &mut Vec<u8>, kind: &[u8; 4], len: u32, data: &[u8]) {
b.extend_from_slice(&len.to_be_bytes());
b.extend_from_slice(kind);
b.extend_from_slice(data);
b.extend_from_slice(&[0; 4]);
}
#[test]
fn hostile_headers_are_errors() {
// a well-formed synthetic JPEG header is read
assert_eq!(read_header(&jpeg_segments(0xC0, 33, 21, 3)).unwrap(), Header { format: Format::Jpeg, width: 33, height: 21, orientation: 1 });
let jpeg_bad = [
jpeg_segments(0xC0, 0, 21, 3), // zero width
jpeg_segments(0xC0, 33, 0, 3), // zero height (DNL)
jpeg_segments(0xC0, 65535, 65535, 3), // 4.3 Gpx
jpeg_segments(0xC9, 33, 21, 3), // arithmetic coding
jpeg_segments(0xC5, 33, 21, 3), // hierarchical
jpeg_segments(0xC0, 33, 21, 2), // two components
jpeg_segments(0xC0, 33, 21, 3)[..30].to_vec(),
vec![0xFF, 0xD8, 0xFF, 0xE1, 0, 0], // segment length 0
vec![0xFF, 0xD8, 0xFF, 0xE1, 0xFF, 0xFF], // segment past the end
vec![0xFF, 0xD8, 0xFF, 0xD9],
];
for (i, b) in jpeg_bad.iter().enumerate() {
assert!(read_header(b).is_err(), "JPEG case {i}");
}
let mut no_sos = jpeg_segments(0xC0, 33, 21, 3);
no_sos.truncate(2 + 2 + 17);
no_sos.extend_from_slice(&[0xFF, 0xD9]);
assert!(read_header(&no_sos).is_err(), "JPEG without a scan");
let mut ok = png_ihdr(5, 3, 8, 2);
png_chunk(&mut ok, b"IDAT", 1, &[0]);
png_chunk(&mut ok, b"IEND", 0, &[]);
assert_eq!(read_header(&ok).unwrap(), Header { format: Format::Png, width: 5, height: 3, orientation: 1 });
let png_case = |ihdr: Vec<u8>, chunks: &[(&[u8; 4], u32, &[u8])]| {
let mut b = ihdr;
for (k, l, d) in chunks {
png_chunk(&mut b, k, *l, d);
}
b
};
let idat: (&[u8; 4], u32, &[u8]) = (b"IDAT", 1, &[0]);
let iend: (&[u8; 4], u32, &[u8]) = (b"IEND", 0, &[]);
let png_bad = [
png_case(png_ihdr(0, 3, 8, 2), &[idat, iend]),
png_case(png_ihdr(5, 0, 8, 2), &[idat, iend]),
png_case(png_ihdr(0x8000_0000, 1, 8, 2), &[idat, iend]),
png_case(png_ihdr(65536, 65536, 8, 2), &[idat, iend]),
png_case(png_ihdr(5, 3, 4, 2), &[idat, iend]), // RGB at 4 bits
png_case(png_ihdr(5, 3, 8, 5), &[idat, iend]), // no colour type 5
png_case(png_ihdr(5, 3, 8, 3), &[idat, iend]), // indexed without PLTE
png_case(png_ihdr(5, 3, 8, 2), &[iend]), // no IDAT
png_case(png_ihdr(5, 3, 8, 2), &[idat]), // nothing after the image data
png_case(png_ihdr(5, 3, 8, 2), &[(b"IDAT", 0xFFFF_FFFF, &[0]), iend]),
png_case(png_ihdr(5, 3, 8, 2), &[(b"tEXt", 0x7FFF_FFFF, &[0]), idat, iend]),
png_ihdr(5, 3, 8, 2)[..20].to_vec(),
b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDX\0\0\0\x05\0\0\0\x03\x08\x02\0\0\0".to_vec(),
];
for (i, b) in png_bad.iter().enumerate() {
assert!(read_header(b).is_err(), "PNG case {i}");
}
let tiff = encode_tiff(&EncodeImage::rgba8(&pixels(6, 4)), TiffCompression::None, &EncodeMeta::default()).unwrap();
let ifd = u32::from_le_bytes([tiff[4], tiff[5], tiff[6], tiff[7]]) as usize;
let mut tiff_bad = vec![b"II*\0\xff\xff\xff\x7f".to_vec(), b"II*\0\x08\0\0\0".to_vec(), b"II*\0\x04\0\0\0".to_vec()];
// cut short after the IFD
assert!(ifd < tiff.len() / 2, "the IFD comes before the image data");
tiff_bad.push(tiff[..tiff.len() - 10].to_vec());
for (i, b) in tiff_bad.iter().enumerate() {
assert!(read_header(b).is_err(), "TIFF case {i}");
}
// a strip offset pointing past the end of the file
let t = |b: &[u8], p: usize| u16::from_le_bytes([b[p], b[p + 1]]);
let n = t(&tiff, ifd) as usize;
let mut far = tiff.clone();
for e in 0..n {
let p = ifd + 2 + e * 12;
if t(&far, p) == 273 && t(&far, p + 2) == 4 && u32::from_le_bytes([far[p + 4], far[p + 5], far[p + 6], far[p + 7]]) == 1 {
far[p + 8..p + 12].copy_from_slice(&0x7FFF_0000u32.to_le_bytes());
}
}
assert_ne!(far, tiff, "the strip offset was found");
assert!(read_header(&far).is_err(), "strip past the end");
let webp_bad = [
b"RIFF\0\0\0\0WEBP".to_vec(),
b"RIFF\0\0\0\0WEBPVP8L\xff\xff\xff\xff\x2f".to_vec(),
b"RIFF\0\0\0\0WEBPXXXX\xff\xff\xff\xff".to_vec(),
b"RIFF\0\0\0\0WEBPVP8 \x04\0\0\0\0\0\0\0".to_vec(),
];
for (i, b) in webp_bad.iter().enumerate() {
assert!(read_header(b).is_err(), "WebP case {i}");
}
let good = psd(4, 3, Some(6), true);
assert_eq!(read_header(&good).unwrap().orientation, 6);
let set = |pos: usize, v: &[u8]| {
let mut b = good.clone();
b[pos..pos + v.len()].copy_from_slice(v);
b
};
let res_len = u32::from_be_bytes([good[30], good[31], good[32], good[33]]) as usize;
let compression = 34 + res_len + 4;
let mut psd_bad = vec![
set(12, &0u16.to_be_bytes()), // no channels
set(12, &57u16.to_be_bytes()), // too many
set(12, &2u16.to_be_bytes()), // too few for RGB
set(14, &0x7FFF_FFFFu32.to_be_bytes()), // absurd height
set(18, &0u32.to_be_bytes()), // zero width
set(22, &1u16.to_be_bytes()), // 1-bit
set(22, &7u16.to_be_bytes()), // bad depth
set(24, &9u16.to_be_bytes()), // Lab
set(24, &77u16.to_be_bytes()), // unknown mode
set(24, &2u16.to_be_bytes()), // indexed without a palette
set(compression, &2u16.to_be_bytes()), // ZIP
set(26, &0xFFFF_FFFFu32.to_be_bytes()), // colour mode data past the end
set(30, &0xFFFF_FFF0u32.to_be_bytes()), // resources past the end
];
// a 1 × 2^30 RLE image declared in a tiny file: refused without reading 2^30 row counts
let mut tall = good.clone();
tall[14..18].copy_from_slice(&(1u32 << 30).to_be_bytes());
tall[18..22].copy_from_slice(&1u32.to_be_bytes());
psd_bad.push(tall);
psd_bad.push(good[..good.len() - 1].to_vec());
for (i, b) in psd_bad.iter().enumerate() {
assert!(read_header(b).is_err(), "PSD case {i}");
}
for b in [&b""[..], b"\xFF\xD8\xFF", b"\x89PNG\r\n\x1a\n", b"II*\0", b"MM\0*", b"8BPS\0\x01", b"GIF89a", b"not an image at all"] {
assert!(read_header(b).is_err(), "{b:?}");
}
}
/// The same with a few random bytes changed: whenever the decode accepts the damaged file, the
/// header agrees with it (deterministic seed).
#[test]
fn mutated_files_agree_with_decode() {
let mut s: u64 = 0x1234_5678_9abc_def1;
let mut rnd = || {
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
s
};
let (mut decodable, mut disagreements) = (0, Vec::new());
for (name, bytes) in fixtures(160, 96) {
for _ in 0..300 {
let mut b = bytes.clone();
for _ in 0..1 + rnd() % 3 {
let i = rnd() as usize % b.len();
b[i] = rnd() as u8;
}
if let Ok(d) = decoded(&b) {
decodable += 1;
let h = read_header(&b);
if h.as_ref().ok() != Some(&d) {
disagreements.push(format!("{name}: decode {d:?}, header {h:?}"));
}
}
}
}
assert!(decodable > 1000, "{decodable}");
assert!(disagreements.is_empty(), "{disagreements:#?}");
}
+1
View File
@@ -32,6 +32,7 @@ log = { workspace = true }
ab_glyph = { workspace = true }
# Apply the font’s vertical substitutions and origins to Japanese watermarks.
harfrust = { version = "0.12", default-features = false, features = ["std"] }
unicode-segmentation = "1.13"
lightcraft-segment = { workspace = true, optional = true }
[features]
+9 -3
View File
@@ -31,7 +31,10 @@ pub(crate) const PROFILE_PROXY: usize = 192;
/// as-shot look (`docs/camera-preview-colour.md`). The catalog's `Photo::relative_wb` matches the
/// same formats by file extension.
pub(crate) fn file_local_look(format: RawFormat) -> bool {
matches!(format, RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2 | RawFormat::Raf | RawFormat::Cr3)
matches!(
format,
RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2 | RawFormat::Raf | RawFormat::Cr3 | RawFormat::Cr2 | RawFormat::Pef
)
}
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
@@ -1017,8 +1020,11 @@ mod tests {
#[test]
fn supported_raws_get_a_file_local_look() {
assert!([RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2, RawFormat::Raf, RawFormat::Cr3].into_iter().all(file_local_look));
assert!(![RawFormat::Dng, RawFormat::Cr2].into_iter().any(file_local_look));
// every decoded raw without a colour matrix of its own (issue #310: CR2 rendered flat with the fallback)
let local = [RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2, RawFormat::Raf, RawFormat::Cr3, RawFormat::Cr2, RawFormat::Pef];
assert!(local.into_iter().all(file_local_look));
// DNG carries its own colour model
assert!(!file_local_look(RawFormat::Dng));
}
#[test]
+97 -10
View File
@@ -44,6 +44,17 @@ const NON_ADJUSTMENT: &[&str] = &[
"crs:AlreadyApplied",
"crs:RawFileName",
"crs:HasCrop",
// preset/library bookkeeping and tool options that don't change the rendering
"crs:Cluster",
"crs:SupportsAmount2",
"crs:RequiresRGBTables",
"crs:SortName",
"crs:Description",
"crs:CropConstrainToWarp",
"crs:OverrideLookVignette",
// the file's as-shot white, recorded next to the edit (ours comes from the file itself)
"crs:AsShotTemperature",
"crs:AsShotTint",
];
/// True if the packet carries any `crs:` adjustment (not just bookkeeping fields).
@@ -233,11 +244,14 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
put(o, "curve.master", c);
}
}
for (crs, ch) in
[("ToneCurvePV2012", "master"), ("ToneCurvePV2012Red", "red"), ("ToneCurvePV2012Green", "green"), ("ToneCurvePV2012Blue", "blue")]
{
if let Some(c) = curve(props, &format!("crs:{crs}")) {
put(o, &format!("curve.{ch}"), c);
// Lightroom Classic reads the red / green / blue curves only together with the master 2012
// curve: a channel curve without `ToneCurvePV2012` leaves the photo unchanged
if let Some(c) = curve(props, "crs:ToneCurvePV2012") {
put(o, "curve.master", c);
for (crs, ch) in [("ToneCurvePV2012Red", "red"), ("ToneCurvePV2012Green", "green"), ("ToneCurvePV2012Blue", "blue")] {
if let Some(c) = curve(props, &format!("crs:{crs}")) {
put(o, &format!("curve.{ch}"), c);
}
}
}
@@ -287,9 +301,12 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
n(o, "GrainSize", "grain.size");
n(o, "GrainFrequency", "grain.roughness");
// ---- Optics (manual corrections; lens profiles are ours, only the switch carries over)
if let Some(b) = boolean(props, "crs:LensProfileEnable") {
put(o, "optics.lens_profile", json!(b));
// ---- Optics (manual corrections; lens profiles are ours, only the switch carries over).
// Our profile corrections are the file's own (DNG-embedded) ones, which Lightroom applies
// whatever its "Enable Profile Corrections" box says [inferred]: so only an enabling switch
// carries over, and `LensProfileEnable=0` (the default in most presets) leaves ours as it is.
if boolean(props, "crs:LensProfileEnable") == Some(true) {
put(o, "optics.lens_profile", json!(true));
}
if let Some(b) = boolean(props, "crs:AutoLateralCA") {
put(o, "optics.remove_ca", json!(b));
@@ -373,8 +390,19 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values
mask_skips = skipped.into_iter().map(|k| format!("Mask: {k}")).collect();
}
}
// fields that only switch a panel on/off or name things: not adjustments by themselves
let quiet = |k: &str| k.starts_with("Enable") || k.starts_with("ToneCurveName") || k == "AutoTone" || k == "AutoGrayscaleMix";
// fields that only switch a panel on/off or name things: not adjustments by themselves; an
// HDR edit mode that is off, and Point Color slots that are all empty (-1)
let value_is = |k: &str, f: &dyn Fn(&[String]) -> bool| props.get(&format!("crs:{k}")).is_some_and(|v| f(v));
let off = |v: &[String]| v.iter().all(|s| s.trim() == "0");
let empty_points = |v: &[String]| v.iter().all(|s| s.split(',').all(|n| n.trim().parse::<f64>().is_ok_and(|x| x == -1.0) || n.trim().is_empty()));
let quiet = |k: &str| {
k.starts_with("Enable")
|| k.starts_with("ToneCurveName")
|| k == "AutoTone"
|| k == "AutoGrayscaleMix"
|| (k == "HDREditMode" && value_is(k, &off))
|| (k == "PointColors" && value_is(k, &empty_points))
};
let mut unmapped: Vec<String> = props
.keys()
.filter(|k| k.starts_with("crs:"))
@@ -539,4 +567,63 @@ mod tests {
assert!(!p.builtin);
assert!(preset_from_xmp("<x/>", "f").is_none());
}
/// A preset packet (written for this test) with the bookkeeping fields Lightroom presets carry.
fn preset_packet(extra_attrs: &str, extra_elems: &str) -> String {
format!(
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
crs:PresetType="Normal" crs:Cluster="" crs:UUID="0123ABCD" crs:SupportsAmount2="True" crs:SupportsAmount="True"
crs:RequiresRGBTables="False" crs:Contrast2012="+25" crs:LensProfileEnable="0" crs:OverrideLookVignette="False"
crs:CropConstrainToWarp="0" crs:AsShotTemperature="5500" crs:AsShotTint="10" {extra_attrs}>
<crs:SortName><rdf:Alt><rdf:li xml:lang="x-default"/></rdf:Alt></crs:SortName>
<crs:Description><rdf:Alt><rdf:li xml:lang="x-default"/></rdf:Alt></crs:Description>
{extra_elems}
</rdf:Description></rdf:RDF></x:xmpmeta>"#
)
}
#[test]
fn channel_curves_need_the_master_curve() {
// Lightroom ignores a red / green / blue curve without `ToneCurvePV2012`
let packet = |master: &str| {
format!(
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:ToneCurveName2012="Custom">{master}
<crs:ToneCurvePV2012Green><rdf:Seq><rdf:li>0, 0</rdf:li><rdf:li>64, 40</rdf:li><rdf:li>255, 255</rdf:li></rdf:Seq></crs:ToneCurvePV2012Green>
</rdf:Description></rdf:RDF></x:xmpmeta>"#
)
};
let alone = to_partial(&props(&packet("")), Some(true));
assert!(alone.pointer("/curve/green").is_none(), "{alone}");
let master = "<crs:ToneCurvePV2012><rdf:Seq><rdf:li>0, 0</rdf:li><rdf:li>255, 255</rdf:li></rdf:Seq></crs:ToneCurvePV2012>";
let with = to_partial(&props(&packet(master)), Some(true));
assert!(with.pointer("/curve/green").is_some() && with.pointer("/curve/master").is_some(), "{with}");
}
fn unmapped(x: &str) -> Vec<String> {
to_partial_report(&props(x), None, None, 1.5).1
}
#[test]
fn preset_bookkeeping_is_not_reported_but_real_gaps_are() {
let empty_points = "<crs:PointColors><rdf:Seq><rdf:li>-1.000000, -1.000000, -1.000000, -1.000000</rdf:li></rdf:Seq></crs:PointColors>";
assert_eq!(unmapped(&preset_packet(r#"crs:HDREditMode="0""#, empty_points)), Vec::<String>::new());
// an HDR edit, a used Point Color slot and an unknown adjustment still are
let used_points = "<crs:PointColors><rdf:Seq><rdf:li>0.5, 0.2, 0.1, 10, 0, 0, 0, 0</rdf:li></rdf:Seq></crs:PointColors>";
let got = unmapped(&preset_packet(r#"crs:HDREditMode="1" crs:FutureSlider="12""#, used_points));
assert_eq!(got, ["FutureSlider", "HDREditMode", "PointColors"]);
}
#[test]
fn disabled_lens_profile_switch_keeps_embedded_corrections() {
// Lightroom applies a file's built-in lens corrections regardless of its profile switch,
// and ours are only those: a preset's `LensProfileEnable=0` must not turn them off
let p = props(&preset_packet("", ""));
let mut d = DevelopSettings::default();
d.optics.lens_profile = true;
assert!(apply_partial(&d, &to_partial(&p, None), 1.0).optics.lens_profile);
let on = props(&preset_packet("", "").replace(r#"crs:LensProfileEnable="0""#, r#"crs:LensProfileEnable="1""#));
assert!(apply_partial(&DevelopSettings::default(), &to_partial(&on, None), 1.0).optics.lens_profile);
}
}
+91 -25
View File
@@ -258,33 +258,41 @@ fn watermark_fonts(craft: &'static [crate::fonts::CraftFont]) -> Vec<ab_glyph::F
.collect()
}
/// The font's vertical form, positioned from its vertical origin within the watermark cell.
/// Shape a whole grapheme in one cell. CJK uses vertical origins and forms; Latin stays upright.
/// Unlike Unicode presentation-form characters, GSUB works with BIZ UD fonts too.
fn vertical_watermark_glyph(
fn watermark_cell_glyphs(
font: &ab_glyph::FontRef<'_>,
data: &harfrust::ShaperData,
ch: char,
text: &str,
vertical: bool,
px: f32,
left: f32,
top: f32,
) -> Option<ab_glyph::Glyph> {
) -> Option<Vec<ab_glyph::Glyph>> {
use ab_glyph::{Font, ScaleFont};
let face = harfrust::FontRef::new(font.font_data()).ok()?;
let mut buf = harfrust::UnicodeBuffer::new();
buf.add(ch, 0);
buf.set_direction(harfrust::Direction::TopToBottom);
buf.push_str(text);
buf.set_direction(if vertical { harfrust::Direction::TopToBottom } else { harfrust::Direction::LeftToRight });
buf.guess_segment_properties();
let shaped = data.shaper(&face).build().shape(buf, harfrust::ShapeOptions::new());
if shaped.glyph_infos().len() != 1 {
if shaped.glyph_infos().is_empty() {
return None;
}
let gid = u16::try_from(shaped.glyph_infos().first()?.glyph_id).ok().filter(|g| *g != 0)?;
let pos = shaped.glyph_positions().first()?;
let sf = font.as_scaled(px);
let (sx, sy) = (sf.h_scale_factor(), sf.v_scale_factor());
let height = -(pos.y_advance as f32) * sy;
let pen = ab_glyph::point(left + px / 2.0 + pos.x_offset as f32 * sx, top + (px - height) / 2.0 - pos.y_offset as f32 * sy);
Some(ab_glyph::GlyphId(gid).with_scale_and_position(px, pen))
let advance_x = shaped.glyph_positions().iter().map(|p| p.x_advance as f32 * sx).sum::<f32>();
let advance_y = shaped.glyph_positions().iter().map(|p| -(p.y_advance as f32) * sy).sum::<f32>();
let (mut x, mut y) = if vertical { (left + px / 2.0, top + (px - advance_y) / 2.0) } else { (left + (px - advance_x) / 2.0, top + sf.ascent()) };
let mut glyphs = Vec::new();
for (info, pos) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) {
let gid = u16::try_from(info.glyph_id).ok().filter(|g| *g != 0)?;
let pen = ab_glyph::point(x + pos.x_offset as f32 * sx, y - pos.y_offset as f32 * sy);
glyphs.push(ab_glyph::GlyphId(gid).with_scale_and_position(px, pen));
x += pos.x_advance as f32 * sx;
y -= pos.y_advance as f32 * sy;
}
Some(glyphs)
}
/// Draw `wm` onto `img` (straight alpha blending of the encoded values).
@@ -355,8 +363,13 @@ fn watermark_coverage(
let mut prev = None;
let columns = text.split('\n').count();
let mut column = 0usize;
for original in text.chars() {
if original == '\n' {
use unicode_segmentation::UnicodeSegmentation;
// Horizontal watermarks retain scalar layout and kerning; only vertical cells use graphemes.
let cells: Box<dyn Iterator<Item = &str>> =
if wm.vertical { Box::new(text.graphemes(true)) } else { Box::new(text.char_indices().filter_map(|(i, c)| text.get(i..i + c.len_utf8()))) };
for cell in cells {
let Some(original) = cell.chars().next() else { continue };
if cell == "\n" || (wm.vertical && cell == "\r\n") {
if wm.vertical {
column = column.saturating_add(1);
tw = tw.max(y);
@@ -369,17 +382,20 @@ fn watermark_coverage(
prev = None;
continue;
}
if wm.vertical
&& matches!(original as u32, 0x3000..=0x30FF | 0x3400..=0x9FFF | 0xFF01..=0xFF60)
&& let Some(face) = fonts.iter().position(|font| font.glyph_id(original).0 != 0)
&& let (Some(font), Some(Some(data))) = (fonts.get(face), vertical_data.get(face))
{
let cjk = matches!(original as u32, 0x3000..=0x30FF | 0x3400..=0x9FFF | 0xFF01..=0xFF60);
if wm.vertical && (cjk || cell.chars().count() > 1) {
let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px;
if let Some(glyph) = vertical_watermark_glyph(font, data, original, px, left, y)
&& (glyph.id != font.glyph_id(original)
|| !matches!(original, '、' | '。' | 'ー' | '(' | ')' | '「' | '」' | '『' | '』' | '【' | '】'))
{
glyphs.push((face, glyph, None));
let shaped = fonts.iter().zip(&vertical_data).enumerate().find_map(|(face, (font, data))| {
let data = data.as_ref()?;
let cluster = watermark_cell_glyphs(font, data, cell, cjk, px, left, y)?;
let substituted = cluster.len() != 1 || cluster.first().is_some_and(|g| g.id != font.glyph_id(original));
if cjk && !substituted && matches!(original, '、' | '。' | 'ー' | '(' | ')' | '「' | '」' | '『' | '』' | '【' | '】') {
return None;
}
Some((face, cluster))
});
if let Some((face, cluster)) = shaped {
glyphs.extend(cluster.into_iter().map(|g| (face, g, None)));
y += px;
continue;
}
@@ -1706,7 +1722,7 @@ mod tests {
assert_eq!(font.glyph_id('︒').0, 0);
let data = harfrust::ShaperData::new(&harfrust::FontRef::new(entry.bytes).unwrap());
for ch in ['、', '。'] {
let glyph = vertical_watermark_glyph(&font, &data, ch, 100.0, 0.0, 0.0).unwrap();
let glyph = watermark_cell_glyphs(&font, &data, &ch.to_string(), true, 100.0, 0.0, 0.0).unwrap().remove(0);
assert_ne!(glyph.id, font.glyph_id(ch), "the font's vertical alternate for {ch}");
let rect = font.outline_glyph(glyph).unwrap().px_bounds();
assert!(rect.min.x > 50.0 && rect.max.y < 50.0, "{ch} in the upper right: {rect:?}");
@@ -1736,6 +1752,56 @@ mod tests {
}
}
#[test]
fn vertical_watermarks_keep_combining_marks_in_one_cell() {
let coverage = |text: &str, craft| {
let wm = Watermark {
text: text.into(),
vertical: true,
size: 0.1,
anchor: Anchor::Center,
inset: 0.0,
shadow: false,
opacity: 1.0,
..Default::default()
};
let mut pixels = vec![0.0f32; 400 * 300];
watermark_coverage(400, 300, &wm, craft, |x, y, k, _| pixels[y * 400 + x] = k);
assert!(pixels.iter().any(|&k| k > 0.0));
pixels
};
let compare = |a: &str, b: &str, craft| {
let (a_pixels, b_pixels) = (coverage(a, craft), coverage(b, craft));
let difference = a_pixels.iter().zip(&b_pixels).position(|(a, b)| a != b);
assert!(difference.is_none(), "{a:?} vs {b:?}: first differing pixel {difference:?}");
};
for (composed, decomposed) in [("éA", "e\u{301}A"), ("Å\nA", "A\u{30a}\nA"), ("A\nB", "A\r\nB"), ("日A", "日\u{e0100}A")] {
compare(composed, decomposed, &[]);
}
if crate::fonts::CRAFT_FONTS.iter().any(|f| f.family == "BIZ UDMincho") {
for (composed, decomposed) in [("が日", "か\u{3099}日"), ("ぱ\n日", "は\u{309a}\n日")] {
compare(composed, decomposed, crate::fonts::CRAFT_FONTS);
}
} else {
eprintln!("skipped Japanese comparisons: built without BIZ UDMincho from craft-fonts");
}
}
#[test]
fn upright_watermark_cell_retains_multiple_glyphs() {
use ab_glyph::{Font, ScaleFont};
let font = ab_glyph::FontRef::try_from_slice(WATERMARK_FONT).unwrap();
let data = harfrust::ShaperData::new(&harfrust::FontRef::new(WATERMARK_FONT).unwrap());
// There is no precomposed A with a combining long solidus overlay.
let glyphs = watermark_cell_glyphs(&font, &data, "A\u{338}", false, 30.0, 0.0, 0.0).unwrap();
assert_eq!(glyphs.len(), 2, "retain the base and the separately positioned combining glyph");
let baseline = font.as_scaled(30.0).ascent();
for glyph in glyphs {
assert!((glyph.position.y - baseline).abs() < 1.0, "no extra vertical cell for the mark");
assert!(font.outline_glyph(glyph).is_some(), "both glyphs have ink");
}
}
#[test]
fn japanese_watermarks_support_vertical_columns() {
use ab_glyph::Font;
+49 -8
View File
@@ -92,8 +92,10 @@ fn ext_upper(name: &str) -> String {
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default()
}
/// Probe a file's bytes: kind, dimensions (oriented), metadata. Raws are described from their
/// headers ([`lightcraft_raw::probe_info`]): no pixel data is decompressed.
/// Probe a file's bytes: kind, dimensions (oriented), metadata. No pixel data is decompressed:
/// raws are described from their headers ([`lightcraft_raw::probe_info`]), other images too
/// ([`lightcraft_codecs::read_header`], which refuses truncated files but can't see damage inside
/// compressed data that is all there: such a file imports and shows as unreadable when rendered).
pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
let content_hash = Some(lightcraft_preview::hash_bytes(bytes).to_string());
let m = lightcraft_meta::extract(bytes);
@@ -152,9 +154,10 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
if !fmt.can_decode() {
return Err(format!("{fmt:?} files are not supported yet"));
}
let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(64, 64)).map_err(|e| e.to_string())?;
let o = Orientation::from_exif(d.orientation);
let (mut w, mut h) = (d.source_width, d.source_height);
// headers only (issue #367: decoding the pixels was nearly all of an import's CPU time)
let header = lightcraft_codecs::read_header(bytes).map_err(|e| e.to_string())?;
let o = Orientation::from_exif(header.orientation);
let (mut w, mut h) = (header.width, header.height);
if o.swaps_axes() {
std::mem::swap(&mut w, &mut h);
}
@@ -307,7 +310,7 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) };
return Ok((img, SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_tone }));
}
let d = lightcraft_codecs::decode(&bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).map_err(|e| e.to_string())?;
let d = lightcraft_codecs::decode(&bytes, fit_box(max_edge)).map_err(|e| e.to_string())?;
drop(bytes);
let img = d.to_working();
let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img };
@@ -375,7 +378,7 @@ fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> {
/// The embedded preview of a raw file as a working-space image no larger than `max_edge`, oriented.
pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, SourceInfo)> {
let d = decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32))?;
let d = decode_raw_preview(bytes, fit_box(max_edge))?;
let img = d.to_working();
let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img };
Some((img.oriented(preview_orientation(bytes, d.orientation)), SourceInfo::default()))
@@ -384,7 +387,7 @@ pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, S
/// The embedded preview of a raw file for display (sRGB, oriented, no larger than `max_edge`): the
/// loupe and grid show it until the raw itself has been developed ([`crate::media::QuickJob`]).
pub fn embedded_preview_srgb(bytes: &[u8], max_edge: usize) -> Option<lightcraft_raster::Rgba8> {
let mut d = decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32))?;
let mut d = decode_raw_preview(bytes, fit_box(max_edge))?;
if d.image.width.max(d.image.height) > max_edge {
d.image = fit(&d.image, max_edge, max_edge, Filter::Box);
d.alpha = None;
@@ -393,6 +396,13 @@ pub fn embedded_preview_srgb(bytes: &[u8], max_edge: usize) -> Option<lightcraft
Some(d.to_srgb8().oriented(o))
}
/// Decode options fitting into a `max_edge` square; `usize::MAX` (a full-size load) or any edge past
/// `u32::MAX` asks for the largest box rather than wrapping to a small one.
fn fit_box(max_edge: usize) -> lightcraft_codecs::DecodeOptions {
let e = u32::try_from(max_edge).unwrap_or(u32::MAX);
lightcraft_codecs::DecodeOptions::fit(e, e)
}
/// Filesystem-backed embedded-preview hook (native).
pub fn fs_preview_loader() -> PreviewLoader {
Arc::new(|path: &str, max_edge: usize| embedded_preview_srgb(&std::fs::read(path).ok()?, max_edge))
@@ -442,6 +452,37 @@ mod tests {
use super::*;
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
/// Issue #367: probes read headers, not pixels, and report what the decode-based probe did:
/// oriented dimensions (EXIF orientation 6 swaps them), metadata, the error for a truncated
/// file or one that isn't an image.
#[test]
fn image_probe_reads_headers_like_the_decode_did() {
let (w, h) = (300u32, 200u32);
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 7 % 251) as u8).collect();
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
for o in [None, Some(1), Some(6), Some(3), Some(8)] {
let exif = o.map(lightcraft_codecs::exif::minimal_exif);
let meta = EncodeMeta { exif: exif.as_deref(), ..Default::default() };
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &meta).unwrap();
let png = lightcraft_codecs::encode_png(&img, &meta).unwrap();
for (name, bytes) in [("a.jpg", &jpeg), ("a.png", &png)] {
let p = probe_bytes(name, bytes).unwrap();
// what the probe computed from a decode before
let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(64, 64)).unwrap();
let swap = Orientation::from_exif(d.orientation).swaps_axes();
let want = if swap { (d.source_height, d.source_width) } else { (d.source_width, d.source_height) };
assert_eq!((p.width, p.height), want, "{name} {o:?}");
assert_eq!((p.width, p.height), if matches!(o, Some(6 | 8)) { (h, w) } else { (w, h) }, "{name} {o:?}");
assert_eq!(p.kind, MediaKind::Image);
assert_eq!(p.format, if name == "a.jpg" { "JPEG" } else { "PNG" });
assert_eq!(p.file_size, bytes.len() as u64);
assert!(p.content_hash.is_some());
assert!(probe_bytes(name, &bytes[..bytes.len() / 2]).is_err(), "{name}: truncated file accepted");
}
}
assert!(probe_bytes("x.jpg", b"not an image").is_err());
}
/// Luminance (from linear RGB) minus its 9 × 9 local mean: tone differences between a render and a camera JPEG
/// mostly cancel, edges remain.
fn detail(w: usize, h: usize, lum: impl Fn(usize, usize) -> f32) -> Vec<f32> {
+197 -3
View File
@@ -62,7 +62,7 @@ impl SettingsHashes {
}
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 14;
pub const RENDER_CACHE_VERSION: u64 = 15;
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
@@ -104,6 +104,20 @@ impl SourceLevel {
}
}
/// Pixels along the source's long edge that an output `out_long` pixels long needs: a crop shows
/// only part of the photo, so its pixels come from a source that many times larger (the source
/// level is chosen from this, or a tight crop shown at its own pixels would be a stretched preview).
fn source_edge_needed(p: &Photo, settings: &DevelopSettings, apply_crop: bool, out_long: usize) -> usize {
let (w, h) = (f64::from(p.width.max(1)), f64::from(p.height.max(1)));
let r = settings.crop.geometry.rect;
let shown = if apply_crop { (r.width() * w).max(r.height() * h) } else { w.max(h) };
if !shown.is_finite() || shown < 1.0 {
return out_long;
}
let needed = out_long as f64 * w.max(h) / shown;
if needed.is_finite() { needed.min(usize::MAX as f64 / 2.0) as usize } else { out_long }
}
/// Decodes a file into a linear Rec.2020 image no larger than `max_edge` (set by the app).
pub type FileLoader = Arc<dyn Fn(&str, usize) -> Result<(Rgb32f, SourceInfo), String> + Send + Sync>;
@@ -389,6 +403,15 @@ impl MediaCache {
(self.thumbs.len(), self.thumbs.cost())
}
/// Whether the decoded source of `id` at `level` is held.
pub fn has_source(&self, id: PhotoId, level: SourceLevel) -> bool {
match level {
SourceLevel::Thumb => self.thumbs.contains(&id),
SourceLevel::Preview => self.previews.iter().any(|e| e.0 == id),
SourceLevel::Full => self.full.as_ref().is_some_and(|e| e.0 == id),
}
}
/// Decoded sources held: (thumbnail level, preview level, full size).
pub fn usage(&self) -> (crate::memory::Usage, crate::memory::Usage, crate::memory::Usage) {
use crate::memory::Usage;
@@ -718,9 +741,13 @@ impl crate::Session {
thumb_bucket: Option<usize>,
) -> Option<RenderJob> {
let p = self.catalog.photo(id)?.clone();
let level = SourceLevel::for_size(max_w.max(max_h));
let source = self.media.source_ref(&p, level);
let settings = if before { Arc::new(self.before_settings(&p)) } else { p.develop.clone() };
let level = SourceLevel::for_size(if thumb_bucket.is_some() {
max_w.max(max_h) // (grid thumbnails of tight crops stay cheap)
} else {
source_edge_needed(&p, &settings, apply_crop, max_w.max(max_h))
});
let source = self.media.source_ref(&p, level);
let request = RenderRequest { apply_crop, ..RenderRequest::fit(max_w, max_h) };
// the photo id is part of the key: two photos with the same settings and size must not
// share a result (a view slot showing photo A would otherwise look current for photo B)
@@ -881,6 +908,69 @@ impl crate::Session {
Some(job)
}
/// A render of one window of the loupe's frame, for a view zoomed past what one whole-frame
/// render can hold: the frame is `full_w × full_h` (the size the loupe would draw it at) and
/// the result is `window`'s pixels of it, at that scale, from the source level that size needs.
/// Nothing is cached here (a window is only worth keeping while it is on screen). `None`: no
/// such photo, or the window reads (spots, Auto Mask strokes) more than one render can hold.
pub fn region_job(
&mut self,
id: PhotoId,
full_w: usize,
full_h: usize,
window: lightcraft_pipeline::PixelWindow,
apply_crop: bool,
) -> Option<RenderJob> {
self.region_job_of(id, full_w, full_h, window, apply_crop, false)
}
/// [`Self::region_job`] of the photo without its edits (its crop kept): the Before side.
pub fn region_job_before(
&mut self,
id: PhotoId,
full_w: usize,
full_h: usize,
window: lightcraft_pipeline::PixelWindow,
apply_crop: bool,
) -> Option<RenderJob> {
self.region_job_of(id, full_w, full_h, window, apply_crop, true)
}
fn region_job_of(
&mut self,
id: PhotoId,
full_w: usize,
full_h: usize,
window: lightcraft_pipeline::PixelWindow,
apply_crop: bool,
before: bool,
) -> Option<RenderJob> {
let mut job = self.render_job(id, full_w, full_h, before, apply_crop)?;
// what the window's spots and Auto Mask strokes read must fit in one render: else the
// caller keeps the whole-frame render (a window alone would come out wrong)
let p = self.catalog.photo(id)?;
let frame = lightcraft_pipeline::geometry::Frame::with_lens(
p.width.max(1) as usize,
p.height.max(1) as usize,
&job.settings,
apply_crop,
p.embedded_lens.as_ref(),
);
let ppl = frame.px_per_long(full_w);
lightcraft_pipeline::spots::window_for_reads_checked(&job.settings, &frame, full_w, full_h, ppl, window.clamped(full_w, full_h))?;
job.request.window = Some(window);
job.key = Hasher128::new()
.u64(job.key)
.str("window")
.u64(window.x as u64)
.u64(window.y as u64)
.u64(window.w as u64)
.u64(window.h as u64)
.finish()
.0 as u64;
Some(job)
}
/// The embedded preview of an unedited raw (path, loader), when the app installed a loader.
fn embedded_of(&self, p: &Photo) -> Option<(String, PreviewLoader)> {
match (&p.source, &self.media.preview_loader) {
@@ -1218,6 +1308,110 @@ mod tests {
assert_ne!(a.key, b.key);
}
// Issue #323: a window of a 1:1 view is its own job: the window's pixels, at the zoom scale,
// from the original, with a key of its own
#[test]
fn region_jobs_render_a_window_of_the_zoomed_frame() {
use lightcraft_pipeline::PixelWindow;
let mut s = crate::Session::with_demo();
let p = s.catalog.photos().next().unwrap().clone();
let (w, h) = (p.width as usize, p.height as usize);
assert!(w.max(h) > 2560, "demo photos are camera-sized");
let win = PixelWindow { x: w / 3, y: h / 3, w: 300, h: 200 };
let job = s.region_job(p.id, w, h, win, true).unwrap();
assert_eq!(job.level, SourceLevel::Full, "a 1:1 window reads the original");
let img = job.run().rendered.unwrap().image;
assert_eq!((img.width, img.height), (300, 200));
// other windows, and the whole-frame job, never share a key
let other = s.region_job(p.id, w, h, PixelWindow { x: win.x + 64, ..win }, true).unwrap();
let whole = s.render_job(p.id, w, h, false, true).unwrap();
let again = s.region_job(p.id, w, h, win, true).unwrap();
assert_ne!(other.key, again.key);
assert_ne!(whole.key, again.key);
assert_eq!(s.region_job(p.id, w, h, win, true).unwrap().key, again.key, "the same window keeps its key");
// a window never writes the photo's view preview or the thumbnail cache
assert!(again.view_cache.is_none() && again.cache.is_none());
}
// Issue #323: a tight crop shown at its own pixels needs the original, not the 2560 px preview
#[test]
fn a_tight_crop_reads_the_source_level_its_pixels_need() {
let mut s = crate::Session::with_demo();
let p = s.catalog.photos().next().unwrap().clone();
let long = p.width.max(p.height) as usize;
assert!(long > 2560);
let id = p.id;
let mut d = (*s.develop_of(id).unwrap()).clone();
// the uncropped photo at 1600 px: the preview is plenty
assert_eq!(s.render_job(id, 1600, 1600, false, true).unwrap().level, SourceLevel::Preview);
// a crop to 30 % of each side shown 1600 px long needs 1600 / 0.3 px of the source
d.crop.geometry.rect = lightcraft_geom::Rect { x0: 0.2, y0: 0.2, x1: 0.5, y1: 0.5 };
s.set_develop(id, d, "Crop").unwrap();
assert_eq!(s.render_job(id, 1600, 1600, false, true).unwrap().level, SourceLevel::Full);
// …but with the crop tool open (the whole photo shown) it does not
assert_eq!(s.render_job(id, 1600, 1600, false, false).unwrap().level, SourceLevel::Preview);
// and a crop shown small enough still reads the preview
assert_eq!(s.render_job(id, 600, 600, false, true).unwrap().level, SourceLevel::Preview);
}
// a window whose spot reads from further away than a render can hold is refused
#[test]
fn a_region_job_is_refused_when_a_spot_reads_beyond_what_fits() {
use lightcraft_pipeline::PixelWindow;
let mut s = crate::Session::with_demo();
let id = s.active().unwrap();
let p = s.catalog.photo(id).unwrap().clone();
let (w, h) = (p.width as usize * 8, p.height as usize * 8);
let mut d = (*s.develop_of(id).unwrap()).clone();
d.spots.push(lightcraft_develop::Spot {
points: vec![lightcraft_geom::Point::new(0.2, 0.5)],
size: 0.01,
source_offset: Some(lightcraft_geom::Point::new(0.6, 0.0)),
..Default::default()
});
s.set_develop(id, d, "Spot").unwrap();
let win = PixelWindow { x: (w as f64 * 0.2) as usize - 100, y: h / 2 - 100, w: 400, h: 300 };
assert!(s.region_job(id, w, h, win, true).is_none());
// a window elsewhere is fine
assert!(s.region_job(id, w, h, PixelWindow { x: 5000, y: 5000, w: 400, h: 300 }, true).is_some());
}
// Issue #323: the Before side of a Before/After view at 1:1 is a window too, of the photo
// without its edits (the crop is kept), with a key of its own
#[test]
fn a_before_window_shows_the_unedited_look() {
use lightcraft_pipeline::PixelWindow;
let mut s = crate::Session::with_demo();
let id = s.active().unwrap();
let p = s.catalog.photo(id).unwrap().clone();
let (w, h) = (p.width as usize, p.height as usize);
let mut d = (*s.develop_of(id).unwrap()).clone();
d.light.exposure = 2.0;
s.set_develop(id, d, "Exposure").unwrap();
let win = PixelWindow { x: w / 3, y: h / 3, w: 200, h: 150 };
let after = s.region_job(id, w, h, win, true).unwrap();
let before = s.region_job_before(id, w, h, win, true).unwrap();
assert_ne!(after.key, before.key);
let (a, b) = (after.run().rendered.unwrap().image, before.run().rendered.unwrap().image);
assert_eq!((a.width, a.height), (b.width, b.height));
let mean = |i: &Rgba8| i.data.iter().map(|p| p[1] as f64).sum::<f64>() / i.data.len() as f64;
assert!(mean(&a) > mean(&b) + 10.0, "two stops brighter after: {} vs {}", mean(&a), mean(&b));
}
#[test]
fn has_source_follows_what_the_session_accepted() {
let mut s = crate::Session::with_demo();
let id = s.active().unwrap();
let p = s.catalog.photo(id).unwrap().clone();
let long = p.width.max(p.height) as usize;
assert!(!s.media.has_source(id, SourceLevel::Full));
let r = s.render_job(id, long, long, false, true).unwrap().run();
s.accept(&r);
assert!(s.media.has_source(id, SourceLevel::Full));
assert!(!s.media.has_source(id, SourceLevel::Preview));
assert!(!s.media.has_source(PhotoId(id.0 + 1), SourceLevel::Full));
}
#[test]
fn variant_jobs_have_their_own_keys_and_hit_the_cache() {
let mut s = crate::Session::with_demo();
+1 -1
View File
@@ -14,7 +14,7 @@ fn active_dev(s: &Session) -> lightcraft_develop::DevelopSettings {
fn changing_one_wb_control_resolves_as_shot_without_stale_tint() {
use lightcraft_catalog::{Photo, PhotoId, Source};
use lightcraft_develop::{DevelopSettings, WbMode};
for format in ["ARW", "NEF", "NRW", "RAF"] {
for format in ["ARW", "NEF", "NRW", "RAF", "CR2", "PEF"] {
let mut s = demo();
let id = PhotoId(100);
let name = format!("synthetic.{format}");
+27
View File
@@ -270,12 +270,39 @@ pub fn set_pool_limit(bytes: u64) {
pub fn trim_pool(keep: u64) {
#[cfg(not(target_arch = "wasm32"))]
if let Some(g) = existing_device() {
if keep == 0 {
render::release_shared_source();
}
g.trim(keep);
}
#[cfg(target_arch = "wasm32")]
let _ = keep;
}
/// Device bytes of the one big source (a zoom window's original) shared by every view of the photo.
pub fn shared_source_bytes() -> usize {
#[cfg(not(target_arch = "wasm32"))]
{
render::shared_source_bytes()
}
#[cfg(target_arch = "wasm32")]
{
0
}
}
/// Big sources uploaded to the shared copy so far (a diagnostic: one big photo is uploaded once, not once per window).
pub fn source_uploads() -> u64 {
#[cfg(not(target_arch = "wasm32"))]
{
render::source_uploads()
}
#[cfg(target_arch = "wasm32")]
{
0
}
}
/// Device bytes held by a view's GPU stages (kept with its [`StageCache`]); 0 when it has none.
pub fn stage_bytes(stages: &StageCache) -> usize {
#[cfg(not(target_arch = "wasm32"))]
+3
View File
@@ -57,6 +57,8 @@ const FIELDS: &[(&str, usize)] = &[
("VIG_POWER", 1),
("VIG_HL", 1),
("VIG_STYLE", 1),
// the window's offset in the whole output and the whole output's size (pixels)
("VIG_VIEW", 4),
("GRAIN", 1),
("GRAIN_AMT", 1),
("GRAIN_SC", 1),
@@ -211,6 +213,7 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr
p.f("VIG_ASPECT_MIX", v.aspect_mix);
p.f("VIG_POWER", v.power);
p.f("VIG_HL", v.highlights);
p.fs("VIG_VIEW", &fp.view);
p.u(
"VIG_STYLE",
match v.style {
+60 -4
View File
@@ -414,6 +414,48 @@ fn warp_params(
/// ~20 % faster than 8 at 6000 × 4000 (fewer interval evaluations, few more edge pixels).
const COVER_ROWS: usize = 16;
/// The largest source (bytes on the device) a view's stages keep uploaded between renders.
const RETAIN_SOURCE_BYTES: usize = 96 << 20;
/// Big sources uploaded to the shared copy so far (diagnostics and tests).
static SOURCE_UPLOADS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// The one big source (the original a zoom window is cut from) kept on the device for all the
/// views of the photo: the Before and After windows and every pan share it, instead of each
/// view's stages holding (or re-uploading) 288 MB of a 24 MP photo. It does not count as a view's
/// stages; it is released with the buffer pool when the app idles.
static SHARED_SOURCE: std::sync::Mutex<Option<(std::sync::Weak<Rgb32f>, Arc<Buf>)>> = std::sync::Mutex::new(None);
fn shared_source(src: &Arc<Rgb32f>, upload: impl FnOnce() -> Buf) -> Arc<Buf> {
let mut g = SHARED_SOURCE.lock().unwrap_or_else(|e| e.into_inner());
if let Some((weak, buf)) = &*g
&& weak.upgrade().is_some_and(|s| Arc::ptr_eq(&s, src))
{
return buf.clone();
}
// the previous photo's copy goes (on a render thread it is retired with the render's other
// buffers, so for a moment both exist)
*g = None;
SOURCE_UPLOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let buf = Arc::new(upload());
*g = Some((Arc::downgrade(src), buf.clone()));
buf
}
/// Free the shared big source (see [`shared_source`]).
pub(crate) fn release_shared_source() {
*SHARED_SOURCE.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
/// Device bytes of the shared big source, 0 when there is none.
pub(crate) fn shared_source_bytes() -> usize {
SHARED_SOURCE.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map_or(0, |(_, b)| b.len * 4)
}
pub(crate) fn source_uploads() -> u64 {
SOURCE_UPLOADS.load(std::sync::atomic::Ordering::Relaxed)
}
/// The reference framing decision ([`Warp::covers`](lightcraft_pipeline::optics::Warp::covers)) for every
/// output pixel, one bit per pixel, rows padded to 32-bit words. Blocks of 32 × [`COVER_ROWS`] pixels whose
/// interval bounds place them clearly inside or outside the image are filled at once
@@ -552,8 +594,11 @@ pub fn render(
Some(e) => e.sampled.clone(),
None if gpu.fits(src.data.len() * 3) => {
let upload = || gpu.upload(rgb_words(src));
// (a source too big for a view's stages to keep is the one shared copy: a window is
// cut from the photo's own pixels, and what its stages keep is its own sampled pixels)
let src_buf = match stages {
Some(c) => c.source(src, upload),
Some(c) if src.data.len() * 12 <= RETAIN_SOURCE_BYTES => c.source(src, upload),
Some(_) => shared_source(src, upload),
None => Arc::new(upload()),
};
sample(&mut cx, src, src_buf, &plan)
@@ -579,7 +624,11 @@ pub fn render(
Some(p) if p.key == plan.lin_key => p,
_ => Planes { key: plan.lin_key, ..Default::default() },
};
let prep = prepare(&mut cx, &lin, &plan, req, &mut planes);
let mut prep = prepare(&mut cx, &lin, &plan, req, &mut planes);
// a window dehazes with the whole frame's airlight
if let Some(a) = plan.fixed_air {
prep.air = a;
}
lap("planes", &mut t, &mut cx);
if let Some(c) = stages {
c.put(Entry { src: src.clone(), geo: plan.geo, sampled, lin: Some((plan.lin_key, lin.clone())), planes });
@@ -650,7 +699,10 @@ pub fn render(
fail(FailKind::Fatal, format!("the GPU returned an incomplete image ({unwritten} of {n} pixels unwritten)"));
return None;
}
let histogram = Histogram::of_srgb8(&image);
let histogram = match plan.keep {
Some(k) => Histogram::of_srgb8(&image.crop(k.x, k.y, k.w, k.h)),
None => Histogram::of_srgb8(&image),
};
lap("histogram", &mut t, &mut cx);
// An entirely black result (upstream work that did not run would give that too): redo it on
// the CPU, which costs time only for the rare photo that really is black.
@@ -669,6 +721,10 @@ pub fn render(
}
});
lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref());
// spots grew the window the render worked on: cut it back to the request
if let Some(k) = plan.keep {
image = image.crop(k.x, k.y, k.w, k.h);
}
Some(Rendered { image, histogram, deep: None })
}
@@ -713,7 +769,7 @@ fn linear(cx: &mut Cx<'_>, sampled: &Buf, info: &SourceInfo, plan: &Plan<'_>, ho
fn denoise(cx: &mut Cx<'_>, img: Buf, plan: &Plan<'_>) -> Buf {
let (w, h) = (plan.w, plan.h);
let n = w * h;
let (lum, col) = local::nr_params(&plan.settings, plan.src_long, w.max(h));
let (lum, col) = local::nr_params(&plan.settings, plan.src_long, plan.frame.output_long(w, h));
let mut img = img;
if let Some(nr) = lum {
let l = cx.gpu.buffer(n);
+5 -4
View File
@@ -432,14 +432,15 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
// --- vignette (display linear, post-crop)
if (pu(F_VIG) != 0u) {
let fw = f32(w);
let fh = f32(h);
// the vignette belongs to the whole output frame, not to the window being drawn
let fw = pf(F_VIG_VIEW + 2u);
let fh = pf(F_VIG_VIEW + 3u);
let aspect = fw / fh;
let amount = pf(F_VIG_AMOUNT);
let mixa = pf(F_VIG_ASPECT_MIX);
let power = pf(F_VIG_POWER);
let u = (f32(x) + 0.5) / fw * 2.0 - 1.0;
let vv = (f32(y) + 0.5) / fh * 2.0 - 1.0;
let u = (pf(F_VIG_VIEW) + f32(x) + 0.5) / fw * 2.0 - 1.0;
let vv = (pf(F_VIG_VIEW + 1u) + f32(y) + 0.5) / fh * 2.0 - 1.0;
let sx = 1.0 + (aspect - 1.0) * mixa;
let sy = 1.0 + (1.0 / aspect - 1.0) * mixa;
let ax = abs(u * max(sx, 1.0) / max(sx, sy));
+99
View File
@@ -660,3 +660,102 @@ fn tint_directions_match_cpu_for_raw_and_rendered_sources() {
}
}
}
/// Issue #323: windows of a zoomed frame. The GPU renders the window like the CPU does: the
/// vignette and airlight belong to the whole frame, spots grow the window they work on.
#[test]
fn windows_match() {
if !gpu() {
return;
}
use lightcraft_pipeline::PixelWindow;
let src = scene(1, 960, 640);
let info = SourceInfo { raw: true, ..Default::default() };
let win = PixelWindow { x: 1200, y: 800, w: 640, h: 480 };
let req = RenderRequest { window: Some(win), ..RenderRequest::fit(3000, 2000) };
let mut cases = cases();
cases.push(("vignette at the corner", |s| {
s.vignette.amount = -80.0;
s.vignette.midpoint = 10.0;
}));
cases.push(("spot reading from outside", |s| {
s.spots.push(Spot {
points: vec![Point::new(0.45, 0.45)],
size: 0.03,
feather: 30.0,
opacity: 100.0,
source_offset: Some(Point::new(0.15, 0.0)),
..Default::default()
});
}));
for (name, edit) in cases {
let mut s = DevelopSettings::default();
edit(&mut s);
let name = format!("window: {name}");
check(&name, &src, &info, &s, &req);
}
// the corner window (where the vignette is strong)
let mut s = DevelopSettings::default();
s.vignette.amount = -80.0;
s.vignette.midpoint = 10.0;
let corner = RenderRequest { window: Some(PixelWindow { x: 2360, y: 1520, w: 640, h: 480 }), ..RenderRequest::fit(3000, 2000) };
check("window: vignette corner", &src, &info, &s, &corner);
}
/// Issue #323: a window of a 24 MP photo is cut from a source of 288 MB on the device. Keeping that
/// upload with the view's stages counted it against the stage budget on every tick of a drag and
/// got the stages cleared, and the upload and resampling redone: the window's stages are its own
/// pixels, not the photo's.
// (these two share the process's one big source: a test lock keeps them from replacing each other's)
static BIG_SOURCE_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn a_window_does_not_keep_the_whole_source_on_the_device() {
let _lock = BIG_SOURCE_TESTS.lock().unwrap_or_else(|e| e.into_inner());
if !gpu() {
return;
}
use lightcraft_pipeline::PixelWindow;
let src = scene(1, 6000, 4000);
let info = SourceInfo { raw: true, ..Default::default() };
let stages = StageCache::default();
let s = DevelopSettings::default();
let req = RenderRequest { window: Some(PixelWindow { x: 2000, y: 1500, w: 640, h: 480 }), ..RenderRequest::fit(6000, 4000) };
let _ = lightcraft_gpu::render(&src, &info, &s, &req, Some(&stages)).expect("gpu render");
let held = lightcraft_gpu::stage_bytes(&stages);
assert!(held < 100 << 20, "the view's GPU stages hold {} MiB for a 640×480 window", held >> 20);
// …and the next tick of a drag reuses what is there
let mut s2 = s.clone();
s2.light.exposure = 0.5;
let again = lightcraft_gpu::render(&src, &info, &s2, &req, Some(&stages)).expect("gpu render");
assert_eq!((again.image.width, again.image.height), (640, 480));
assert!(lightcraft_gpu::stage_bytes(&stages) < 100 << 20);
}
/// …but the 288 MB is uploaded once for every window of every view of the photo, not once per
/// window: panning at 1:1, and the Before and After windows, share one device copy of it, which
/// goes when the app idles (`trim_pool(0)`).
#[test]
fn windows_of_a_big_photo_share_one_uploaded_source() {
let _lock = BIG_SOURCE_TESTS.lock().unwrap_or_else(|e| e.into_inner());
if !gpu() {
return;
}
use lightcraft_pipeline::PixelWindow;
let src = scene(1, 6000, 4000);
let info = SourceInfo { raw: true, ..Default::default() };
let s = DevelopSettings::default();
let (after, before) = (StageCache::default(), StageCache::default());
let at = |x: usize| RenderRequest { window: Some(PixelWindow { x, y: 1500, w: 640, h: 480 }), ..RenderRequest::fit(6000, 4000) };
let start = lightcraft_gpu::source_uploads();
for (cache, x) in [(&after, 1000), (&after, 1256), (&after, 1512), (&before, 1512), (&before, 1768)] {
lightcraft_gpu::render(&src, &info, &s, &at(x), Some(cache)).expect("gpu render");
}
assert_eq!(lightcraft_gpu::source_uploads() - start, 1, "one upload for five windows of two views");
assert!(lightcraft_gpu::shared_source_bytes() >= 280_000_000, "{}", lightcraft_gpu::shared_source_bytes());
assert!(lightcraft_gpu::stage_bytes(&after) < 100 << 20, "and it is not in the views' stages");
lightcraft_gpu::trim_pool(0);
assert_eq!(lightcraft_gpu::shared_source_bytes(), 0, "an idle app gives it back");
lightcraft_gpu::render(&src, &info, &s, &at(1000), Some(&after)).expect("gpu render");
assert_eq!(lightcraft_gpu::source_uploads() - start, 2);
}
+8 -3
View File
@@ -190,6 +190,8 @@ pub struct FinishParams {
pub w: usize,
pub h: usize,
pub px_per_long: f64,
/// The window's offset and the whole output's size (pixels): `(0, 0, w, h)` unless windowed.
pub view: [f32; 4],
}
impl FinishParams {
@@ -255,6 +257,7 @@ impl FinishParams {
w,
h,
px_per_long,
view: frame.view.map_or([0.0, 0.0, w as f32, h as f32], |v| [v.x as f32, v.y as f32, v.full_w as f32, v.full_h as f32]),
}
}
}
@@ -344,7 +347,9 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
let terms: Vec<[f32; MASK_TERMS]> = p.masks.iter().map(|m| mask_terms(&m.adjust)).collect();
let out_to_norm = fp.out_to_norm;
let long = fp.ow.max(fp.oh);
let aspect = w as f32 / h as f32;
let [vx, vy, vw, vh] = fp.view;
// the vignette belongs to the whole output frame, not to the window being drawn
let aspect = vw / vh;
let srgb = srgb_lut();
let mut out = vec![T::default(); w * h];
@@ -502,8 +507,8 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
// --- vignette (display linear, post-crop)
if let Some(v) = vig {
let u = (x as f32 + 0.5) / w as f32 * 2.0 - 1.0;
let vv = (y as f32 + 0.5) / h as f32 * 2.0 - 1.0;
let u = (vx + x as f32 + 0.5) / vw * 2.0 - 1.0;
let vv = (vy + y as f32 + 0.5) / vh * 2.0 - 1.0;
let sx = 1.0 + (aspect - 1.0) * v.aspect_mix;
let sy = 1.0 + (1.0 / aspect - 1.0) * v.aspect_mix;
let (ax, ay) = ((u * sx.max(1.0) / sx.max(sy)).abs(), (vv * sy.max(1.0) / sx.max(sy)).abs());
+37
View File
@@ -31,6 +31,18 @@ pub struct Frame {
pub flip_v: bool,
/// Lens corrections / perspective (None = identity).
pub warp: Option<Warp>,
/// Set when this frame draws only a window of a larger output (see [`Frame::window`]).
pub view: Option<ViewWindow>,
}
/// Where a windowed [`Frame`]'s pixels sit in the full output: its offset and the full output's
/// size, for effects defined by the whole frame (post-crop vignette).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ViewWindow {
pub x: f64,
pub y: f64,
pub full_w: f64,
pub full_h: f64,
}
impl Frame {
@@ -62,6 +74,7 @@ impl Frame {
flip_h: apply_crop && s.crop.flip_h,
flip_v: apply_crop && s.crop.flip_v,
warp: (!warp.is_identity()).then_some(warp),
view: None,
};
if apply_crop && s.geometry.constrain_crop {
f.constrain_to_image();
@@ -97,6 +110,30 @@ impl Frame {
self.crop.rect = Rect::from_center(r.center(), r.width() * t, r.height() * t);
}
/// Long edge, in pixels, of the whole output this frame draws (its own size unless it is a
/// window of a larger one): what size-relative effects (noise reduction, edge refinement)
/// must scale with, so a window matches the same pixels of the whole render.
pub fn output_long(&self, w: usize, h: usize) -> usize {
self.view.map_or(w.max(h), |v| v.full_w.max(v.full_h) as usize)
}
/// This frame narrowed to `win` of its `full_w × full_h` output: the same mapping, but the
/// output is only that window (the crop rectangle shrinks to it; flips are as drawn).
pub fn window(&self, full_w: usize, full_h: usize, win: crate::PixelWindow) -> Frame {
let (fw, fh) = (full_w.max(1) as f64, full_h.max(1) as f64);
let (x0, x1) = (win.x as f64 / fw, (win.x + win.w) as f64 / fw);
let (y0, y1) = (win.y as f64 / fh, (win.y + win.h) as f64 / fh);
// flips happen on the output before it maps onto the crop: undo them for the range
let (x0, x1) = if self.flip_h { (1.0 - x1, 1.0 - x0) } else { (x0, x1) };
let (y0, y1) = if self.flip_v { (1.0 - y1, 1.0 - y0) } else { (y0, y1) };
let r = self.crop.rect;
let (rw, rh) = (r.width(), r.height());
let mut f = self.clone();
f.crop.rect = Rect { x0: r.x0 + x0 * rw, y0: r.y0 + y0 * rh, x1: r.x0 + x1 * rw, y1: r.y0 + y1 * rh };
f.view = Some(ViewWindow { x: win.x as f64, y: win.y as f64, full_w: fw, full_h: fh });
f
}
/// Add automatically estimated lateral CA (`[α_R, α_B]`, see [`crate::optics::estimate_lateral_ca`]).
pub fn add_lateral_ca(&mut self, ca: [f64; 2]) {
if ca == [0.0; 2] {
+83 -6
View File
@@ -99,6 +99,31 @@ pub struct RenderRequest {
pub depth: OutputDepth,
/// Soft proofing (CPU only; see [`Proof`]).
pub proof: Option<Proof>,
/// Render only this window of the output that `max_w × max_h` describes (a zoomed view):
/// the result is that window's pixels, as in the whole render. See [`PixelWindow`].
pub window: Option<PixelWindow>,
}
/// A window of the (virtual) full output, in its pixels: what a zoomed view needs, rendered
/// without materializing the whole frame. The output frame is the one `max_w × max_h` fits, after
/// crop, straighten and flips, as drawn. Out-of-range windows are clamped into it. Spatial stages
/// (clarity, dehaze, sharpening…) read their neighbours, so ask for a margin around what is kept.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PixelWindow {
pub x: usize,
pub y: usize,
pub w: usize,
pub h: usize,
}
impl PixelWindow {
/// This window inside a `full_w × full_h` frame: at least one pixel, wholly inside.
pub fn clamped(self, full_w: usize, full_h: usize) -> PixelWindow {
let (fw, fh) = (full_w.max(1), full_h.max(1));
let x = self.x.min(fw - 1);
let y = self.y.min(fh - 1);
PixelWindow { x, y, w: self.w.clamp(1, fw - x), h: self.h.clamp(1, fh - y) }
}
}
impl RenderRequest {
@@ -112,6 +137,7 @@ impl RenderRequest {
space: OutputSpace::Srgb,
depth: OutputDepth::U8,
proof: None,
window: None,
}
}
}
@@ -300,8 +326,17 @@ pub struct Plan<'a> {
pub lin_key: u64,
/// Red eye / pet eye corrections with their detected pupils, in output pixels.
pub eyes: Vec<redeye::EyeK>,
/// The whole frame's dehaze airlight, for a windowed render (which can't see the whole frame).
pub fixed_air: Option<f32>,
/// A windowed render works on a larger window when spots reach into it (see
/// [`spots::window_for_reads`]); this is the requested window inside the rendered one, which
/// the result is cut to.
pub keep: Option<PixelWindow>,
}
/// Long edge of the small render a windowed render estimates the whole frame's airlight from.
const AIRLIGHT_PROXY_EDGE: usize = 384;
/// Resolve `s` against `src` for `req` (see [`Plan`]).
pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &RenderRequest) -> Plan<'a> {
let mut settings: Cow<'a, DevelopSettings> = match profiles::effective(s) {
@@ -309,10 +344,39 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R
Cow::Owned(o) => Cow::Owned(upright::resolve(src, info, &o).into_owned()),
};
visualize::adjust_settings(req.overlay, &mut settings);
// a window must not choose a spot's source from the pixels it happens to hold: fix them
// from a fixed-size render of the whole frame first
if req.window.is_some() && settings.spots.iter().any(|sp| sp.source_offset.is_none()) {
let picked: Vec<Option<lightcraft_geom::Point>> =
settings.spots.iter().map(|sp| sp.source_offset.or_else(|| spots::pick_source(src, info, &settings, sp, None))).collect();
for (sp, o) in settings.to_mut().spots.iter_mut().zip(picked) {
sp.source_offset = o;
}
}
let s = &*settings;
let frame = frame_for(src, info, s, req.apply_crop);
let (w, h) = frame.fit(req.max_w, req.max_h);
let px_per_long = frame.px_per_long(w);
let (full_w, full_h) = frame.fit(req.max_w, req.max_h);
// sizes and scale are those of the whole output; a window only narrows what is drawn
let px_per_long = frame.px_per_long(full_w);
let mut fixed_air = None;
let mut keep = None;
let (frame, w, h) = match req.window {
Some(win) => {
let win = win.clamped(full_w, full_h);
if s.section_enabled("effects") && (s.effects.dehaze != 0.0 || s.masks.iter().any(|m| m.adjust.dehaze != 0.0)) {
let k = (AIRLIGHT_PROXY_EDGE as f64 / full_w.max(full_h) as f64).min(1.0);
let (pw, ph) = (((full_w as f64 * k).round() as usize).max(1), ((full_h as f64 * k).round() as usize).max(1));
fixed_air = Some(local::frame_airlight(src, info, s, &frame, pw, ph));
}
// (spots grow the rendered window; only 8-bit renders are cut back to the request)
let work = if req.depth == OutputDepth::U8 { spots::window_for_reads(s, &frame, full_w, full_h, px_per_long, win) } else { win };
if work != win {
keep = Some(PixelWindow { x: win.x - work.x, y: win.y - work.y, ..win });
}
(frame.window(full_w, full_h, work), work.w, work.h)
}
None => (frame, full_w, full_h),
};
let src_long = src.width.max(src.height);
let geo = hash_of((format!("{frame:?}"), w, h));
let (wb_t, wb_tint) = local::effective_wb(info, s);
@@ -328,7 +392,7 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R
[d.nr_luminance, d.nr_detail, d.nr_color, d.nr_color_detail, d.nr_color_smoothness].map(f64::to_bits),
src_long,
));
Plan { settings, frame, w, h, px_per_long, src_long, geo, lin_key, eyes }
Plan { settings, frame, w, h, px_per_long, src_long, geo, lin_key, eyes, fixed_air, keep }
}
/// Whether the scene-linear stage needs work only the CPU does (defringe, spot removal).
@@ -401,7 +465,7 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende
// Without a cache the resampled buffer is ours: work on it in place.
let mut img = if shared.is_some() { (*sampled).clone() } else { Arc::unwrap_or_clone(sampled.clone()) };
lin_cpu(&mut img, info, &plan);
local::denoise(&mut img, s, src_long, w.max(h));
local::denoise(&mut img, s, src_long, frame.output_long(w, h));
Arc::new(img)
}
};
@@ -410,7 +474,10 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende
Some(p) if p.key == lin_key => p,
_ => local::Planes { key: lin_key, ..Default::default() },
};
let prep = local::prepare(lin.clone(), s, frame, px_per_long, req.quality, &mut planes);
let mut prep = local::prepare(lin.clone(), s, frame, px_per_long, req.quality, &mut planes);
if let Some(a) = plan.fixed_air {
prep.air = a;
}
lap("prepare", &mut t);
if let Some((a, c)) = shared {
c.put(CacheEntry { src: a.clone(), geo, sampled, lin: Some((lin_key, lin)), planes });
@@ -424,11 +491,19 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende
}
let image = finish::finish(&prep, s, frame, info, req.space, req.proof);
lap("finish", &mut t);
let histogram = Histogram::of_srgb8(&image);
let cut = |i: &Rgba8| match plan.keep {
Some(k) => i.crop(k.x, k.y, k.w, k.h),
None => i.clone(),
};
let histogram = match plan.keep {
Some(_) => Histogram::of_srgb8(&cut(&image)),
None => Histogram::of_srgb8(&image),
};
lap("histogram", &mut t);
let mut image = image;
let mask = overlay_alpha(req.overlay, &plan, &prep);
visualize::apply(&mut image, req.overlay, &plan, mask.as_ref());
let image = if plan.keep.is_some() { cut(&image) } else { image };
Rendered { image, histogram, deep: None }
}
@@ -510,3 +585,5 @@ mod tests;
mod tests_geometry;
#[cfg(test)]
mod tests_local;
#[cfg(test)]
mod tests_window;
+9
View File
@@ -328,6 +328,15 @@ pub(crate) fn prepare(img: Arc<Rgb32f>, s: &DevelopSettings, frame: &Frame, px_p
Prepared { img, log_l, base, clarity_blur, texture_blur, dark, chroma_blur, air, masks, px_per_long }
}
/// The airlight of the whole output frame, estimated on a small render of it (`proxy_w` px wide):
/// a windowed render must dehaze with the frame's airlight, not that of the pixels it holds.
pub(crate) fn frame_airlight(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, frame: &Frame, proxy_w: usize, proxy_h: usize) -> f32 {
let mut img = frame.sample(src, proxy_w, proxy_h);
white_balance(&mut img, info, s);
let sigma = (0.02 * frame.px_per_long(proxy_w)).max(1.0) as f32;
airlight(&gaussian(&img.map(dark_of), sigma))
}
/// The airlight is estimated from every `AIRLIGHT_STEP`-th value of the dark channel.
pub const AIRLIGHT_STEP: usize = 7;
+6 -4
View File
@@ -60,7 +60,7 @@ pub fn evaluate_one(m: &Mask, frame: &Frame, w: usize, h: usize, img: &Rgb32f, l
// Refine Edges: the mask's edges follow the photo's (window up to ~4 % of the long edge,
// the width of a soft brush edge or gradient)
let k = (m.refine / 100.0).clamp(0.0, 1.0) as f32;
let sigma = (0.04 * w.max(h) as f32 * k).max(1.0);
let sigma = (0.04 * frame.output_long(w, h) as f32 * k).max(1.0);
let refined = guided_cross(log_l, &alpha, sigma, 0.02);
for (a, r) in alpha.data.iter_mut().zip(&refined.data) {
*a += (r - *a) * k.sqrt();
@@ -336,11 +336,13 @@ pub fn auto_similarity(l: f32, ch: [f32; 3], rl: f32, rch: [f32; 3]) -> f32 {
1.0 - smooth(0.5, 1.0, (dl * dl + dc * dc).sqrt())
}
/// The output pixel a dab at `d` samples its reference colour from.
/// The output pixel a dab at `d` samples its reference colour from. The position is taken at f32
/// precision (like the GPU kernel): a dab on a pixel boundary must not fall on either side of it
/// by an f64 rounding error, which depends on where the window of a zoomed view starts.
#[inline]
pub fn dab_pixel(d: Point, w: usize, h: usize) -> usize {
let x = (d.x.floor().max(0.0) as usize).min(w - 1);
let y = (d.y.floor().max(0.0) as usize).min(h - 1);
let x = ((d.x as f32).floor().max(0.0) as usize).min(w - 1);
let y = ((d.y as f32).floor().max(0.0) as usize).min(h - 1);
y * w + x
}
+220
View File
@@ -106,6 +106,135 @@ pub fn pick_source(
Some(Point::new(n1.x - n0.x, n1.y - n0.y))
}
/// The most a window grows to take in the spots that touch it.
const MAX_SPOT_SPAN: usize = if cfg!(target_arch = "wasm32") { 4096 } else { 8192 };
/// Strokes with more dabs than this are not examined dab by dab (the render would be too slow anyway).
const MAX_DABS_CONSIDERED: usize = 200_000;
/// `win` (a window of the `full_w × full_h` output of `frame`) grown to hold, for every spot (or
/// Auto Mask stroke) that reaches into it, the whole spot and the patch it reads from: [`apply`] works on the pixels it
/// is given, so a spot cut by the window's edge, or whose source lies outside it, would come out
/// different from the same spot in the whole render. Spots with an automatic source (`None`)
/// search up to 4.2 radii around themselves. Growth that would pass [`MAX_SPOT_SPAN`] pixels is not
/// done (the window comes back as asked; see [`window_for_reads_checked`] to know).
pub fn window_for_reads(
s: &lightcraft_develop::DevelopSettings,
frame: &Frame,
full_w: usize,
full_h: usize,
ppl: f64,
win: crate::PixelWindow,
) -> crate::PixelWindow {
window_for_reads_checked(s, frame, full_w, full_h, ppl, win).unwrap_or(win)
}
/// [`window_for_reads`], or `None` when holding everything the window reads would take more than
/// [`MAX_SPOT_SPAN`] pixels along an axis: a caller that can show something else (the loupe's
/// whole-frame render) must, because the window alone would come out wrong.
pub fn window_for_reads_checked(
s: &lightcraft_develop::DevelopSettings,
frame: &Frame,
full_w: usize,
full_h: usize,
ppl: f64,
win: crate::PixelWindow,
) -> Option<crate::PixelWindow> {
let spots = &s.spots;
let autos: Vec<&lightcraft_develop::BrushStroke> = s
.masks
.iter()
.filter(|m| m.visible)
.flat_map(|m| &m.components)
.filter_map(|c| match &c.shape {
lightcraft_develop::MaskShape::Brush { strokes } => Some(strokes),
_ => None,
})
.flatten()
.filter(|st| st.auto_mask)
.collect();
if spots.is_empty() && autos.is_empty() {
return Some(win);
}
let to_out = frame.norm_to_out(full_w, full_h);
let origin = to_out.apply(Point::new(0.0, 0.0));
// (target box, source box) of each spot, in output pixels: [x0, y0, x1, y1]
let boxes: Vec<([f64; 4], [f64; 4])> = spots
.iter()
.filter_map(|spot| {
let r = (spot.size * ppl).max(1.0);
let pts: Vec<_> = spot.points.iter().map(|p| to_out.apply(*p)).collect();
let first = pts.first()?;
let (mut x0, mut y0, mut x1, mut y1) = (first.x, first.y, first.x, first.y);
for p in &pts {
(x0, y0, x1, y1) = (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y));
}
// the box `apply` works in: the stroke plus a radius, plus a radius of padding
let t = [x0 - 2.0 * r, y0 - 2.0 * r, x1 + 2.0 * r, y1 + 2.0 * r];
let s = match spot.source_offset {
Some(o) => {
let b = to_out.apply(o);
let (dx, dy) = (b.x - origin.x, b.y - origin.y);
[t[0] + dx, t[1] + dy, t[2] + dx, t[3] + dy]
}
None => [t[0] - 4.2 * r, t[1] - 4.2 * r, t[2] + 4.2 * r, t[3] + 4.2 * r],
};
Some((t, s)).filter(|(t, s)| t.iter().chain(s).all(|v| v.is_finite()))
})
.collect();
// Auto Mask strokes weigh each dab by its similarity to the pixel under the dab's centre and
// refine the stroke over a couple of radii: each dab matters to pixels within 2.5 radii
let mut dab_boxes: Vec<[f64; 4]> = Vec::new();
for st in autos {
let dabs = crate::masks::brush_dabs(st, frame, full_w, full_h);
let r = dabs.r;
if dabs.dabs.len() > MAX_DABS_CONSIDERED {
continue;
}
for d in &dabs.dabs {
let b = [d.x - 2.5 * r, d.y - 2.5 * r, d.x + 2.5 * r, d.y + 2.5 * r];
if b.iter().all(|v| v.is_finite()) {
dab_boxes.push(b);
}
}
}
let (fw, fh) = (full_w as f64, full_h as f64);
let mut r = [win.x as f64, win.y as f64, (win.x + win.w) as f64, (win.y + win.h) as f64];
let touches = |r: &[f64; 4], b: &[f64; 4]| b[0] < r[2] && b[2] > r[0] && b[1] < r[3] && b[3] > r[1];
// a dab reaching the requested window needs its surroundings, once: the pixels that brings in
// are only read, not drawn on (unlike spots, whose patches other spots may read)
let asked = r;
for b in dab_boxes.iter().filter(|b| touches(&asked, b)) {
r = [r[0].min(b[0]), r[1].min(b[1]), r[2].max(b[2]), r[3].max(b[3])];
}
r = [r[0].max(0.0), r[1].max(0.0), r[2].min(fw), r[3].min(fh)];
if r[2] - r[0] > MAX_SPOT_SPAN as f64 || r[3] - r[1] > MAX_SPOT_SPAN as f64 {
return None;
}
for _ in 0..=boxes.len() {
let mut grown = r;
for (t, s) in &boxes {
if touches(&r, t) {
for b in [t, s] {
grown = [grown[0].min(b[0]), grown[1].min(b[1]), grown[2].max(b[2]), grown[3].max(b[3])];
}
}
}
let grown = [grown[0].max(0.0), grown[1].max(0.0), grown[2].min(fw), grown[3].min(fh)];
if grown == r {
break;
}
if grown[2] - grown[0] > MAX_SPOT_SPAN as f64 || grown[3] - grown[1] > MAX_SPOT_SPAN as f64 {
return None;
}
r = grown;
}
let (x, y) = (r[0].floor() as usize, r[1].floor() as usize);
Some(
crate::PixelWindow { x, y, w: (r[2].ceil() as usize).saturating_sub(x).max(1), h: (r[3].ceil() as usize).saturating_sub(y).max(1) }
.clamped(full_w, full_h),
)
}
/// Apply all spots to `img` (output resolution). `ppl` = output pixels per long-edge unit.
pub fn apply(img: &mut Rgb32f, spots: &[Spot], frame: &Frame, ppl: f64) {
if spots.is_empty() {
@@ -248,4 +377,95 @@ mod tests {
assert!(img.get(25, 50)[0] > 0.8);
assert!(img.get(5, 50)[0] < 0.2);
}
fn with_spots(spots: Vec<Spot>) -> DevelopSettings {
DevelopSettings { spots, ..Default::default() }
}
fn spot_at(x: f64, y: f64, size: f64, offset: Option<Point>) -> Spot {
Spot { points: vec![Point::new(x, y)], size, source_offset: offset, ..Default::default() }
}
#[test]
fn a_window_grows_to_hold_the_spots_that_touch_it_and_their_sources() {
use crate::PixelWindow;
let frame = Frame::new(4000, 3000, &DevelopSettings::default(), true);
let ppl = frame.px_per_long(4000);
let win = PixelWindow { x: 1000, y: 1000, w: 400, h: 300 };
// none: the window is left alone
assert_eq!(window_for_reads(&with_spots(vec![]), &frame, 4000, 3000, ppl, win), win);
// a spot far away is no business of this window
let far = spot_at(0.9, 0.9, 0.01, Some(Point::new(0.02, 0.0)));
assert_eq!(window_for_reads(&with_spots(vec![far]), &frame, 4000, 3000, ppl, win), win);
// a spot inside it, reading from 300 px to the right, widens it to the source
let near = spot_at(0.30, 0.38, 0.01, Some(Point::new(0.075, 0.0)));
let g = window_for_reads(&with_spots(vec![near]), &frame, 4000, 3000, ppl, win);
assert!(g.x <= win.x && g.x + g.w >= 1200 + 300 + 80 && g.y <= win.y && g.y + g.h >= win.y + win.h, "{g:?}");
// a spot that only reaches in with its edge counts too
let edge = spot_at(0.24, 0.38, 0.01, Some(Point::new(-0.05, 0.0)));
assert!(window_for_reads(&with_spots(vec![edge]), &frame, 4000, 3000, ppl, win).x < win.x);
}
#[test]
fn spot_windows_are_bounded_and_survive_hostile_spots() {
use crate::PixelWindow;
let frame = Frame::new(40_000, 30_000, &DevelopSettings::default(), true);
let ppl = frame.px_per_long(40_000);
let win = PixelWindow { x: 1000, y: 1000, w: 400, h: 300 };
// a spot whose source is a long way off would need an enormous window: not granted
let wide = spot_at(0.03, 0.04, 0.01, Some(Point::new(0.6, 0.0)));
let g = window_for_reads(&with_spots(vec![wide]), &frame, 40_000, 30_000, ppl, win);
assert!(g.w <= MAX_SPOT_SPAN && g.h <= MAX_SPOT_SPAN, "{g:?}");
for bad in [f64::NAN, f64::INFINITY, 1e300, -1e300] {
let s = spot_at(bad, bad, bad, Some(Point::new(bad, bad)));
let g = window_for_reads(&with_spots(vec![s]), &frame, 40_000, 30_000, ppl, win);
assert_eq!(g, win);
}
let empty = Spot { points: vec![], ..Default::default() };
assert_eq!(window_for_reads(&with_spots(vec![empty]), &frame, 40_000, 30_000, ppl, win), win);
}
// Given an Auto Mask stroke reaching into the window, the window grows to hold all of it
#[test]
fn a_window_grows_to_hold_an_auto_mask_stroke() {
use crate::PixelWindow;
use lightcraft_develop::{BrushStroke, Mask, MaskComponent, MaskOp, MaskShape};
let frame = Frame::new(4000, 3000, &DevelopSettings::default(), true);
let ppl = frame.px_per_long(4000);
let win = PixelWindow { x: 1000, y: 1000, w: 400, h: 300 };
let stroke =
|auto| BrushStroke { points: vec![Point::new(0.30, 0.38), Point::new(0.5, 0.38)], size: 0.01, auto_mask: auto, ..Default::default() };
let mask = |auto| Mask {
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke(auto)] } }],
..Default::default()
};
let plain = DevelopSettings { masks: vec![mask(false)], ..Default::default() };
assert_eq!(window_for_reads(&plain, &frame, 4000, 3000, ppl, win), win, "a plain stroke reads nothing around it");
let auto = DevelopSettings { masks: vec![mask(true)], ..Default::default() };
let g = window_for_reads(&auto, &frame, 4000, 3000, ppl, win);
// the dabs near the window, not the whole stroke (it runs on to x = 2000)
assert!(g.x <= 1000 && g.x + g.w > 1400 && g.x + g.w < 1700, "{g:?}");
// a window at the far end of a long stroke does not grow back along all of it
let far = window_for_reads(&auto, &frame, 4000, 3000, ppl, PixelWindow { x: 1900, ..win });
assert!(far.x > 1500, "{far:?}");
// a hidden mask reads nothing
let hidden = DevelopSettings { masks: vec![Mask { visible: false, ..auto.masks[0].clone() }], ..Default::default() };
assert_eq!(window_for_reads(&hidden, &frame, 4000, 3000, ppl, win), win);
}
// Given a window that would have to grow past the cap, there is no answer (never a wrong one)
#[test]
fn a_window_that_cannot_hold_what_it_reads_is_refused() {
use crate::PixelWindow;
let frame = Frame::new(40_000, 30_000, &DevelopSettings::default(), true);
let ppl = frame.px_per_long(40_000);
let win = PixelWindow { x: 1000, y: 1000, w: 400, h: 300 };
let far = with_spots(vec![spot_at(0.03, 0.04, 0.01, Some(Point::new(0.6, 0.0)))]);
assert_eq!(window_for_reads_checked(&far, &frame, 40_000, 30_000, ppl, win), None);
let near = with_spots(vec![spot_at(0.03, 0.04, 0.01, Some(Point::new(0.02, 0.0)))]);
assert!(window_for_reads_checked(&near, &frame, 40_000, 30_000, ppl, win).is_some());
// a spot that doesn't touch the window is no business of it, however far it reads
let elsewhere = with_spots(vec![spot_at(0.9, 0.9, 0.01, Some(Point::new(-0.8, 0.0)))]);
assert_eq!(window_for_reads_checked(&elsewhere, &frame, 40_000, 30_000, ppl, win), Some(win));
}
}
+343
View File
@@ -0,0 +1,343 @@
//! Region rendering (issue #323): a window of the virtual full-size output is rendered on its own
//! and must match the same pixels of the full render, so zoomed views can be sharp at any zoom
//! without materializing the whole frame.
use lightcraft_develop::{DevelopSettings, controls};
use lightcraft_geom::{Orientation, Rect};
use lightcraft_raster::{Rgb32f, Rgba8};
use crate::{PixelWindow, RenderRequest, SourceInfo, render};
const W: usize = 960;
const H: usize = 640;
/// Context rendered around the pixels a caller keeps (spatial filters read their neighbours).
const HALO: usize = 64;
fn scene() -> Rgb32f {
lightcraft_scenes::demo_library()[0].render(480, 320)
}
fn full(s: &DevelopSettings) -> Rgba8 {
render(&scene(), &SourceInfo::default(), s, &RenderRequest::fit(W, H)).image
}
fn window(s: &DevelopSettings, win: PixelWindow) -> Rgba8 {
let req = RenderRequest { window: Some(win), ..RenderRequest::fit(W, H) };
render(&scene(), &SourceInfo::default(), s, &req).image
}
/// Largest per-channel difference and mean absolute difference between `win` (rendered for
/// `at`, kept `HALO` px in from its edges) and the same pixels of `full`.
fn compare(full: &Rgba8, win: &Rgba8, at: PixelWindow) -> (u8, f64) {
compare_inset(full, win, at, HALO)
}
/// [`compare`] keeping `inset` px in from the window's edges.
fn compare_inset(full: &Rgba8, win: &Rgba8, at: PixelWindow, inset: usize) -> (u8, f64) {
let (mut max, mut sum, mut n) = (0u8, 0f64, 0f64);
for y in inset..at.h - inset {
for x in inset..at.w - inset {
let (a, b) = (full.get(at.x + x, at.y + y), win.get(x, y));
for k in 0..3 {
let d = a[k].abs_diff(b[k]);
max = max.max(d);
sum += d as f64;
n += 1.0;
}
}
}
(max, sum / n)
}
fn check(name: &str, s: &DevelopSettings, win: PixelWindow, max_ok: u8, mean_ok: f64) {
let (f, w) = (full(s), window(s, win));
assert_eq!((w.width, w.height), (win.w, win.h), "{name}: the window's own size");
let (max, mean) = compare(&f, &w, win);
assert!(max <= max_ok && mean <= mean_ok, "{name}: max {max} (≤ {max_ok}), mean {mean:.3} (≤ {mean_ok})");
}
const MID: PixelWindow = PixelWindow { x: 300, y: 180, w: 400, h: 300 };
// Given the window is the whole output, then it is the full render, byte for byte
#[test]
fn the_whole_frame_as_a_window_is_the_full_render() {
let s = DevelopSettings::default();
let f = full(&s);
let w = window(&s, PixelWindow { x: 0, y: 0, w: f.width, h: f.height });
assert_eq!(f.data, w.data);
}
// Given default settings, a window in the middle matches the full render
#[test]
fn a_window_matches_the_full_render() {
check("default", &DevelopSettings::default(), MID, 1, 0.1);
}
// Given a window at each corner, it matches too (edges of the frame are not special)
#[test]
fn windows_at_the_corners_match() {
let s = DevelopSettings::default();
for (x, y) in [(0, 0), (W - 400, 0), (0, H - 300), (W - 400, H - 300)] {
check("corner", &s, PixelWindow { x, y, w: 400, h: 300 }, 1, 0.1);
}
}
// Given crop, straighten and flip, the window still lands on the same pixels
#[test]
fn a_cropped_straightened_flipped_view_matches() {
let mut s = DevelopSettings::default();
s.crop.geometry.rect = Rect { x0: 0.1, y0: 0.15, x1: 0.85, y1: 0.9 };
s.crop.geometry.angle = 4.0;
s.crop.flip_h = true;
check("crop+angle+flipH", &s, MID, 3, 0.3);
s.crop.flip_h = false;
s.crop.flip_v = true;
check("crop+angle+flipV", &s, MID, 3, 0.3);
}
// Given a rotated photo, the same
#[test]
fn rotated_and_mirrored_orientations_match() {
for o in [Orientation::Rotate90, Orientation::Rotate270, Orientation::FlipH, Orientation::Transpose] {
let s = DevelopSettings { orientation: o, ..Default::default() };
let f = full(&s);
let win = PixelWindow { x: f.width / 4, y: f.height / 4, w: f.width / 2, h: f.height / 2 };
check(&format!("{o:?}"), &s, win, 3, 0.3);
}
}
// Given a post-crop vignette, the window shows the vignette of the whole frame, not its own
#[test]
fn the_vignette_belongs_to_the_frame_not_the_window() {
let mut s = DevelopSettings::default();
controls::set(&mut s, "vignette.amount", -90.0);
controls::set(&mut s, "vignette.midpoint", 10.0);
// the vignette is really there: the corner is much darker than without it
let (with, without) = (full(&s), full(&DevelopSettings::default()));
let corner = |i: &Rgba8| i.get(W - 20, H - 20)[1] as i32;
assert!(corner(&without) - corner(&with) > 30, "{} vs {}", corner(&without), corner(&with));
// …and a window over that corner shows it, at the same place
check("vignette", &s, PixelWindow { x: W - 400, y: H - 300, w: 400, h: 300 }, 2, 0.2);
check("vignette middle", &s, MID, 2, 0.2);
}
// Given spatial adjustments, the window agrees away from its halo
#[test]
fn spatial_adjustments_agree_inside_the_halo() {
for (id, v) in
[("effects.clarity", 70.0), ("effects.dehaze", 40.0), ("detail.sharpenAmount", 80.0), ("light.shadows", 60.0), ("effects.texture", 60.0)]
{
let mut s = DevelopSettings::default();
controls::set(&mut s, id, v);
check(id, &s, MID, 6, 0.8);
}
}
// Given a huge virtual frame (48000 px: an 800 % zoom), only the window is rendered
#[test]
fn a_window_of_a_huge_frame_is_cheap() {
let req = RenderRequest { window: Some(PixelWindow { x: 20_000, y: 12_000, w: 256, h: 192 }), ..RenderRequest::fit(48_000, 32_000) };
let r = render(&scene(), &SourceInfo::default(), &DevelopSettings::default(), &req);
assert_eq!((r.image.width, r.image.height), (256, 192));
}
// Hostile windows (from UI state) are clamped into the frame, never a panic or a huge buffer
#[test]
fn windows_outside_or_degenerate_are_clamped() {
let s = DevelopSettings::default();
for win in [
PixelWindow { x: W + 500, y: 5, w: 100, h: 100 },
PixelWindow { x: 0, y: 0, w: 0, h: 0 },
PixelWindow { x: usize::MAX, y: usize::MAX, w: usize::MAX, h: usize::MAX },
PixelWindow { x: 900, y: 600, w: 5000, h: 5000 },
] {
let r = window(&s, win);
assert!(r.width >= 1 && r.height >= 1 && r.width <= W && r.height <= H, "{win:?}: {}×{}", r.width, r.height);
}
}
/// A textured, noisy source: what noise reduction and edge refinement act on.
fn noisy_scene() -> Rgb32f {
let base = scene();
Rgb32f::from_fn(base.width, base.height, |x, y| {
let mut v = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841);
v ^= v >> 13;
v = v.wrapping_mul(0x5bd1_e995);
v ^= v >> 15;
let n = 1.0 + 0.25 * ((v & 0xffff) as f32 / 32768.0 - 1.0);
base.data[y * base.width + x].map(|c| c * n)
})
}
fn check_noisy(name: &str, s: &DevelopSettings, win: PixelWindow, max_ok: u8, mean_ok: f64) {
let src = noisy_scene();
let f = render(&src, &SourceInfo::default(), s, &RenderRequest::fit(W, H)).image;
let w = render(&src, &SourceInfo::default(), s, &RenderRequest { window: Some(win), ..RenderRequest::fit(W, H) }).image;
let (max, mean) = compare(&f, &w, win);
assert!(max <= max_ok && mean <= mean_ok, "{name}: max {max} (≤ {max_ok}), mean {mean:.3} (≤ {mean_ok})");
}
// Given noise reduction, its strength follows the whole frame's size, not the window's
#[test]
fn noise_reduction_does_not_depend_on_the_window() {
for (id, v) in [("detail.nrLuminance", 80.0), ("detail.nrColor", 80.0)] {
let mut s = DevelopSettings::default();
controls::set(&mut s, id, v);
check_noisy(id, &s, MID, 3, 0.15);
}
}
// Given a mask with Refine Edges, the refinement width follows the whole frame's size
#[test]
fn mask_edge_refinement_does_not_depend_on_the_window() {
use lightcraft_develop::{LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape};
use lightcraft_geom::Point;
let shape = MaskShape::Linear { start: Point::new(0.45, 0.5), end: Point::new(0.55, 0.5) };
let mut s = DevelopSettings::default();
s.masks.push(Mask {
id: 1,
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }],
adjust: LocalAdjustments { exposure: 1.5, ..Default::default() },
refine: 100.0,
..Default::default()
});
check_noisy("refine", &s, MID, 3, 0.2);
}
// ---- Deep zoom: the wide stages' kernels are far wider than any margin -------------------------
/// Hard-edged blocks of very different brightness at several scales: the wide stages' edge-aware
/// filters and the dark channel have something to be wrong about.
fn blocks() -> Rgb32f {
Rgb32f::from_fn(600, 400, |x, y| {
let cell = |size: usize, k: usize| ((x / size * 7 + y / size * 13 + k) % 5) as f32 / 4.0;
let l = 0.02 + 0.5 * cell(97, 1) + 0.25 * cell(31, 2) + 0.1 * cell(11, 3);
[l * 1.1, l, l * 0.8]
})
}
const BIG_W: usize = 7680;
const BIG_H: usize = 5120;
/// A window of a 7680 × 5120 frame against the same pixels of the whole render. The wide stages
/// (dehaze, clarity, highlights/shadows base) have XX/// can't contain them, so they come from the whole frame at reduced size.
fn check_big(name: &str, s: &DevelopSettings, max_ok: u8, mean_ok: f64) {
let src = blocks();
let f = render(&src, &SourceInfo::default(), s, &RenderRequest::fit(BIG_W, BIG_H)).image;
let win = PixelWindow { x: 3000, y: 1800, w: 512, h: 384 };
let w = render(&src, &SourceInfo::default(), s, &RenderRequest { window: Some(win), ..RenderRequest::fit(BIG_W, BIG_H) }).image;
let (max, mean) = compare(&f, &w, win);
assert!(max <= max_ok && mean <= mean_ok, "{name}: max {max} (≤ {max_ok}), mean {mean:.3} (≤ {mean_ok})");
}
#[test]
fn dehaze_at_depth_matches_the_whole_render() {
let mut s = DevelopSettings::default();
controls::set(&mut s, "effects.dehaze", 60.0);
check_big("dehaze", &s, 6, 0.6);
}
#[test]
fn clarity_at_depth_matches_the_whole_render() {
let mut s = DevelopSettings::default();
controls::set(&mut s, "effects.clarity", 80.0);
check_big("clarity", &s, 6, 0.6);
}
#[test]
fn highlights_and_shadows_at_depth_match_the_whole_render() {
let mut s = DevelopSettings::default();
controls::set(&mut s, "light.shadows", 80.0);
controls::set(&mut s, "light.highlights", -80.0);
check_big("shadows+highlights", &s, 6, 0.6);
}
// ---- Spot removal reads outside the spot -------------------------------------------------------
/// Like [`check`], over every pixel of the window (a spot at its edge must be right too).
fn check_whole(name: &str, s: &DevelopSettings, win: PixelWindow, max_ok: u8, mean_ok: f64) {
let (f, w) = (full(s), window(s, win));
let (max, mean) = compare_inset(&f, &w, win, 0);
assert_eq!((w.width, w.height), (win.w, win.h), "{name}: the window's own size");
assert!(max <= max_ok && mean <= mean_ok, "{name}: max {max} (≤ {max_ok}), mean {mean:.3} (≤ {mean_ok})");
}
fn with_spot(mode: lightcraft_develop::SpotMode, at: (f64, f64), offset: Option<(f64, f64)>) -> DevelopSettings {
use lightcraft_develop::Spot;
use lightcraft_geom::Point;
let mut s = DevelopSettings::default();
s.spots.push(Spot {
mode,
points: vec![Point::new(at.0, at.1)],
size: 0.04,
feather: 30.0,
opacity: 100.0,
source_offset: offset.map(|(x, y)| Point::new(x, y)),
});
s
}
// Given a spot whose source patch lies outside the window, the window still shows it healed
#[test]
fn a_spot_with_its_source_outside_the_window_matches_the_whole_render() {
use lightcraft_develop::SpotMode;
// the target is at x ≈ 0.36 of the frame; its source is 0.12 of the width to the right
for mode in [SpotMode::Clone, SpotMode::Heal] {
let s = with_spot(mode, (0.40, 0.5), Some((0.12, 0.05)));
// a window that holds the target (x ≈ 384) but not the source (x ≈ 499..)
check_whole(&format!("{mode:?}"), &s, PixelWindow { x: 250, y: 200, w: 200, h: 200 }, 1, 0.1);
}
}
// Given a spot just outside the window whose feathered edge reaches in, the window shows it
#[test]
fn a_spot_overlapping_the_window_edge_matches_the_whole_render() {
let s = with_spot(lightcraft_develop::SpotMode::Heal, (0.30, 0.5), Some((-0.1, 0.0)));
check_whole("edge", &s, PixelWindow { x: 300, y: 200, w: 240, h: 240 }, 1, 0.1);
}
// Given a spot with an automatic source, two windows over the same spot choose the same source
#[test]
fn an_automatic_spot_source_does_not_depend_on_the_window() {
let s = with_spot(lightcraft_develop::SpotMode::Heal, (0.5, 0.5), None);
// windows so tight that the candidate sources can't all be inside them
let (a, b) = (PixelWindow { x: 430, y: 280, w: 110, h: 90 }, PixelWindow { x: 450, y: 290, w: 110, h: 90 });
let (ia, ib) = (window(&s, a), window(&s, b));
let (mut max, mut n) = (0u8, 0);
for y in 0..80 {
for x in 0..90 {
let (p, q) = (ia.get(x + 20, y + 10), ib.get(x, y));
for k in 0..3 {
max = max.max(p[k].abs_diff(q[k]));
n += 1;
}
}
}
assert!(n > 0 && max <= 2, "overlapping windows disagree by up to {max}");
}
// Given Auto Mask strokes that run in and out of the window, the window shows the whole render's mask
#[test]
fn auto_mask_strokes_match_the_whole_render() {
use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape};
use lightcraft_geom::Point;
let st = |pts: &[(f64, f64)], auto_mask, erase| BrushStroke {
points: pts.iter().map(|p| Point::new(p.0, p.1)).collect(),
size: 0.06,
feather: 40.0,
flow: 80.0,
auto_mask,
erase,
..Default::default()
};
let strokes = vec![st(&[(0.2, 0.55), (0.5, 0.6), (0.8, 0.5)], true, false), st(&[(0.5, 0.58)], true, true)];
let s = DevelopSettings {
masks: vec![Mask {
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes } }],
adjust: LocalAdjustments { exposure: 1.0, saturation: -50.0, ..Default::default() },
..Default::default()
}],
..Default::default()
};
check_noisy("auto mask", &s, PixelWindow { x: 400, y: 300, w: 300, h: 250 }, 2, 0.1);
}
+59 -3
View File
@@ -103,21 +103,33 @@ pub(crate) fn decode(sample: &[u8], config: &Cr3Compression) -> Result<Vec<u16>>
output.resize(count, 0);
}
for (i, &value) in decoded.iter().enumerate() {
let sensor_value =
value.checked_add(midpoint).filter(|&v| v >= 0 && v <= maximum).ok_or_else(|| corrupt("decoded sample outside its bit depth"))?;
let sensor_value = sensor_sample(value, midpoint, maximum, config.levels != 0)?;
let (x, y) = (tile.x + 2 * (i % (tile.width / 2)) + p % 2, tile.y + 2 * (i / (tile.width / 2)) + p / 2);
let slot = y
.checked_mul(width)
.and_then(|n| n.checked_add(x))
.and_then(|n| output.get_mut(n))
.ok_or_else(|| corrupt("tile writes outside image"))?;
*slot = sensor_value as u16;
*slot = sensor_value;
}
}
}
Ok(output)
}
/// A decoded plane value as a sensor sample of the CMP1 bit depth.
///
/// Lossless planes reproduce the sensor exactly, so a value outside the bit depth is corrupt data.
/// Wavelet (C-RAW) planes are quantized: next to clipped highlights the reconstruction overshoots
/// the sensor's range by up to a few quantization steps (measured on 17 CC0 files: at most 36 codes
/// above 16383 at 14 bits, never below 0), while lossless files of the same bodies clip at exactly
/// 16383. Those samples are clamped to the range the sensor can record.
fn sensor_sample(value: i32, midpoint: i32, maximum: i32, quantized: bool) -> Result<u16> {
let sample = value.checked_add(midpoint).ok_or_else(|| corrupt("decoded sample outside its bit depth"))?;
let sample = if quantized { sample.clamp(0, maximum) } else { sample };
u16::try_from(sample).ok().filter(|_| sample <= maximum).ok_or_else(|| corrupt("decoded sample outside its bit depth"))
}
/// Header-only probing must reject the same unsupported coding variants as full decoding.
pub(crate) fn validate(sample: &[u8], config: &Cr3Compression) -> Result<()> {
supports(config)?;
@@ -913,6 +925,50 @@ mod tests {
}
}
#[test]
fn craw_overshoot_at_clipped_highlights_is_clamped_to_the_bit_depth() {
// As `three_level_craw_reconstructs_sensor_samples`, but each LL3 coefficient is +8200
// (Rice escape: 41 zeros, 1, 21-bit literal 16400 = signed +8200). Flat synthesis gives
// 8192 + 8200 = 16392, 9 codes above the 14-bit maximum, as in clipped C-RAW highlights.
let mut config = coding();
config.width = 16;
config.height = 16;
config.tile_width = 16;
config.tile_height = 16;
config.header_size = 540;
config.levels = 3;
let mut sample = Vec::new();
marker(&mut sample, 0xff01, 80, 0);
for plane in 0..4 {
marker(&mut sample, 0xff02, 20, (plane << 28) | 0x0800_0000);
for band in 0..10 {
marker(&mut sample, 0xff03, [8, 1, 1, 1, 1, 1, 1, 2, 2, 2][band as usize], (band << 28) | 0x0020_0000);
}
}
for _ in 0..4 {
sample.extend([0, 0, 0, 0, 0, 0x40, 0x80, 0x20]); // LL3 = +8200.
sample.extend([0x80; 3]); // The three 1×1 HF3 bands.
sample.extend([0xf0; 3]); // Three 2×2 all-zero HF2 bands.
for _ in 0..3 {
sample.extend([0xff, 0xf8]); // Three 4×4 all-zero HF1 bands.
}
}
validate(&sample, &config).unwrap();
assert_eq!(decode(&sample, &config).unwrap(), vec![16383; 256]);
}
#[test]
fn only_quantized_planes_clamp_out_of_range_samples() {
assert_eq!(sensor_sample(8200, 8192, 16383, true).unwrap(), 16383);
assert_eq!(sensor_sample(-8300, 8192, 16383, true).unwrap(), 0);
assert_eq!(sensor_sample(8191, 8192, 16383, false).unwrap(), 16383);
assert_eq!(sensor_sample(-8192, 8192, 16383, false).unwrap(), 0);
// Lossless planes reproduce the sensor exactly: outside the bit depth is corrupt data.
assert!(matches!(sensor_sample(8192, 8192, 16383, false), Err(RawError::Corrupt(_))));
assert!(matches!(sensor_sample(-8193, 8192, 16383, false), Err(RawError::Corrupt(_))));
assert!(matches!(sensor_sample(i32::MAX, 8192, 16383, true), Err(RawError::Corrupt(_))));
}
#[test]
fn measured_quantization_and_allocation_limits() {
for (quant, expected) in [(4, 1), (9, 1), (10, 2), (16, 4), (22, 8), (26, 12), (32, 25), (36, 40), (41, 72), (42, 80), (43, 90)] {
+1 -1
View File
@@ -183,7 +183,7 @@
"… and {more} more": "… und {more} weitere",
"LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft kann diese RAW-Variante noch nicht dekodieren ({}). Du bearbeitest die eingebettete JPEG-Vorschau der Kamera. Sie enthält bereits deren Bildstil (z. B. Monochrom) und Weißabgleich.",
"Filtered: {label}": "Gefiltert: {label}",
"{n} similar photo{} · View ▸ Clear Filters to see all": "Ähnliche Fotos: {n}{:.0} · Ansicht ▸ Filter löschen zeigt alle Fotos",
"{n} similar photo{} · View ▸ Clear Filters to see all": "Ähnliche Fotos: {n}{:.0} · Ansicht → Filter löschen zeigt alle Fotos",
"Moved; {} photo(s) relinked": "Verschoben; {} Fotos neu verknüpft",
"{} label (click more labels to show any of them)": "Markierung {} (weitere Markierungen auswählen, um alle passenden Fotos zu zeigen)",
", {} star{}": ", Sterne: {}{:.0}",
+5 -2
View File
@@ -129,6 +129,7 @@
"Auto-Tag from Tracklog: GPS locations for your photos from a GPX track log, matched by capture time.": "Automatisches Verorten anhand eines GPS-Tracks: GPS-Orte für deine Fotos aus einem GPX-Track, abgeglichen nach Aufnahmezeit.",
"Auto-Tag from Tracklog…": "Anhand eines GPS-Tracks automatisch verorten…",
"Automatic": "Automatisch",
"Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "Automatisch rendert die Detailansicht in der angezeigten Größe, bis zu den eigenen Pixeln des Fotos. Eine Größe wählen, um sie zu begrenzen: kleiner ist schneller.",
"Automatically Write Changes into XMP": "Änderungen automatisch in XMP schreiben",
"Automatically write changes into XMP sidecars": "Änderungen automatisch in XMP-Begleitdateien schreiben",
"Autumn Gold": "Herbstgold",
@@ -741,7 +742,6 @@
"Label": "Farbmarkierung",
"Landscape": "Landschaft",
"Language": "Sprache",
"Largest long edge the Detail view renders at; larger is sharper on big displays but slower.": "Längste Kante beim Rendern der Detailansicht; größere Werte sind auf großen Bildschirmen schärfer, aber langsamer.",
"Last": "Letzter",
"Last GPU fallback": "Letzter GPU-Fallback",
"Last commit": "Letzter Commit",
@@ -1287,6 +1287,8 @@
"Scanning": "Wird durchsucht",
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Die Werte bleiben an den Fotos gespeichert: Filtere danach oder erstelle Smart-Alben mit Schärfe und „Beste ähnlicher Aufnahmen“.",
"Screen": "Bildschirm",
"Scroll menu down": "Menü nach unten scrollen",
"Scroll menu up": "Menü nach oben scrollen",
"Search": "Suche",
"Search Photos": "Fotos suchen",
"Search commands or keys": "Befehle oder Tasten suchen",
@@ -1794,5 +1796,6 @@
"Only": "Nur diese",
"How to install the model": "So installierst du das Modell",
"Install the SAM 3 Model": "SAM-3-Modell installieren",
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Diese Version kann das Modell noch nicht herunterladen. Zum Installieren von Hand lege model.safetensors, vocab.json und merges.txt aus Metas facebook/sam3 in den obigen Ordner: Objekt und Beschreiben funktionieren, sobald sie dort liegen."
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Diese Version kann das Modell noch nicht herunterladen. Zum Installieren von Hand lege model.safetensors, vocab.json und merges.txt aus Metas facebook/sam3 in den obigen Ordner: Objekt und Beschreiben funktionieren, sobald sie dort liegen.",
"Open Log Folder": "Protokollordner öffnen"
}
+3 -3
View File
@@ -25,7 +25,7 @@
"Added {what} to {name}": "{what} añadido a {name}",
"Adding photos… {} of {}": "Añadiendo fotos… {} de {}",
"All {n} selected photos move by the same amount.": "Las {n} fotos seleccionadas se desplazan la misma cantidad.",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Ya en la biblioteca, en Eliminadas recientemente: {0} foto{1} seleccionada{1} · clic derecho ▸ Restaurar, o Eliminar definitivamente para importar de nuevo",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Ya en la biblioteca, en Eliminadas recientemente: {0} foto{1} seleccionada{1} · clic derecho → Restaurar, o Eliminar definitivamente para importar de nuevo",
"Already in the library: {} photo{} selected in All Photos": "Ya en la biblioteca: {0} foto{1} seleccionada{1} en Todas las fotos",
"Auto ({})": "Automático ({})",
"Auto Import: added {} photo{}": "Importación automática: {0} foto{1} añadida{1}",
@@ -63,7 +63,7 @@
"Imported {n} point curve preset{}": "{n} ajuste{0} preestablecido{0} de curva de puntos importado{0}",
"Imported {n} preset{}": "{n} ajuste{0} preestablecido{0} importado{0}",
"Imported {n} preset{}, {f} file{} not readable": "{n} ajuste{0} preestablecido{0} importado{0}, {f} archivo{1} ilegible{1}",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "Perfiles importados: {profiles}{:.0} (Explorador de perfiles ▸ sus grupos)",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "Perfiles importados: {profiles}{:.0} (Explorador de perfiles → sus grupos)",
"Imported {} photo{}": "{0} foto{1} importada{1}",
"Insert at the cursor (also written {})": "Insertar en el cursor (también se escribe {})",
"Label Set: {name}": "Conjunto de etiquetas: {name}",
@@ -183,7 +183,7 @@
"… and {more} more": "… y {more} más",
"LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft aún no puede decodificar esta variante RAW ({}). Se está editando la vista previa JPEG incrustada de la cámara, que ya incluye el estilo de imagen de la cámara (p. ej. Monocromo) y el balance de blancos.",
"Filtered: {label}": "Filtrado: {label}",
"{n} similar photo{} · View ▸ Clear Filters to see all": "Fotos similares: {n}{:.0} · Vista ▸ Borrar filtros para ver todas",
"{n} similar photo{} · View ▸ Clear Filters to see all": "Fotos similares: {n}{:.0} · Vista → Borrar filtros para ver todas",
"Moved; {} photo(s) relinked": "Movida; {} foto(s) vinculada(s) de nuevo",
"{} label (click more labels to show any of them)": "Etiqueta {} (hacer clic en más etiquetas para mostrar cualquiera de ellas)",
", {} star{}": ", {} estrella{}",
+9 -6
View File
@@ -112,6 +112,7 @@
"Auto-Tag from Tracklog": "Etiquetar automáticamente desde el registro de ruta",
"Auto-Tag from Tracklog…": "Etiquetar automáticamente desde el registro de ruta…",
"Automatic": "Automático",
"Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "Automático muestra la vista Detalle al tamaño en que se ve, hasta los píxeles propios de la foto. Elegir un tamaño para limitarla: cuanto más pequeño, más rápido.",
"Automatically Write Changes into XMP": "Escribir automáticamente los cambios en XMP",
"Automatically write changes into XMP sidecars": "Escribir automáticamente los cambios en los archivos XMP complementarios",
"Autumn Gold": "Oro otoñal",
@@ -704,7 +705,6 @@
"Label": "Etiqueta",
"Landscape": "Paisaje",
"Language": "Idioma",
"Largest long edge the Detail view renders at; larger is sharper on big displays but slower.": "Lado largo máximo con el que se renderiza la vista Detalle; un valor mayor es más nítido en pantallas grandes, pero más lento.",
"Last": "Último",
"Last GPU fallback": "Último recurso alternativo de GPU",
"Last commit": "Último commit",
@@ -990,7 +990,7 @@
"Photo Grid": "Cuadrícula de fotos",
"Photo Grid (G)": "Cuadrícula de fotos (G)",
"Photo Merge": "Combinación de fotos",
"Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Foto ▸ Editar en el editor externo (⇧⌘E) renderiza una copia TIFF de 16 bits, la apila con el original y la abre aquí (un nombre de aplicación en macOS, una ruta de programa en otros sistemas).",
"Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Foto → Editar en el editor externo (⇧⌘E) renderiza una copia TIFF de 16 bits, la apila con el original y la abre aquí (un nombre de aplicación en macOS, una ruta de programa en otros sistemas).",
"Photos": "Fotos",
"Photos per bracket (0 = auto)": "Fotos por horquillado (0 = automático)",
"Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Las fotos vistas en Local, pero nunca añadidas ni modificadas, salen del catálogo cuando su carpeta no se ha explorado durante este tiempo (se comprueba al abrir la biblioteca). Los archivos permanecen en el disco; al explorar la carpeta vuelven a aparecer.",
@@ -1018,7 +1018,7 @@
"Presets & Profiles": "Ajustes preestablecidos y perfiles",
"Presets (Shift+P)": "Ajustes preestablecidos (Mayús+P)",
"Presets and profiles": "Ajustes preestablecidos y perfiles",
"Presets ▾": "Ajustes preestablecidos ▾",
"Presets ▾": "Ajustes preestablecidos ⌄",
"Press keys…": "Pulsar teclas…",
"Preview": "Vista previa",
"Preview Build Progress": "Progreso de generación de vistas previas",
@@ -1142,7 +1142,7 @@
"Remove from survey": "Quitar de Encuesta",
"Remove location info": "Quitar información de ubicación",
"Remove shortcut": "Quitar método abreviado",
"Remove this face box (undo with Edit ▸ Undo)": "Quitar este recuadro de cara (deshacer con Editar ▸ Deshacer)",
"Remove this face box (undo with Edit ▸ Undo)": "Quitar este recuadro de cara (deshacer con Editar → Deshacer)",
"Remove this filter": "Quitar este filtro",
"Remove this group": "Quitar este grupo",
"Remove this rule": "Quitar esta regla",
@@ -1192,7 +1192,7 @@
"Revert": "Revertir",
"Review": "Revisión",
"Review Import": "Revisar importación",
"Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Clic derecho en una foto ▸ Definir como foto de referencia y, después, Vista → Vista de referencia (Mayús+R)",
"Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Clic derecho en una foto → Definir como foto de referencia y, después, Vista → Vista de referencia (Mayús+R)",
"Right-click a preset to delete it": "Clic derecho en un ajuste preestablecido para eliminarlo",
"Rights Usage Terms": "Condiciones de uso de los derechos",
"Rotate": "Rotar",
@@ -1235,6 +1235,8 @@
"Scanning": "Explorando",
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Las puntuaciones se quedan en las fotos: se pueden filtrar o crear álbumes inteligentes con Enfoque y La mejor de las tomas similares.",
"Screen": "Pantalla",
"Scroll menu down": "Desplazar el menú hacia abajo",
"Scroll menu up": "Desplazar el menú hacia arriba",
"Search": "Buscar",
"Search Photos": "Buscar fotos",
"Search commands or keys": "Buscar comandos o teclas",
@@ -1639,5 +1641,6 @@
"Preview only: editing the camera's embedded JPEG": "Solo vista previa: editando el JPEG incrustado de la cámara",
"Captured from": "Capturadas desde",
"Captured until": "Capturadas hasta",
"Only": "Solo"
"Only": "Solo",
"Open Log Folder": "Abrir carpeta de registros"
}
+3 -3
View File
@@ -111,7 +111,7 @@
"Adding photos… {} of {}": "写真を追加中:{1}枚中{0}枚",
" ({} in Recently Deleted)": "(うち{}枚は最近削除した写真)",
"Already in the library: {} photo{} selected in All Photos": "ライブラリに登録済み:すべての写真で{}枚を選択しました{:.0}",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "ライブラリの「最近削除した写真」にあります:{}枚を選択しました{:.0}・右クリック ▸ 復元、または完全に削除してから読み込み直し",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "ライブラリの「最近削除した写真」にあります:{}枚を選択しました{:.0}・右クリック → 復元、または完全に削除してから読み込み直し",
"Auto ({})": "自動 ({})",
"Build 1:1 Previews ({scope})": "1:1プレビューを作成({scope})",
"Build Smart Previews ({scope})": "スマートプレビューを作成({scope})",
@@ -153,7 +153,7 @@
"Folder “{}” (and its subfolders)": "フォルダ「{}」(サブフォルダを含む)",
"Found {n} missing photo{}; {left} still missing{unsure}": "見つからない写真を{n}枚見つけました{:.0}。{left}枚はまだ見つかりません{unsure}",
"Import failed: {e}": "読み込みに失敗しました:{e}",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "{profiles}個のプロファイルを読み込みました{:.0}(プロファイルブラウザー ▸ 各グループ)",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "{profiles}個のプロファイルを読み込みました{:.0}(プロファイルブラウザー → 各グループ)",
"Insert at the cursor (also written {})": "カーソル位置に挿入({}とも表記可)",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0。フォント:{}(OFL)。アイコン:オリジナル",
"Painting “{k}”: click photos to add or remove it · Esc stops": "「{k}」を適用中:写真をクリックして付け外し・Escで終了",
@@ -183,7 +183,7 @@
"{} — show their photos": "{} — この人物の写真を表示",
"LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraftはこのRAWの形式({})をまだデコードできません。カメラの埋め込みJPEGプレビューを編集しています。このプレビューには、カメラのピクチャースタイル(モノクロなど)とホワイトバランスがすでに反映されています",
"Filtered: {label}": "フィルター適用中:{label}",
"{n} similar photo{} · View ▸ Clear Filters to see all": "似た写真{n}枚{:.0}・すべて表示するには「表示 ▸ フィルターを解除」",
"{n} similar photo{} · View ▸ Clear Filters to see all": "似た写真{n}枚{:.0}・すべて表示するには「表示 → フィルターを解除」",
"Moved; {} photo(s) relinked": "移動しました。{}枚の写真の参照先を更新しました",
"{} label (click more labels to show any of them)": "{}ラベル(ほかのラベルもクリックすると、いずれかに一致する写真を表示)",
", {} star{}": "、星{}つ{:.0}",
+6 -3
View File
@@ -684,7 +684,7 @@
"Remove a filter above, or choose Clear all": "上のフィルターを解除するか、「すべて解除」を選択してください",
"Remove from Target Album": "追加先アルバムから除去",
"Remove shortcut": "ショートカットを削除",
"Remove this face box (undo with Edit ▸ Undo)": "この顔の枠を除去(「編集 ▸ 取り消す」で元に戻せます)",
"Remove this face box (undo with Edit ▸ Undo)": "この顔の枠を除去(「編集 → 取り消す」で元に戻せます)",
"Rename Component": "構成要素の名前を変更",
"Rename Folder": "フォルダ名を変更",
"Rename Mask": "マスク名を変更",
@@ -829,7 +829,7 @@
"Keywords you add appear here; ⌥1–⌥9 apply them.": "追加したキーワードはここに表示されます(⌥1〜⌥9で適用)",
"Landscape": "風景",
"Language": "言語",
"Largest long edge the Detail view renders at; larger is sharper on big displays but slower.": "詳細表示の長辺の上限です。大きいほど鮮明になりますが、描画に時間がかかります",
"Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "自動では、詳細表示を表示サイズ(写真本来の画素数まで)で描画します。サイズを選ぶと上限になります。小さいほど高速です",
"Last": "前回",
"Last view": "前回の表示",
"Lateral Chromatic Aberration": "倍率色収差",
@@ -1172,6 +1172,8 @@
"Scanning": "検索中",
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "評価結果は写真に保存されます。ピント評価や類似写真内のベストショットで絞り込み・スマートアルバム作成ができます",
"Screen": "画面用",
"Scroll menu down": "メニューを下にスクロール",
"Scroll menu up": "メニューを上にスクロール",
"Search": "検索",
"Search Photos": "写真を検索",
"Search commands or keys": "コマンドまたはキーを検索",
@@ -1639,5 +1641,6 @@
"繁體中文(台灣)": "繁體中文(台灣)",
"How to install the model": "モデルのインストール方法",
"Install the SAM 3 Model": "SAM 3モデルのインストール",
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "このビルドではまだモデルをダウンロードできません。手動でインストールするには、Meta の facebook/sam3 の model.safetensors、vocab.json、merges.txt を上のフォルダに置いてください。置いた時点で「オブジェクト」と「説明で選択」が使えるようになります。"
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "このビルドではまだモデルをダウンロードできません。手動でインストールするには、Meta の facebook/sam3 の model.safetensors、vocab.json、merges.txt を上のフォルダに置いてください。置いた時点で「オブジェクト」と「説明で選択」が使えるようになります。",
"Open Log Folder": "ログフォルダーを開く"
}
+3 -3
View File
@@ -15,7 +15,7 @@
"Added {n} photo{} to “{}”": "Adicionada {n} foto{:.0} a “{}”",
"Adding photos… {} of {}": "Adicionando fotos… {} de {}",
"All {n} selected photos move by the same amount.": "Todas as {n} fotos selecionadas se movem pela mesma quantidade.",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Já na biblioteca, em Excluídos recentemente: {} foto{:.0} selecionada · clique com o botão direito ▸ Restaurar, ou Excluir permanentemente para importar de novo",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Já na biblioteca, em Excluídos recentemente: {} foto{:.0} selecionada · clique com o botão direito → Restaurar, ou Excluir permanentemente para importar de novo",
"Already in the library: {} photo{} selected in All Photos": "Já na biblioteca: {} foto{:.0} selecionada em Todas as fotos",
"Auto ({})": "Automático ({})",
"Auto Sync: edits apply to {n} photos": "Sincronização automática: edições aplicadas a {n} fotos",
@@ -153,7 +153,7 @@
"Folder “{}” (and its subfolders)": "Pasta “{}” (e suas subpastas)",
"Found {n} missing photo{}; {left} still missing{unsure}": "{n} foto{0} ausente{0} encontrada{0}; {left} ainda ausente(s){unsure}",
"Import failed: {e}": "Falha na importação: {e}",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "{profiles} perfil(is) importado(s){:.0} (Navegador de perfis ▸ seus grupos)",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "{profiles} perfil(is) importado(s){:.0} (Navegador de perfis → seus grupos)",
"Insert at the cursor (also written {})": "Inserir no cursor (também escrito {})",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Fontes: {} (OFL). Ícones: originais.",
"Painting “{k}”: click photos to add or remove it · Esc stops": "Aplicando “{k}”: clique nas fotos para adicionar ou remover · Esc para",
@@ -183,7 +183,7 @@
"{} — show their photos": "{} — mostrar as fotos desta pessoa",
"LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "O LightCraft ainda não consegue decodificar esta variante RAW ({}). Você está editando a prévia JPEG incorporada da câmera, que já inclui o estilo de imagem da câmera (p. ex. Monocromático) e o balanço de branco.",
"Filtered: {label}": "Filtrado: {label}",
"{n} similar photo{} · View ▸ Clear Filters to see all": "{n} foto{0} semelhante{0} · Exibir ▸ Limpar filtros para ver todas",
"{n} similar photo{} · View ▸ Clear Filters to see all": "{n} foto{0} semelhante{0} · Exibir → Limpar filtros para ver todas",
"Moved; {} photo(s) relinked": "Movida; {} foto(s) vinculada(s) novamente",
"{} label (click more labels to show any of them)": "Rótulo {} (clique em mais rótulos para mostrar qualquer um deles)",
", {} star{}": ", {} estrela{}",
+5 -2
View File
@@ -712,7 +712,7 @@
"Label Set": "Conjunto de rótulos",
"Landscape": "Paisagem",
"Language": "Idioma",
"Largest long edge the Detail view renders at; larger is sharper on big displays but slower.": "Maior aresta longa em que a exibição Detalhe é renderizada; maior deixa mais nítida em telas grandes, mas mais lenta.",
"Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "Automático renderiza a exibição Detalhe no tamanho em que é mostrada, até os pixels da própria foto. Escolha um tamanho para limitá-la: menor é mais rápido.",
"Last": "Último",
"Last GPU fallback": "Último fallback de GPU",
"Last commit": "Último commit",
@@ -941,6 +941,7 @@
"Opacity (%)": "Opacidade (%)",
"Open Library": "Abrir biblioteca",
"Open Library…": "Abrir biblioteca…",
"Open Log Folder": "Abrir pasta de logs",
"Open in": "Abrir em",
"Open in Detail": "Abrir em Detalhe",
"Open the place in OpenStreetMap": "Abrir o local no OpenStreetMap",
@@ -1152,7 +1153,7 @@
"Remove from survey": "Remover da inspeção",
"Remove location info": "Remover informações de local",
"Remove shortcut": "Remover atalho",
"Remove this face box (undo with Edit ▸ Undo)": "Remover esta caixa de rosto (desfaça com Editar ▸ Desfazer)",
"Remove this face box (undo with Edit ▸ Undo)": "Remover esta caixa de rosto (desfaça com Editar → Desfazer)",
"Remove this filter": "Remover este filtro",
"Remove this group": "Remover este grupo",
"Remove this rule": "Remover esta regra",
@@ -1245,6 +1246,8 @@
"Scanning": "Examinando",
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "As pontuações ficam nas fotos: filtre ou crie álbuns inteligentes com Foco e Melhor de fotos semelhantes.",
"Screen": "Tela",
"Scroll menu down": "Rolar o menu para baixo",
"Scroll menu up": "Rolar o menu para cima",
"Search": "Pesquisar",
"Search Photos": "Pesquisar fotos",
"Search commands or keys": "Pesquisar comandos ou teclas",
+6 -1
View File
@@ -91,6 +91,7 @@
"Auto-Tag from Tracklog": "Автопривязка по треку",
"Auto-Tag from Tracklog…": "Автопривязка по треку…",
"Automatic": "Автоматически",
"Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "«Автоматически» отрисовывает детальный просмотр в показанном размере, но не больше собственных пикселей фото. Выберите размер, чтобы ограничить его: меньше — быстрее.",
"Automatically Write Changes into XMP": "Автоматически записывать изменения в XMP",
"Automatically write changes into XMP sidecars": "Автоматически записывать изменения в XMP-файлы",
"Autumn Gold": "Осеннее золото",
@@ -203,6 +204,7 @@
"Color Noise Reduction": "Подавление цветового шума",
"Color Priority": "Приоритет цвета",
"Color Range": "Цветовой диапазон",
"Color label cleared": "Цветовая метка снята",
"Color space": "Цветовое пространство",
"Color space: sRGB (AVIF)": "Цветовое пространство: sRGB (AVIF)",
"Combine files…": "Объединить файлы…",
@@ -586,7 +588,6 @@
"Keywords you add appear here; ⌥1–⌥9 apply them.": "Добавленные вами ключевые слова появятся здесь; ⌥1–⌥9 применяют их.",
"Landscape": "Пейзаж",
"Language": "Язык",
"Largest long edge the Detail view renders at; larger is sharper on big displays but slower.": "Максимальная длинная сторона, с которой рисуется режим «Детализация»; больше — чётче на больших экранах, но медленнее.",
"Last": "Последний",
"Last view": "Последний вид",
"Lateral Chromatic Aberration": "Боковая хроматическая аберрация",
@@ -775,6 +776,7 @@
"Opacity (%)": "Непрозрачность (%)",
"Open Library": "Открыть библиотеку",
"Open Library…": "Открыть библиотеку…",
"Open Log Folder": "Открыть папку журналов",
"Open in": "Открыть в",
"Open in Detail": "Открыть в режиме «Детализация»",
"Open the place in OpenStreetMap": "Открыть место в OpenStreetMap",
@@ -1027,6 +1029,8 @@
"Scanning": "Сканирование",
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Оценки остаются на фото: фильтруйте или создавайте смарт-альбомы по Focus и Best of Similar Shots.",
"Screen": "Экран",
"Scroll menu down": "Прокрутить меню вниз",
"Scroll menu up": "Прокрутить меню вверх",
"Search Photos": "Поиск фото",
"Search commands or keys": "Поиск команд или клавиш",
"Search…": "Поиск…",
@@ -1116,6 +1120,7 @@
"Skip": "Пропустить",
"Sky": "Небо",
"Slideshow": "Слайд-шоу",
"Slideshow · Space pauses · Esc ends": "Слайд-шоу · Пробел — пауза · Esc — выход",
"Smart Album": "Смарт-альбом",
"Smart Album Rule Fields": "Поля условий смарт-альбома",
"Smart Preview Status": "Состояние смарт-просмотров",
+3 -3
View File
@@ -111,7 +111,7 @@
"Adding photos… {} of {}": "正在添加照片… 第 {0} 张,共 {1} 张",
" ({} in Recently Deleted)": "(其中 {0} 张在“最近删除”中)",
"Already in the library: {} photo{} selected in All Photos": "已在图库中:已在“所有照片”中选择 {0} 张照片{1:.0}",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在图库的“最近删除”中:已选择 {0} 张照片{1:.0} · 右键单击 ▸ 恢复,或永久删除后重新导入",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在图库的“最近删除”中:已选择 {0} 张照片{1:.0} · 右键单击 → 恢复,或永久删除后重新导入",
"Auto ({})": "自动({})",
"Build 1:1 Previews ({scope})": "生成 1:1 预览({scope})",
"Build Smart Previews ({scope})": "生成智能预览({scope})",
@@ -153,7 +153,7 @@
"Folder “{}” (and its subfolders)": "文件夹“{0}”(含子文件夹)",
"Found {n} missing photo{}; {left} still missing{unsure}": "已找到 {n} 张缺失的照片{0:.0};仍有 {left} 张缺失{unsure}",
"Import failed: {e}": "导入失败:{e}",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "已导入 {profiles} 个配置文件{0:.0}(配置文件浏览器 ▸ 各自的分组)",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "已导入 {profiles} 个配置文件{0:.0}(配置文件浏览器 → 各自的分组)",
"Insert at the cursor (also written {})": "在光标处插入(也可写作 {0})",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0。字体:{0}(OFL)。图标:原创。",
"Painting “{k}”: click photos to add or remove it · Esc stops": "正在应用“{k}”:单击照片以添加或移除 · 按 Esc 停止",
@@ -183,7 +183,7 @@
"{} — show their photos": "{0} — 显示其照片",
"LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft 暂时无法解码这种 RAW 变体({0})。你正在编辑相机内嵌的 JPEG 预览,其中已包含相机的照片风格(如单色)和白平衡。",
"Filtered: {label}": "已筛选:{label}",
"{n} similar photo{} · View ▸ Clear Filters to see all": "{n} 张相似照片{0:.0} · 选择“视图 ▸ 清除过滤器”可查看全部",
"{n} similar photo{} · View ▸ Clear Filters to see all": "{n} 张相似照片{0:.0} · 选择“视图 → 清除过滤器”可查看全部",
"Moved; {} photo(s) relinked": "已移动;已重新链接 {0} 张照片",
"{} label (click more labels to show any of them)": "{0}标签(单击更多标签可显示符合其中任一标签的照片)",
", {} star{}": ",{0} 星{1:.0}",
+6 -3
View File
@@ -630,7 +630,7 @@
"Remove a filter above, or choose Clear all": "请移除上方的某个筛选条件,或选择“全部清除”",
"Remove from Target Album": "从目标相册中移除",
"Remove shortcut": "移除快捷键",
"Remove this face box (undo with Edit ▸ Undo)": "移除此人脸框(可通过“编辑 ▸ 撤销”恢复)",
"Remove this face box (undo with Edit ▸ Undo)": "移除此人脸框(可通过“编辑 → 撤销”恢复)",
"Rename Component": "重命名组件",
"Reorder Masks": "重新排序蒙版",
"Reset All": "全部重置",
@@ -809,7 +809,7 @@
"Keywords you add appear here; ⌥1–⌥9 apply them.": "您添加的关键字会显示在此处;按 ⌥1–⌥9 即可应用。",
"Landscape": "横向",
"Language": "语言",
"Largest long edge the Detail view renders at; larger is sharper on big displays but slower.": "详细视图渲染的最大长边;在大显示器上越大越清晰,但速度更慢。",
"Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "“自动”按详细视图的显示大小渲染(最高为照片自身像素)。选择一个大小可设上限:越小越快。",
"Last": "上次",
"Last view": "上次视图",
"Lateral Chromatic Aberration": "横向色差",
@@ -1233,6 +1233,8 @@
"Scanning": "正在扫描",
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "评分会保留在照片上:可使用“对焦”和“相似照片中的最佳”进行筛选或创建智能相册。",
"Screen": "屏幕",
"Scroll menu down": "向下滚动菜单",
"Scroll menu up": "向上滚动菜单",
"Search": "搜索",
"Search Photos": "搜索照片",
"Search commands or keys": "搜索命令或按键",
@@ -1639,5 +1641,6 @@
"That includes the photos in the folders inside it.": "其中文件夹里的照片也包括在内。",
"How to install the model": "如何安装模型",
"Install the SAM 3 Model": "安装 SAM 3 模型",
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚无法下载模型。如需手动安装,请将 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的文件夹:放入后即可使用“对象”和“描述”。"
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚无法下载模型。如需手动安装,请将 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的文件夹:放入后即可使用“对象”和“描述”。",
"Open Log Folder": "打开日志文件夹"
}
+3 -3
View File
@@ -132,7 +132,7 @@
"… and {more} more": "…以及另外 {more} 個",
" ({} in Recently Deleted)": "(其中 {} 張在「最近刪除」中)",
"Already in the library: {} photo{} selected in All Photos": "已在照片庫中:已在「所有照片」中選取 {} 張照片{:.0}",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在照片庫的「最近刪除」中:已選取 {} 張照片{:.0} · 按右鍵 ▸ 還原,或永久刪除後重新匯入",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在照片庫的「最近刪除」中:已選取 {} 張照片{:.0} · 按右鍵 → 還原,或永久刪除後重新匯入",
"Remove “{name}” and its {count} photo{} from the library?": "要將「{name}」及其 {count} 張照片從照片庫移除嗎?{:.0}",
" ({unsure} with several look-alike files: use Locate)": "(其中 {unsure} 張有多個相似檔案:請使用「尋找」)",
" · {:.0}% clipped": " · {:.0}% 剪裁",
@@ -153,7 +153,7 @@
"Folder “{}” (and its subfolders)": "資料夾「{}」(含子資料夾)",
"Found {n} missing photo{}; {left} still missing{unsure}": "已找到 {n} 張遺失的照片{:.0};仍有 {left} 張遺失{unsure}",
"Import failed: {e}": "匯入失敗:{e}",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "已匯入 {profiles} 個描述檔{:.0}(描述檔瀏覽器 ▸ 各自的群組)",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "已匯入 {profiles} 個描述檔{:.0}(描述檔瀏覽器 → 各自的群組)",
"Insert at the cursor (also written {})": "在游標處插入(也可寫成 {})",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0。字型:{}(OFL)。圖示:原創。",
"Painting “{k}”: click photos to add or remove it · Esc stops": "正在套用「{k}」:點擊照片即可加入或移除 · 按 Esc 停止",
@@ -183,7 +183,7 @@
"{} — show their photos": "{} — 顯示其照片",
"LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft 目前還無法解碼這種 RAW 變體({})。你正在編輯相機內嵌的 JPEG 預覽,其中已包含相機的相片風格(例如單色)與白平衡。",
"Filtered: {label}": "已篩選:{label}",
"{n} similar photo{} · View ▸ Clear Filters to see all": "{n} 張相似照片{:.0} · 選擇「檢視 ▸ 清除篩選條件」可查看全部",
"{n} similar photo{} · View ▸ Clear Filters to see all": "{n} 張相似照片{:.0} · 選擇「檢視 → 清除篩選條件」可查看全部",
"Moved; {} photo(s) relinked": "已移動;已重新連結 {} 張照片",
"{} label (click more labels to show any of them)": "{}標籤(點擊更多標籤可顯示符合其中任一標籤的照片)",
", {} star{}": ",{} 顆星{:.0}",
+6 -3
View File
@@ -630,7 +630,7 @@
"Remove a filter above, or choose Clear all": "請移除上方的某個篩選條件,或選擇「全部清除」",
"Remove from Target Album": "從目標相簿移除",
"Remove shortcut": "移除快速鍵",
"Remove this face box (undo with Edit ▸ Undo)": "移除此臉部框(可用「編輯 ▸ 復原」還原)",
"Remove this face box (undo with Edit ▸ Undo)": "移除此臉部框(可用「編輯 → 復原」還原)",
"Rename Component": "重新命名元件",
"Reorder Masks": "重新排序遮色片",
"Reset All": "全部重設",
@@ -809,7 +809,7 @@
"Keywords you add appear here; ⌥1–⌥9 apply them.": "新增的關鍵字會顯示於此;⌥1–⌥9 可套用。",
"Landscape": "風景",
"Language": "語言",
"Largest long edge the Detail view renders at; larger is sharper on big displays but slower.": "詳細檢視預覽的最長邊尺寸;較大的尺寸在大螢幕上更清晰,但速度較慢。",
"Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "「自動」會依詳細檢視的顯示大小來渲染(最高為相片本身的像素)。選擇尺寸可設定上限:越小越快。",
"Last": "最後",
"Last view": "上次檢視",
"Lateral Chromatic Aberration": "橫向色差",
@@ -1233,6 +1233,8 @@
"Scanning": "正在掃描",
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "分數會保留在照片上:可依焦點與相似照片中的最佳照片篩選,或建立智慧型相簿。",
"Screen": "螢幕",
"Scroll menu down": "向下捲動選單",
"Scroll menu up": "向上捲動選單",
"Search": "搜尋",
"Search Photos": "搜尋照片",
"Search commands or keys": "搜尋命令或按鍵",
@@ -1639,5 +1641,6 @@
"That includes the photos in the folders inside it.": "其中資料夾裡的照片也包含在內。",
"How to install the model": "如何安裝模型",
"Install the SAM 3 Model": "安裝 SAM 3 模型",
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚無法下載模型。如需手動安裝,請將 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的資料夾:放入後即可使用「物件」與「描述」。"
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚無法下載模型。如需手動安裝,請將 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的資料夾:放入後即可使用「物件」與「描述」。",
"Open Log Folder": "開啟記錄資料夾"
}
+11 -1
View File
@@ -92,7 +92,15 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
"activeMask": app.session.active_mask,
"widgetCount": app.widgets.len(),
"perf": {"frameMs": app.perf.frame_ms, "logicMs": app.perf.logic_ms, "updateMs": app.perf.update_ms, "maxUpdateMs": app.perf.max_update_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "pendingSlots": app.renderer.pending_slots(), "mergeRunning": app.merge.busy(), "lastMerge": app.merge.last_result, "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "variantTextures": app.renderer.variant_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten(), "gpuReason": lightcraft_engine::gpu::unavailable_reason(), "gpuFallback": lightcraft_engine::gpu::last_fallback()},
"loupe": app.loupe_shown.map(|(p, src)| json!({"photo": p.0, "source": src, "pending": app.renderer.is_pending(crate::render::Slot::Main)})),
"loupe": app.loupe_shown.map(|(p, src)| json!({
"photo": p.0,
"source": src,
"pending": app.renderer.is_pending(crate::render::Slot::Main),
// zoomed past the whole-frame render: the window rendered at no more than 100 % (pixels of its frame)
"region": app.region_view.map(|r| json!({"full": [r.full.0, r.full.1], "window": [r.window.x, r.window.y, r.window.w, r.window.h], "pending": app.renderer.is_pending(crate::render::Slot::Region)})),
// …and the same for the Before side of a Before/After view
"regionBefore": app.region_before_view.map(|r| json!({"full": [r.full.0, r.full.1], "window": [r.window.x, r.window.y, r.window.w, r.window.h], "pending": app.renderer.is_pending(crate::render::Slot::RegionBefore)})),
})),
"hoverPreview": app.hover_preview.as_ref().map(|h| h.label.clone()),
"status": app.ui.status,
"notices": app.notices,
@@ -103,6 +111,8 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
"export": {"running": app.export.as_ref().map(crate::export_task::ExportTask::status), "last": app.last_export_result},
"import": app.import.as_ref().map(crate::import::ImportTask::status),
"tasks": app.tasks.labels(),
// commands waiting on a native file dialog shown off the UI thread (`pick`)
"fileDialogs": app.pending_picks.iter().map(|p| p.command.clone()).collect::<Vec<_>>(),
"memory": memory(app),
})
}
+20 -4
View File
@@ -32,7 +32,7 @@ const FRAME_DT: f64 = 1.0 / 60.0;
pub struct HeadlessView {
pub ctx: egui::Context,
pub textures: TextureStore,
shapes: Vec<egui::epaint::ClippedShape>,
pub(crate) shapes: Vec<egui::epaint::ClippedShape>,
pixels_per_point: f32,
size: egui::Vec2,
frames: u64,
@@ -107,14 +107,20 @@ impl HeadlessView {
pub struct Headless {
pub app: LightcraftApp,
pub view: HeadlessView,
/// The largest texture the pretend GPU takes (what a WebGL device may report: 2048).
pub max_texture_side: usize,
/// Logical size (points) and scale.
pub size: egui::Vec2,
pub pixels_per_point: f32,
time: f64,
frames: u64,
events: Vec<egui::Event>,
pub(crate) events: Vec<egui::Event>,
control: Sender<ControlRequest>,
quit: bool,
/// The simulated window is zoomed (`Maximized(true)` seen, reported back to the app like a real host).
pub window_maximized: bool,
/// Window-management commands the app sent (`StartDrag`, `Maximized`, …), oldest first.
pub window_commands: Vec<ViewportCommand>,
}
impl Headless {
@@ -126,6 +132,7 @@ impl Headless {
Headless {
app,
view: HeadlessView::new(),
max_texture_side: 16384,
size: egui::vec2(size[0], size[1]),
pixels_per_point,
time: 0.0,
@@ -133,6 +140,8 @@ impl Headless {
events: vec![],
control: tx,
quit: false,
window_maximized: false,
window_commands: vec![],
}
}
@@ -148,6 +157,8 @@ impl Headless {
/// Run one frame.
pub fn step(&mut self) {
let mut raw = HeadlessView::raw_input(self.size, self.pixels_per_point, self.time, std::mem::take(&mut self.events));
raw.viewports.entry(ViewportId::ROOT).or_default().maximized = Some(self.window_maximized);
raw.max_texture_side = Some(self.max_texture_side);
self.app.raw_input_hook(&mut raw);
let app = &mut self.app;
let commands = self.view.run(raw, |ui| {
@@ -164,6 +175,11 @@ impl Headless {
}
ViewportCommand::InnerSize(s) if s.x >= 1.0 && s.y >= 1.0 => self.size = s,
ViewportCommand::Close => self.quit = true,
ViewportCommand::Maximized(on) => {
self.window_maximized = on;
self.window_commands.push(c);
}
ViewportCommand::StartDrag => self.window_commands.push(c),
_ => {}
}
}
@@ -1882,8 +1898,8 @@ mod tests {
h.request("ui.clickWidget", json!({"id": "button:settingsTab-performance"}), t);
h.request("ui.clickWidget", json!({"id": "button:settingsCache-0"}), t);
assert_eq!(h.app.session.cache_mb, 512);
h.request("ui.clickWidget", json!({"id": "button:settingsPreview-2"}), t);
assert_eq!(h.app.ui.settings.preview_edge, 3840);
h.request("ui.clickWidget", json!({"id": "button:settingsPreview-3"}), t);
assert_eq!(h.app.ui.settings.preview_limit, 3840);
h.request("ui.clickWidget", json!({"id": "button:settingsMemory-2"}), t);
assert_eq!(h.app.ui.settings.memory_mb, 1024);
h.step();
+15
View File
@@ -566,6 +566,21 @@ mod tests {
});
}
#[test]
fn translated_catalogs_do_not_use_missing_menu_triangles() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales");
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.extension().is_none_or(|ext| ext != "json") {
continue;
}
let messages: BTreeMap<String, String> = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
for (source, text) in messages {
assert!(!text.contains(['▸', '▾']), "{}: {source:?} uses an unpaintable menu triangle", path.display());
}
}
}
/// The language menu covers every language, and a language's own command selects it.
#[test]
fn language_commands_cover_every_language() {
+38
View File
@@ -15,16 +15,20 @@ pub mod icons;
pub mod import;
pub mod lightroom_import;
pub mod links;
pub mod menu_level;
pub mod menubar;
pub mod menus;
pub mod merge;
pub mod panels;
pub mod pick;
pub mod region;
pub mod render;
pub mod shortcuts;
pub mod softpaint;
pub mod state;
pub mod tasks;
pub mod theme;
pub mod titlebar;
pub mod widgets;
#[cfg(test)]
@@ -44,17 +48,25 @@ mod tests_masking;
#[cfg(test)]
mod tests_masking_layout;
#[cfg(test)]
mod tests_menubar;
#[cfg(test)]
mod tests_offline;
#[cfg(test)]
mod tests_panels;
#[cfg(test)]
mod tests_preview_limit;
#[cfg(test)]
mod tests_quit_unsaved;
#[cfg(test)]
mod tests_scroll;
#[cfg(test)]
mod tests_switch_library;
#[cfg(test)]
mod tests_titlebar;
#[cfg(test)]
mod tests_unsaved;
#[cfg(test)]
mod tests_zoom;
use std::sync::mpsc::{Receiver, Sender};
@@ -88,6 +100,10 @@ pub type HostAction = Box<dyn FnMut(&mut Session) -> Result<Value, String>>;
/// Platform services injected by the host app (desktop or web).
#[derive(Default)]
pub struct Services {
/// Native file dialogs for commands, shown off the UI thread and answered later
/// (`pick`; #191). With it, the synchronous pickers below serve only the buttons inside
/// dialogs; without it (the web, tests) they answer the commands too.
pub picker: Option<pick::Picker>,
/// Show an open dialog for photos; returns paths.
pub pick_files: Option<PickFiles>,
/// Open dialog for preset files (`.lcpreset`, `.xmp`, `.lrtemplate`, `.zip`, `.dng`, Luminar `.lmp` / `.mplumpack`).
@@ -107,6 +123,9 @@ pub struct Services {
pub png: Option<PngEncode>,
/// Show a file in the system file manager (desktop only).
pub reveal: Option<RevealFn>,
/// The host's current log file (`<settings>/logs/lightcraft.log`), which Help ▸ Open Log
/// Folder reveals (#260). None where no log is kept: the web, `--memory`, `LIGHTCRAFT_NO_PREFS`.
pub log_file: Option<String>,
/// Choose a folder (Settings → General → Open Library…; desktop only).
pub pick_folder: Option<PickFolder>,
/// Open a Lightroom Classic `.lrcat` catalog for read-only import.
@@ -171,6 +190,8 @@ pub struct LightcraftApp {
shadow: Option<headless::HeadlessView>,
/// Synthetic input events (from the control channel) injected one step per frame.
pub synthetic: Vec<egui::Event>,
/// Native file dialogs up for commands (`pick`): each command runs again when its closes.
pub(crate) pending_picks: Vec<pick::Pending>,
/// Modifiers announced for synthetic input (held from a button down to its release).
synthetic_mods: egui::Modifiers,
/// Clear `synthetic_mods` on the next frame.
@@ -195,6 +216,16 @@ pub struct LightcraftApp {
/// What the loupe drew last frame: photo and source ("render", "cached", "embedded", "small",
/// "thumb", "none").
pub loupe_shown: Option<(lightcraft_catalog::PhotoId, &'static str)>,
/// The window render the loupe asked for last, by job key (see [`region`]); kept for the few
/// windows whose textures can be on screen, and for the inspector.
pub region_view: Option<region::RegionView>,
/// The same for the Before side of a Before/After view.
pub region_before_view: Option<region::RegionView>,
/// The loupe's render sizes while a pinch or two-finger scroll runs.
pub(crate) size_hold: region::SizeHold,
/// (photo, look, window frame size) a window was refused for: it reads more than one render holds.
pub(crate) window_refused: Option<(lightcraft_catalog::PhotoId, u64, usize)>,
pub(crate) region_tiles: std::collections::HashMap<(bool, u64), region::RegionView>,
/// Photo Merge dialog previews and background merges.
pub merge: merge::MergeState,
/// An import in progress (the import review dialog's batches).
@@ -249,6 +280,7 @@ impl LightcraftApp {
screenshot_token: 0,
shadow: None,
synthetic: vec![],
pending_picks: vec![],
synthetic_mods: egui::Modifiers::NONE,
synthetic_mods_release: false,
styled: false,
@@ -262,6 +294,11 @@ impl LightcraftApp {
widgets: vec![],
gesture: None,
loupe_shown: None,
region_view: None,
region_before_view: None,
size_hold: Default::default(),
window_refused: None,
region_tiles: Default::default(),
merge: merge::MergeState::default(),
import: None,
scan: None,
@@ -873,6 +910,7 @@ impl LightcraftApp {
import::scan_progress(self, &ctx);
lightroom_import::progress(self, &ctx);
export_task::poll(self, &ctx);
pick::poll(self, &ctx);
panels::grid::drag_feedback(self, &ctx);
panels::toast(self, &ctx);
self.widgets = widgets::take_registry(&ctx);
+179
View File
@@ -0,0 +1,179 @@
//! Menus bounded by the window (#189). egui keeps a popup inside the window: a menu or submenu
//! taller than the room below the menu bar was slid up over the bar, which hid the menu titles,
//! and the pointer that had rested on the title then rested on the menu's first row and opened
//! its submenu by itself. Here every level of a menu gets only the room it has below the bar (a
//! submenu: from its row down, or up to the bar when egui opens it upward) and scrolls inside
//! it: wheel, dragging, and ▲ / ▼ strips that scroll while the pointer rests on them, like
//! native menus on a small display. The same approach as PhotoCraft's menu bar (its #319).
use egui::{Id, Rect, Sense, Ui, pos2, vec2};
/// Height of a scroll-arrow strip.
pub const ARROW: f32 = 16.0;
/// Gap kept between a menu and the window edge.
const EDGE: f32 = 6.0;
/// How fast resting on a scroll arrow scrolls, in points per second.
const SPEED: f32 = 600.0;
fn anchor_id(depth: usize) -> Id {
Id::new(("lc-menu-anchor", depth))
}
/// Remember the row the submenu at `depth` (2 = a top-level menu's submenu) hangs from. It is
/// read on the next frame: a submenu is drawn while its row is, before the row's rect is known.
pub fn set_anchor(ctx: &egui::Context, depth: usize, row: Rect) {
let pass = ctx.cumulative_pass_nr();
ctx.data_mut(|d| d.insert_temp(anchor_id(depth), (row, pass)));
}
/// The row the submenu at `depth` hung from on the previous frame (none when it has just opened,
/// or when a stored row belongs to a submenu that has closed since).
fn anchor(ctx: &egui::Context, depth: usize) -> Option<Rect> {
anchor_at(ctx.data(|d| d.get_temp(anchor_id(depth))), ctx.cumulative_pass_nr())
}
/// The anchor `stored` as `(row, pass it was set on)`, if that was the pass before `pass`.
fn anchor_at(stored: Option<(Rect, u64)>, pass: u64) -> Option<Rect> {
stored.filter(|(_, at)| at.saturating_add(1) == pass).map(|(row, _)| row)
}
/// The height a menu level's rows may take. Every level stays below the menu bar: a top-level
/// menu hangs from it, and a submenu that is too tall for the window between its row and the
/// bottom, or (opening upward, as egui does when that fits) between the bar and its row, scrolls
/// instead of egui sliding it up over the bar. `anchor` is the submenu's row (unknown on its
/// first frame), `rows` the level's rows' height when known (the popup shrinks to it), and
/// `frame` the popup's margins.
pub fn level_room(screen: Rect, bar_bottom: Option<f32>, depth: usize, anchor: Option<Rect>, rows: Option<f32>, frame: f32) -> f32 {
let top = bar_bottom.unwrap_or(screen.top()).max(screen.top()) + EDGE;
let bottom = screen.bottom() - EDGE;
let below_bar = bottom - top;
let room = match anchor.filter(|_| depth > 1) {
Some(row) => {
// Downward from the row, or upward from it, whichever leaves more room. egui opens a
// submenu upward only when the downward one doesn't fit the window (it hangs from the
// row's top less half the frame margin).
let (down, up) = (bottom - row.top(), row.bottom() - top);
let opens_up = |h: f32| row.top() - (frame - 2.0) / 2.0 + h + frame > screen.bottom();
let want = rows.unwrap_or(f32::INFINITY);
if want > down && up > down && opens_up(want.min(up)) { up } else { down }.min(below_bar)
}
None => below_bar,
} - frame;
if room.is_finite() { room.max(4.0 * ARROW) } else { 4.0 * ARROW }
}
/// Is the pointer over `rect` on this ui's layer (not under a submenu drawn on top)?
fn pointer_on(ui: &Ui, rect: Rect) -> bool {
ui.ctx().pointer_hover_pos().is_some_and(|p| rect.contains(p) && ui.ctx().layer_id_at(p) == Some(ui.layer_id()))
}
/// Draw one level of a menu, bounded by the window: when its rows don't fit the room below the
/// menu bar they scroll, with scroll arrows at the top and bottom. `depth` is 1 for a top-level
/// menu; `bar_bottom` is where the menu bar ends. The level's rect is registered as
/// `menu-level:<depth>` for the control channel and tests.
pub fn level(ui: &mut Ui, depth: usize, bar_bottom: Option<f32>, rows: impl FnOnce(&mut Ui)) {
let ctx = ui.ctx().clone();
let screen = ctx.content_rect();
// The menu frame's margin and stroke around the rows.
let frame = ui.spacing().menu_margin.sum().y + 2.0;
let key = ui.id().with(("lc-menu-level", depth));
// Rows' height from the last frame: does this level overflow?
let content: Option<f32> = ctx.data(|d| d.get_temp(key));
let room = level_room(screen, bar_bottom, depth, anchor(&ctx, depth), content, frame);
let over = content.is_some_and(|h| h > room + 0.5);
let up = over.then(|| ui.allocate_exact_size(vec2(0.0, ARROW), Sense::hover()).0);
let height = if over { room - 2.0 * ARROW } else { room };
// The popup's `Ui` is only as tall as the popup was last frame (egui's default area size on
// the first), and a scroll area never grows past that. Ask for the room the window has;
// auto-shrink then fits the area to its rows, so a menu scrolls only when they don't fit.
let out = egui::ScrollArea::vertical()
.id_salt(("menu-level", depth))
.max_height(height)
.min_scrolled_height(height)
.scroll_source(egui::containers::scroll_area::ScrollSource::ALL)
.show(ui, rows);
let down = over.then(|| ui.allocate_exact_size(vec2(0.0, ARROW), Sense::hover()).0);
ctx.data_mut(|d| d.insert_temp(key, out.content_size.y));
crate::widgets::register(&ctx, format!("menu-level:{depth}"), ui.min_rect());
let (Some(up), Some(down)) = (up, down) else { return };
let t = crate::theme::Tokens::get(&ctx);
let x = ui.min_rect().x_range();
let max = (out.content_size.y - out.inner_rect.height()).max(0.0);
let offset = out.state.offset.y;
let dt = ctx.input(|i| i.stable_dt).clamp(0.0, 0.1);
let mut target = offset;
for (strip, dir, live) in [(up, -1.0, offset > 0.5), (down, 1.0, offset < max - 0.5)] {
let strip = Rect::from_x_y_ranges(x, strip.y_range());
let c = strip.center();
let (h, w) = (4.5, 6.0);
let tri = if dir < 0.0 {
vec![pos2(c.x - w, c.y + h * 0.5), pos2(c.x + w, c.y + h * 0.5), pos2(c.x, c.y - h * 0.5)]
} else {
vec![pos2(c.x - w, c.y - h * 0.5), pos2(c.x + w, c.y - h * 0.5), pos2(c.x, c.y + h * 0.5)]
};
ui.painter().add(egui::Shape::convex_polygon(tri, if live { t.text } else { t.text_disabled }, egui::Stroke::NONE));
let id = key.with(dir < 0.0);
let r = ui.interact(strip, id, Sense::hover());
r.widget_info(|| {
egui::WidgetInfo::labeled(egui::WidgetType::Button, live, crate::i18n::tr(if dir < 0.0 { "Scroll menu up" } else { "Scroll menu down" }))
});
if live && pointer_on(ui, strip) {
target = (offset + dir * SPEED * dt.max(1.0 / 120.0)).clamp(0.0, max);
ctx.request_repaint();
}
// The wheel scrolls over the arrows too.
if pointer_on(ui, strip) {
let wheel = ctx.input(|i| i.smooth_scroll_delta.y);
if wheel != 0.0 {
target = (target - wheel).clamp(0.0, max);
}
}
}
if target != offset {
let mut st = out.state;
st.offset.y = target;
st.store(&ctx, out.id);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn level_room_keeps_every_level_below_the_bar() {
let screen = Rect::from_min_size(egui::Pos2::ZERO, vec2(1280.0, 703.0));
let (bar, frame) = (Some(42.0), 14.0);
let below = 703.0 - EDGE - (42.0 + EDGE) - frame;
assert_eq!(level_room(screen, bar, 1, None, None, frame), below);
// A submenu from a row near the top opens downward with the room under it…
let row = Rect::from_min_size(pos2(100.0, 80.0), vec2(200.0, 30.0));
assert_eq!(level_room(screen, bar, 2, Some(row), None, frame), 703.0 - EDGE - 80.0 - frame);
// …and from a row near the bottom, upward to the bar.
let low = Rect::from_min_size(pos2(100.0, 600.0), vec2(200.0, 30.0));
assert_eq!(level_room(screen, bar, 2, Some(low), None, frame), 630.0 - (42.0 + EDGE) - frame);
// A short submenu from a low row still fits downward: it keeps that room.
assert_eq!(level_room(screen, bar, 2, Some(low), Some(50.0), frame), 703.0 - EDGE - 600.0 - frame);
// Never more than the space under the bar, never less than the scroll arrows need.
let odd = Rect::from_min_size(pos2(100.0, -50.0), vec2(200.0, 30.0));
assert!(level_room(screen, bar, 2, Some(odd), None, frame) <= below);
assert_eq!(level_room(Rect::from_min_size(egui::Pos2::ZERO, vec2(100.0, 40.0)), bar, 1, None, None, frame), 4.0 * ARROW);
assert_eq!(level_room(screen, Some(f32::NAN), 1, None, None, frame), level_room(screen, None, 1, None, None, frame));
assert_eq!(level_room(Rect::NOTHING, bar, 2, Some(row), None, frame), 4.0 * ARROW);
// A top-level menu ignores a stale anchor.
assert_eq!(level_room(screen, bar, 1, Some(low), None, frame), below);
}
#[test]
fn an_anchor_is_read_on_the_next_frame_only() {
let row = Rect::from_min_size(pos2(10.0, 20.0), vec2(200.0, 24.0));
// set on pass 7: the submenu's room is measured from it on pass 8…
assert_eq!(anchor_at(Some((row, 7)), 8), Some(row));
// …not on the pass it was set (the row is drawn after its submenu), nor once the
// submenu has closed and stopped refreshing it
assert_eq!(anchor_at(Some((row, 7)), 7), None);
assert_eq!(anchor_at(Some((row, 7)), 9), None);
assert_eq!(anchor_at(None, 8), None);
assert_eq!(anchor_at(Some((row, u64::MAX)), 0), None);
}
}
+58 -7
View File
@@ -231,6 +231,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
"app.whatsNew",
"app.shortcuts",
"app.systemInfo",
"app.openLogFolder",
"---",
"app.about",
],
@@ -360,7 +361,7 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
vec![
item(
"library.buildPreviews",
json!({"size": "standard", "edge": app.ui.settings.preview_edge}),
json!({"size": "standard", "edge": app.ui.settings.standard_preview_edge()}),
crate::i18n::tr_format!("Build Standard-Sized Previews ({scope})", scope = crate::i18n::tr(scope)),
None,
!running,
@@ -746,22 +747,27 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32
let mut clicked: Option<(String, Value)> = None;
let start = ui.cursor().left();
let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac;
// Menus hang below the whole top bar and never grow past the window (#189): a tall menu
// scrolls instead of egui sliding it up over the titles.
let bar_bottom = Some(ui.max_rect().bottom());
if total <= max_width {
let saved = ui.spacing().item_spacing.x;
ui.spacing_mut().item_spacing.x = TITLE_GAP;
for (title, items) in &bar {
let r = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr(title)).font(font.clone()).color(t.text_label)).frame(false));
crate::widgets::register(ui.ctx(), format!("menu:{title}"), r.rect);
egui::Popup::menu(&r).show(|ui| nodes_ui(ui, items, mac, &mut clicked));
egui::Popup::menu(&r).show(|ui| crate::menu_level::level(ui, 1, bar_bottom, |ui| nodes_ui(ui, items, mac, &mut clicked, 1, bar_bottom)));
}
ui.spacing_mut().item_spacing.x = saved;
} else {
let r = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr("Menu")).font(font.clone()).color(t.text_label)).frame(false));
crate::widgets::register(ui.ctx(), "menu:all", r.rect);
egui::Popup::menu(&r).show(|ui| {
for (title, items) in &bar {
ui.menu_button(crate::i18n::tr(title), |ui| nodes_ui(ui, items, mac, &mut clicked));
}
crate::menu_level::level(ui, 1, bar_bottom, |ui| {
for (title, items) in &bar {
submenu(ui, title, crate::i18n::tr(title).to_string(), 1, bar_bottom, |ui| nodes_ui(ui, items, mac, &mut clicked, 2, bar_bottom));
}
});
});
}
if let Some((id, params)) = clicked {
@@ -776,7 +782,19 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32
ui.cursor().left() - start
}
fn nodes_ui(ui: &mut egui::Ui, nodes: &[MenuNode], mac: bool, clicked: &mut Option<(String, Value)>) {
/// A submenu row showing `text` at `depth`, whose rows `children` draws one level deeper,
/// bounded like every level. The row is registered as `menusub:<label>` (the untranslated
/// label), and while its submenu is open it is the anchor the next frame's room is measured from.
fn submenu(ui: &mut egui::Ui, label: &str, text: String, depth: usize, bar_bottom: Option<f32>, children: impl FnOnce(&mut egui::Ui)) {
let r = ui.menu_button(text, |ui| crate::menu_level::level(ui, depth + 1, bar_bottom, children));
crate::widgets::register(ui.ctx(), format!("menusub:{label}"), r.response.rect);
if r.inner.is_some() {
crate::menu_level::set_anchor(ui.ctx(), depth + 1, r.response.rect);
}
}
/// `depth` is 1 for a top-level menu's rows; `bar_bottom` is where the menu bar ends.
fn nodes_ui(ui: &mut egui::Ui, nodes: &[MenuNode], mac: bool, clicked: &mut Option<(String, Value)>, depth: usize, bar_bottom: Option<f32>) {
ui.set_min_width(220.0);
for n in nodes {
match n {
@@ -784,7 +802,9 @@ fn nodes_ui(ui: &mut egui::Ui, nodes: &[MenuNode], mac: bool, clicked: &mut Opti
ui.separator();
}
MenuNode::Submenu { label, children } => {
ui.menu_button(format!(" {}", crate::i18n::tr(label)), |ui| nodes_ui(ui, children, mac, clicked));
submenu(ui, label, format!(" {}", crate::i18n::tr(label)), depth, bar_bottom, |ui| {
nodes_ui(ui, children, mac, clicked, depth + 1, bar_bottom)
});
}
MenuNode::Item { id, params, label, shortcut, enabled, checked } => {
// a gutter for check marks, like native menus
@@ -1067,6 +1087,37 @@ mod tests {
assert!(run_item(&mut app, "merge.hdrLast", Value::Null).is_err());
}
/// #260: Help ▸ Open Log Folder reveals the log file the host names. Without one (the web,
/// `--memory`) or without a file manager to show it, the item is off and the command says why.
#[test]
fn open_log_folder_reveals_the_hosts_log_file() {
let mut app = app();
let help = |app: &LightcraftApp| menu_bar(app).into_iter().find(|(t, _)| t == "Help").map(|(_, items)| items).unwrap_or_default();
assert!(matches!(find(&help(&app), "app.openLogFolder"), Some(MenuNode::Item { enabled: false, .. })), "listed in Help, off without a log");
assert!(!crate::menus::ui_enabled(&app, "app.openLogFolder"));
assert!(run_item(&mut app, "app.openLogFolder", Value::Null).is_err());
let shown = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let s = shown.clone();
app.services.reveal = Some(Box::new(move |p: &str| {
s.lock().unwrap().push(p.to_string());
Ok(())
}));
// a file manager alone is not enough: this session keeps no log
assert!(!crate::menus::ui_enabled(&app, "app.openLogFolder"));
assert!(run_item(&mut app, "app.openLogFolder", Value::Null).is_err());
assert!(shown.lock().unwrap().is_empty());
let log = "/home/a/.config/lightcraft/logs/lightcraft.log";
app.services.log_file = Some(log.into());
assert!(crate::menus::ui_enabled(&app, "app.openLogFolder"));
assert!(matches!(find(&help(&app), "app.openLogFolder"), Some(MenuNode::Item { enabled: true, .. })));
let r = run_item(&mut app, "app.openLogFolder", Value::Null).unwrap();
assert_eq!(r["path"], log);
assert_eq!(shown.lock().unwrap().as_slice(), [log]);
// the file manager's failure is the command's
app.services.reveal = Some(Box::new(|_: &str| Err("no file manager".into())));
assert_eq!(run_item(&mut app, "app.openLogFolder", Value::Null).unwrap_err(), "no file manager");
}
#[test]
fn find_missing_and_locate() {
let dir = std::env::temp_dir().join(format!("lc-ui-missing-{}", std::process::id()));
+109 -23
View File
@@ -5,6 +5,7 @@ use serde::Serialize;
use serde_json::{Value, json};
use crate::LightcraftApp;
use crate::pick::{PickRequest, Picked};
use crate::state::{BeforeAfter, Dialog, RightPanel, ViewMode, Zoom};
/// (id, label, shortcut, menu path)
@@ -167,6 +168,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("app.openLibrary", "Open Library…", None, "File"),
("app.about", "About LightCraft", None, "Help"),
("app.systemInfo", "System Info…", None, "Help"),
("app.openLogFolder", "Open Log Folder", None, "Help"),
("app.whatsNew", "What's New", None, "Help"),
("dialog.cull", "Assisted Culling…", None, "Photo"),
("app.help", "LightCraft Help", Some("F1"), "Help"),
@@ -959,7 +961,13 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
),
("GPU rendering".to_string(), if app.ui.settings.gpu { "on".into() } else { "off".into() }),
("Memory budget".to_string(), mb(lightcraft_engine::memory::default_budget() as u64)),
("Preview size".to_string(), format!("{} px", app.ui.settings.preview_edge)),
(
"Preview size".to_string(),
match app.ui.settings.preview_limit {
0 => "automatic".to_string(),
n => format!("{n} px"),
},
),
("Photos".to_string(), info["photos"].to_string()),
("Albums".to_string(), info["albums"].to_string()),
];
@@ -1004,7 +1012,13 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
}
let paths = match p.get("paths").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
None => app.services.pick_files.as_mut().map(|f| f()).unwrap_or_default(),
None => {
let req = PickRequest::files(None, crate::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS);
match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_files.as_mut().map(|f| f())) {
Picked::Now(paths) => paths,
Picked::Later | Picked::Unavailable => return Some(Ok(Value::Null)),
}
}
};
if paths.is_empty() {
return Some(Ok(Value::Null));
@@ -1013,8 +1027,17 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
crate::import::open(app, paths)
}
"file.importLightroom" => {
let path =
p.get("path").and_then(Value::as_str).map(str::to_string).or_else(|| app.services.pick_lightroom_catalog.as_mut().and_then(|f| f()));
let path = match p.get("path").and_then(Value::as_str).map(str::to_string) {
Some(path) => Some(path),
None => {
let req =
PickRequest::file(crate::i18n::tr("Import Lightroom Catalog"), crate::i18n::tr("Lightroom Classic Catalog"), &["lrcat"]);
match crate::pick::ask(app, id, p, "path", req, |s| s.pick_lightroom_catalog.as_mut().and_then(|f| f()).map(|x| vec![x])) {
Picked::Now(v) => v.into_iter().next(),
Picked::Later | Picked::Unavailable => None,
}
}
};
match path {
Some(path) => app.run("library.importLightroom", json!({"path":path})),
None => Ok(Value::Null),
@@ -1032,7 +1055,12 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
"file.findMissing" => {
let folder = match p.get("folder").and_then(Value::as_str) {
Some(f) => Some(f.to_string()),
None => app.services.pick_folder.as_mut().and_then(|f| f()),
None => match crate::pick::ask(app, id, p, "folder", PickRequest::folder(crate::i18n::tr("Find Missing Photos…")), |s| {
s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x])
}) {
Picked::Now(v) => v.into_iter().next(),
Picked::Later | Picked::Unavailable => None,
},
};
let Some(folder) = folder else { return Some(Ok(Value::Null)) };
// the search (checking every photo's file, walking the folder) runs on a worker thread;
@@ -1087,7 +1115,13 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
// a GPX file → GPS for the selected photos by capture time (one undo step)
let path = match p.get("path").and_then(Value::as_str) {
Some(x) => Some(x.to_string()),
None => app.services.pick_tracklog.as_mut().and_then(|f| f().into_iter().next()),
None => {
let req = PickRequest::file(crate::i18n::tr("Auto-Tag from Tracklog"), crate::i18n::tr("GPS Track Log"), &["gpx"]);
match crate::pick::ask(app, id, p, "path", req, |s| s.pick_tracklog.as_mut().map(|f| f())) {
Picked::Now(v) => v.into_iter().next(),
Picked::Later | Picked::Unavailable => None,
}
}
};
let Some(path) = path else { return Some(Ok(Value::Null)) };
let mut params = p.as_object().cloned().unwrap_or_default();
@@ -1128,7 +1162,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
let Some(id) = app.session.active() else { return Some(Err("no photo selected".into())) };
let path = match p.get("path").and_then(Value::as_str) {
Some(x) => Some(x.to_string()),
None => app.services.pick_files.as_mut().and_then(|f| f().into_iter().next()),
None => {
let req =
PickRequest::file(crate::i18n::tr("Locate Missing File…"), crate::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS);
match crate::pick::ask(app, "photo.locate", p, "path", req, |s| s.pick_files.as_mut().map(|f| f())) {
Picked::Now(v) => v.into_iter().next(),
Picked::Later | Picked::Unavailable => None,
}
}
};
match path {
Some(path) => app.run("photo.relink", json!({"id": id.0, "path": path})),
@@ -1157,7 +1198,12 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
// a folder (searched recursively) into the import review
let path = match p.get("path").and_then(Value::as_str) {
Some(x) => Some(x.to_string()),
None => app.services.pick_folder.as_mut().and_then(|f| f()),
None => match crate::pick::ask(app, id, p, "path", PickRequest::folder(crate::i18n::tr("Import from Folder…")), |s| {
s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x])
}) {
Picked::Now(v) => v.into_iter().next(),
Picked::Later | Picked::Unavailable => None,
},
};
match path {
Some(path) => crate::import::open(app, vec![path]),
@@ -1167,10 +1213,18 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
"file.importPresets" => {
let paths = match p.get("paths").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
None => match app.services.pick_preset_files.as_mut() {
Some(f) => f(),
None => return Some(Err("no file dialog on this platform".into())),
},
None => {
let req = PickRequest::files(
Some(crate::i18n::tr("Import Presets").into()),
crate::i18n::tr("Presets & Profiles"),
crate::pick::PRESET_EXTENSIONS,
);
match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_preset_files.as_mut().map(|f| f())) {
Picked::Now(paths) => paths,
Picked::Later => return Some(Ok(Value::Null)),
Picked::Unavailable => return Some(Err("no file dialog on this platform".into())),
}
}
};
if paths.is_empty() {
return Some(Ok(Value::Null));
@@ -1249,9 +1303,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
Some(x) => Some(x.to_string()),
None => {
let name = format!("{}.lcpreset", group.as_deref().unwrap_or("LightCraft Presets"));
match app.services.save_preset_file.as_mut() {
Some(f) => f(&name),
None => return Some(Err("no file dialog on this platform".into())),
let req = PickRequest::save(crate::i18n::tr("Export Presets"), crate::i18n::tr("LightCraft Preset"), &["lcpreset"], name.clone());
match crate::pick::ask(app, id, p, "path", req, |s| s.save_preset_file.as_mut().map(|f| f(&name).into_iter().collect())) {
Picked::Now(v) => v.into_iter().next(),
Picked::Later => return Some(Ok(Value::Null)),
Picked::Unavailable => return Some(Err("no file dialog on this platform".into())),
}
}
};
@@ -1272,10 +1328,18 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
"file.importCurvePresets" => {
let paths: Vec<String> = match p.get("paths").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
None => match app.services.pick_curve_preset_files.as_mut() {
Some(f) => f(),
None => return Some(Err("no file dialog on this platform".into())),
},
None => {
let req = PickRequest::files(
Some(crate::i18n::tr("Import Point Curve Presets").into()),
crate::i18n::tr("Point Curve Presets"),
crate::pick::CURVE_PRESET_EXTENSIONS,
);
match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_curve_preset_files.as_mut().map(|f| f())) {
Picked::Now(paths) => paths,
Picked::Later => return Some(Ok(Value::Null)),
Picked::Unavailable => return Some(Err("no file dialog on this platform".into())),
}
}
};
if paths.is_empty() {
return Some(Ok(Value::Null));
@@ -1295,10 +1359,21 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
"file.exportCurvePresets" => {
let path = match p.get("path").and_then(Value::as_str) {
Some(x) => Some(x.to_string()),
None => match app.services.save_curve_preset_file.as_mut() {
Some(f) => f("Point Curves.lccurve"),
None => return Some(Err("no file dialog on this platform".into())),
},
None => {
let req = PickRequest::save(
crate::i18n::tr("Export Point Curve Presets"),
crate::i18n::tr("Point Curve Presets"),
&["lccurve"],
"Point Curves.lccurve",
);
match crate::pick::ask(app, id, p, "path", req, |s| {
s.save_curve_preset_file.as_mut().map(|f| f("Point Curves.lccurve").into_iter().collect())
}) {
Picked::Now(v) => v.into_iter().next(),
Picked::Later => return Some(Ok(Value::Null)),
Picked::Unavailable => return Some(Err("no file dialog on this platform".into())),
}
}
};
let Some(path) = path else { return Some(Ok(Value::Null)) };
let mut params = json!({"path": path});
@@ -1313,6 +1388,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
}
"app.export" => crate::control::export_active(app, p),
"app.showInFinder" => show_in_finder(app),
"app.openLogFolder" => open_log_folder(app),
"app.discord" | "app.website" | "app.github" | "app.artcraft" | "app.help" | "app.feedback" => {
let url = crate::links::url_of(id).unwrap_or(crate::links::WEBSITE);
crate::links::open(app, url)
@@ -1349,6 +1425,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
"view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen,
"app.openLibrary" | "file.addFolder" => app.services.pick_folder.is_some() && !crate::lightroom_import::is_running(app),
"file.backupLibrary" => app.services.backup_library.is_some(),
"app.openLogFolder" => app.services.reveal.is_some() && app.services.log_file.is_some(),
"file.restoreLibrary" => app.services.restore_library.is_some(),
"compare.swap" | "compare.makeSelect" => app.ui.view == ViewMode::Compare,
s if s.starts_with("dialog.merge") || (s.starts_with("merge.") && s.ends_with("Last")) => {
@@ -1418,6 +1495,15 @@ pub fn reveal_label() -> &'static str {
}
}
/// Help ▸ Open Log Folder: reveal the host's log file in the system file manager, so it can be
/// attached to a report without hunting for the settings folder (#260).
fn open_log_folder(app: &mut LightcraftApp) -> Result<Value, String> {
let path = app.services.log_file.clone().ok_or("this session keeps no log file")?;
let reveal = app.services.reveal.as_mut().ok_or("not available here")?;
reveal(&path)?;
Ok(json!({"path": path}))
}
/// Reveal the active photo's original in the system file manager.
fn show_in_finder(app: &mut LightcraftApp) -> Result<Value, String> {
let id = app.session.active().ok_or("no photo selected")?;
+3 -2
View File
@@ -159,7 +159,7 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo
photo.embedded_lens.as_ref(),
);
let aspect = frame.aspect() as f32;
let native = [photo.width.max(1) as usize, photo.height.max(1) as usize];
let native = super::detail::output_px(&frame);
let mut img = super::detail::fit_rect(img_area, aspect, zoom, native, ppp, app.ui.pan);
if matches!(app.ui.view, ViewMode::Compare | ViewMode::Reference) {
if super::detail::navigate_gesture(app, ui, &resp, img_area, img, native) {
@@ -168,7 +168,8 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo
super::detail::pan_image(app, img_area, img, resp.drag_delta());
}
}
let want = (img.width().max(img.height()).min(img_area.width().max(img_area.height()) * 4.0) * ppp).min(2560.0) as usize;
let want = (img.width().max(img.height()).min(img_area.width().max(img_area.height()) * 4.0) * ppp)
.min(2560.0_f32.min(super::detail::texture_side(ui.ctx()) as f32)) as usize;
let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) };
if let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), false, true) {
app.renderer.request(slot, job, 60);
+223 -18
View File
@@ -115,7 +115,7 @@ pub(crate) fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2],
}
}
Zoom::Percent(p) => {
// full-resolution pixels at p% (the photo's native width)
// the photo's own pixels at p% (its width as shown: cropped and rotated)
let w = img_px[0] as f32 * p / 100.0 / ppp;
(w, w / aspect)
}
@@ -187,6 +187,20 @@ pub(crate) fn navigate_gesture(app: &mut LightcraftApp, ui: &mut egui::Ui, resp:
true
}
/// The largest texture the GPU behind `ctx` takes (egui reports it; 2048 when it doesn't, the
/// smallest limit WebGL devices have).
pub(crate) fn texture_side(ctx: &egui::Context) -> usize {
ctx.input(|i| i.raw.max_texture_side).unwrap_or(2048)
}
/// The photo's own pixels as it is shown: its size after the crop and the user's rotation (what
/// 100 % zoom and the render size limit are measured against), at least 1 × 1.
pub(crate) fn output_px(frame: &Frame) -> [usize; 2] {
let (w, h) = frame.native_size();
let px = |v: f64| if v.is_finite() { v.round().clamp(1.0, 1e9) as usize } else { 1 };
[px(w), px(h)]
}
/// Ease the loupe rect toward `target` while a click-zoom animation runs; otherwise follow it exactly.
fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect {
let t = if *anim { 0.22 } else { 0.0 };
@@ -200,6 +214,98 @@ fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect {
r
}
/// What a window render of the loupe needs to know about the view it belongs to.
struct WindowCtx {
id: PhotoId,
/// Long edge of the frame the windows are cut from (`None`: no windows).
frame_edge: Option<usize>,
drawn_long: f32,
aspect: f32,
ppp: f32,
texture_side: usize,
crop_tool: bool,
interacting: bool,
/// Hash of the photo's develop settings.
look: u64,
/// A pinch or two-finger scroll is running: keep the windows there are.
holding: bool,
}
/// One side of the loupe that gets a window render.
#[derive(Clone, Copy)]
struct WindowView {
slot: Slot,
/// The Before side (the photo without its edits).
before: bool,
/// The rect the photo is drawn in.
img: Rect,
/// The rect it is requested for (the drawn rect without a click-zoom animation).
target: Rect,
/// The area whose pixels are on screen.
visible: Rect,
}
/// Ask for the window of `v` that holds what is on screen; remember it so its texture can be
/// drawn where it belongs. `None`: no window is wanted (or possible) for this view.
fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Option<crate::region::RegionView> {
let frame_edge = c.frame_edge?;
// while a pinch or scroll runs the windows there are keep being drawn, magnified: no new one
// per frame (zooming out would ask for ever wider ones)
if c.holding {
return if v.before { app.region_before_view } else { app.region_view }.filter(|w| w.photo == c.id);
}
let (fw, fh) =
if c.aspect >= 1.0 { (frame_edge as f32, frame_edge as f32 / c.aspect) } else { (frame_edge as f32 * c.aspect, frame_edge as f32) };
let (fw, fh) = (fw.round().max(1.0) as usize, fh.round().max(1.0) as usize);
// what is on screen, in pixels of the window's frame (the drawn frame, scaled)
let to_px = c.ppp * frame_edge as f32 / c.drawn_long.max(1.0);
let visible = (
to_px * (v.visible.left() - v.target.left()),
to_px * (v.visible.top() - v.target.top()),
to_px * (v.visible.right() - v.target.left()),
to_px * (v.visible.bottom() - v.target.top()),
);
let win = crate::region::window_for(fw, fh, visible, crate::region::max_span(c.texture_side))?;
// the Before side waits for the original the After is decoding (see `defer_before_window`)
let original_held = app.session.media.has_source(c.id, lightcraft_engine::SourceLevel::for_size(fw.max(fh)));
if crate::region::defer_before_window(v.before, app.renderer.is_pending(Slot::Region), original_held) {
return app.region_before_view.filter(|w| w.photo == c.id);
}
let job = if v.before {
app.session.region_job_before(c.id, fw, fh, win, !c.crop_tool)
} else {
app.session.region_job(c.id, fw, fh, win, !c.crop_tool)
};
// refused: what the window reads (a spot's source, an Auto Mask stroke) is too far for one
// render, so the whole frame is rendered at the drawn size instead (see `region::plan`)
let Some(job) = job else {
app.window_refused = Some((c.id, c.look, frame_edge));
return None;
};
// a drag renders drafts of the window, as it does of the whole frame
let job = if c.interacting { job.draft() } else { job };
let look = job.settings.hash64();
let view = crate::region::RegionView { photo: c.id, before: v.before, key: job.key, full: (fw, fh), window: win, settings: look };
// windows of other photos, looks and zooms of this side can't be drawn any more
app.region_tiles.retain(|(before, _), t| *before != v.before || t.is_current(c.id, (fw, fh), look, c.interacting));
app.region_tiles.insert((v.before, job.key), view);
app.renderer.request(v.slot, job, 99);
Some(view)
}
/// Draw `v`'s window texture over the whole-frame render, if it is the one asked for (or, in a
/// drag, an earlier draft of it).
fn draw_window(p: &egui::Painter, app: &LightcraftApp, c: &WindowCtx, v: &WindowView, wanted: &crate::region::RegionView) {
let Some(tex) = app.renderer.textures.get(&v.slot).filter(|t| t.photo == c.id) else { return };
let Some(tile) = app.region_tiles.get(&(v.before, tex.key)).filter(|t| t.is_current(c.id, wanted.full, wanted.settings, c.interacting)) else {
return;
};
let (fw, fh) = (tile.full.0 as f32, tile.full.1 as f32);
let at = v.img.min + vec2(tile.window.x as f32 / fw * v.img.width(), tile.window.y as f32 / fh * v.img.height());
let dst = Rect::from_min_size(at, vec2(tile.window.w as f32 / fw * v.img.width(), tile.window.h as f32 / fh * v.img.height()));
p.image(tex.tex.id(), dst, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let full = ui.max_rect();
@@ -230,8 +336,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
} else {
24.0
});
let max_edge = app.ui.settings.preview_edge.clamp(512, 8192) as f32;
let native = [photo.width.max(1) as usize, photo.height.max(1) as usize];
let native = output_px(&frame);
let texture_side = texture_side(ui.ctx());
// two views (before, after): side by side or stacked
let split = matches!(app.ui.before_after, BeforeAfter::SideBySide | BeforeAfter::TopBottom);
let split_view = matches!(app.ui.before_after, BeforeAfter::Split | BeforeAfter::SplitTopBottom);
@@ -283,8 +389,35 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let interacting = app.session.interaction.is_some();
let scale = if interacting { 0.6 } else { 1.0 };
// render at the final size: a click-zoom animation only changes how the result is drawn
let want = (target_rect.width().max(target_rect.height()) * ppp * scale).min(max_edge) as usize;
let native_long = native[0].max(native[1]);
let drawn_long = target_rect.width().max(target_rect.height()) * ppp;
// the whole frame at about canvas size; the zoomed part of it is a window render (below)
let look = d.hash64();
// (modes that draw another picture over the loupe, or reads too far for one window, have the
// whole frame rendered at the size it is drawn, as they did before windows)
let sizes = crate::region::ViewSizes {
drawn_long,
canvas_long: canvas.width().max(canvas.height()) * ppp,
native_long,
texture_side,
draft_scale: scale,
windows: !app.ui.soft_proof && view_overlay(app, &d) == lightcraft_pipeline::Overlay::None,
};
let mut plan = crate::region::plan(&app.ui.settings, sizes);
if plan.window_edge.is_some_and(|edge| app.window_refused == Some((id, look, edge))) {
plan = crate::region::plan(&app.ui.settings, crate::region::ViewSizes { windows: false, ..sizes });
}
// a pinch changes the zoom every frame: keep the sizes of before it until it is quiet
let now = ui.input(|i| i.time);
let plan = app.size_hold.apply(id, now, navigating, plan);
if app.size_hold.holding(now) {
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(crate::region::HOLD_SECS));
}
let want = plan.main_edge;
let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) };
// hover and before renders are stand-ins: the preview size is plenty and keeps them cheap
let se = app.ui.settings.stand_in_edge(want, texture_side);
let (sw, sh) = if aspect >= 1.0 { (se, (se as f32 / aspect) as usize) } else { ((se as f32 * aspect) as usize, se) };
if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), !crop_tool) {
let job = if interacting { job.draft() } else { job };
let job = job.with_overlay(view_overlay(app, &d)).with_proof(app.ui.soft_proof.then_some(app.ui.proof));
@@ -293,7 +426,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
// hovering a preset or profile: the photo with that look, shown instead of the loupe render
// once it is ready (nothing is committed)
let hover_key = match app.hover_preview.clone() {
Some(h) if !interacting => app.session.preview_job(id, rw.max(8), rh.max(8), !crop_tool, &h.settings).map(|job| {
Some(h) if !interacting => app.session.preview_job(id, sw.max(8), sh.max(8), !crop_tool, &h.settings).map(|job| {
let key = job.key;
app.renderer.request(Slot::Hover, job, 105);
(key, h.label)
@@ -312,8 +445,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let Some(np) = app.session.catalog.photo(nid).cloned() else { continue };
let nf = Frame::with_lens(np.width.max(1) as usize, np.height.max(1) as usize, &np.develop, !crop_tool, np.embedded_lens.as_ref());
let na = nf.aspect() as f32;
let nr = fit_rect(main_area, na, app.ui.zoom, [np.width.max(1) as usize, np.height.max(1) as usize], ppp, app.ui.pan);
let nw = (nr.width().max(nr.height()) * ppp).min(max_edge) as usize;
let nr = fit_rect(main_area, na, app.ui.zoom, output_px(&nf), ppp, app.ui.pan);
let nw =
app.ui.settings.prefetch_edge(nr.width().max(nr.height()) * ppp, output_px(&nf).into_iter().max().unwrap_or(1), texture_side);
let (w, h) = if na >= 1.0 { (nw, (nw as f32 / na) as usize) } else { ((nw as f32 * na) as usize, nw) };
if let Some(job) = app.session.loupe_job(nid, w.max(8), h.max(8), !crop_tool) {
app.renderer.prefetch(Slot::Prefetch(n as u8), job, PREFETCH_PRIORITY);
@@ -329,10 +463,67 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
let show_before = app.ui.before_after == BeforeAfter::Original || ui.input(|i| i.key_down(egui::Key::Backslash));
if (split || show_before || split_view)
&& let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), true, !crop_tool)
&& let Some(job) = app.session.render_job(id, sw.max(8), sh.max(8), true, !crop_tool)
{
app.renderer.request(Slot::Before, job, 90);
}
// zoomed past what the whole-frame render holds: also render the part on screen, at no more
// than 100 % (the GPU magnifies beyond that), for each side that is shown. Not while hovering a
// look or showing an overlay (those draw another image), nor where the shown picture isn't the
// frame's own (an unsupported raw's embedded JPEG with another crop: window coordinates are
// the frame's).
let plain_view = hover_key.is_none()
&& !app.ui.soft_proof
&& view_overlay(app, &d) == lightcraft_pipeline::Overlay::None
&& crate::region::same_aspect(display_aspect, aspect);
let window_ctx = WindowCtx {
id,
frame_edge: plan.window_edge.filter(|_| plain_view),
drawn_long,
aspect,
ppp,
texture_side,
crop_tool,
interacting,
look,
holding: navigating || app.size_hold.holding(now),
};
// (slot, before side?, rect to draw in, rect it is requested for, area whose pixels show)
let mut window_views: Vec<WindowView> = Vec::new();
if window_ctx.frame_edge.is_some() {
let before_rect = |r: Rect, area: Rect| WindowView { slot: Slot::RegionBefore, before: true, img: r, target: r, visible: area };
let after = WindowView { slot: Slot::Region, before: false, img: img_rect, target: target_rect, visible: main_area };
if split {
let br = fit_rect(areas[0], aspect, app.ui.zoom, native, ppp, app.ui.pan);
window_views.push(before_rect(br, areas[0]));
window_views.push(after);
} else if show_before {
window_views.push(WindowView { slot: Slot::RegionBefore, before: true, img: img_rect, target: target_rect, visible: canvas });
} else if split_view {
window_views.push(WindowView { slot: Slot::RegionBefore, before: true, img: img_rect, target: target_rect, visible: canvas });
window_views.push(WindowView { visible: canvas, ..after });
} else {
window_views.push(WindowView { visible: canvas, ..after });
}
}
let mut windows: Vec<(WindowView, crate::region::RegionView)> = Vec::new();
for v in window_views {
if let Some(view) = request_window(app, &window_ctx, &v) {
windows.push((v, view));
}
}
for (slot, before) in [(Slot::Region, false), (Slot::RegionBefore, true)] {
let shown = windows.iter().find(|(v, _)| v.before == before).map(|(_, view)| *view);
if before {
app.region_before_view = shown;
} else {
app.region_view = shown;
}
if shown.is_none() {
app.region_tiles.retain(|(b, _), _| *b != before);
app.renderer.release(slot);
}
}
let p = ui.painter_at(canvas);
// what a view slot shows: its own render of this photo, else the stand-ins (no blank frame
// between photos, and the full render replaces them in place)
@@ -398,22 +589,36 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
}
app.loupe_shown = Some((id, shown));
// a wipe shows the Before whole-frame render on its side of the line…
if app.ui.before_after == BeforeAfter::Split
&& let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id)
{
let before_rect = fit_texture_rect(img_rect, tex.size);
let left = Rect::from_min_max(before_rect.min, pos2(before_rect.center().x, before_rect.bottom()));
p.image(tex.tex.id(), left, Rect::from_min_max(pos2(0.0, 0.0), pos2(0.5, 1.0)), Color32::WHITE);
}
if app.ui.before_after == BeforeAfter::SplitTopBottom
&& let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id)
{
let before_rect = fit_texture_rect(img_rect, tex.size);
let top = Rect::from_min_max(before_rect.min, pos2(before_rect.right(), before_rect.center().y));
p.image(tex.tex.id(), top, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 0.5)), Color32::WHITE);
}
// …and the window renders go over the whole-frame ones, all of them, where they belong in the
// frame (clipped to their pane, or their side of the line)
for (v, view) in &windows {
// (holding `\` in a wipe shows the Before everywhere: it is the Before view alone then)
let mode = if show_before { BeforeAfter::Original } else { app.ui.before_after };
let clip = crate::region::window_clip(mode, v.before, v.visible, canvas, img_rect);
draw_window(&p.with_clip_rect(clip), app, &window_ctx, v, view);
}
// the line of a wipe over both
if app.ui.before_after == BeforeAfter::Split {
let mid = img_rect.center().x;
if let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) {
let before_rect = fit_texture_rect(img_rect, tex.size);
let left = Rect::from_min_max(before_rect.min, pos2(before_rect.center().x, before_rect.bottom()));
p.image(tex.tex.id(), left, Rect::from_min_max(pos2(0.0, 0.0), pos2(0.5, 1.0)), Color32::WHITE);
}
p.line_segment([pos2(mid, img_rect.top()), pos2(mid, img_rect.bottom())], Stroke::new(1.5, Color32::WHITE));
}
if app.ui.before_after == BeforeAfter::SplitTopBottom {
let mid = img_rect.center().y;
if let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) {
let before_rect = fit_texture_rect(img_rect, tex.size);
let top = Rect::from_min_max(before_rect.min, pos2(before_rect.right(), before_rect.center().y));
p.image(tex.tex.id(), top, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 0.5)), Color32::WHITE);
}
p.line_segment([pos2(img_rect.left(), mid), pos2(img_rect.right(), mid)], Stroke::new(1.5, Color32::WHITE));
}
if app.ui.show_clipping && !show_before && !fullscreen {
+2 -1
View File
@@ -37,7 +37,8 @@ fn body(app: &mut LightcraftApp, ui: &mut egui::Ui) {
};
// a render sized for this window
let ppp = ui.ctx().pixels_per_point();
let (w, h) = (((area.width() * ppp) as usize).clamp(64, 4096), ((area.height() * ppp) as usize).clamp(64, 4096));
let side = super::detail::texture_side(ui.ctx()).clamp(64, 4096);
let (w, h) = (((area.width() * ppp) as usize).clamp(64, side), ((area.height() * ppp) as usize).clamp(64, side));
if let Some(job) = app.session.loupe_job(id, w, h, true)
&& app.renderer.textures.get(&Slot::Second).is_none_or(|t| t.key != job.key)
&& !app.renderer.is_pending(Slot::Second)
+22 -11
View File
@@ -12,7 +12,7 @@ use egui::RichText;
use serde_json::{Value, json};
use crate::LightcraftApp;
use crate::state::{GridBadges, PREVIEW_EDGES, StartupView};
use crate::state::{GridBadges, PREVIEW_LIMITS, StartupView};
use crate::theme::Tokens;
use crate::widgets::register;
@@ -372,11 +372,18 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
};
hint(ui, t, &status);
row(ui, t, crate::i18n::tr("Preview size"), |ui| {
let opts: Vec<(u32, String)> = PREVIEW_EDGES.iter().map(|e| (*e, format!("{e} px"))).collect();
let opts: Vec<(u32, String)> =
PREVIEW_LIMITS.iter().map(|e| (*e, if *e == 0 { crate::i18n::tr("Automatic").to_string() } else { format!("{e} px") })).collect();
let opts: Vec<(u32, &str)> = opts.iter().map(|(e, l)| (*e, l.as_str())).collect();
choices(ui, "settingsPreview", &opts, &mut app.ui.settings.preview_edge);
choices(ui, "settingsPreview", &opts, &mut app.ui.settings.preview_limit);
});
hint(ui, t, crate::i18n::tr("Largest long edge the Detail view renders at; larger is sharper on big displays but slower."));
hint(
ui,
t,
crate::i18n::tr(
"Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.",
),
);
row(ui, t, crate::i18n::tr("Memory for caches"), |ui| {
let auto = crate::i18n::tr_format!("Automatic ({} MB)", lightcraft_engine::memory::default_budget() >> 20);
let opts = [(0u32, auto.as_str()), (512, "512 MB"), (1024, "1 GB"), (2048, "2 GB"), (4096, "4 GB")];
@@ -548,13 +555,17 @@ pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result<Value, String>
}
let path = match p.get("path").and_then(Value::as_str) {
Some(x) => x.to_string(),
None => match app.services.pick_folder.as_mut() {
Some(pick) => match pick() {
Some(x) => x,
None => return Ok(Value::Null),
},
None => return Err("no folder dialog on this platform".into()),
},
None => {
let req = crate::pick::PickRequest::folder(crate::i18n::tr("Open Library"));
match crate::pick::ask(app, "app.openLibrary", p, "path", req, |s| s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x])) {
crate::pick::Picked::Now(v) => match v.into_iter().next() {
Some(x) => x,
None => return Ok(Value::Null),
},
crate::pick::Picked::Later => return Ok(Value::Null),
crate::pick::Picked::Unavailable => return Err("no folder dialog on this platform".into()),
}
}
};
app.session.close_library().map_err(|e| e.to_string())?;
app.session.open_library(&path, false).map_err(|e| e.to_string())?;
+37
View File
@@ -8,6 +8,40 @@ use crate::icons::{Icon, paint};
use crate::theme::Tokens;
use crate::widgets::{icon_button, register};
/// The bar doubles as the window's title bar: dragging its empty space moves the window and a
/// double-click zooms or restores it. Registered before the bar's widgets so they win the click.
fn window_handle(app: &LightcraftApp, ui: &mut egui::Ui, content: Rect, margin_left: f32, margin_right: f32) {
use crate::titlebar::{Gesture, WindowState, command_for};
let bar = Rect::from_min_max(pos2(content.left() - margin_left, content.top()), pos2(content.right() + margin_right, content.bottom()));
register(ui.ctx(), "region:titlebar", bar);
let handle = egui::Id::new("titlebar-handle");
let resp = ui.interact(bar, handle, Sense::click_and_drag());
// a press that began on a button or the search field is theirs: dragging off it must not move the window
// (while pressing, egui hovers every widget under the pointer; once the drag starts, only the dragged one)
let ctx = ui.ctx().clone();
let memo = egui::Id::new("titlebar-press-on-widget");
if resp.is_pointer_button_down_on() && ctx.input(|i| i.pointer.primary_pressed()) {
let hovered = ctx.interaction_snapshot(|s| s.hovered.clone());
let on_widget =
hovered.iter().any(|id| *id != handle && ctx.read_response(*id).is_some_and(|r| r.sense.senses_click() || r.sense.senses_drag()));
ctx.data_mut(|d| d.insert_temp(memo, on_widget));
}
let on_widget = ctx.data(|d| d.get_temp::<bool>(memo)).unwrap_or(false);
let gesture = if resp.double_clicked_by(egui::PointerButton::Primary) {
Some(Gesture::DoubleClicked)
} else if resp.drag_started_by(egui::PointerButton::Primary) && !on_widget {
Some(Gesture::DragStarted)
} else {
None
};
if let Some(g) = gesture {
let window = WindowState { maximized: ui.input(|i| i.viewport().maximized).unwrap_or(false), fullscreen: app.window_is_fullscreen };
if let Some(cmd) = command_for(g, window) {
ui.ctx().send_viewport_cmd(cmd);
}
}
}
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let left = if app.integrated_titlebar { 78 } else { 10 };
@@ -16,6 +50,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left, right: 12, top: 0, bottom: 0 }))
.show(ui, |ui| {
let full = ui.max_rect();
if app.integrated_titlebar {
window_handle(app, ui, full, left as f32, 12.0);
}
let mut sw = 640.0f32.min(full.width() - 460.0).max(200.0);
if !app.native_menu {
// leave room for the in-window menus left of the (centred) search field
+289
View File
@@ -0,0 +1,289 @@
//! Native file dialogs for commands, off the UI thread (#191). A synchronous dialog blocks the
//! thread that shows it, and on the desktop that was the UI thread: the window stopped answering
//! the compositor (GNOME marked LightCraft "not responding" and offered to kill it), the menu the
//! command came from stayed drawn, and the control channel went quiet until the dialog closed.
//!
//! A host that can run a dialog elsewhere installs a [`Picker`] in `Services`: it starts the
//! dialog and answers through a channel. A command that needs a path the caller didn't give
//! [`ask`]s for one and returns at once; when the dialog closes, [`poll`] runs the command again
//! with the answer filled in under the parameter it was missing. Hosts without a `Picker` (the
//! web, where browser pickers are asynchronous already; tests) answer through the synchronous
//! services as before.
use crate::{LightcraftApp, Services};
use serde_json::{Value, json};
use std::sync::mpsc::{Receiver, TryRecvError};
/// What a dialog chooses.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PickKind {
/// Any number of files.
Files,
/// One file.
File,
/// One folder.
Folder,
/// Where to save one file.
Save,
}
/// A dialog to show. Texts are translated already.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PickRequest {
pub kind: PickKind,
/// The dialog's title; the host's default when `None`.
pub title: Option<String>,
/// One file-type filter: its name and the extensions it admits (without dots).
pub filter: Option<(String, &'static [&'static str])>,
/// [`PickKind::Save`]: the suggested file name.
pub file_name: Option<String>,
}
impl PickRequest {
pub fn files(title: Option<String>, filter: impl Into<String>, extensions: &'static [&'static str]) -> Self {
PickRequest { kind: PickKind::Files, title, filter: Some((filter.into(), extensions)), file_name: None }
}
pub fn file(title: impl Into<String>, filter: impl Into<String>, extensions: &'static [&'static str]) -> Self {
PickRequest { kind: PickKind::File, title: Some(title.into()), filter: Some((filter.into(), extensions)), file_name: None }
}
pub fn folder(title: impl Into<String>) -> Self {
PickRequest { kind: PickKind::Folder, title: Some(title.into()), filter: None, file_name: None }
}
pub fn save(title: impl Into<String>, filter: impl Into<String>, extensions: &'static [&'static str], file_name: impl Into<String>) -> Self {
PickRequest { kind: PickKind::Save, title: Some(title.into()), filter: Some((filter.into(), extensions)), file_name: Some(file_name.into()) }
}
}
/// What File ▸ Import Profiles & Presets… opens.
pub const PRESET_EXTENSIONS: &[&str] = &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"];
/// What Import Point Curve Presets… opens.
pub const CURVE_PRESET_EXTENSIONS: &[&str] = &["lccurve", "json"];
/// The host's dialog starter: shows the request without blocking and answers through the
/// channel with the chosen paths (none = cancelled). `Err` when no dialog could be started.
pub type Picker = Box<dyn FnMut(PickRequest) -> Result<Receiver<Vec<String>>, String>>;
/// A dialog that is up for a command. When it closes, the command runs again with the answer
/// under `key`.
pub struct Pending {
pub command: String,
params: Value,
key: &'static str,
kind: PickKind,
rx: Receiver<Vec<String>>,
}
/// How a command's request for a path was answered.
pub enum Picked {
/// A synchronous dialog answered (an empty list: cancelled).
Now(Vec<String>),
/// The dialog is up (or was already); the command runs again when it closes.
Later,
/// This host has no such dialog.
Unavailable,
}
/// Ask for a path for `command`, which was run with `params` but without `key`. With a [`Picker`]
/// the dialog runs off the UI thread and the command comes back through [`poll`] with `key` filled
/// in; a dialog already up for the command is not opened twice. Without one, `now` asks the
/// synchronous service.
pub fn ask(
app: &mut LightcraftApp,
command: &str,
params: &Value,
key: &'static str,
request: PickRequest,
now: impl FnOnce(&mut Services) -> Option<Vec<String>>,
) -> Picked {
if app.pending_picks.iter().any(|p| p.command == command) {
return Picked::Later;
}
if let Some(start) = app.services.picker.as_mut() {
let kind = request.kind;
match start(request) {
Ok(rx) => {
app.pending_picks.push(Pending { command: command.to_string(), params: params.clone(), key, kind, rx });
return Picked::Later;
}
Err(e) => log::warn!("{command}: no background file dialog ({e}); asking on the UI thread"),
}
}
match now(&mut app.services) {
Some(paths) => Picked::Now(paths),
None => Picked::Unavailable,
}
}
/// `params` with the dialog's answer under `key`: the whole list for a `paths` key, the first
/// path otherwise.
pub fn with_answer(params: &Value, key: &str, kind: PickKind, paths: &[String]) -> Value {
let mut p = params.as_object().cloned().unwrap_or_default();
let answer = match kind {
PickKind::Files => json!(paths),
_ => json!(paths.first().cloned().unwrap_or_default()),
};
p.insert(key.to_string(), answer);
Value::Object(p)
}
/// Each frame: run the commands whose dialogs have closed with something chosen; forget the
/// cancelled ones and those whose dialog went away without an answer.
pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
if app.pending_picks.is_empty() {
return;
}
let mut answered = Vec::new();
let mut waiting = Vec::new();
for p in std::mem::take(&mut app.pending_picks) {
match p.rx.try_recv() {
Ok(paths) if paths.is_empty() => {}
Ok(paths) => answered.push((p, paths)),
Err(TryRecvError::Empty) => waiting.push(p),
Err(TryRecvError::Disconnected) => log::warn!("{}: the file dialog ended without an answer", p.command),
}
}
if !waiting.is_empty() {
// the host repaints when a dialog closes; this is the net under it
ctx.request_repaint_after(std::time::Duration::from_millis(250));
}
app.pending_picks = waiting;
for (p, paths) in answered {
let params = with_answer(&p.params, p.key, p.kind, &paths);
if let Err(e) = app.run(&p.command, params) {
app.toast_error(ctx, e);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::headless::Headless;
use crate::state::Dialog;
use std::sync::mpsc::{Sender, channel};
use std::sync::{Arc, Mutex};
use std::time::Duration;
const SETTLE: Duration = Duration::from_secs(20);
type Asked = Arc<Mutex<Vec<(PickRequest, Sender<Vec<String>>)>>>;
/// A windowless app whose host shows dialogs "elsewhere": each request is recorded with the
/// sender that answers it. Frames run through the headless harness, so [`poll`] and the import
/// scan happen as in the app.
fn app_with_picker() -> (Headless, Asked) {
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
let asked: Asked = Default::default();
let a = asked.clone();
app.services.picker = Some(Box::new(move |req| {
let (tx, rx) = channel();
a.lock().unwrap().push((req, tx));
Ok(rx)
}));
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
h.settle(SETTLE);
(h, asked)
}
fn photo_in(dir: &std::path::Path) -> String {
std::fs::create_dir_all(dir).unwrap();
let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, 50, 255]);
let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() };
let path = dir.join("one.png");
std::fs::write(&path, lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap();
path.to_string_lossy().to_string()
}
/// The import review's sources once frames have run (the review opens after a background
/// scan of the chosen paths).
fn import_sources(h: &mut Headless) -> Option<Vec<String>> {
h.settle(SETTLE);
match &h.app.ui.dialog {
Some(Dialog::Import { opts }) => Some(opts.sources.clone()),
_ => None,
}
}
#[test]
fn import_photos_waits_for_its_dialog_and_runs_with_the_answer() {
let dir = std::env::temp_dir().join(format!("lc-pick-{}", std::process::id()));
let photo = photo_in(&dir);
let (mut h, asked) = app_with_picker();
// the command returns at once: no review of its own yet, the native dialog is up
assert_eq!(h.app.run("file.addPhotos", json!({})).unwrap(), Value::Null);
assert!(import_sources(&mut h).is_none());
assert_eq!(h.app.pending_picks.len(), 1);
{
let asked = asked.lock().unwrap();
assert_eq!(asked.len(), 1);
assert_eq!(asked[0].0.kind, PickKind::Files);
assert_eq!(asked[0].0.filter.as_ref().map(|f| f.1), Some(lightcraft_engine::import::EXTENSIONS));
}
// asking again while it is up opens no second dialog
assert_eq!(h.app.run("file.addPhotos", json!({})).unwrap(), Value::Null);
h.settle(SETTLE);
assert_eq!(asked.lock().unwrap().len(), 1);
assert_eq!(h.app.pending_picks.len(), 1, "still waiting");
// the user chooses a photo: the command runs again with it and opens the import review
asked.lock().unwrap()[0].1.send(vec![photo.clone()]).unwrap();
assert_eq!(import_sources(&mut h), Some(vec![photo]));
assert!(h.app.pending_picks.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_cancelled_or_vanished_dialog_runs_nothing() {
let (mut h, asked) = app_with_picker();
h.app.run("file.addPhotos", json!({})).unwrap();
asked.lock().unwrap()[0].1.send(vec![]).unwrap();
assert!(import_sources(&mut h).is_none(), "cancelled: no import review");
assert!(h.app.pending_picks.is_empty());
// the dialog thread ends without answering (the host dropped the sender)
h.app.run("file.addFolder", json!({})).unwrap();
assert_eq!(h.app.pending_picks.len(), 1);
asked.lock().unwrap().pop();
assert!(import_sources(&mut h).is_none());
assert!(h.app.pending_picks.is_empty());
}
#[test]
fn a_folder_dialog_fills_in_one_path_and_a_save_dialog_names_the_file() {
let dir = std::env::temp_dir().join(format!("lc-pick-folder-{}", std::process::id()));
let _photo = photo_in(&dir);
let (mut h, asked) = app_with_picker();
h.app.run("file.addFolder", json!({})).unwrap();
assert_eq!(asked.lock().unwrap()[0].0.kind, PickKind::Folder);
asked.lock().unwrap()[0].1.send(vec![dir.to_string_lossy().to_string()]).unwrap();
assert_eq!(import_sources(&mut h), Some(vec![dir.to_string_lossy().to_string()]));
// a save dialog carries the suggested name; the command's other parameters survive
h.app.run("file.exportPresets", json!({"group": "Mine"})).unwrap();
let req = asked.lock().unwrap().last().map(|(r, _)| r.clone()).unwrap();
assert_eq!(req.kind, PickKind::Save);
assert_eq!(req.file_name.as_deref(), Some("Mine.lcpreset"));
let p = with_answer(&json!({"group": "Mine"}), "path", PickKind::Save, &["/tmp/Mine.lcpreset".to_string(), "/tmp/other".to_string()]);
assert_eq!(p, json!({"group": "Mine", "path": "/tmp/Mine.lcpreset"}));
assert_eq!(with_answer(&Value::Null, "paths", PickKind::Files, &["a".to_string(), "b".to_string()]), json!({"paths": ["a", "b"]}));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn without_a_picker_the_synchronous_dialog_answers_at_once() {
let dir = std::env::temp_dir().join(format!("lc-pick-sync-{}", std::process::id()));
let photo = photo_in(&dir);
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
let p = photo.clone();
app.services.pick_files = Some(Box::new(move || vec![p.clone()]));
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
h.app.run("file.addPhotos", json!({})).unwrap();
assert!(h.app.pending_picks.is_empty());
assert_eq!(import_sources(&mut h), Some(vec![photo]));
// and a host with neither shows nothing, as before
let mut bare = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
assert_eq!(bare.run("file.addPhotos", json!({})).unwrap(), Value::Null);
assert!(bare.run("file.importPresets", json!({})).is_err(), "no dialog on this platform");
let _ = std::fs::remove_dir_all(&dir);
}
}
+546
View File
@@ -0,0 +1,546 @@
//! Sharp zoomed views (issue #323). The loupe renders the whole frame in one texture, which can't
//! be as large as a 1:1 or 8:1 view of a big photo. Past that size it also renders just the window
//! of the frame that is on screen, at the zoom scale, and draws it over the whole-frame render.
//!
//! This module is the geometry: when a window is needed and which one. Windows are snapped to a
//! grid and carry a margin, so panning by a few pixels re-uses the render and the spatial stages
//! (clarity, dehaze…) have the context they read around the visible edge.
use lightcraft_engine::pipeline::PixelWindow;
/// Windows start and end on multiples of this many pixels of the zoomed frame.
pub const SNAP: usize = 256;
/// The most context rendered beyond the visible pixels (a multiple of [`SNAP`]).
pub const MAX_MARGIN: usize = 768;
/// The most a window may span on either axis: a bound on its texture whatever the window size.
pub const MAX_SPAN: usize = if cfg!(target_arch = "wasm32") { 3072 } else { 6144 };
/// A window render the loupe asked for: which photo, the size of the zoomed frame it is a window
/// of, and the window. The texture that comes back is drawn at this place of the frame.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RegionView {
pub photo: lightcraft_catalog::PhotoId,
/// The Before side of a Before/After view (the photo without its edits).
pub before: bool,
pub key: u64,
pub full: (usize, usize),
pub window: PixelWindow,
/// Hash of the develop settings it was rendered with.
pub settings: u64,
}
impl RegionView {
/// Whether this window's pixels still belong in a loupe showing `photo` with `settings` in a
/// frame of `full` pixels (they are drawn until the next window arrives, if so).
///
/// While a slider is dragged (`drafting`) a window made for an earlier value of it still
/// belongs: drafts follow the drag, and waiting for an exact match would show none.
pub fn is_current(&self, photo: lightcraft_catalog::PhotoId, full: (usize, usize), settings: u64, drafting: bool) -> bool {
self.photo == photo && self.full == full && (drafting || self.settings == settings)
}
}
/// Context to render around the visible pixels of a frame `full_long` pixels along: enough for the
/// wide stages (base, clarity and dehaze planes have sigmas up to 0.02 of it) up to a cap.
pub fn margin_for(full_long: usize) -> usize {
let want = (full_long as f64 * 0.045).min(MAX_MARGIN as f64) as usize;
want.div_ceil(SNAP).max(1) * SNAP
}
/// The widest a window may be on a host whose GPU textures are at most `texture_side` px: a
/// window is one texture, and a texture over the limit makes some backends (egui_glow, i.e. the
/// browser build) panic. A multiple of [`SNAP`], at least one grid step, at most [`MAX_SPAN`].
pub fn max_span(texture_side: usize) -> usize {
(texture_side.min(MAX_SPAN) / SNAP * SNAP).max(SNAP)
}
/// A window render is worth it only when the whole-frame render is this much smaller than the
/// zoomed frame: a little softness (a Retina fit view of 2800 px from the 2560 px preview) is not
/// worth decoding the original for.
pub const WINDOW_RATIO: f32 = 1.25;
/// How big the loupe's view of the photo is, and what the host allows.
#[derive(Clone, Copy, Debug)]
pub struct ViewSizes {
/// Long edge of the photo as drawn (physical px): the zoomed frame.
pub drawn_long: f32,
/// Long edge of the canvas it is drawn in (physical px).
pub canvas_long: f32,
/// The photo's own long edge as shown (after crop and rotation).
pub native_long: usize,
/// The largest texture the GPU takes.
pub texture_side: usize,
/// 1.0, or less while a slider is dragged (a draft of the whole-frame render).
pub draft_scale: f32,
/// Whether a window can be drawn over the whole-frame render (not with an overlay, soft
/// proofing, or a read too far for one window): if not, the whole frame is rendered at the
/// size it is drawn.
pub windows: bool,
}
/// What the loupe renders for a view: the whole frame at about canvas size, and, once the view is
/// zoomed past what that holds, the window on screen at no more than 100 %.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LoupePlan {
/// Long edge of the whole-frame render ([`Slot::Main`](crate::render::Slot::Main)): never more
/// than the canvas shows, the user's preview limit, the texture side or the preview source level.
pub main_edge: usize,
/// Long edge of the zoomed frame the window is cut from (never above the photo's own pixels:
/// beyond 100 % the GPU magnifies the window), or `None`: the whole-frame render is enough.
pub window_edge: Option<usize>,
}
/// The loupe's render plan. The cost of a render follows the canvas, not the zoom: the whole-frame
/// render is canvas-sized, and the window holds only the pixels on screen at no more than 100 %.
pub fn plan(settings: &crate::state::AppSettings, v: ViewSizes) -> LoupePlan {
let canvas_long = if v.canvas_long.is_finite() { v.canvas_long.max(8.0) } else { 8.0 };
let drawn_long = if v.drawn_long.is_finite() { v.drawn_long.max(8.0) } else { canvas_long };
let scale = if v.draft_scale.is_finite() { v.draft_scale.clamp(0.1, 1.0) } else { 1.0 };
if !v.windows {
// the whole frame at the size it is drawn (an overlay or soft proof has no window); while a
// slider drags, a canvas-sized draft as with windows: a drag at 1:1 must not render the
// photo's every pixel each frame
let long = if scale < 1.0 { drawn_long.min(canvas_long) } else { drawn_long };
let main_edge = settings.loupe_edge(long * scale, v.native_long, v.texture_side);
return LoupePlan { main_edge, window_edge: None };
}
// a user who chose a size above the preview source level (3840, 5120) means it; otherwise the
// original is not decoded for the whole-frame render
let source_cap = if settings.preview_limit == 0 { lightcraft_engine::SourceLevel::Preview.max_edge() } else { settings.preview_limit as usize };
let edge_at = |scale: f32| settings.loupe_edge(drawn_long.min(canvas_long) * scale, v.native_long, v.texture_side).min(source_cap);
let main_edge = edge_at(scale);
let frame_edge = settings.window_frame_edge(drawn_long, v.native_long);
// a user who capped the preview size has chosen speed over sharpness at fit
let fit = drawn_long <= canvas_long * 1.05;
// (against the undrafted whole-frame render: a drag must not switch the window off and on)
let window_edge = (frame_edge as f32 > edge_at(1.0) as f32 * WINDOW_RATIO && !(fit && settings.preview_limit != 0)).then_some(frame_edge);
LoupePlan { main_edge, window_edge }
}
/// How long the render sizes stay as they were after a pinch or two-finger scroll stops (s).
pub const HOLD_SECS: f64 = 0.25;
/// Holds the loupe's render sizes while a gesture runs (a continuous pinch changes the zoom every
/// frame; re-planning each time would be a stream of renders of every size in between). The view
/// keeps being drawn from the renders it has, magnified by the GPU, and is re-planned once the
/// gesture has been quiet for [`HOLD_SECS`].
#[derive(Clone, Debug, Default)]
pub struct SizeHold {
photo: Option<lightcraft_catalog::PhotoId>,
plan: Option<LoupePlan>,
until: f64,
}
impl SizeHold {
/// The plan to render this frame: `fresh` normally; the plan from before the gesture while
/// one is running (`gesturing`) and for [`HOLD_SECS`] after. `now` is in seconds.
pub fn apply(&mut self, photo: lightcraft_catalog::PhotoId, now: f64, gesturing: bool, fresh: LoupePlan) -> LoupePlan {
if self.photo != Some(photo) {
*self = SizeHold { photo: Some(photo), plan: Some(fresh), until: f64::NEG_INFINITY };
return fresh;
}
if gesturing {
self.until = now + HOLD_SECS;
}
match self.plan {
Some(held) if gesturing || now < self.until => held,
_ => {
self.plan = Some(fresh);
fresh
}
}
}
/// Whether sizes are being held at `now` (the caller asks for a repaint when it ends).
pub fn holding(&self, now: f64) -> bool {
now < self.until
}
}
/// Where a window of one side of a Before/After view may be drawn: its own pane in side by side
/// and stacked views (a window carries margin beyond what its pane shows, which would otherwise
/// spill into the neighbour), its side of the line in a wipe, else the canvas.
pub fn window_clip(mode: crate::state::BeforeAfter, before_side: bool, pane: egui::Rect, canvas: egui::Rect, img: egui::Rect) -> egui::Rect {
use crate::state::BeforeAfter;
let (min, max) = (canvas.min, canvas.max);
let clip = match (mode, before_side) {
(BeforeAfter::Split, true) => egui::Rect::from_min_max(min, egui::pos2(img.center().x, max.y)),
(BeforeAfter::SplitTopBottom, true) => egui::Rect::from_min_max(min, egui::pos2(max.x, img.center().y)),
(BeforeAfter::Split, false) => egui::Rect::from_min_max(egui::pos2(img.center().x, min.y), max),
(BeforeAfter::SplitTopBottom, false) => egui::Rect::from_min_max(egui::pos2(min.x, img.center().y), max),
(BeforeAfter::SideBySide | BeforeAfter::TopBottom, _) => pane,
_ => canvas,
};
clip.intersect(canvas)
}
/// Whether the Before window waits for the After's. Both are cut from the same decoded original;
/// asked for together while it isn't held yet, each worker decodes its own copy (two full-size
/// decodes, two uploads, one thrown away). The Before follows once the original is in.
pub fn defer_before_window(before_side: bool, after_pending: bool, original_held: bool) -> bool {
before_side && after_pending && !original_held
}
/// Whether the picture on screen has the frame's aspect (to the one pixel a texture's whole-pixel
/// size is off by): a window is a part of the frame, so it can only be placed over a picture that
/// is the frame (an unsupported raw's embedded JPEG may be cropped differently).
pub fn same_aspect(shown: f32, frame: f32) -> bool {
shown.is_finite() && frame.is_finite() && frame > 0.0 && (shown / frame - 1.0).abs() <= 0.02
}
/// The window of a `full_w × full_h` frame to render so that `visible` — `(x0, y0, x1, y1)` in the
/// frame's pixels, as much of the frame as is on screen — is covered: expanded by a margin,
/// snapped outwards to the [`SNAP`] grid (margin: see [`margin_for`]) and clamped into the frame,
/// at most `max_span` ([`max_span`]) wide or high. `None`: nothing is visible.
pub fn window_for(full_w: usize, full_h: usize, visible: (f32, f32, f32, f32), max_span: usize) -> Option<PixelWindow> {
if full_w == 0 || full_h == 0 || [visible.0, visible.1, visible.2, visible.3].iter().any(|v| !v.is_finite()) {
return None;
}
let margin = margin_for(full_w.max(full_h));
let axis = |lo: f32, hi: f32, full: usize| -> Option<(usize, usize)> {
let (lo, hi) = (lo.max(0.0), hi.min(full as f32));
if hi <= lo {
return None;
}
let a = (lo as usize).saturating_sub(margin) / SNAP * SNAP;
let b = ((hi.ceil() as usize).saturating_add(margin)).div_ceil(SNAP).saturating_mul(SNAP).min(full);
// a window larger than MAX_SPAN keeps the visible part (centred on it), snapped
if b - a > max_span {
let mid = ((lo + hi) / 2.0) as usize;
let a = mid.saturating_sub(max_span / 2) / SNAP * SNAP;
return Some((a, (a + max_span).min(full)));
}
Some((a, b))
};
let (x0, x1) = axis(visible.0, visible.2, full_w)?;
let (y0, y1) = axis(visible.1, visible.3, full_h)?;
Some(PixelWindow { x: x0, y: y0, w: x1 - x0, h: y1 - y0 })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::AppSettings;
const BIG: usize = 16384;
fn sizes(drawn: f32, canvas: f32, native: usize) -> ViewSizes {
ViewSizes { drawn_long: drawn, canvas_long: canvas, native_long: native, texture_side: BIG, draft_scale: 1.0, windows: true }
}
fn auto() -> AppSettings {
AppSettings::default()
}
// Given a fit view on a Retina Mac (about 2800 px), the preview is enough: no original decoded
#[test]
fn a_retina_fit_view_needs_no_window() {
let p = plan(&auto(), sizes(2800.0, 2800.0, 6000));
assert_eq!(p.main_edge, 2560, "the preview source level");
assert_eq!(p.window_edge, None);
}
// Given a 4K display, the fit view is 1.5x the preview: the window makes it sharp
#[test]
fn a_large_fit_view_gets_a_window() {
let p = plan(&auto(), sizes(3840.0, 3840.0, 6000));
assert_eq!((p.main_edge, p.window_edge), (2560, Some(3840)));
}
// Given 1:1 on a 24 MP photo in a 2800 px canvas, the whole frame stays canvas-sized and the
// window is cut from the photo's own pixels
#[test]
fn one_to_one_renders_the_canvas_and_a_native_window() {
let p = plan(&auto(), sizes(6000.0, 2800.0, 6000));
assert_eq!((p.main_edge, p.window_edge), (2560, Some(6000)));
}
// Given 800 %, the window is still at 100 % (the GPU magnifies it): the cost does not grow
#[test]
fn beyond_one_to_one_the_window_stays_at_native_resolution() {
let p = plan(&auto(), sizes(48000.0, 2800.0, 6000));
assert_eq!((p.main_edge, p.window_edge), (2560, Some(6000)));
let q = plan(&auto(), sizes(480.0 * 100.0, 2800.0, 6000));
assert_eq!(q.window_edge, Some(6000));
}
// Given a small photo zoomed in, the whole-frame render already holds all its pixels
#[test]
fn a_small_photo_needs_no_window() {
let p = plan(&auto(), sizes(8000.0, 1400.0, 1000));
assert_eq!((p.main_edge, p.window_edge), (1000, None));
}
// Given a slider drag at 400 % (drafts at 0.6 scale), the window plan does not change: it is
// the whole-frame draft that gets cheaper
#[test]
fn a_drag_shrinks_the_whole_frame_draft_not_the_window() {
let still = plan(&auto(), sizes(24000.0, 2800.0, 6000));
let drag = plan(&auto(), ViewSizes { draft_scale: 0.6, ..sizes(24000.0, 2800.0, 6000) });
assert!(drag.main_edge < still.main_edge, "{drag:?} vs {still:?}");
assert_eq!(drag.window_edge, still.window_edge);
}
// Given no window (an overlay, soft proofing), the view at rest renders the whole frame at the
// size it is drawn, but a slider drag drafts at the canvas size like the windowed path
#[test]
fn without_a_window_a_drag_drafts_at_the_canvas_size() {
let no_window = |draft_scale: f32| ViewSizes { windows: false, draft_scale, ..sizes(24000.0, 2800.0, 6000) };
let still = plan(&auto(), no_window(1.0));
let drag = plan(&auto(), no_window(0.6));
assert_eq!(still.window_edge, None);
assert!(still.main_edge > 2800, "at rest the zoomed frame: {still:?}");
assert!(drag.main_edge <= 2800, "a drag drafts at most the canvas: {drag:?}");
}
// Given the user capped the preview size, the whole-frame render obeys it, and a fit view
// stays on it (no original decoded); a zoomed view still gets its sharp window
#[test]
fn a_preview_limit_caps_the_whole_frame_render_and_spares_the_fit_view() {
let s = AppSettings { preview_limit: 1600, ..Default::default() };
let fit = plan(&s, sizes(2800.0, 2800.0, 6000));
assert_eq!((fit.main_edge, fit.window_edge), (1600, None));
let zoomed = plan(&s, sizes(6000.0, 2800.0, 6000));
assert_eq!((zoomed.main_edge, zoomed.window_edge), (1600, Some(6000)));
}
// Given a GPU with 2048 px textures, the whole-frame render obeys it, the window frame need not
// (a window is cut to the texture side separately)
#[test]
fn the_texture_side_caps_the_whole_frame_render_only() {
let p = plan(&auto(), ViewSizes { texture_side: 2048, ..sizes(6000.0, 2800.0, 6000) });
assert_eq!((p.main_edge, p.window_edge), (2048, Some(6000)));
}
// Given a picture that is not the frame (an embedded JPEG cropped differently), no window
#[test]
fn a_window_needs_a_picture_with_the_frames_aspect() {
assert!(same_aspect(1.5, 1.5) && same_aspect(1.51, 1.5));
assert!(!same_aspect(1.5, 1.0) && !same_aspect(f32::NAN, 1.0) && !same_aspect(1.0, 0.0));
}
// Given a drag at fit on a 4K or 5K display (where the fit view has a window), the window stays:
// it is only the whole-frame draft that gets smaller
#[test]
fn a_drag_at_fit_on_a_large_display_keeps_the_window() {
for drawn in [3300.0, 3840.0, 4096.0, 5120.0] {
let still = plan(&auto(), sizes(drawn, drawn, 6000));
let drag = plan(&auto(), ViewSizes { draft_scale: 0.6, ..sizes(drawn, drawn, 6000) });
assert!(still.window_edge.is_some(), "{drawn}: {still:?}");
assert_eq!(drag.window_edge, still.window_edge, "{drawn}");
}
}
// Given a mode whose picture can't be a window (a mask overlay, soft proofing, a spot read too
// far for one render), the whole-frame render is as big as the view, as it was before windows
#[test]
fn without_windows_the_whole_frame_is_rendered_at_the_drawn_size() {
let p = plan(&auto(), ViewSizes { windows: false, ..sizes(6000.0, 2800.0, 6000) });
assert_eq!((p.main_edge, p.window_edge), (6000, None));
let fit = plan(&auto(), ViewSizes { windows: false, ..sizes(2800.0, 2800.0, 6000) });
assert_eq!((fit.main_edge, fit.window_edge), (2816, None));
let deep = plan(&auto(), ViewSizes { windows: false, ..sizes(48000.0, 2800.0, 6000) });
assert_eq!(deep.main_edge, 6000, "never above the photo's own pixels");
let small = plan(&auto(), ViewSizes { windows: false, texture_side: 2048, ..sizes(6000.0, 2800.0, 6000) });
assert_eq!(small.main_edge, 2048);
}
// Given a user who chose a preview size above the preview source level (3840 or 5120 on a big
// display), the whole-frame render is that big
#[test]
fn a_large_preview_limit_is_honoured() {
for limit in [3840u32, 5120] {
let s = AppSettings { preview_limit: limit, ..Default::default() };
let p = plan(&s, sizes(5120.0, 5120.0, 6000));
assert_eq!(p.main_edge, limit as usize, "{limit}");
assert_eq!(p.window_edge, None, "a fit view at the limit needs no window");
}
let s = AppSettings { preview_limit: 2560, ..Default::default() };
assert_eq!(plan(&s, sizes(5120.0, 5120.0, 6000)).main_edge, 2560);
}
// Given a medium format photo wider than 8192 px, 1:1 is its own pixels
#[test]
fn a_window_frame_can_be_as_big_as_the_photo() {
let p = plan(&auto(), sizes(40000.0, 2800.0, 11648));
assert_eq!(p.window_edge, Some(11648));
}
// Given a drag at fit on a Retina Mac, the window does not switch on (it would flicker with the drag)
#[test]
fn a_drag_at_fit_does_not_switch_the_window_on() {
let p = plan(&auto(), ViewSizes { draft_scale: 0.6, ..sizes(2800.0, 2800.0, 6000) });
assert_eq!(p.window_edge, None);
}
// Given a pinch from fit to 400 %, the sizes of the first frame hold until the gesture is quiet
#[test]
fn a_pinch_holds_the_render_sizes() {
use lightcraft_catalog::PhotoId;
let (fit, zoomed) = (plan(&auto(), sizes(1400.0, 1400.0, 6000)), plan(&auto(), sizes(24000.0, 1400.0, 6000)));
assert_ne!(fit, zoomed);
let mut hold = SizeHold::default();
let p = PhotoId(1);
assert_eq!(hold.apply(p, 0.0, false, fit), fit);
// the pinch runs for a second: every frame would plan something else
for k in 1..60 {
assert_eq!(hold.apply(p, k as f64 / 60.0, true, zoomed), fit, "frame {k}");
}
// it stopped at t = 59/60: still held for a moment (no flicker between two events)…
assert!(hold.holding(1.1));
assert_eq!(hold.apply(p, 1.1, false, zoomed), fit);
// …then the view is planned for where it ended
assert!(!hold.holding(59.0 / 60.0 + HOLD_SECS + 0.01));
assert_eq!(hold.apply(p, 1.3, false, zoomed), zoomed);
assert_eq!(hold.apply(p, 1.4, false, zoomed), zoomed);
}
// Given another photo, the held sizes are forgotten
#[test]
fn another_photo_is_planned_afresh() {
use lightcraft_catalog::PhotoId;
let (fit, zoomed) = (plan(&auto(), sizes(1400.0, 1400.0, 6000)), plan(&auto(), sizes(24000.0, 1400.0, 6000)));
let mut hold = SizeHold::default();
hold.apply(PhotoId(1), 0.0, false, fit);
assert_eq!(hold.apply(PhotoId(2), 0.1, true, zoomed), zoomed);
}
// Given side by side, a window is drawn in its pane only; given a wipe, on its side of the line
#[test]
fn a_window_is_clipped_to_its_pane_or_its_side_of_the_wipe() {
use crate::state::BeforeAfter::*;
let canvas = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1000.0, 600.0));
let (left, right) = (
egui::Rect::from_min_max(egui::pos2(24.0, 24.0), egui::pos2(494.0, 576.0)),
egui::Rect::from_min_max(egui::pos2(506.0, 24.0), egui::pos2(976.0, 576.0)),
);
assert_eq!(window_clip(SideBySide, true, left, canvas, left), left);
assert_eq!(window_clip(SideBySide, false, right, canvas, right), right);
let img = egui::Rect::from_min_max(egui::pos2(100.0, 50.0), egui::pos2(900.0, 550.0));
let before = window_clip(Split, true, canvas, canvas, img);
let after = window_clip(Split, false, canvas, canvas, img);
assert_eq!((before.max.x, after.min.x), (500.0, 500.0), "the line is the image's middle");
assert_eq!(window_clip(SplitTopBottom, true, canvas, canvas, img).max.y, 300.0);
assert_eq!(window_clip(SplitTopBottom, false, canvas, canvas, img).min.y, 300.0);
assert_eq!(window_clip(Off, false, canvas, canvas, img), canvas);
assert_eq!(window_clip(Original, true, canvas, canvas, img), canvas);
}
// Given Before and After windows asked for at once at first open, only the After's goes: the
// Before's waits for the decoded original instead of decoding another copy
#[test]
fn the_before_window_waits_for_the_original_the_after_is_decoding() {
assert!(defer_before_window(true, true, false));
assert!(!defer_before_window(true, true, true), "the original is in");
assert!(!defer_before_window(true, false, false), "nothing is decoding it");
assert!(!defer_before_window(false, true, false), "the After never waits");
}
// Hostile numbers give a plan, never a panic
#[test]
fn hostile_sizes_give_a_usable_plan() {
for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -3.0, 0.0] {
let p = plan(&auto(), ViewSizes { drawn_long: v, canvas_long: v, draft_scale: v, ..sizes(1.0, 1.0, 6000) });
assert!((8..=2560).contains(&p.main_edge), "{v}: {p:?}");
}
let p = plan(&auto(), sizes(1000.0, 1000.0, 0));
assert!(p.main_edge >= 8);
}
// Given a visible area in the middle of a big frame, the window covers it with a margin, on the grid
#[test]
fn the_window_covers_the_visible_area_with_a_margin_on_the_grid() {
let w = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0), MAX_SPAN).unwrap();
let m = margin_for(48_000);
assert!(w.x + m <= 20_000 && w.x + w.w >= 21_400 + m, "{w:?}");
assert!(w.y + m <= 10_000 && w.y + w.h >= 10_900 + m, "{w:?}");
assert_eq!((w.x % SNAP, w.y % SNAP, w.w % SNAP, w.h % SNAP), (0, 0, 0, 0));
}
// Given a small pan, the same window is kept (no re-render); a big pan changes it
#[test]
fn small_pans_keep_the_window() {
let a = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0), MAX_SPAN).unwrap();
let b = window_for(48_000, 32_000, (20_040.0, 10_030.0, 21_440.0, 10_930.0), MAX_SPAN).unwrap();
assert_eq!(a, b);
let c = window_for(48_000, 32_000, (26_000.0, 10_000.0, 27_400.0, 10_900.0), MAX_SPAN).unwrap();
assert_ne!(a, c);
}
// Given a frame smaller than the margin, or a view at its edge, the window is clamped into it
#[test]
fn the_window_stays_inside_the_frame() {
let w = window_for(3000, 2000, (-500.0, -500.0, 900.0, 700.0), MAX_SPAN).unwrap();
assert_eq!((w.x, w.y), (0, 0));
let w = window_for(3000, 2000, (2500.0, 1500.0, 9000.0, 9000.0), MAX_SPAN).unwrap();
assert!(w.x + w.w <= 3000 && w.y + w.h <= 2000, "{w:?}");
assert_eq!((w.x + w.w, w.y + w.h), (3000, 2000));
}
// Given a deeper zoom, the margin grows with the wide stages' kernels (3σ of the base, clarity
// and dehaze planes are 0.045 of the frame), up to a cap that bounds the window
#[test]
fn the_margin_grows_with_the_zoom_up_to_a_cap() {
assert_eq!(margin_for(1000), SNAP, "never less than one grid step");
assert!(margin_for(8000) > margin_for(3000));
assert_eq!(margin_for(48_000), MAX_MARGIN);
assert_eq!(margin_for(0), SNAP);
assert!(margin_for(usize::MAX) <= MAX_MARGIN);
for l in [100, 3000, 8000, 48_000, 400_000] {
assert_eq!(margin_for(l) % SNAP, 0);
}
}
// Given a tile rendered for other settings, another photo or another zoom, it is not drawn
#[test]
fn a_tile_is_drawn_only_while_it_shows_what_the_loupe_shows() {
use lightcraft_catalog::PhotoId;
let v = RegionView {
photo: PhotoId(1),
before: false,
key: 7,
full: (6000, 4000),
window: PixelWindow { x: 0, y: 0, w: 256, h: 256 },
settings: 11,
};
assert!(v.is_current(PhotoId(1), (6000, 4000), 11, false));
assert!(!v.is_current(PhotoId(2), (6000, 4000), 11, false), "another photo");
assert!(!v.is_current(PhotoId(1), (6000, 4001), 11, false), "another zoom");
assert!(!v.is_current(PhotoId(1), (6000, 4000), 12, false), "another look (an edit, an undo)");
assert!(v.is_current(PhotoId(1), (6000, 4000), 12, true), "a drag's drafts follow the value");
assert!(!v.is_current(PhotoId(2), (6000, 4000), 12, true), "…of this photo only");
}
// Given a GPU that allows 2048 px textures, no window is wider than that
#[test]
fn a_window_fits_the_texture_side() {
for tex in [512, 2048, 4096] {
let span = max_span(tex);
assert!(span <= tex.max(SNAP) && span.is_multiple_of(SNAP), "{tex}: {span}");
let w = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0), span).unwrap();
assert!(w.w <= span && w.h <= span, "{tex}: {w:?}");
}
assert_eq!(max_span(100_000), MAX_SPAN);
assert_eq!(max_span(0), SNAP);
}
// Given nothing visible, or hostile numbers, there is no window (never a panic)
#[test]
fn nothing_visible_or_hostile_input_is_no_window() {
assert_eq!(window_for(1000, 1000, (2000.0, 0.0, 3000.0, 100.0), MAX_SPAN), None);
assert_eq!(window_for(1000, 1000, (10.0, 10.0, 10.0, 90.0), MAX_SPAN), None);
assert_eq!(window_for(0, 1000, (0.0, 0.0, 10.0, 10.0), MAX_SPAN), None);
for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
assert_eq!(window_for(1000, 1000, (v, 0.0, 10.0, 10.0), MAX_SPAN), None);
}
let w = window_for(usize::MAX / 2, usize::MAX / 2, (0.0, 0.0, 1e30, 1e30), MAX_SPAN).unwrap();
assert!(w.w <= MAX_SPAN && w.h <= MAX_SPAN, "{w:?}");
}
// Given a view of an enormous area, the window is bounded
#[test]
fn a_window_is_bounded() {
let w = window_for(100_000, 100_000, (0.0, 0.0, 90_000.0, 90_000.0), MAX_SPAN).unwrap();
assert!(w.w <= MAX_SPAN && w.h <= MAX_SPAN);
}
}
+242 -7
View File
@@ -30,6 +30,11 @@ pub enum Slot {
/// A thumbnail's stand-in (embedded preview of an unedited raw).
ThumbQuick(PhotoId),
Main,
/// The loupe zoomed past what `Main` can hold: the window of the frame that is on screen,
/// rendered at the zoom scale and drawn over `Main` (see [`crate::region`]).
Region,
/// The Before side of a Before/After view zoomed the same way (see [`Slot::Region`]).
RegionBefore,
/// The loupe's stand-in until `Main` has the photo.
Preview,
Before,
@@ -79,6 +84,74 @@ struct Queued {
job: RenderJob,
}
/// The texture of a render: its pixels as egui wants them. Renders are opaque, so the common case
/// is a plain copy; only a pixel with alpha pays for premultiplying. (The generic conversion, on
/// the UI thread, was most of the time a 5 MP zoom window took to show.)
pub(crate) fn color_image(img: &lightcraft_raster::Rgba8) -> egui::ColorImage {
let pixels = img
.data
.iter()
.map(|p| if p[3] == 255 { egui::Color32::from_rgb(p[0], p[1], p[2]) } else { egui::Color32::from_rgba_unmultiplied(p[0], p[1], p[2], p[3]) })
.collect();
egui::ColorImage { size: [img.width, img.height], source_size: egui::vec2(img.width as f32, img.height as f32), pixels }
}
/// How far over the budget the open photo's window and before caches may run before they are cut.
const HARD_FACTOR: usize = 4;
/// Which stage caches to clear, in order, so that the rest fit `budget` bytes.
///
/// What nothing is looking at goes first, the cheapest to rebuild and least reused first (hover,
/// compare, second window, background work). The open photo's own views are what a slider drag
/// reuses on every tick, and a cache cleared every frame is rebuilt every frame (a drag on a
/// 2800 px canvas got 3.6x slower), so they stay — counted and reported — up to
/// [`HARD_FACTOR`] times the budget; past that (a runaway: 8:1 on a huge canvas, both sides of
/// Before/After) the before side's and the zoom window's go, the before side first. The
/// whole-frame view's cache is never named. Empty caches are never named.
pub(crate) fn stage_trim_order(sizes: &[(Slot, usize)], budget: usize) -> Vec<Slot> {
let idle_rank = |s: Slot| match s {
Slot::Hover => 0,
Slot::Compare(_) => 2,
Slot::Second => 3,
_ => 5,
};
let view_rank = |s: Slot| match s {
Slot::RegionBefore => Some(0),
Slot::Before => Some(1),
Slot::Region => Some(2),
_ => None,
};
let is_view = |s: Slot| matches!(s, Slot::Main) || view_rank(s).is_some();
let mut held: usize = sizes.iter().map(|(_, b)| *b).fold(0, usize::saturating_add);
let mut out = Vec::new();
let mut idle: Vec<(Slot, usize)> = sizes.iter().copied().filter(|(s, b)| *b > 0 && !is_view(*s)).collect();
idle.sort_by_key(|(s, _)| idle_rank(*s));
for (slot, bytes) in idle {
if held <= budget {
break;
}
held = held.saturating_sub(bytes);
out.push(slot);
}
let hard = budget.saturating_mul(HARD_FACTOR);
let mut views: Vec<(Slot, usize, u8)> = sizes.iter().filter_map(|(s, b)| view_rank(*s).filter(|_| *b > 0).map(|r| (*s, *b, r))).collect();
views.sort_by_key(|(_, _, r)| *r);
for (slot, bytes, _) in views {
if held <= hard {
break;
}
held = held.saturating_sub(bytes);
out.push(slot);
}
out
}
/// The share of the memory budget the per-view stage caches (the loupe's, the zoom window's, the
/// before / hover renders') may hold together before the idle ones are cleared (see
/// [`stage_trim_order`] for the open photo's own): they are the decoded-image sized buffers that the
/// engine's own caches do not count.
const STAGE_BUDGET_SHARE: usize = 4;
/// Variant thumbnails kept as textures (LRU).
pub const VARIANT_TEXTURES: usize = 96;
/// Priority of variant thumbnail jobs: below on-screen grid thumbnails and the loupe.
@@ -128,6 +201,11 @@ pub struct Renderer {
variant_used: HashMap<u64, u64>,
/// Frames polled so far.
frame: u64,
/// Stage caches cleared to stay in budget (see [`stage_trim_order`]).
stages_trimmed: u64,
/// A budget other than the memory budget's share (tests).
#[cfg(test)]
pub(crate) stage_budget_override: Option<usize>,
/// Since when nothing has been pending, and whether the GPU pool was trimmed since.
#[cfg(not(target_arch = "wasm32"))]
idle: Option<(std::time::Instant, bool)>,
@@ -137,6 +215,14 @@ pub struct Renderer {
#[cfg(not(target_arch = "wasm32"))]
const IDLE_TRIM: std::time::Duration = std::time::Duration::from_secs(3);
impl Slot {
/// An interactive view of the open photo: slider drags redo only the stages they feed, and a
/// draft that finishes late still replaces older pixels.
pub fn is_view(self) -> bool {
matches!(self, Slot::Main | Slot::Region | Slot::RegionBefore | Slot::Before | Slot::Hover)
}
}
impl Default for Renderer {
fn default() -> Self {
let threads = if cfg!(target_arch = "wasm32") { 0 } else { JobPool::<Slot, RenderResult>::default_threads().min(6) };
@@ -173,6 +259,9 @@ impl Renderer {
prefetched: HashMap::new(),
variant_used: HashMap::new(),
frame: 0,
stages_trimmed: 0,
#[cfg(test)]
stage_budget_override: None,
#[cfg(not(target_arch = "wasm32"))]
idle: None,
}
@@ -219,6 +308,12 @@ impl Renderer {
}
true
}
/// Free a view slot's texture and stage cache (the view is no longer shown).
pub fn release(&mut self, slot: Slot) {
self.textures.remove(&slot);
self.stages.remove(&slot);
}
/// Run jobs through `offload` from now on (instead of the job pool / inline).
pub fn set_offload(&mut self, offload: Box<dyn RenderOffload>) {
self.offload = Some(offload);
@@ -247,8 +342,7 @@ impl Renderer {
job.request_id = lightcraft_preview::next_tick();
self.request_ids.insert(slot, job.request_id);
// (an offload keeps its own per-view stage caches; the flag tells it to)
let job =
if matches!(slot, Slot::Main | Slot::Before | Slot::Hover) { job.with_stages(self.stages.entry(slot).or_default().clone()) } else { job };
let job = if slot.is_view() { job.with_stages(self.stages.entry(slot).or_default().clone()) } else { job };
if self.offload.is_some() {
self.seq += 1;
self.queue.retain(|q| q.slot != slot);
@@ -429,6 +523,7 @@ impl Renderer {
self.preview_generation = Some((Arc::downgrade(cache), generation));
self.catalog_rev = session.catalog.revision;
self.frame += 1;
self.trim_stages();
// wasm: run one job per frame on this thread, timed with the host clock
#[cfg(target_arch = "wasm32")]
let inline_ms = {
@@ -450,9 +545,11 @@ impl Renderer {
for (mut slot, r, ms) in finished {
self.completed += 1;
// Ignore obsolete pixels, failures and decoded sources, even for the same render key;
// an interactive view still takes a superseded draft that is newer than what it shows.
// an interactive view still takes a superseded draft that is newer than what it
// shows, and the source it decoded: a drag supersedes every request before it
// finishes, and a source thrown away with them is decoded again for every tick.
if self.request_ids.get(&slot) != Some(&r.request_id) {
let newer_draft = matches!(slot, Slot::Main | Slot::Before | Slot::Hover)
let newer_draft = slot.is_view()
&& self.request_ids.contains_key(&slot)
&& r.request_id >= self.epoch
&& self.shown_ids.get(&slot).is_none_or(|shown| r.request_id > *shown)
@@ -460,11 +557,12 @@ impl Renderer {
if !newer_draft {
continue;
}
session.accept(&r);
} else {
self.request_ids.remove(&slot);
session.accept(&r);
}
if matches!(slot, Slot::Main | Slot::Before | Slot::Hover) {
if slot.is_view() {
self.shown_ids.insert(slot, r.request_id);
}
if self.pending.get(&slot).is_some_and(|p| p.0 == r.key) {
@@ -499,7 +597,7 @@ impl Renderer {
self.textures.remove(&Slot::ThumbQuick(id));
}
let img = &rendered.image;
let color = std::sync::Arc::new(egui::ColorImage::from_rgba_unmultiplied([img.width, img.height], &img.as_bytes()));
let color = std::sync::Arc::new(color_image(img));
let pixels = self.keep_pixels.then(|| color.clone());
let name = format!("{slot:?}");
match self.textures.get_mut(&slot) {
@@ -567,6 +665,26 @@ impl Renderer {
}
}
/// Bytes the per-view stage caches (CPU images and GPU buffers) may hold together.
pub fn stage_budget(&self) -> usize {
#[cfg(test)]
if let Some(b) = self.stage_budget_override {
return b;
}
lightcraft_engine::memory::budget() / STAGE_BUDGET_SHARE
}
/// Clear stage caches, least useful first, until what they hold fits [`Self::stage_budget`].
fn trim_stages(&mut self) {
let sizes: Vec<(Slot, usize)> = self.stages.iter().map(|(slot, c)| (*slot, c.bytes() + lightcraft_engine::gpu::stage_bytes(c))).collect();
for slot in stage_trim_order(&sizes, self.stage_budget()) {
if let Some(c) = self.stages.get(&slot) {
c.clear();
self.stages_trimmed += 1;
}
}
}
/// What the renderer holds: per-view stage caches (CPU images and GPU buffers) and textures.
pub fn memory(&self) -> serde_json::Value {
let cpu: usize = self.stages.values().map(|s| s.bytes()).sum();
@@ -574,7 +692,7 @@ impl Renderer {
let tex: usize = self.textures.values().map(|t| t.size[0] * t.size[1] * 4).sum();
let copies: usize = self.textures.values().filter_map(|t| t.pixels.as_ref()).map(|p| p.pixels.len() * 4).sum();
serde_json::json!({
"stageCaches": {"count": self.stages.len(), "cpuBytes": cpu, "gpuBytes": gpu},
"stageCaches": {"count": self.stages.len(), "cpuBytes": cpu, "gpuBytes": gpu, "budgetBytes": self.stage_budget(), "trimmed": self.stages_trimmed, "sharedSourceBytes": lightcraft_engine::gpu::shared_source_bytes()},
"textures": {"count": self.textures.len(), "bytes": tex, "cpuCopyBytes": copies},
})
}
@@ -1025,3 +1143,120 @@ mod thumbnail_tests {
);
}
}
#[cfg(test)]
mod stage_budget_tests {
use super::*;
const MB: usize = 1 << 20;
// Given stage caches within the budget, nothing is cleared
#[test]
fn nothing_is_trimmed_within_the_budget() {
let sizes = [(Slot::Main, 300 * MB), (Slot::Region, 100 * MB)];
assert_eq!(stage_trim_order(&sizes, 512 * MB), vec![]);
}
// Given too much held, the caches nothing is looking at go first (a hover render's, a compare
// tile's: rebuilt cheaply), the least useful first, and only as many as needed
#[test]
fn the_idle_caches_go_first_and_only_as_many_as_needed() {
let sizes =
[(Slot::Main, 300 * MB), (Slot::Hover, 100 * MB), (Slot::Second, 80 * MB), (Slot::Compare(0), 60 * MB), (Slot::Compare(1), 60 * MB)];
// 600 held, 500 allowed: the hover render's 100 is enough
assert_eq!(stage_trim_order(&sizes, 500 * MB), vec![Slot::Hover]);
// 400 allowed: the compare tiles' too (380 left), then, at 350, the second window's
assert_eq!(stage_trim_order(&sizes, 400 * MB), vec![Slot::Hover, Slot::Compare(0), Slot::Compare(1)]);
assert_eq!(stage_trim_order(&sizes, 350 * MB), vec![Slot::Hover, Slot::Compare(0), Slot::Compare(1), Slot::Second]);
}
// Given the open photo's views over the budget, they are not trimmed: they are what a slider
// drag reuses on every tick, and clearing them every frame costs far more than the memory
// (issue #323 review: 3.6x slower drags on a 2800 px canvas). They are counted and reported,
// and only a runaway (more than HARD_FACTOR times the budget) cuts the window and before
// caches, never the whole-frame view's.
#[test]
fn the_open_photos_views_are_trimmed_only_in_a_runaway_and_never_the_whole_frame() {
let views = [(Slot::Main, 400 * MB), (Slot::Region, 500 * MB), (Slot::RegionBefore, 500 * MB), (Slot::Before, 100 * MB)];
assert_eq!(stage_trim_order(&views, 400 * MB), vec![], "1.5 GB against a 400 MB budget is a busy Before/After, not a runaway");
// against a 100 MB budget (HARD_FACTOR 4 = 400 MB) it is: the before side goes first, then the window
assert_eq!(stage_trim_order(&views, 100 * MB), vec![Slot::RegionBefore, Slot::Before, Slot::Region]);
let mut with_idle = views.to_vec();
with_idle.push((Slot::Hover, 50 * MB));
assert_eq!(stage_trim_order(&with_idle, 400 * MB), vec![Slot::Hover]);
// a whole-frame view of any size stays
assert_eq!(stage_trim_order(&[(Slot::Main, 9000 * MB)], MB), vec![]);
}
// Given caches of slots that hold nothing, they are never "trimmed"
#[test]
fn empty_caches_are_left_alone() {
assert_eq!(stage_trim_order(&[(Slot::Hover, 0), (Slot::Second, 900 * MB)], 100 * MB), vec![Slot::Second]);
}
// Given real stage caches over the budget, the renderer clears them (the open photo's last)
#[test]
fn the_renderer_trims_real_stage_caches() {
use lightcraft_engine::pipeline::{RenderRequest, SourceInfo, render_cached};
let src = std::sync::Arc::new(lightcraft_raster::Rgb32f::from_fn(320, 240, |x, y| [x as f32 / 320.0, y as f32 / 240.0, 0.3]));
let mut r = Renderer::default();
for slot in [Slot::Main, Slot::Hover] {
let cache: Arc<StageCache> = Default::default();
let mut s = lightcraft_engine::develop::DevelopSettings::default();
s.effects.clarity = 50.0;
render_cached(&src, &SourceInfo::default(), &s, &RenderRequest::fit(320, 240), &cache);
assert!(cache.bytes() > 0);
r.stages.insert(slot, cache);
}
let total: usize = r.stages.values().map(|c| c.bytes()).sum();
r.stage_budget_override = Some(total * 3 / 4);
r.trim_stages();
assert_eq!(r.stages[&Slot::Hover].bytes(), 0, "the hover render's cache went");
assert!(r.stages[&Slot::Main].bytes() > 0, "the open photo's stayed");
assert_eq!(r.stages_trimmed, 1);
r.stage_budget_override = Some(0);
r.trim_stages();
assert!(r.stages[&Slot::Main].bytes() > 0, "the open photo's view is never trimmed");
r.trim_stages();
assert_eq!(r.stages_trimmed, 1, "and nothing is trimmed again");
}
// The renderer counts and trims what it holds, and says so
#[test]
fn the_renderer_reports_its_stage_budget() {
let r = Renderer::default();
let m = r.memory();
assert!(m["stageCaches"]["budgetBytes"].as_u64().unwrap() > 0, "{m}");
assert_eq!(m["stageCaches"]["trimmed"], 0);
assert!(m["stageCaches"]["sharedSourceBytes"].is_u64(), "the one device copy of a big original is reported: {m}");
}
}
#[cfg(test)]
mod color_image_tests {
use super::*;
// The texture a render becomes is what egui would make of its bytes, whatever the alpha
#[test]
fn the_fast_conversion_equals_eguis() {
let img = lightcraft_raster::Rgba8::from_fn(37, 29, |x, y| {
let a = match (x + y) % 5 {
0 => 0,
1 => 17,
2 => 128,
_ => 255,
};
[(x * 7) as u8, (y * 9) as u8, (x * y) as u8, a]
});
let want = egui::ColorImage::from_rgba_unmultiplied([img.width, img.height], &img.as_bytes());
let got = color_image(&img);
assert_eq!(got.size, want.size);
assert_eq!(got.pixels, want.pixels);
}
#[test]
fn an_empty_image_is_an_empty_texture() {
let img = lightcraft_raster::Rgba8::new(0, 0);
assert!(color_image(&img).pixels.is_empty());
}
}
+67 -7
View File
@@ -140,8 +140,10 @@ pub struct AppSettings {
pub confirm_delete: bool,
/// GPU rendering allowed (`app.gpu`).
pub gpu: bool,
/// Largest long edge (pixels) the loupe renders at.
pub preview_edge: u32,
/// Largest long edge (pixels) the user lets the loupe render at; 0 = Automatic (the size it is
/// drawn at, up to the photo's own pixels and [`LOUPE_EDGE_CEILING`]). Not the old
/// `previewEdge` key: its 2560 px default was the soft-image bug (issue #323).
pub preview_limit: u32,
/// Edit in External Editor: the application ("" = the system's default for TIFF files).
pub external_editor: String,
/// Memory the caches may hold together, in MB (0 = automatic; `app.memoryBudget`).
@@ -163,7 +165,7 @@ impl Default for AppSettings {
startup_view: StartupView::Last,
confirm_delete: false,
gpu: true,
preview_edge: 2560,
preview_limit: 0,
external_editor: String::new(),
memory_mb: 0,
film_names: true,
@@ -174,8 +176,66 @@ impl Default for AppSettings {
}
}
/// Preview sizes offered in Settings → Performance.
pub const PREVIEW_EDGES: [u32; 4] = [1600, 2560, 3840, 5120];
/// Preview size limits offered in Settings → Performance (0 = Automatic).
pub const PREVIEW_LIMITS: [u32; 5] = [0, 1600, 2560, 3840, 5120];
/// The most the loupe ever renders in one go (memory and GPU texture size), whatever the setting.
pub const LOUPE_EDGE_CEILING: u32 = 8192;
/// The most a zoomed frame (the one a window is cut from) may be along its long edge: far more
/// than the photos there are (a window holds only what is on screen, so the frame costs nothing).
pub const WINDOW_FRAME_CEILING: usize = 65536;
/// The smallest GPU texture side the loupe plans for, whatever the host reports.
pub const MIN_TEXTURE_SIDE: usize = 512;
/// Loupe render sizes move in steps of this many pixels.
pub const LOUPE_EDGE_STEP: usize = 64;
/// Long edge of Build Standard-Sized Previews when the limit is Automatic.
pub const STANDARD_PREVIEW_EDGE: u32 = 2560;
impl AppSettings {
/// Long edge (pixels) to render the loupe at when it is drawn `wanted_px` wide on screen:
/// that size (rounded up to [`LOUPE_EDGE_STEP`], so resizing a window doesn't re-render at
/// every pixel), never above the photo's own `native_long_edge`, the user's limit, the ceiling
/// or `texture_side`, the largest texture the GPU allows (egui_glow, i.e. the browser build,
/// panics on a bigger one, and many WebGL devices allow only 2048 or 4096).
pub fn loupe_edge(&self, wanted_px: f32, native_long_edge: usize, texture_side: usize) -> usize {
let limit = if self.preview_limit == 0 { LOUPE_EDGE_CEILING } else { self.preview_limit.clamp(512, LOUPE_EDGE_CEILING) } as usize;
let ceiling = limit.min(texture_side.max(MIN_TEXTURE_SIDE));
let wanted = if wanted_px.is_nan() { 8.0 } else { wanted_px.clamp(8.0, ceiling as f32) } as usize;
let stepped = wanted.div_ceil(LOUPE_EDGE_STEP) * LOUPE_EDGE_STEP;
stepped.min(native_long_edge.max(8)).min(ceiling)
}
/// Long edge for warming a neighbouring photo: its loupe size but never above the preview
/// source level. A full-size decode would replace the open photo's single full-resolution
/// source in the cache, and nothing is gained by it.
pub fn prefetch_edge(&self, wanted_px: f32, native_long_edge: usize, texture_side: usize) -> usize {
self.loupe_edge(wanted_px, native_long_edge, texture_side).min(lightcraft_engine::SourceLevel::Preview.max_edge())
}
/// Long edge for the hover (preset / profile) and Before renders, which are stand-ins drawn
/// over the loupe: capped at the preview source level like the old default.
pub fn stand_in_edge(&self, loupe_edge: usize, texture_side: usize) -> usize {
loupe_edge.min(lightcraft_engine::SourceLevel::Preview.max_edge()).min(texture_side.max(MIN_TEXTURE_SIDE))
}
/// Long edge of the zoomed frame a window render is cut from, for a photo drawn `drawn_long`
/// px along its long edge: as drawn, but never above the photo's own pixels (beyond 100 % the
/// GPU magnifies the window) or the ceiling. The preview size limit is not applied: it is about
/// the whole-frame render (see [`crate::region::plan`]).
pub fn window_frame_edge(&self, drawn_long: f32, native_long_edge: usize) -> usize {
let wanted = if drawn_long.is_nan() { 8.0 } else { drawn_long.clamp(8.0, WINDOW_FRAME_CEILING as f32) } as usize;
wanted.min(native_long_edge.max(8)).min(WINDOW_FRAME_CEILING)
}
/// The edge Build Standard-Sized Previews uses.
pub fn standard_preview_edge(&self) -> u32 {
if self.preview_limit == 0 { STANDARD_PREVIEW_EDGE } else { self.preview_limit }
}
}
/// Click-zoom ratios offered (percent): 1:1, 2:1, 3:1, 4:1, 8:1.
pub const CLICK_ZOOMS: [u32; 5] = [100, 200, 300, 400, 800];
@@ -685,8 +745,8 @@ impl UiState {
self.brush_size = self.brush_size.clamp(0.002, 0.5);
self.dialog = None;
self.fullscreen = false;
if !crate::state::PREVIEW_EDGES.contains(&self.settings.preview_edge) {
self.settings.preview_edge = AppSettings::default().preview_edge;
if !crate::state::PREVIEW_LIMITS.contains(&self.settings.preview_limit) {
self.settings.preview_limit = AppSettings::default().preview_limit;
}
match self.settings.startup_view {
StartupView::Last => {}
+86
View File
@@ -0,0 +1,86 @@
//! #189: the in-window menu bar's menus and submenus stay below the bar. On a short window a
//! tall menu (Photo) used to be slid up over the bar by egui, hiding the menu titles, and the
//! pointer left on the title then opened the first row's submenu by itself.
use crate::headless::Headless;
use crate::{LightcraftApp, Services};
use serde_json::json;
use std::time::Duration;
const T: Duration = Duration::from_secs(5);
const SETTLE: Duration = Duration::from_secs(10);
fn app(size: [f32; 2]) -> Headless {
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
let mut h = Headless::new(app, size, 1.0);
h.settle(SETTLE);
h
}
fn widget(h: &Headless, id: &str) -> Option<egui::Rect> {
h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r)
}
fn widgets_with(h: &Headless, prefix: &str) -> Vec<(String, egui::Rect)> {
h.app.widgets.iter().filter(|(w, _)| w.starts_with(prefix)).cloned().collect()
}
/// The open level `id` hangs below every menu title and ends inside the window.
fn check_bar_clear(h: &Headless, id: &str, what: &str) {
let level = widget(h, id).unwrap_or_else(|| panic!("{what}: no {id} on screen"));
let titles = widgets_with(h, "menu:");
assert!(!titles.is_empty(), "{what}: no menu titles");
let bar_bottom = titles.iter().map(|(_, r)| r.bottom()).fold(f32::MIN, f32::max);
assert!(level.top() >= bar_bottom - 0.5, "{what}: {id} {level:?} starts above the bar's bottom {bar_bottom}");
assert!(level.bottom() <= h.size.y + 0.5, "{what}: {id} {level:?} runs past the window's bottom {}", h.size.y);
for (title, r) in &titles {
assert!(!level.intersects(*r), "{what}: {id} {level:?} covers {title} {r:?}");
}
}
#[test]
fn no_menu_or_submenu_covers_the_menu_bar() {
// The reporter's 1280 × 720 display less the title bar, and a shorter window where even a
// scrolled menu's submenus must open upward.
let sizes = [[1280.0, 703.0], [1160.0, 560.0]];
let mut submenus = 0;
for size in sizes {
for top in ["Photo", "File", "View"] {
let what = format!("{size:?} {top}");
let mut h = app(size);
assert!(widget(&h, "menu-level:1").is_none(), "{what}: a menu is open before any click");
let r = h.request("ui.clickWidget", json!({"id": format!("menu:{top}")}), T);
assert_eq!(r["ok"], true, "{what}: {r}");
h.settle(SETTLE);
check_bar_clear(&h, "menu-level:1", &what);
// Hover each submenu row in view, top to bottom: rows low in the menu open upward.
let level = widget(&h, "menu-level:1").unwrap_or_else(|| panic!("{what}: menu"));
let rows: Vec<String> = widgets_with(&h, "menusub:").into_iter().filter(|(_, r)| level.contains_rect(*r)).map(|(id, _)| id).collect();
for row in rows {
let r = h.request("ui.hoverWidget", json!({"id": row}), T);
assert_eq!(r["ok"], true, "{what}: {r}");
h.settle(SETTLE);
if widget(&h, "menu-level:2").is_none() {
continue;
}
submenus += 1;
check_bar_clear(&h, "menu-level:2", &format!("{what} › {row}"));
}
}
}
assert!(submenus >= 6, "opened {submenus} submenus");
}
#[test]
fn a_tall_menu_scrolls_instead_of_growing_past_the_window() {
let mut h = app([1160.0, 420.0]);
let r = h.request("ui.clickWidget", json!({"id": "menu:Photo"}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
check_bar_clear(&h, "menu-level:1", "420 pt window");
let level = widget(&h, "menu-level:1").expect("Photo menu");
// Not every row fits: some are scrolled out of view, none drawn outside the menu.
let rows = widgets_with(&h, "menusub:");
assert!(!rows.is_empty(), "no submenu rows registered");
assert!(rows.iter().any(|(_, r)| !level.contains_rect(*r)), "every row fits a 420 pt window: {rows:?}");
}
+705
View File
@@ -0,0 +1,705 @@
//! Issue #323: the Detail view must not be softened by a hidden render-size cap. By default the
//! loupe renders at the size it is drawn at (up to the photo's own pixels and a safety ceiling);
//! "Preview size" in Settings only lowers that when the user asks for it.
use crate::state::{AppSettings, LOUPE_EDGE_CEILING, PREVIEW_LIMITS, STANDARD_PREVIEW_EDGE};
/// A texture side no GPU we run on refuses (see the tests of small ones below).
const BIG: usize = 16384;
fn with_limit(preview_limit: u32) -> AppSettings {
AppSettings { preview_limit, ..Default::default() }
}
// Given a fresh install
#[test]
fn default_is_automatic() {
assert_eq!(AppSettings::default().preview_limit, 0);
assert_eq!(PREVIEW_LIMITS[0], 0, "Automatic is the first choice");
}
// Given Automatic, when a 24 MP photo is shown at 1:1, then the render is the photo's own width
#[test]
fn automatic_renders_a_one_to_one_view_at_native_size() {
assert_eq!(with_limit(0).loupe_edge(6000.0, 6000, BIG), 6000);
}
// Given Automatic, a Retina fit view of 3024 px is not held to the old 2560 px cap
#[test]
fn automatic_does_not_cap_a_fit_view_at_the_old_default() {
assert_eq!(with_limit(0).loupe_edge(3024.0, 6000, BIG), 3072, "rounded up to the next 64 px step");
}
// Dragging a window edge must not re-render (and re-key the caches) on every pixel
#[test]
fn render_sizes_move_in_steps() {
let s = with_limit(0);
assert_eq!(s.loupe_edge(2945.0, 6000, BIG), s.loupe_edge(3008.0, 6000, BIG));
assert_ne!(s.loupe_edge(3008.0, 6000, BIG), s.loupe_edge(3009.0, 6000, BIG));
}
// Neighbour prefetch and hover / before stand-ins stay at the preview level: a full-size decode
// would evict the open photo's single full-resolution source (review of #323)
#[test]
fn prefetch_and_stand_ins_never_ask_for_the_full_source_level() {
use lightcraft_engine::SourceLevel;
let s = with_limit(0);
for wanted in [1000.0, 2560.0, 3024.0, 6000.0, 48000.0] {
assert!(s.prefetch_edge(wanted, 6000, BIG) <= SourceLevel::Preview.max_edge(), "{wanted}");
assert!(s.stand_in_edge(s.loupe_edge(wanted, 6000, BIG), BIG) <= SourceLevel::Preview.max_edge());
}
assert_eq!(s.prefetch_edge(1000.0, 6000, BIG), 1024, "a small view is not raised");
}
// Given Automatic, a small photo zoomed far in is not rendered above its own pixels
#[test]
fn never_renders_above_the_photos_own_pixels() {
assert_eq!(with_limit(0).loupe_edge(8000.0, 1000, BIG), 1000);
}
// Given Automatic, a huge zoom on a huge photo stays under the memory/GPU ceiling
#[test]
fn automatic_is_bounded_by_the_ceiling() {
assert_eq!(with_limit(0).loupe_edge(48000.0, 12000, BIG), LOUPE_EDGE_CEILING as usize);
}
// Given the user chose 1600 px, the render never exceeds it
#[test]
fn an_explicit_limit_caps_the_render() {
assert_eq!(with_limit(1600).loupe_edge(6000.0, 6000, BIG), 1600);
assert_eq!(with_limit(1600).loupe_edge(900.0, 6000, BIG), 960, "and does not raise a small view past its step");
}
// Given a browser whose GPU allows 2048 px textures (egui_glow panics on a bigger one), no render
// the loupe asks for is larger than that: the loupe, its neighbours' prefetch and the stand-ins
#[test]
fn nothing_is_rendered_larger_than_the_gpu_texture_side() {
let s = with_limit(0);
for tex in [512, 2048, 4096, 8192] {
for wanted in [100.0, 2000.0, 6000.0, 48000.0, f32::INFINITY] {
let e = s.loupe_edge(wanted, 12000, tex);
assert!(e <= tex, "loupe {e} > {tex}");
assert!(s.prefetch_edge(wanted, 12000, tex) <= tex);
assert!(s.stand_in_edge(e, tex) <= tex);
}
}
assert_eq!(s.loupe_edge(6000.0, 6000, 2048), 2048);
}
// A host that reports nothing sensible gets the smallest safe sizes, never a panic or zero
#[test]
fn a_nonsense_texture_side_is_clamped() {
let s = with_limit(0);
for tex in [0, 1, 7] {
let e = s.loupe_edge(1000.0, 6000, tex);
assert!((8..=512).contains(&e), "{tex}: {e}");
}
}
// Whatever the inputs (hostile or degenerate), the result is a usable size
#[test]
fn degenerate_inputs_give_a_usable_size() {
let s = with_limit(0);
for wanted in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -5.0, 0.0] {
let e = s.loupe_edge(wanted, 6000, BIG);
assert!((8..=LOUPE_EDGE_CEILING as usize).contains(&e), "{wanted}: {e}");
}
assert!((8..=LOUPE_EDGE_CEILING as usize).contains(&s.loupe_edge(1000.0, 0, BIG)) && s.prefetch_edge(f32::NAN, 0, BIG) >= 8);
assert!(with_limit(7).loupe_edge(1000.0, 6000, BIG) >= 8, "a corrupt limit is clamped");
}
// Build Standard-Sized Previews needs a number even when the limit is Automatic
#[test]
fn standard_previews_use_the_limit_or_the_standard_size() {
assert_eq!(with_limit(0).standard_preview_edge(), STANDARD_PREVIEW_EDGE);
assert_eq!(with_limit(3840).standard_preview_edge(), 3840);
}
// Given a ui.json from before this setting (key previewEdge), when it loads, then the user is on
// Automatic: the old default of 2560 was the bug, and the key no longer means anything
#[test]
fn an_old_saved_preview_edge_does_not_pin_the_cap() {
let s: AppSettings = serde_json::from_str(r#"{"previewEdge": 2560}"#).unwrap();
assert_eq!(s.preview_limit, 0);
}
// A saved limit is kept; an unknown one (hand-edited file) falls back to Automatic
#[test]
fn saved_limits_round_trip_and_unknown_ones_are_reset() {
let mut ui = crate::UiState::default();
ui.settings.preview_limit = 3840;
let back = serde_json::from_str::<crate::UiState>(&serde_json::to_string(&ui).unwrap()).unwrap().sanitized();
assert_eq!(back.settings.preview_limit, 3840);
ui.settings.preview_limit = 123;
assert_eq!(ui.sanitized().settings.preview_limit, 0);
}
// ---- Behaviour in the running UI -------------------------------------------------------------
mod in_the_loupe {
use std::time::Duration;
use serde_json::json;
use crate::headless::Headless;
use crate::render::Slot;
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
const SETTLE: Duration = Duration::from_secs(120);
/// The demo library in a 1400×900 window, the first photo open in Detail.
fn detail() -> (Headless, usize) {
detail_in([1400.0, 900.0])
}
fn detail_in(size: [f32; 2]) -> (Headless, usize) {
let services = Services { png: None, ..Default::default() };
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
let mut h = Headless::new(app, size, 1.0);
let r = h.request("ui.set", json!({"view": "detail", "right": "none", "filmstrip": false}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
let native = {
let id = h.app.session.active().expect("a photo is open");
let p = h.app.session.catalog.photo(id).expect("it is in the catalog");
p.width.max(p.height) as usize
};
(h, native)
}
fn rendered_long_edge(h: &Headless) -> usize {
let t = h.app.renderer.textures.get(&Slot::Main).expect("the loupe rendered");
t.size[0].max(t.size[1])
}
// Given a photo larger than the canvas, when I zoom to 1:1, then the whole frame stays about
// canvas-sized (a drag redoes that, not 24 MP) and the pixels on screen come from a window
// cut from the photo's own pixels
#[test]
fn one_to_one_is_a_canvas_sized_frame_plus_a_native_window() {
let (mut h, native) = detail();
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.settle(SETTLE);
let main = rendered_long_edge(&h);
assert!(main <= 2560 && main < native, "native {native}: the whole-frame render is {main}");
let region = h.app.region_view.expect("a window render");
assert_eq!(region.full.0.max(region.full.1), native, "the window is cut from the photo's own pixels");
let (w, hh) = region_tile(&h).expect("its texture");
assert!(w >= 1300 && hh >= 700, "it covers the canvas: {w}×{hh}");
}
// Given the user capped the preview at 1600 px, then 1:1 is rendered no larger than that
#[test]
fn an_explicit_limit_still_caps_one_to_one() {
let (mut h, native) = detail();
assert!(native > 1600, "the demo photo must be larger than the cap for this to mean anything (is {native})");
h.app.ui.settings.preview_limit = 1600;
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.settle(SETTLE);
assert!(rendered_long_edge(&h) <= 1600, "rendered {}", rendered_long_edge(&h));
}
fn region_tile(h: &Headless) -> Option<(usize, usize)> {
h.app.renderer.textures.get(&Slot::Region).map(|t| (t.size[0], t.size[1]))
}
// Given a fit view, the whole-frame render is all there is: no second render, no original decoded
#[test]
fn a_fit_view_makes_no_window_render() {
let (h, _) = detail();
assert_eq!(region_tile(&h), None);
assert_eq!(h.app.region_view, None);
}
// Given the user capped the preview at 1600 px, when I zoom to 1:1, then the visible part is
// rendered on its own at the zoom scale and drawn over the whole-frame render
#[test]
fn a_capped_one_to_one_view_gets_a_sharp_window() {
let (mut h, native) = detail();
h.app.ui.settings.preview_limit = 1600;
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.settle(SETTLE);
let (w, hh) = region_tile(&h).expect("a window render");
// it covers what is on screen (the 1400×900 window less its chrome), and is not the frame
assert!(w >= 1300 && hh >= 700, "{w}×{hh}");
assert!(w < native && hh < native, "{w}×{hh} of a {native} px frame");
let region = h.app.region_view.expect("the window is described for the inspector");
assert_eq!(region.full.0.max(region.full.1), native, "1:1 means the frame is the photo's own size");
}
// Given 800 % zoom (a frame of 48000 px), the window is rendered and bounded
#[test]
fn eight_to_one_renders_only_what_is_visible() {
let (mut h, native) = detail();
h.app.ui.zoom = crate::state::Zoom::Percent(800.0);
h.settle(SETTLE);
let (w, hh) = region_tile(&h).expect("a window render");
assert!(w.max(hh) <= crate::region::MAX_SPAN && w < native * 8 / 2, "{w}×{hh}");
// …and panning far away asks for another window
let before = h.app.region_view.unwrap().window;
h.app.ui.pan = (0.9, 0.9);
h.settle(SETTLE);
assert_ne!(h.app.region_view.unwrap().window, before);
}
// The window is drawn where it belongs: its pixels agree with the whole-frame render at the
// same place of the frame (a shift, a flip or a wrong scale would not)
#[test]
fn the_window_lines_up_with_the_whole_frame_render() {
let (mut h, _) = detail();
h.app.renderer.keep_pixels = true;
h.app.ui.settings.preview_limit = 1600;
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.settle(SETTLE);
let view = h.app.region_view.expect("a window render");
let tile = h.app.renderer.textures.get(&Slot::Region).and_then(|t| t.pixels.clone()).expect("tile pixels");
let main = h.app.renderer.textures.get(&Slot::Main).and_then(|t| t.pixels.clone()).expect("main pixels");
let (mut sum, mut n) = (0.0f32, 0.0f32);
for j in (8..tile.size[1] - 8).step_by(37) {
for i in (8..tile.size[0] - 8).step_by(37) {
// the same point of the frame in the whole-frame render
let u = (view.window.x + i) as f32 / view.full.0 as f32;
let v = (view.window.y + j) as f32 / view.full.1 as f32;
let (mx, my) =
(((u * main.size[0] as f32) as usize).min(main.size[0] - 1), ((v * main.size[1] as f32) as usize).min(main.size[1] - 1));
let (a, b) = (tile.pixels[j * tile.size[0] + i], main.pixels[my * main.size[0] + mx]);
sum += (0..3).map(|k| (a.to_array()[k] as f32 - b.to_array()[k] as f32).abs()).sum::<f32>() / 3.0;
n += 1.0;
}
}
assert!(sum / n < 6.0, "mean difference {:.2} / 255 between the window and the frame render", sum / n);
}
// Given a Fit view, a slider drag (whose Main draft is at 0.6 scale) does not start window renders
#[test]
fn a_slider_drag_at_fit_makes_no_window_render() {
let (mut h, _) = detail();
h.app.session.begin_interaction("Exposure").unwrap();
for _ in 0..5 {
h.step();
}
assert_eq!(h.app.region_view, None);
assert_eq!(region_tile(&h), None);
}
// Given a zoomed view with a window render, when I zoom back to Fit, then the window's texture is freed
#[test]
fn the_window_texture_is_freed_when_it_is_not_needed() {
let (mut h, _) = detail();
h.app.ui.settings.preview_limit = 1600;
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.settle(SETTLE);
assert!(region_tile(&h).is_some());
h.request("engine.execute", json!({"command": "view.zoomFit"}), T);
h.settle(SETTLE);
assert_eq!(region_tile(&h), None);
assert_eq!(h.app.region_view, None);
}
// Given a zoomed view, when the photo is edited, then the old window (made with the old look)
// is no longer current and is replaced by one for the new look
#[test]
fn an_edit_replaces_the_window() {
let (mut h, _) = detail();
h.app.ui.settings.preview_limit = 1600;
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.settle(SETTLE);
let first = h.app.region_view.expect("a window");
h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 1.0}}), T);
h.settle(SETTLE);
let second = h.app.region_view.expect("a window");
assert_ne!(first.settings, second.settings);
assert_ne!(first.key, second.key);
assert_eq!(h.app.renderer.textures.get(&Slot::Region).map(|t| t.key), Some(second.key), "the new window arrived");
}
// Given a photo with spot removal, a zoomed view still gets its sharp window
#[test]
fn a_photo_with_spots_gets_a_window_too() {
let (mut h, _) = detail();
let id = h.app.session.active().unwrap();
let mut s = (*h.app.session.develop_of(id).unwrap()).clone();
s.spots.push(lightcraft_develop::Spot {
points: vec![lightcraft_geom::Point::new(0.5, 0.5)],
size: 0.01,
source_offset: Some(lightcraft_geom::Point::new(0.05, 0.0)),
..Default::default()
});
h.app.session.set_develop(id, s, "Spot").unwrap();
h.app.ui.settings.preview_limit = 1600;
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.settle(SETTLE);
assert!(region_tile(&h).is_some(), "a window render");
}
// Given a browser GPU that takes only 2048 px textures (egui_glow panics on a bigger one), when
// I zoom to 1:1 and 8:1, then no texture the loupe holds is larger than that
#[test]
fn no_loupe_texture_exceeds_the_gpu_limit() {
let (mut h, _) = detail();
h.max_texture_side = 2048;
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.settle(SETTLE);
for zoom in [crate::state::Zoom::Fit, crate::state::Zoom::Percent(100.0), crate::state::Zoom::Percent(800.0)] {
h.app.ui.zoom = zoom;
h.settle(SETTLE);
for (slot, t) in &h.app.renderer.textures {
assert!(t.size[0] <= 2048 && t.size[1] <= 2048, "{zoom:?} {slot:?}: {}×{}", t.size[0], t.size[1]);
}
let main = h.app.renderer.textures.get(&Slot::Main).expect("loupe");
assert!(main.size[0].max(main.size[1]) >= 700, "still a real render, not a thumbnail: {:?}", main.size);
}
}
// Given 400 % on a big photo, when a slider is dragged, then the work per frame follows the
// canvas, not the zoom: a draft of the whole frame at canvas scale and a draft of the window
#[test]
fn a_slider_drag_when_zoomed_in_renders_canvas_sized_drafts() {
let (mut h, native) = detail();
h.app.ui.zoom = crate::state::Zoom::Percent(400.0);
h.settle(SETTLE);
h.app.session.begin_interaction("Exposure").unwrap();
h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), T);
h.settle(SETTLE);
let main = rendered_long_edge(&h);
assert!(main <= 1000 && main * 4 < native, "the whole-frame draft is {main} px for a {native} px photo");
let region = h.app.region_view.expect("the window is drafted too");
assert_eq!(region.full.0.max(region.full.1), native, "at 100 %, magnified by the GPU");
let (w, hh) = region_tile(&h).expect("a window draft");
assert!(w * hh <= 3_000_000, "{w}×{hh}: the window holds what is on screen, not the frame");
}
fn tile(h: &Headless, slot: Slot) -> Option<(usize, usize)> {
h.app.renderer.textures.get(&slot).map(|t| (t.size[0], t.size[1]))
}
// Given Before/After side by side at 1:1, both sides are sharp windows of the same frame: the
// Before is not a 2560 px stand-in beside a full resolution After
#[test]
fn side_by_side_at_one_to_one_compares_like_with_like() {
let (mut h, native) = detail();
h.app.ui.before_after = crate::state::BeforeAfter::SideBySide;
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.settle(SETTLE);
let (after, before) = (h.app.region_view.expect("After window"), h.app.region_before_view.expect("Before window"));
assert_eq!(after.full, before.full, "the same frame");
assert_eq!(after.full.0.max(after.full.1), native);
let (a, b) = (tile(&h, Slot::Region).expect("After tile"), tile(&h, Slot::RegionBefore).expect("Before tile"));
assert_eq!(a, b, "the same window of each");
let main = h.app.renderer.textures.get(&Slot::Main).unwrap();
let before_main = h.app.renderer.textures.get(&Slot::Before).unwrap();
assert_eq!(main.size, before_main.size, "and the same whole-frame size under them");
}
// Given the Before view alone (\), only the Before gets a window
#[test]
fn the_original_view_windows_only_the_before() {
let (mut h, _) = detail();
h.app.ui.before_after = crate::state::BeforeAfter::Original;
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.settle(SETTLE);
assert!(h.app.region_before_view.is_some());
assert_eq!(h.app.region_view, None);
assert!(tile(&h, Slot::RegionBefore).is_some() && tile(&h, Slot::Region).is_none());
}
// Given a wipe (Split), both sides are windows, drawn over the same rect
#[test]
fn a_wipe_windows_both_sides() {
let (mut h, _) = detail();
h.app.ui.before_after = crate::state::BeforeAfter::Split;
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.settle(SETTLE);
assert_eq!(h.app.region_view.map(|v| v.window), h.app.region_before_view.map(|v| v.window));
assert!(tile(&h, Slot::Region).is_some() && tile(&h, Slot::RegionBefore).is_some());
}
// Given the Before view turned off, its window is freed
#[test]
fn leaving_before_after_frees_the_before_window() {
let (mut h, _) = detail();
h.app.ui.before_after = crate::state::BeforeAfter::SideBySide;
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.settle(SETTLE);
assert!(tile(&h, Slot::RegionBefore).is_some());
h.app.ui.before_after = crate::state::BeforeAfter::Off;
h.settle(SETTLE);
assert_eq!(tile(&h, Slot::RegionBefore), None);
assert_eq!(h.app.region_before_view, None);
assert!(tile(&h, Slot::Region).is_some());
}
// Given a trackpad pinch from fit to well past 100 %, the render sizes are held while it runs:
// no stream of renders of every size in between, and the sharp window arrives once it stops
#[test]
fn a_pinch_holds_the_render_sizes_until_it_stops() {
let (mut h, native) = detail();
let main_at_fit = rendered_long_edge(&h);
let at = h.app.canvas_rect.unwrap().center();
h.request("ui.move", json!({"x": at.x, "y": at.y}), T);
for _ in 0..40 {
h.request("ui.zoom", json!({"factor": 1.08}), T);
assert_eq!(h.app.region_view, None, "no window while the pinch runs");
}
assert!(matches!(h.app.ui.zoom, crate::state::Zoom::Percent(p) if p > 100.0), "{:?}", h.app.ui.zoom);
assert_eq!(rendered_long_edge(&h), main_at_fit, "the whole-frame render keeps its size");
assert!(!h.app.renderer.is_pending(Slot::Region), "no window was asked for during the pinch");
for _ in 0..60 {
h.step();
}
h.settle(SETTLE);
let region = h.app.region_view.expect("the window arrives when the pinch is over");
assert_eq!(region.full.0.max(region.full.1), native);
}
// Given a picture on screen that is not the frame (an unsupported raw's embedded JPEG cropped
// differently, #280), no window is rendered: its coordinates are the frame's
#[test]
fn no_window_over_a_picture_that_is_not_the_frame() {
let (mut h, _) = detail();
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.settle(SETTLE);
assert!(h.app.region_view.is_some());
let t = h.app.renderer.textures.get_mut(&Slot::Main).unwrap();
t.size = [t.size[0], t.size[1] / 2];
h.step();
assert_eq!(h.app.region_view, None);
assert_eq!(region_tile(&h), None);
}
/// `cargo test -p lightcraft-ui-egui --release profile_slider_drag -- --ignored --nocapture`:
/// milliseconds from a slider tick to the loupe showing it, at 100 % and 400 % of the demo
/// library's 24 MP photos (what the maintainers asked for in the review of #351).
#[test]
#[ignore = "a measurement, not a check"]
fn profile_slider_drag() {
for (size, percent) in
[([1400.0, 900.0], 0.0), ([1400.0, 900.0], 100.0), ([1400.0, 900.0], 400.0), ([2800.0, 1700.0], 100.0), ([2800.0, 1700.0], 400.0)]
{
let (mut h, native) = detail_in(size);
h.app.ui.zoom = if percent == 0.0 { crate::state::Zoom::Fit } else { crate::state::Zoom::Percent(percent) };
h.settle(SETTLE);
h.app.session.begin_interaction("Exposure").unwrap();
let mut ms = Vec::new();
for tick in 0..20 {
let t0 = std::time::Instant::now();
let r = h.request(
"engine.execute",
json!({"command": "develop.set", "params": {"control": "light.exposure", "value": tick as f64 * 0.05}}),
T,
);
assert_eq!(r["ok"], true);
let mut frames = 0;
while (h.app.renderer.is_pending(Slot::Main) || h.app.renderer.is_pending(Slot::Region)) && frames < 2000 {
h.step();
std::thread::sleep(std::time::Duration::from_micros(200));
frames += 1;
}
ms.push(t0.elapsed().as_secs_f64() * 1e3);
}
ms.sort_by(|a, b| a.total_cmp(b));
let (w, hh) = region_tile(&h).unwrap_or((0, 0));
eprintln!(
"PROFILE {}x{} canvas, {percent:>4}% (0 = fit) of {native} px: tick→shown median {:.1} ms, p90 {:.1} ms, max {:.1} ms; whole-frame render {} px, window tile {w}×{hh}",
size[0],
size[1],
ms[ms.len() / 2],
ms[ms.len() * 9 / 10],
ms[ms.len() - 1],
rendered_long_edge(&h)
);
eprintln!(" {}", h.app.renderer.memory());
let job_ms = |slot| h.app.renderer.textures.get(&slot).map_or(0.0, |t| t.ms);
eprintln!(" last job times: whole frame {:.1} ms, window {:.1} ms", job_ms(Slot::Main), job_ms(Slot::Region));
}
}
// Given a slider drag that outpaces the renders (each tick supersedes the last), the original
// the window was cut from is kept once it is decoded: not decoded again for every tick
#[test]
fn a_drag_keeps_the_decoded_original_even_when_every_result_is_superseded() {
let (mut h, _) = detail();
assert_eq!(h.app.session.memory_report().full_source.bytes, 0, "fit needed no original");
h.app.ui.zoom = crate::state::Zoom::Percent(400.0);
h.app.session.begin_interaction("Exposure").unwrap();
let mut ticks = 0;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
while !h.app.renderer.textures.contains_key(&Slot::Region) && std::time::Instant::now() < deadline {
let v = (ticks % 40) as f64 * 0.02;
h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": v}}), T);
h.step();
ticks += 1;
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(h.app.renderer.textures.contains_key(&Slot::Region), "a window arrived");
assert!(h.app.session.memory_report().full_source.bytes > 0, "the original stayed in the cache while the drag went on");
}
/// The meshes the last frame drew with `slot`'s texture: (paint order, clip rect, bounds).
fn drawn(h: &Headless, slot: Slot) -> Vec<(usize, egui::Rect, egui::Rect)> {
let Some(tex) = h.app.renderer.textures.get(&slot) else { return vec![] };
h.view
.shapes
.iter()
.enumerate()
.filter_map(|(i, c)| match &c.shape {
egui::epaint::Shape::Mesh(m) if m.texture_id == tex.tex.id() => Some((i, c.clip_rect, m.calc_bounds())),
_ => None,
})
.collect()
}
// Given a wipe at 1:1, the Before window is painted after (over) the low-resolution Before
// stand-in on its side of the line, never under it, and inside its side of the line
#[test]
fn a_wipe_paints_the_before_window_over_its_stand_in() {
for mode in [crate::state::BeforeAfter::Split, crate::state::BeforeAfter::SplitTopBottom] {
let (mut h, _) = detail();
h.app.ui.before_after = mode;
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.settle(SETTLE);
h.step();
let (stand_in, window) = (drawn(&h, Slot::Before), drawn(&h, Slot::RegionBefore));
assert!(!stand_in.is_empty() && !window.is_empty(), "{mode:?}: the stand-in {stand_in:?} and the window {window:?} are both painted");
assert!(window[0].0 > stand_in[0].0, "{mode:?}: the Before window is under its stand-in");
let mid = h.app.image_rect.unwrap().center();
let clip = window[0].1;
match mode {
crate::state::BeforeAfter::Split => assert!(clip.right() <= mid.x + 0.5, "{clip:?} crosses the line at x = {}", mid.x),
_ => assert!(clip.bottom() <= mid.y + 0.5, "{clip:?} crosses the line at y = {}", mid.y),
}
let after = drawn(&h, Slot::Region);
assert!(!after.is_empty());
match mode {
crate::state::BeforeAfter::Split => assert!(after[0].1.left() >= mid.x - 0.5),
_ => assert!(after[0].1.top() >= mid.y - 0.5),
}
}
}
// Given `\` held in a wipe at 1:1, the Before is shown everywhere, so its window is painted
// everywhere (not only left of the line)
#[test]
fn holding_backslash_in_a_wipe_paints_the_before_window_across_the_canvas() {
let (mut h, _) = detail();
h.app.ui.before_after = crate::state::BeforeAfter::Split;
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.settle(SETTLE);
h.events.push(egui::Event::Key {
key: egui::Key::Backslash,
physical_key: Some(egui::Key::Backslash),
pressed: true,
repeat: false,
modifiers: egui::Modifiers::NONE,
});
h.settle(SETTLE);
// (the key's own shortcut switches to the Before view; the wipe is chosen again with the
// key still down, which is what holding `\` in a wipe is)
h.app.ui.before_after = crate::state::BeforeAfter::Split;
h.settle(SETTLE);
h.step();
let canvas = h.app.canvas_rect.unwrap();
let window = drawn(&h, Slot::RegionBefore);
assert!(!window.is_empty());
assert!(window[0].1.right() >= canvas.right() - 0.5, "the Before window stops at the wipe line: {:?}", window[0].1);
}
// Given Before and After side by side at 1:1, each window is painted inside its own pane: its
// margin (a window is wider than what its pane shows) does not spill into the neighbour
#[test]
fn side_by_side_windows_stay_in_their_panes() {
let (mut h, _) = detail_in([1800.0, 900.0]);
h.app.ui.before_after = crate::state::BeforeAfter::SideBySide;
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.settle(SETTLE);
h.step();
let canvas = h.app.canvas_rect.unwrap();
let (before, after) = (drawn(&h, Slot::RegionBefore), drawn(&h, Slot::Region));
assert!(!before.is_empty() && !after.is_empty());
assert!(before.iter().all(|(_, clip, _)| clip.right() <= canvas.center().x), "the Before window spills into the After pane: {before:?}");
assert!(after.iter().all(|(_, clip, _)| clip.left() >= canvas.center().x), "the After window spills into the Before pane: {after:?}");
}
// Given a Before and an After with the same look (an unedited photo), both windows are kept
// apart: the job key is the same, and one tile must not replace the other
#[test]
fn identical_before_and_after_keep_a_tile_each() {
let (mut h, _) = detail();
h.app.ui.before_after = crate::state::BeforeAfter::SideBySide;
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.settle(SETTLE);
assert_eq!(h.app.region_tiles.len(), 2, "{:?}", h.app.region_tiles.keys().collect::<Vec<_>>());
}
// Given soft proofing at 1:1 (a mode that draws one image, so no window), the whole-frame
// render is as big as the view, not a canvas-sized upscale
#[test]
fn soft_proofing_at_one_to_one_renders_the_whole_frame_at_full_size() {
let (mut h, native) = detail();
h.app.ui.soft_proof = true;
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.settle(SETTLE);
assert_eq!(h.app.region_view, None);
assert_eq!(rendered_long_edge(&h), native);
}
// Given a window the photo's spots make too big for one render (refused), the loupe falls back
// to the whole frame at the drawn size, and tries windows again when the look changes
#[test]
fn a_refused_window_falls_back_to_a_full_size_render() {
let (mut h, native) = detail();
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.settle(SETTLE);
assert!(h.app.region_view.is_some());
let id = h.app.session.active().unwrap();
let look = h.app.session.develop_of(id).unwrap().hash64();
h.app.window_refused = Some((id, look, native));
h.settle(SETTLE);
assert_eq!(h.app.region_view, None);
assert_eq!(rendered_long_edge(&h), native);
h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), T);
h.settle(SETTLE);
assert!(h.app.region_view.is_some(), "a new look tries a window again");
}
// Given a pinch out from 400 %, the window there is is kept (magnified, then shrunk) until the
// pinch stops: a new, ever wider window every frame is not asked for
#[test]
fn pinching_out_asks_for_no_new_windows() {
let (mut h, _) = detail();
h.app.ui.zoom = crate::state::Zoom::Percent(400.0);
h.settle(SETTLE);
let key = h.app.region_view.expect("a window").key;
let at = h.app.canvas_rect.unwrap().center();
h.request("ui.move", json!({"x": at.x, "y": at.y}), T);
for _ in 0..12 {
h.request("ui.zoom", json!({"factor": 0.9}), T);
assert_eq!(h.app.region_view.map(|v| v.key), Some(key), "the window of before the pinch");
}
}
// Given a stage budget below what the open photo's views hold (a 2800 px canvas at 1:1), a
// slider drag still reuses them: they are not cleared every frame
#[test]
fn a_drag_over_the_stage_budget_does_not_clear_the_open_views() {
let (mut h, _) = detail();
h.app.ui.zoom = crate::state::Zoom::Percent(100.0);
h.settle(SETTLE);
let held = |h: &Headless| {
let m = h.app.renderer.memory();
(m["stageCaches"]["gpuBytes"].as_u64().unwrap() + m["stageCaches"]["cpuBytes"].as_u64().unwrap()) as usize
};
let before = held(&h);
assert!(before > 3, "the views hold something ({before} bytes)");
// a third of it is the budget: over it, but inside the runaway limit (four times)
h.app.renderer.stage_budget_override = Some(before / 3);
h.app.session.begin_interaction("Exposure").unwrap();
for tick in 0..8 {
h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": tick as f64 * 0.1}}), T);
h.settle(SETTLE);
}
assert_eq!(h.app.renderer.memory()["stageCaches"]["trimmed"], 0);
assert!(held(&h) > before / 3, "and they are still there");
}
}
+117
View File
@@ -0,0 +1,117 @@
//! The integrated title bar behaves like a native one: drag the empty space to move the window,
//! double-click it to zoom and again to restore. Buttons and the search field keep their clicks.
use std::time::Duration;
use egui::ViewportCommand;
use serde_json::json;
use crate::headless::Headless;
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
/// Given LightCraft with the integrated title bar (macOS) in a 1200×800 window.
fn integrated() -> Headless {
let services = Services { png: None, ..Default::default() };
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
app.integrated_titlebar = true;
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
h.settle(Duration::from_secs(120));
h
}
fn widget(h: &Headless, id: &str) -> egui::Rect {
h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}"))
}
/// Empty top-bar space: where the traffic lights sit, left of the sidebar button.
fn empty_spot(h: &Headless) -> egui::Pos2 {
let bar = widget(h, "region:titlebar");
egui::pos2(bar.left() + 20.0, bar.center().y)
}
fn play(h: &mut Headless) {
for _ in 0..6 {
h.step();
}
}
// Scenario: double-clicking the empty top bar zooms the window, and again restores it
#[test]
fn double_click_on_the_empty_bar_zooms_then_restores() {
let mut h = integrated();
let p = empty_spot(&h);
h.request("ui.click", json!({"x": p.x, "y": p.y, "count": 2}), T);
play(&mut h);
assert_eq!(h.window_commands, vec![ViewportCommand::Maximized(true)]);
// a pause longer than egui's multi-click window, or the second pair would count as a triple click
for _ in 0..30 {
h.step();
}
h.request("ui.click", json!({"x": p.x, "y": p.y, "count": 2}), T);
play(&mut h);
assert_eq!(h.window_commands, vec![ViewportCommand::Maximized(true), ViewportCommand::Maximized(false)]);
}
// Scenario: dragging the empty top bar starts a window move
#[test]
fn dragging_the_empty_bar_moves_the_window() {
let mut h = integrated();
let p = empty_spot(&h);
h.request("ui.drag", json!({"x": p.x, "y": p.y, "toX": p.x + 120.0, "toY": p.y + 40.0}), T);
play(&mut h);
assert_eq!(h.window_commands, vec![ViewportCommand::StartDrag]);
}
// Scenario: a single click on the empty bar does nothing
#[test]
fn single_click_on_the_empty_bar_does_nothing() {
let mut h = integrated();
let p = empty_spot(&h);
h.request("ui.click", json!({"x": p.x, "y": p.y}), T);
play(&mut h);
assert!(h.window_commands.is_empty(), "{:?}", h.window_commands);
}
// Scenario: double-clicking a button does not zoom the window
#[test]
fn double_click_on_a_button_does_not_zoom() {
let mut h = integrated();
let r = widget(&h, "icon:help");
h.request("ui.click", json!({"x": r.center().x, "y": r.center().y, "count": 2}), T);
play(&mut h);
assert!(h.window_commands.is_empty(), "{:?}", h.window_commands);
}
// Scenario: pressing a button and dragging off it does not move the window (as in a native title bar)
#[test]
fn dragging_from_a_button_does_not_move_the_window() {
let mut h = integrated();
let r = widget(&h, "icon:help");
h.request("ui.drag", json!({"x": r.center().x, "y": r.center().y, "toX": r.center().x - 150.0, "toY": r.center().y + 30.0}), T);
play(&mut h);
assert!(h.window_commands.is_empty(), "{:?}", h.window_commands);
}
// Scenario: double-clicking the search field selects a word, it does not zoom the window
#[test]
fn double_click_in_the_search_field_does_not_zoom() {
let mut h = integrated();
let r = widget(&h, "field:search");
h.request("ui.click", json!({"x": r.center().x, "y": r.center().y, "count": 2}), T);
play(&mut h);
assert!(h.window_commands.is_empty(), "{:?}", h.window_commands);
}
// Scenario: a window with its native title bar (Windows, Linux) leaves the bar alone
#[test]
fn without_the_integrated_title_bar_the_bar_is_not_a_window_handle() {
let services = Services { png: None, ..Default::default() };
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
h.settle(Duration::from_secs(120));
assert!(!h.app.integrated_titlebar);
let bar = h.app.widgets.iter().find(|(w, _)| w == "region:titlebar");
assert!(bar.is_none());
}
+71
View File
@@ -0,0 +1,71 @@
//! 100 % means the photo's own pixels as it is shown: after the crop and the user's rotation.
use std::time::Duration;
use serde_json::json;
use crate::headless::Headless;
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
const SETTLE: Duration = Duration::from_secs(120);
/// The demo library in a 1400×900 window at 1 px per point, the first photo open in Detail.
fn detail() -> Headless {
let services = Services { png: None, ..Default::default() };
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
let mut h = Headless::new(app, [1400.0, 900.0], 1.0);
let r = h.request("ui.set", json!({"view": "detail", "right": "none", "filmstrip": false}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
h
}
fn edit(h: &mut Headless, f: impl FnOnce(&mut lightcraft_develop::DevelopSettings)) {
let id = h.app.session.active().expect("a photo is open");
let mut s = (*h.app.session.develop_of(id).expect("settings")).clone();
f(&mut s);
h.app.session.set_develop(id, s, "Test").expect("edit");
}
fn at_100(h: &mut Headless) -> (f32, f32) {
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.settle(SETTLE);
let r = h.app.image_rect.expect("the loupe drew the photo");
(r.width(), r.height())
}
fn size_of(h: &Headless) -> (f32, f32) {
let id = h.app.session.active().expect("a photo is open");
let p = h.app.session.catalog.photo(id).expect("photo");
(p.width as f32, p.height as f32)
}
// Given an uncropped photo, 100 % is its pixel size
#[test]
fn an_uncropped_photo_at_100_is_its_pixel_size() {
let mut h = detail();
let (nw, nh) = size_of(&h);
let (w, hh) = at_100(&mut h);
assert!((w - nw).abs() < 1.5 && (hh - nh).abs() < 1.5, "{w}×{hh} for {nw}×{nh}");
}
// Given a crop to half the width and a third of the height, 100 % is the cropped pixels
#[test]
fn a_cropped_photo_at_100_is_the_crops_pixel_size() {
let mut h = detail();
let (nw, nh) = size_of(&h);
edit(&mut h, |s| s.crop.geometry.rect = lightcraft_geom::Rect { x0: 0.25, y0: 0.2, x1: 0.75, y1: 0.2 + 1.0 / 3.0 });
let (w, hh) = at_100(&mut h);
assert!((w - nw / 2.0).abs() < 1.5 && (hh - nh / 3.0).abs() < 1.5, "{w}×{hh} for a crop of {}×{}", nw / 2.0, nh / 3.0);
}
// Given a photo rotated a quarter turn, 100 % is its pixel size with the axes swapped
#[test]
fn a_rotated_photo_at_100_is_its_pixel_size_swapped() {
let mut h = detail();
let (nw, nh) = size_of(&h);
edit(&mut h, |s| s.orientation = lightcraft_geom::Orientation::Rotate90);
let (w, hh) = at_100(&mut h);
assert!((w - nh).abs() < 1.5 && (hh - nw).abs() < 1.5, "{w}×{hh} for {nh}×{nw}");
}
+76
View File
@@ -0,0 +1,76 @@
//! The integrated title bar's window gestures (macOS: the native title bar is hidden and the top
//! bar takes its place, so dragging and double-click-to-zoom have to be done here).
//!
//! Pure policy, no egui widgets: [`command_for`] maps a gesture on the bar's empty space and the
//! window's current state to the viewport command the host should run.
use egui::ViewportCommand;
/// What the user did on the bar's empty space.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Gesture {
/// A primary-button drag began: the window follows the pointer.
DragStarted,
/// A primary-button double-click: zoom the window, or restore it when already zoomed.
DoubleClicked,
}
/// The window as the host reports it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct WindowState {
/// Zoomed (macOS) / maximized (elsewhere).
pub maximized: bool,
/// In the OS's full-screen mode.
pub fullscreen: bool,
}
/// The viewport command for `gesture`, if any. Full screen has no title bar to drag or zoom.
pub fn command_for(gesture: Gesture, window: WindowState) -> Option<ViewportCommand> {
if window.fullscreen {
return None;
}
match gesture {
Gesture::DragStarted => Some(ViewportCommand::StartDrag),
Gesture::DoubleClicked => Some(ViewportCommand::Maximized(!window.maximized)),
}
}
#[cfg(test)]
mod tests {
use super::*;
const NORMAL: WindowState = WindowState { maximized: false, fullscreen: false };
const ZOOMED: WindowState = WindowState { maximized: true, fullscreen: false };
const FULL: WindowState = WindowState { maximized: false, fullscreen: true };
// Scenario: dragging the title bar moves the window
#[test]
fn given_a_normal_window_when_dragging_then_the_window_follows_the_pointer() {
assert_eq!(command_for(Gesture::DragStarted, NORMAL), Some(ViewportCommand::StartDrag));
}
// Scenario: a zoomed window can still be dragged (macOS lets you pull it free)
#[test]
fn given_a_zoomed_window_when_dragging_then_the_window_follows_the_pointer() {
assert_eq!(command_for(Gesture::DragStarted, ZOOMED), Some(ViewportCommand::StartDrag));
}
// Scenario: double-click zooms
#[test]
fn given_a_normal_window_when_double_clicking_then_it_zooms() {
assert_eq!(command_for(Gesture::DoubleClicked, NORMAL), Some(ViewportCommand::Maximized(true)));
}
// Scenario: double-click again restores
#[test]
fn given_a_zoomed_window_when_double_clicking_then_it_is_restored() {
assert_eq!(command_for(Gesture::DoubleClicked, ZOOMED), Some(ViewportCommand::Maximized(false)));
}
// Scenario: full screen ignores both
#[test]
fn given_a_full_screen_window_then_neither_gesture_does_anything() {
assert_eq!(command_for(Gesture::DragStarted, FULL), None);
assert_eq!(command_for(Gesture::DoubleClicked, FULL), None);
}
}
+9 -2
View File
@@ -1,6 +1,6 @@
# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF and Canon CR3 starting look
# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2 and Pentax PEF starting look
ARW, NEF, RW2, RAF and CR3 decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost.
ARW, NEF, RW2, RAF, CR3, CR2 and PEF decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost.
## Colour and tone are separate
@@ -84,3 +84,10 @@ Nikon maker-note `CropArea` (0x0045) supplies the default `[left, top, width, he
An embedded JPEG with neither ICC nor EXIF metadata uses Nikon maker-note `ColorSpace` (0x001e: 1 = sRGB, 2 = Adobe RGB). This fallback is applied before transfer decoding and resizing, and used by quick previews, preview-only RAW and the existing camera-look reference decode. A JPEG's own metadata takes precedence. No fitting algorithm, training split, highlight reconstruction or white-balance control changes are included. Render cache version 12 invalidates older previews.
Synthetic tests cover crop bounds/CFA preservation, colour-space tags, fallback transfer decoding and metadata precedence. A private Nikon Z 8 sample reports an 8256 × 5504 default crop from an 8280 × 5520 sensor. No private sample or Adobe asset is included.
### Canon CR2 and Pentax PEF
CR2 and PEF decode without a camera colour matrix too, but were left out of the fit until issue #310 (CR2 files opening
flat and desaturated: the camera's preview first, then the neutral fallback). They now use the same fit, gates and
relative white balance. This change was not measured on the corpus: the gates keep the fallback for any file whose fit
doesn't pass, so a file can only stay as before or move closer to its camera JPEG. Per-model numbers are still to come.
+1 -1
View File
@@ -30,7 +30,7 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation:
| `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled |
| `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) |
| `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus |
| `ui.inspect` | — | UI state, window, canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: unsaved changes; `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`) |
| `ui.inspect` | — | UI state, window, `loupe` (`source`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: unsaved changes; `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes) |
| `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) |
| `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, fx?, fy?}` / `{id, toX?, toY?, dx?, dy?, steps?}` / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews) |
| `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points |
+5 -2
View File
@@ -22,8 +22,11 @@ CRX version `0x100`, four Bayer planes and `ff01`/`ff02`/`ff03` marker headers
support lossless RAW and three-level C-RAW with horizontal tiles. Uniform
quantization values 4–43 are independently verified. Version `0x200` C-RAW
supports the `ff11`/`ff12`/`ff13` marker family, a single 14-bit Bayer tile and
its adaptive quantization map. Other unverified coding variants return explicit
unsupported errors. Header probing validates the same coding variants and
its adaptive quantization map. C-RAW is quantized, so next to clipped highlights
its reconstruction can overshoot the sensor range by a few quantization steps
(up to 36 codes above 16383 in 17 CC0 files); those samples are clamped to the
CMP1 bit depth. A lossless sample outside the bit depth is still corrupt data.
Other unverified coding variants return explicit unsupported errors. Header probing validates the same coding variants and
tile/plane layout as full decoding.
`crates/raw/tests/cr3_corpus.rs` checks every full-sensor sample against a
+16 -1
View File
@@ -69,6 +69,16 @@ rendering; `set VK_LOADER_DRIVERS_DISABLE=*igvk64*` (Vulkan loader) hides a spec
from every program started with it. Help ▸ System Info and `app.gpu` show the adapter and backend in
use (e.g. `Intel(R) UHD Graphics 630 (Dx12)`).
**Window never appears, "not responding" (Windows, NVIDIA; issue #374).** With *Shader Cache Size*
set to **Disabled** in NVIDIA Control Panel (Manage 3D settings → Global Settings), NVIDIA's driver
walks the whole system drive while the window's GPU device is created (`D3D12CreateDevice` →
`nvwgf2umx.dll` → `NvMemMapStoragex.dll!TotalDiskUsage`), before the first frame: a black,
unresponsive window for minutes. The Vulkan driver shares that code, so another backend doesn't
help. Set *Shader Cache Size* back to **Driver Default** (or any size); the setting is global, there
is no per-program override. Other programs that create a DX12 or Vulkan device at startup hang the
same way (PhotoCraft does). It is a driver issue: nothing in LightCraft itself
walks the drive.
## Where it is used
- `lightcraft_engine::media::develop` (called by every `RenderJob`): loupe / before / compare views,
`render_now` (CLI, MCP, control channel renders) and exports render on the GPU when one is
@@ -171,7 +181,11 @@ bounds **mean |Δ| < 0.5 LSB and max |Δ| ≤ 3 LSB** per channel. Measured (App
three mask sets incl. CPU-evaluated shapes, spots + defringe), all 8 orientations ± crop, embedded
DNG lens data, display-referred sources, draft quality, full-size renders; the 24 MP export in
`render_bench` also differs by max 1 LSB. Cached (slider-drag) GPU renders are bit-identical to
fresh ones. The tests skip (pass with a note) when no adapter exists.
fresh ones. `windows_match` runs the settings cases again as a window of a 3000 × 2000 frame
(`RenderRequest::window`, the zoomed loupe, issue #323) plus a vignette in a corner and a spot reading
from outside the window: the vignette geometry arrives as the `VIG_VIEW` parameter, the airlight and
noise-reduction size come from the whole frame, and a window that spots or Auto Mask strokes grew is
cut back after the readback. The tests skip (pass with a note) when no adapter exists.
Remaining differences come from f32 vs f64 coordinate math, fast-math transcendental functions on
Metal and running-sum order in the box filters — all far below one 8-bit step.
@@ -208,6 +222,7 @@ the device on their first GPU render. `lightcraft_gpu::ready()` asks without blo
developed image in ~50 ms.
## Memory (M5.6)
A view's stages keep an uploaded source only up to 96 MB (a Preview-level source); a bigger one (the original a zoom window is cut from) is kept once, outside the views' stages, for all views of the photo (the Before and After windows and every pan share it; the Before window of a Before/After waits at first open until the After's job has handed the original over, or each would decode and upload its own copy) and given back when the app idles (issue #323: kept per view it counted against the stage budget on every tick of a drag, and not kept every new window uploaded 288 MB again).
- `library.memory` reports what the engine's caches hold (decoded thumbnail / preview / full-size
sources, rendered previews) and the GPU renderer's device buffers (allocated, of which pooled
and retired); `ui.inspect` → `memory` adds the loupe's stage caches (CPU images, GPU buffers)
+15 -10
View File
@@ -44,13 +44,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
| V. Preferences (PREF) | 6 | 0 | 3 | 2 | 1/1 (100%) | 4/4 (100%) |
| W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — |
| X. Cross-cutting behaviours (BEHAV) | 17 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) |
| Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
| X. Cross-cutting behaviours (BEHAV) | 17 | 5 | 1 | 1 | 8/8 (100%) | 6/8 (75%) |
| Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
| Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) |
| Lightroom Classic extras | 32 | 11 | 39 | 9 | — | 22/24 (92%) |
| **Total** | 392 | 35 | 86 | 36 | 193/200 (97%) | 142/152 (93%) |
| **Total** | 393 | 36 | 86 | 36 | 193/200 (97%) | 142/152 (93%) |
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 513 in-scope rows — P0 98.2% of 200 · P1 95.7% of 152 · P2 41.6% of 160.
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 515 in-scope rows — P0 98.2% of 200 · P1 95.7% of 152 · P2 42.0% of 162.
<!-- /parity:summary -->
## Top gaps
@@ -71,6 +71,8 @@ Take the first one nobody is working on.
colour-filter layout, fixed in #85 by reading the file's own tag).
4. **LR-BEHAV-RENDER-FIDELITY** (P1): a side-by-side fidelity suite against Lightroom renders (kept local in `plan/`),
then tune tone, highlights, texture/clarity/dehaze, NR and sharpening against it.
4b. **LR-VIEW-ZOOM** (P0): finish the sharp zoomed views of issue #323: windows for compare, overlays and soft proofing
(Before/After has them); check sharpness and the wide-stage margin on real raw photos (only synthetic scenes so far).
5. **LR-EDIT-OPTICS-PROFILE** (P1): a lens-profile database of our own (corrections embedded in DNG files and the
distortion correction Panasonic / Leica cameras record in RW2 / RWL files work today; issue #256).
6. **AI masks and Enhance** (LR-MASK-SUBJECT / SKY / PEOPLE / OBJECTS, LR-EDIT-DETAIL-DENOISE, SUPERRES, LENSBLUR):
@@ -132,7 +134,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-IMP-PRESET | Preset on import | P2 | ✅ | `cmd:library.import` (`preset`) | chosen in the import review; one History entry |
| LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it |
| LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | |
| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported; profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported |
| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported |
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) |
| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars |
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop; full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS TIFFs: IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", HEIC/AVIF decode, palette (indexed-colour) TIFF (refused by the `tiff` crate) |
@@ -183,7 +185,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | |
| LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen |
| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid |
| LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). Missing: native-resolution detail beyond the configured whole-image preview size (2,560 px default); no visible-region tiles |
| LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render |
| LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans |
| LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session |
| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `crates/ui-egui/src/panels/compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; no zoom-link toggle |
@@ -265,7 +267,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-EDIT-DETAIL-SUPERRES | Super resolution | P2 | ⬜ | | |
| LR-EDIT-DETAIL-AISHARPEN | AI sharpen | OOS | 🚫 | | |
| LR-EDIT-OPTICS-CA | Remove chromatic aberration | P1 | ✅ | `crates/ui-egui/src/panels/edit.rs` (checkbox), `ctl:optics.caRed`, `ctl:optics.caBlue` | |
| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); vignetting and lateral CA data of other raw formats (Olympus, Fujifilm, Sony) not read yet; no lens-profile database |
| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); vignetting and lateral CA data of other raw formats (Olympus, Fujifilm, Sony) not read yet; no lens-profile database |
)
| LR-EDIT-OPTICS-DEFRINGE | Defringe | P1 | ✅ | `ctl:optics.defringe*` | no fringe eyedropper |
| LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | |
| LR-EDIT-GEOM-UPRIGHT | Upright | P1 | ✅ | `cmd:geometry.upright`, `cmd:geometry.guides` | |
@@ -287,7 +290,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome |
| LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | |
| LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | |
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138); Sony ARW, Nikon NEF, Panasonic RW2 and Fujifilm RAF and Canon CR3 get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25) (`docs/camera-preview-colour.md`); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today |
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2 and Pentax PEF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25) (`docs/camera-preview-colour.md`); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today |
| LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched |
| LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | |
| LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF |
@@ -445,7 +448,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-EXP-HDR | HDR output | P2 | ⬜ | | |
| LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | |
| LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | |
| LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`) or a graphic with transparency (`image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor`, `inset` (fraction of the short edge, 0..0.4), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183) |
| LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (`image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor`, `inset` (fraction of the short edge, 0..0.4), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183) |
| LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`, `startNumber`), `crates/engine/src/rename.rs` (`expand_tokens`) | one template language for export, Rename Photos and import renaming: `{name}`, `{num}` (number at the end of the name), `{seq}` / `{seq:N}`, `{date}` / `{date:%Y-%m-%d}`, `{folder}`, `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; free-form template rather than a list of named schemes |
| LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) |
| LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet |
@@ -478,7 +481,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-PREF-LOCALSTORAGE | Storage & cache | P1 | ✅ | `cmd:library.preferences`, `cmd:library.clearPreviews`, `cmd:library.compact` | thumbnail cache size + clear in Settings → Performance; library location + Open Library… in General |
| LR-PREF-ACCOUNT | Account | OOS | 🚫 | | |
| LR-PREF-INTERFACE | Interface options | P1 | ✅ | `cmd:app.settings` | filmstrip names/badges, grid badges (auto/always/never), square-grid names, navigator, info overlay |
| LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off, preview size (1600–5120 px), memory budget, thumbnail cache size in Settings |
| LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off, preview size (Automatic = the whole-frame loupe render at canvas size, up to 2560 px; or a 1600–5120 px size for it; zoomed views add a window at up to 100 %, issue #323), memory budget (a quarter of it is the idle per-view stage caches' share; the open photo's views are counted and reported, and cut only past four times that, the before side's and the zoom window's first — a Before/After at 1:1 on a 2800 px canvas holds about 1 GB of GPU stages, plus the one 288 MB copy of the original, which `ui.inspect` reports as `sharedSourceBytes`), thumbnail cache size in Settings |
| LR-PREF-PEOPLE | Face recognition | P2 | ⬜ | | |
| LR-PREF-WATERMARK | Watermark settings | P1 | ✅ | `cmd:export.savePreset`, `crates/ui-egui/src/panels/dialogs.rs` | set in the Export dialog; kept with Export with Previous and in saved export presets |
| LR-PREF-SHORTCUTS | Shortcut customisation | — | ✅ | `cmd:app.setShortcut`, `cmd:app.resetShortcuts`, `crates/ui-egui/src/panels/keymap.rs` | an extra (the reference app has no keymap editor): Help ▸ Keyboard Shortcuts (⌘/) lists every command; click a shortcut and press the new keys (Esc cancels), × removes, ↺ restores, Reset All; a key another command had moves over; saved in `ui.json`; native menu accelerators follow. Rating/label digits and secondary keys are listed read-only and yield to a key given to a command |
@@ -522,6 +525,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | |
| LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights |
| LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | |
| LR-BEHAV-TITLEBAR | Title bar drag and double-click zoom (macOS) | P2 | 🟡 | `crates/ui-egui/src/titlebar.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/tests_titlebar.rs` | the native title bar is hidden on macOS, so the top bar's empty space (including the traffic-light margin) moves the window and a double-click zooms or restores it; buttons and the search field keep their clicks; ignored in full screen. Zoom is winit's `Maximized`: it does not follow System Settings › “Double-click a window's title bar to” (Fill / Minimize). a press that starts on a button or the search field never moves the window. Known gap: egui-winit reads the zoomed state only at start-up and after our own `Maximized` command, so a window zoomed another way (green button, Window ▸ Zoom, snapping) makes the first double-click a no-op before the toggle catches up. Checked by hand on a real macOS window (drag, zoom, restore) |
## Y. Menus
@@ -614,6 +618,7 @@ visible menu bar yet: the menu model is only exposed through the control channel
| MENU-HELP-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew` | |
| MENU-HELP-SHORTCUTS | Keyboard shortcuts | P1 | ✅ | `cmd:app.shortcuts` | |
| MENU-HELP-FEEDBACK | Send feedback | P2 | ✅ | `cmd:app.feedback` | opens a new issue on the project's GitHub |
| MENU-HELP-LOGFOLDER | Open log folder | P2 | ✅ | `cmd:app.openLogFolder` | an extra (the reference app has no such item): Help ▸ Open Log Folder reveals `logs/lightcraft.log` in the system file manager (Finder, Explorer, or the folder on Linux); off on the web and in `--memory` sessions, which keep no log (issue #260) |
| MENU-HELP-SYSINFO | System info | P2 | ✅ | `cmd:app.systemInfo`, `cmd:library.info` | Help ▸ System Info…: version, OS, CPU threads, GPU, memory budget, preview size, library, timings; Copy to Clipboard; JSON for agents (`open: false`) |
| MENU-CTX-GRID | Photo context menu | P0 | ✅ | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, label, add to / remove from album, rename, virtual copy, version, stack, copy / paste / paste selected, reset, merge, rotate, show in Finder, export / export with preset, set as album cover (in an album), delete (restore / delete permanently in Recently Deleted) |
| MENU-CTX-DETAIL | Loupe context menu | P1 | ✅ | `crates/ui-egui/src/panels/detail.rs` | Zoom submenu (fit, 100%, in, out), then the photo menu |
+20
View File
@@ -0,0 +1,20 @@
# Vertical text watermarks
Set `watermark.vertical` to `true` in export options, or choose Vertical in the export dialog. Text runs down each column; a newline starts the next column to its left. The size, nine anchors, inset, opacity, colour and shadow use the same controls as horizontal watermarks. Existing presets without `vertical` remain horizontal.
## Character cells
A vertical cell contains one extended grapheme cluster, not one Unicode scalar. For example, `が` and `か` followed by U+3099 COMBINING KATAKANA-HIRAGANA VOICED SOUND MARK occupy the same space and produce the same image with the Japanese fonts from craft-fonts. The source string is not normalized or rewritten. CRLF and LF each start one new column.
The renderer shapes the complete cluster, retaining all resulting glyphs and their offsets, even when a base and its combining mark remain separate glyphs. It tries the existing watermark faces in order and rejects a shaped cluster containing a missing glyph. Without a suitable font, the existing fallback draws the first character's missing-glyph box in one cell rather than allocating cells to its marks or variation selectors.
Japanese cells use the font's vertical substitutions and origins. Latin text remains upright, as in the existing watermark renderer; clusters with Latin combining marks are shaped horizontally within their cell. This is not a general mixed-script paragraph composer. It does not add rotated Latin runs, tate-chu-yoko, ruby, kinsoku, automatic column wrapping or mojikumi spacing.
## References and scope
- [Unicode UAX #29, §3 Grapheme Cluster Boundaries](https://www.unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries) specifically identifies vertical-text segmentation as a use for grapheme clusters and states that boundaries must not split combining sequences. This is the basis for the cell fix.
- [OpenType `vert`](https://learn.microsoft.com/en-us/typography/opentype/spec/features_uz#tag-vert) describes upright vertical forms and their script sensitivity. The renderer uses harfrust's top-to-bottom shaping for Japanese cells, retaining the previously implemented font-origin placement.
- [W3C Requirements for Japanese Text Layout](https://www.w3.org/TR/jlreq/) describes Japanese paragraph composition, including punctuation spacing and prohibited line boundaries. Those rules are relevant to a future paragraph composer; this fix does not claim full JLReq conformance.
- [Lightroom Classic's public watermark workflow](https://helpx.adobe.com/lightroom-classic/help/using-watermark-editor.html) documents text/graphic watermarks, opacity, size, inset and nine anchors. It does not document Japanese vertical composition. LightCraft's vertical cells are an extension, not a claim to match an undocumented Lightroom vertical-text mode.
Regression coverage in `crates/engine/src/export.rs` compares actual coverage pixels for composed and decomposed Japanese and Latin text, checks multi-glyph mark placement, and retains the existing punctuation and right-to-left-column tests. Japanese font-specific comparisons run with `CRAFT_FONTS_DIR`; Latin and missing-font comparisons run without it.
+11
View File
@@ -15,6 +15,8 @@
belonged to another command moves to the new one. Menus show the new keys; agents use `app.setShortcut`.
### RAW decoding
- Canon CR2 and Pentax PEF raws get the same starting look fitted to the camera's own JPEG as ARW, NEF, RW2, RAF
and CR3, instead of opening flat and desaturated (issue #310). Photos already imported pick it up when re-rendered.
- Panasonic and Leica raws (RW2, RWL) are now corrected for lens distortion the way the camera corrects its own JPEG
(issue #256): the correction the camera records in the file is applied under Lens Corrections ▸ Enable Profile
Corrections, on by default for newly imported photos, with the same framing as the camera's JPEG. At 12 mm the
@@ -45,6 +47,11 @@
- Luminar looks: `.lmp` files and `.mplumpack` collections import as presets (grouped by collection); the sliders
with a counterpart here come along, the rest is listed.
- 23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners.
- Importing XMP presets no longer lists bookkeeping fields (`Cluster`, `SortName`, `SupportsAmount2`, the as-shot
white, empty Point Color slots…) as settings that couldn't be carried over.
- A preset whose lens-profile switch is off no longer turns off the lens corrections built into a DNG (iPhone ProRAW
and other files with embedded distortion / vignetting corrections), matching what the preset does elsewhere.
- A red / green / blue curve in an XMP preset without the master curve is ignored, as Lightroom ignores it.
- Imported `.cube` LUT profiles appear in the Profile menu and the profile browser, grouped by their folder, and stay
favourites across restarts (issue #328).
@@ -82,6 +89,8 @@
### Library
- Choosing a date under By Date or a keyword under Keywords shows those photos from All Photos, as their counts
promise, instead of filtering whatever album or folder was open, which often showed nothing (issue #341).
)
)
### Reliability
- If the desktop app can't open its window (for example when no graphics device can be used), it now says so in a
@@ -97,6 +106,8 @@
with the logs of the two previous runs beside it, so warnings and crashes of a run started from a desktop menu or the
Dock can be attached to a bug report. `LIGHTCRAFT_LOG` works as before; `RUST_LOG` takes env_logger-style
directives. See README → Quick start → Logs.
- Help → Open Log Folder shows that log file in the file manager (Finder, Explorer, or the folder on Linux), so it
can be attached to a report without hunting for the settings folder (issue #260).
- `lightcraft-cli` logs warnings on stderr too (issue #168); `LIGHTCRAFT_LOG` or `RUST_LOG` picks another level.
- LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD
630 drivers): on Windows the window and GPU rendering use DirectX 12 only and never load the Vulkan driver unless
+3 -3
View File
@@ -117,7 +117,7 @@ names are read (e.g. `Exposure2012`, not the older `Exposure`).
| `ParametricShadows`, `ParametricDarks`, `ParametricLights`, `ParametricHighlights` | `curve.shadows/darks/lights/highlights` | |
| `ParametricShadowSplit`, `ParametricMidtoneSplit`, `ParametricHighlightSplit` | `curve.split_shadows/split_mid/split_highlights` | |
| `CurveRefineSaturation` | `curve.refine_saturation` | 0..100 (100 = curve saturation unchanged) |
| `ToneCurvePV2012`, `ToneCurvePV2012Red/Green/Blue` | `curve.master/red/green/blue` | `"x, y"` points in 0..255 → 0..1; a straight 0→255 line = no curve |
| `ToneCurvePV2012`, `ToneCurvePV2012Red/Green/Blue` | `curve.master/red/green/blue` | `"x, y"` points in 0..255 → 0..1; a straight 0→255 line = no curve; the red / green / blue curves are read only with `ToneCurvePV2012` (Lightroom Classic ignores them without it) |
| `SplitToningShadowHue/Saturation`, `SplitToningHighlightHue/Saturation` | `grading.shadows/highlights.hue/sat` | |
| `ColorGradeShadowLum`, `ColorGradeHighlightLum` | `grading.shadows/highlights.lum` | |
| `ColorGradeMidtoneHue/Sat/Lum`, `ColorGradeGlobalHue/Sat/Lum` | `grading.midtones/global.*` | |
@@ -128,7 +128,7 @@ names are read (e.g. `Exposure2012`, not the older `Exposure`).
| `PostCropVignetteAmount/Midpoint/Roundness/Feather/HighlightContrast` | `vignette.amount/midpoint/roundness/feather/highlights` | |
| `PostCropVignetteStyle` | `vignette.style` | 1 highlight priority, 2 colour priority, 3 paint overlay |
| `GrainAmount`, `GrainSize`, `GrainFrequency` | `grain.amount`, `grain.size`, `grain.roughness` | |
| `LensProfileEnable`, `AutoLateralCA` | `optics.lens_profile`, `optics.remove_ca` | the switch only; lens profiles are our own |
| `LensProfileEnable`, `AutoLateralCA` | `optics.lens_profile`, `optics.remove_ca` | lens profiles are the file's own (DNG-embedded) corrections, which Lightroom applies whatever its profile switch says (our inference): only `LensProfileEnable=1` carries over, `0` leaves the switch as it is |
| `LensManualDistortionAmount`, `VignetteAmount`, `VignetteMidpoint` | `optics.distortion`, `optics.vignetting`, `optics.vignetting_midpoint` | |
| `DefringePurple/GreenAmount/HueLo/HueHi` | `optics.defringe_*` | |
| `ShadowTint`, `RedHue/Saturation`, `GreenHue/Saturation`, `BlueHue/Saturation` | `calibration.shadows_tint`, `calibration.red_hue/red_sat`, … | Calibration panel, 1:1 |
@@ -170,7 +170,7 @@ carry over are listed in `preset.import`'s `unmapped` as `Mask: <kind>`.
|---|---|
| Ours: `.lcpreset` | JSON `{"format": "lightcraft.preset", "version": 1, "presets": [{id, name, group, settings}]}`, where `settings` is a partial develop-settings object (only the groups the preset includes). A file can hold one preset or many, and every preset keeps its group. Import also accepts a bare preset object or an array of them. |
| Export | `preset.export {path, ids?, group?}`: all user presets by default, or the given ids or one group. In the app: File ▸ Export Presets…, Presets panel ▸ ⋯ ▸ Export User Presets…, or right-click a group ▸ Export Group…. |
| Import | `preset.import {paths, group?, dryRun?}`: files or folders (recursive): `.lcpreset`, `.xmp`, classic `.lrtemplate` (a Lua table: `value.settings` holds the same field names as `crs:`), photos that carry their edits in XMP ("DNG presets" from mobile apps; their crop, geometry and custom white balance are left out) and `.zip` bundles of any of these. Presets in a folder (or a folder inside a zip) go to a group named after it, unless the file names its own group. The result lists, per preset, the settings that couldn't be carried over (`unmapped`, e.g. `CameraProfile`, `Look`, local masks), and the app's toast names them. Older (process version 2010) fields — `Exposure`, `Contrast`, `FillLight`, `HighlightRecovery`, `Shadows`, `Brightness`, `Clarity`, `ToneCurve` — are approximated with today's sliders when a preset has no 2012-era fields. Dropping preset files on the window imports them too. In the app: File ▸ Import Presets…, or Presets panel ▸ ⋯ ▸ Import Presets…. A preset that's already there (same name, group and settings) is skipped. If an id clashes, the import gets a fresh `user.*` id, and built-in presets are never replaced. |
| Import | `preset.import {paths, group?, dryRun?}`: files or folders (recursive): `.lcpreset`, `.xmp`, classic `.lrtemplate` (a Lua table: `value.settings` holds the same field names as `crs:`), photos that carry their edits in XMP ("DNG presets" from mobile apps; their crop, geometry and custom white balance are left out) and `.zip` bundles of any of these. Presets in a folder (or a folder inside a zip) go to a group named after it, unless the file names its own group. The result lists, per preset, the settings that couldn't be carried over (`unmapped`, e.g. `CameraProfile`, `Look`, local masks), and the app's toast names them; fields that only describe the preset or the photo (`Cluster`, `SortName`, `Description`, `SupportsAmount2`, `RequiresRGBTables`, `CropConstrainToWarp`, `OverrideLookVignette`, `AsShotTemperature` / `AsShotTint`, an off `HDREditMode`, empty `PointColors` slots) aren't listed. Older (process version 2010) fields — `Exposure`, `Contrast`, `FillLight`, `HighlightRecovery`, `Shadows`, `Brightness`, `Clarity`, `ToneCurve` — are approximated with today's sliders when a preset has no 2012-era fields. Dropping preset files on the window imports them too. In the app: File ▸ Import Presets…, or Presets panel ▸ ⋯ ▸ Import Presets…. A preset that's already there (same name, group and settings) is skipped. If an id clashes, the import gets a fresh `user.*` id, and built-in presets are never replaced. |
| XMP presets | Read with the `crs:` table above, with `crs:Name` as the name (falling back to the file name) and `crs:Group` as the group (falling back to "Imported Presets"). Only the fields the preset sets are included, so applying it leaves everything else alone and the Amount slider scales it like any other preset. We only read XMP presets; we don't write them. |
LightCraft ships no third-party presets. Its built-in presets are its own values (`crates/engine/src/presets.rs`).