Compare commits

...
Author SHA1 Message Date
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
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
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
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
Claude cb0e0cb2e5 Follow-up to #393: the embedded-JPEG scan shares a work budget
scan_for_jpeg walks every JPEG-like start in a container it can't parse;
each walk runs through the marker segments that follow (up to 4096) and
their entropy data. A crafted CRW/MRW/X3F (or unsupported TIFF raw) of
fake starts one after another makes every walk cover most of the file:
quadratic, about a minute for 40 MB, on the import's probe thread. The
walks now report how far they read and share a budget of eight times the
file's size (at least 1 MiB); real files' candidates fail within a few
bytes or are the preview itself. Test: 200,000 fake starts, 0.15 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 22:03:26 +00:00
Claude 0fb3351c91 Merge PR #393 2026-10-08 22:00:34 +00:00
Claude b9254242a2 Merge PR #391 2026-10-08 22:00:10 +00:00
BFlat ab9e8d372c Merge pull request #394 from storytold/claude/integrate-batch-7
Integrate part of #374 (bounded folder walks, launch imports off the UI thread) and #385, #386, #387
2026-10-09 04:43:24 +07:00
Claude b11e5399c6 Merge PR #387 2026-10-08 21:26:34 +00:00
Claude 83058f717e Merge PR #386 2026-10-08 21:26:34 +00:00
Claude 7962b66a06 Merge PR #385 2026-10-08 21:26:33 +00:00
Claude c140f63372 Merge branch 'fix-374-startup-drive-walk' into integ-b7 2026-10-08 21:19:42 +00:00
Claude 790a341776 Part of #374: launch imports run off the UI thread; folder walks are bounded and never take a drive root or home implicitly
Root cause (in 0.2.1 and still on main): files and folders named on the
command line were imported from the eframe app-creator closure with
`app.run("library.import", …)`, which runs the engine command synchronously:
`import::expand` walked the folder recursively (one `is_dir()` open per entry,
no depth or count bound, following links) and probed every file before the
first frame could be drawn. An argument that resolves to a drive root (`.` or
`..` with a launcher's working folder at `C:\`, `/` or `\` on Windows) or to
the home folder therefore walked the whole drive with the window stuck as a
black "not responding" box. The library-problem banner's pending import had
the same synchronous call. Every other recursive walk (Local's Include
Subfolders, Find Missing Photos, preset and LUT folders, MCP) was also
unbounded, which is the "Local folders with subfolders: not responding"
pattern of filmcraft#23, and a link loop recursed until the stack overflowed.

- engine `walk::files_in`: one bounded, iterative walker (32 folder levels,
  1,000,000 entries; `Walked::truncated` says it stopped), folder or file
  taken from the directory listing (no open per entry), links and junctions
  to a folder followed once. `import::expand` (+ `expand_within`), preset and
  LUT folder expansion and MCP `expand_paths` use it.
- engine `import::launch_path`: an empty launch argument is ignored; a
  relative one is resolved against the working folder; a drive or file-system
  root becomes its memory card's DCIM folder or is refused, and so is the home
  folder (a notice says why and to use File > Import Photos).
- desktop: launch arguments go through it and are imported with
  `import::start_paths` (the import worker), like dropped files; so does the
  library-problem banner's pending import.

Not verified on Windows (no machine here). The report says PhotoCraft 0.5.0
and `--memory` hang the same way with no arguments; nothing in LightCraft
walks the disk at startup without arguments (strace of a headless demo run
with HOME=/ and cwd /: no recursive listing), so that part may be outside
this code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 21:19:42 +00:00
BFlat 67b7bc8bc6 Merge pull request #389 from storytold/claude/integrate-batch-6
Integrate #373 (keymap editor), #218 (Lightroom catalog import), #367 (parallel import copies), #344 (import file types)
2026-10-09 04:19:01 +07:00
Claude 41e67e1edd Batch fix: the CLI snapshot test settles before its screenshots
Under load (the full workspace suite) the photo grid was captured before
its thumbnails rendered, darker than the export dialog dimmed over them,
and the dimming comparison failed; it passes alone. Each screenshot now
follows ui.settle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:56:35 +00:00
Claude 5d437489b9 Merge branch 'fix-344-import-file-types' into integ-b6 2026-10-08 20:53:11 +00:00
Claude 1e6f0e28e1 Batch fix: the Lightroom catalog dialog filter goes through add_filter_nocase
#218's catalog picker called rfd's add_filter, which #362's guard test
(no_dialog_bypasses_the_helper) forbids: on Linux the filter would hide
CATALOG.LRCAT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:53:11 +00:00
Claude 4af709995f Fix #344: the import review checks or unchecks a file type at once
With several file types among the candidates, the Import Photos review
shows a toggle per type (ARW · 120, JPG · 120): unchecking JPG leaves
the JPEGs kept beside the raws out of the import, checking a type checks
its importable files (duplicates stay unchecked). JPEG/JPG and TIFF/TIF
are one type; a file whose probe named no format goes by its extension.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:52:35 +00:00
Claude 9b0d0fd05f Parity summary regenerated (cargo xtask parity --write)
Main's summary block had a duplicated pair of total lines left from an
earlier merge; the regenerated one counts #373 and #218's rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:50:04 +00:00
Claude b1d8ba4284 Merge branch 'fix-367-import-parallel' into integ-b6 2026-10-08 20:48:31 +00:00
Claude d8efa6a147 Spanish for the keymap editor (#373) and the Lightroom catalog import (#218)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:48:31 +00:00
Claude d4c0a7a3d4 Merge remote-tracking branch 'origin/main' into integ-b6
# Conflicts:
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant.json
2026-10-08 20:48:15 +00:00
BFlat 68e0827633 Merge pull request #383 from storytold/claude/test-376-masking-layout
Part of #372: painted-text layout tests from #376
2026-10-09 03:47:22 +07:00
ClaudeandElliot Graham cad024fbff Part of #372: painted-text layout tests from #376
The tests from @QuirkyTurtle94's #376, a duplicate fix for #372 opened
while #379 was in review: they read the painted text shapes (not the
registered rects) at 1x and 2x scale, in English, German and Brazilian
Portuguese, at panel widths 250-520, and check that tile labels fit
unelided inside their tiles, that a long mask name is elided before the
eye, and that a long Describe prompt keeps Select inside the panel.
They pass unchanged against main's implementation.

Co-Authored-By: Elliot Graham <elliotgraham94@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:47:07 +00:00
Claude 86deb5b004 Fix #367: import copies and verifies files on several threads
Import -> Copy (and Move, and Copy as DNG) handled one file after another
on the import worker: copy, fsync, read back and hash, optionally decode
and re-encode the raw as DNG. Every other CPU-heavy step of an import was
already parallel (the probes, up to 8 threads), so with the probes cached
by the review dialog's scan the transfer phase ran on one core.

ImportJob::prepare_files now plans each file in order, as before
(duplicates by path and content, sidecars, rename sequence numbers,
folders, Kept notes) and collects the copies/moves, which then run on up
to 8 threads (std::thread::scope; sequential on wasm32). Outcomes keep
their places, so results, order, names and failure messages are those of
a one-by-one import:
- a file whose content matches a transfer still pending, or whose name
  could collide with one (same folder, same name family: IMG.jpg,
  IMG-1.jpg, img.dng), waits until that one has finished;
- cancel stops before the next file; transfers not started leave no item;
- a panic in one transfer fails that file instead of the batch;
- Copy as DNG holds a memory-gate permit (6x the file size) while it runs.
Copy verification and crash safety are unchanged (same copy_new / place).
The desktop's import worker prepares batches of 2 x workers files (8..16,
lightcraft_engine::import::batch_size) instead of 8, so a batch's slowest
file holds back fewer threads; the browser build keeps 8.

Measured (4-core shared VM, 200 procedural 3.7 MB JPEGs = 754 MB, warm
page cache, throwaway benchmark not committed):
- probe: ~80 ms CPU per file is the JPEG decode (DCT-scaled to 64 px),
  1.2 ms the content hash; the dialog's scan: 4.4 s, 3.6 cores busy.
- Copy, probes cached: copy+fsync+verify 1.59 s at 0.43 cores before,
  0.94 s at 0.81 cores after. Copy, not cached: 6.45 s -> 5.51 s.
- Add, probes cached: 2 ms prepare; nothing to parallelize.
- commit of a batch on the UI thread (journal append + fsync): ~4 ms,
  flat for libraries of 0, 5k, 20k and 50k photos (~0.7 s for 1300).
Copy as DNG could not be measured (no raw corpus here); it is the most
CPU-heavy per-file step and was sequential.

Test: copies_run_side_by_side_with_the_same_outcome (copies under the
test fault Slow run on more than one thread; names, order and in-batch
duplicates match a one-by-one copy; the outcome assertions also pass on
the old code, the thread assertion fails there).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:47:04 +00:00
BFlat a673b2bb7a Merge pull request #382 from storytold/claude/i18n-371-spanish
Fix #371: Spanish interface
2026-10-09 03:41:48 +07:00
Claude 305ff71474 Part of #371: Spanish for the SAM 3 install dialog (#366)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
(cherry picked from commit 73b22c8524ef17b209ff2fffbb16c248524e2e96)
2026-10-08 20:41:31 +00:00
Claude a0e49629d3 Merge remote-tracking branch 'origin/main' into i18n-371-spanish 2026-10-08 20:41:31 +00:00
BFlat c8f697a128 Merge pull request #381 from storytold/claude/fix-366-sam3-install-help
Fix #366: the SAM 3 dialog without a download location shows how to install by hand
2026-10-09 03:41:25 +07:00
BFlat 232cbace63 Merge pull request #379 from storytold/claude/fix-372-mask-labels
Fix #372: masking tile labels and long mask names stay inside their boxes
2026-10-09 03:41:22 +07:00
radialmonster 1fa3af8740 LRC-LIB-LIBFOLDERS: the sidebar rename test builds its folder ids with forward slashes on Windows
The folder tree writes paths with forward slashes and its widget ids
carry them; the test put the temp folder in the id with backslashes.
Found running cargo xtask ci on Windows.
2026-10-08 16:41:12 -04:00
radialmonster 8e4d7545ff M11.4: the X-T20 corpus RAF is fitted since the away-from-edges retry; update the test and the doc that said it was rejected
Found running cargo xtask ci with the corpus on Windows. The two changes
landed on the same day: the Fujifilm test (#282) expected the compressed
X-T20 file to keep the neutral fallback because its fit failed the gates
on all pixels (held-out RMS 0.114 against 0.10). The retry away from
edges (#263, issue #232) fits it (0.056), so the fit is accepted at both
sizes. Expect true.
2026-10-08 16:41:12 -04:00
radialmonster 1028ed6b88 LRC-LIB-LIBFOLDERS: a chosen folder that follows a move keeps its names' case and the platform's separators
follow_library_folder rebuilt the chosen subfolder from the lower-cased
folder_key on Windows and joined it with '/', so after a move the folder
shown in the sidebar came back as c:\archive\trip/day1 with its names in
lower case. Add folder_rest (compares by folder_key, returns the names as
spelled) and join them with the platform's separator. Tests compare
folders by identity where they only mean the same folder, and a new test
fails on Windows without the fix.
2026-10-08 16:41:11 -04:00
Claude efe7cad73c Follow-up to #218: Lightroom catalog import translated
The progress, result and menu strings of the Lightroom catalog import in
the ja, pt-br, zh-hans, zh-hant, de and ru catalogs. The formats catalogs
held English copies of the seven new format messages; they are
translated, and German and Russian (which the PR predates) get them too,
so the formats build check passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:36:22 +00:00
Claude a86defd1b9 Merge PR #218 2026-10-08 20:35:39 +00:00
radialmonster 881bc85dac LR-IMP-FORMATS: import CRW, MRW, X3F, KDC, MOS, ERF and IIQ files from folders as preview only
Folder import, drag and drop and MCP folder expansion skipped these extensions, so
the preview-only fallback for these containers was only reachable by naming a file.
Adds exactly the containers that now import as preview only to import::EXTENSIONS and
the headless PHOTO_EXTENSIONS. 3FR, FFF, DCR and STI still fail and stay out.
2026-10-08 16:32:17 -04:00
radialmonster dbe73f0f9c LR-IMP-FORMATS: raws we cannot decode import as preview only when any usable JPEG is embedded
Widens the preview-only fallback. A recognised raw whose decode fails now falls back
to its embedded JPEG for any decode error (unsupported, corrupt, limit, TIFF
structure), not only an unsupported variant, and the preview search covers more
containers:
- Canon CRW, Minolta MRW and Sigma X3F are recognised by their magic (RawFormat
  Crw, Mrw, X3f) instead of failing as "RawOther files are not supported yet".
- A TIFF whose IFD0 has no ImageWidth/ImageLength but a JPEG pointer or SubIFDs
  (Kodak KDC) is a raw container, not a malformed image. TIFF compression 99
  (Leaf MOS) counts as a raw compression.
- When no tag points to a preview, a recognised raw we cannot decode is scanned
  for the largest complete three-component DCT JPEG. A preview whose first byte
  was overwritten (Minolta, Epson) is restored. A one-component JPEG is skipped
  (a PowerShot CRW stores its mosaic that way).
Files with no JPEG at all still fail with "without an embedded preview".
2026-10-08 16:32:17 -04:00
radialmonster 0b8c7a237a LR-IMP-FORMATS: TIFF raws with a thumbnail-only IFD0 import as preview only, not as a tiny image
Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS TIFFs keep their raw data in
a private block. IFD0 is only a 160-640 px RGB copy and no IFD is marked as raw, so
they opened as an ordinary TIFF of that size (a 150 MP file rendered 296x220) with
no preview-only label.

probe() now recognises such a file from its structure (OtherTiff): a picture at
least 4x larger than IFD0 in both directions is declared by a SubIFDs child, or by
Exif PixelXDimension/PixelYDimension together with enough bytes outside every IFD's
strips and tiles to hold it at one bit per pixel. decode() reports the sizes as the
reason. A raw we cannot decode now falls back to its embedded JPEG, else to the TIFF
reader's view of its first image, so it imports preview-only with that reason.
RENDER_CACHE_VERSION 12: these files render differently.
2026-10-08 16:31:49 -04:00
Claude 43aa795539 Follow-up to #373: translated keymap editor, contextual keys noted
- The keymap editor's strings, the fixed-key labels and the two new
  command labels in the ja, pt-br, zh-hans, zh-hant, de and ru catalogs;
  'Restore {}' and 'Removed from {}' go through tr_format!.
- The Presets and Soft Proofing rows say what their key does in the
  Library grids (Flag as Pick and advance / expand or collapse the
  stack), which main's static Shortcuts list showed as a separate label.
- The macOS menu publishes no shortcuts while a key is being recorded
  (merged with main's owned_by_menu).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:24:13 +00:00
Claude 38cdd49274 Merge PR #373 2026-10-08 20:23:12 +00:00
Claude 0b587a0a8a Fix #371: Spanish interface
A Spanish (es) catalog and formats catalog covering the same messages as
the Brazilian Portuguese, Japanese and Simplified Chinese catalogs (1595
messages, 180 formats), an Edit > Language > Español command
(app.language.spanish), es tag tests (es, es-ES, es_MX.UTF-8, es-419),
docs/localization-es.md with the terminology used, and the language
lists in docs/localization.md, parity and What's New.

Instructions use infinitives or impersonal forms (no usted). Plural
endings follow the existing convention: the English "s" passes through
where Spanish adds one, else {:.0} swallows it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:20:17 +00:00
Claude 60a98354d9 Fix #366: the SAM 3 dialog without a download location shows how to install by hand
With no mirror configured the dialog was titled "Download the SAM 3
Model?", only said to put "the files" in the folder (see
docs/ai-masks.md) and offered Close. It is now titled "Install the SAM 3
Model", names the three files (model.safetensors, vocab.json,
merges.txt from facebook/sam3), and has a Show in Finder / Explorer /
File Manager button that creates and shows the model folder, plus a link
to the installation guide. Object and Describe work as soon as the files
are there.

The download location itself (a hosted mirror) is still a maintainer
decision: DEFAULT_MIRRORS stays empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:18:47 +00:00
Claude f16f413942 Fix #372: masking tile labels and long mask names stay inside their boxes
The Create New Mask tiles painted their (untranslated) labels with an
unconstrained Painter::text into tiles capped at 52 pt, so Background and
Luminance ran past their tiles, and translations would more so. The grid
now measures the translated labels: four 52 pt tiles when they fit (at
10.5 pt, or 9.5 pt before dropping a column), else three or two, a tile
growing past 52 pt for a long label; only in a panel too narrow for even
that is a label cut short (full text on hover).

A mask row's name is cut short with an ellipsis before the eye (full name
on hover). The Describe field places the translated Select button first
and gives the field what is left, instead of reserving a fixed 64 pt.
Status lines wrap explicitly.

The painted label and name rects are registered (maskNewLabel:*,
maskName:*), so the sidebar test checks the text, not just the widgets,
at four panel widths with a long name and a long prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:09:16 +00:00
BFlat 48cd9c5e3a Merge pull request #375 from storytold/claude/integrate-batch-4
Integrate #279 (Canon CR3 decoding) and #362 (file dialog filters)
2026-10-09 03:08:11 +07:00
parkerHurstandClaude Opus 5.5 e2b626b7f7 LR-PREF-SHORTCUTS: editable keyboard shortcuts in Help ▸ Keyboard Shortcuts (⌘/)
The ⌘/ dialog is now a keymap editor: click a shortcut and press the new
keys (Esc cancels), × removes it, ↺ restores the default, Reset All clears
every change. A key another command had moves to the new command, with a
toast. Changes go through the new app.setShortcut / app.resetShortcuts
commands into AppSettings.keymap (saved in ui.json) and show up in the
menus, the native macOS menu and the control channel's command list.

Modifier keys alone never record (holding ⌘ waits for the key it
modifies), ⌘C/⌘X/⌘V are recorded from the copy/cut/paste events, and
saved entries that don't parse fall back to the default shortcut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:54:50 -05:00
Claude 28db7b200b Merge fixes for #279: CR3 joins RAF in the format lists, render cache version 13
Main already had Fujifilm RAF in relative WB, the file-local look, the
docs and the corpus lists; CR3 is added next to it. RENDER_CACHE_VERSION
was already 12 on main (from #255/#258/#282), so CR3 thumbnails rendered
by main from embedded previews would not be redone: 13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 19:44:32 +00:00
Claude 3f63db0a0b Merge PR #279
# Conflicts:
#	README.md
#	ROADMAP.md
#	crates/catalog/src/model.rs
#	crates/engine/src/camera_preview.rs
#	crates/raw/src/lib.rs
#	crates/raw/tests/corpus.rs
#	docs/camera-preview-colour.md
#	docs/parity.md
#	docs/whats-new.md
2026-10-08 19:44:21 +00:00
Claude f611e76d0e Merge PR #362
# Conflicts:
#	apps/lightcraft/src/main.rs
#	docs/parity.md
2026-10-08 19:43:31 +00:00
BFlat eeb136b9ee Merge pull request #370 from storytold/claude/fix-260-startup-failure-dialog
Part of #260: a desktop start that fails says why, in a message box naming the log
2026-10-09 02:43:05 +07:00
BFlat 3f10ba4f3b Merge pull request #369 from storytold/claude/fix-171-unlocked-library-warning
Fix #171: a library that can't be locked opens with a warning the user sees
2026-10-09 02:43:01 +07:00
Claude 7fb24029f2 Part of #260: a desktop start that fails says why, in a message box naming the log
When eframe could not open the window (no usable graphics device, a
broken driver), main returned the error to standard error, which a
Windows release build (no console) or an app started from Finder never
shows: the app just vanished. The error now goes to the log (the file
#286 added) and into a native message box, in the user's language, that
names the log file. The saved UI language is applied before the window
starts so the message uses it. Scripts and tests (LIGHTCRAFT_NO_PREFS)
get no dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 19:30:26 +00:00
Claude 5601d7879a Fix #171: a library that can't be locked opens with a warning the user sees
When catalog.lock can't be locked (a file system without locks, such as
some network shares), the library opens unprotected, as documented, but
the promised warning went only to the log. It is now the first of the
library's warnings: library.info lists it under settingsWarnings, the
app shows it once in its notices, and lightcraft-cli run / mcp print the
library's warnings on stderr (stdout stays results / protocol only). A
normally locked library shows none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 19:27:32 +00:00
Adrian D'souza 095c7dce87 fix sqlite test payload extension 2026-10-09 00:57:19 +05:30
Иван Лапочкин f96f732baa M11.1: add CR3 decoding
Decode lossless CRX and version 0x100/0x200 C-RAW in portable Rust,
including sensor geometry, timed white balance and black/white levels.
Verify all sensor samples against six independent M50/R100/R8 references.

Any CR3 decode error falls back to the embedded JPEG, as CR3 files did
before this decoder. The full sensor buffer is allocated only after the
first plane decodes, and plane buffers grow row by row. Tile planes
decode in parallel with a windowed bit reader (24 MP: 424 -> 89 ms
lossless, 519 -> 138 ms C-RAW on an M4).

Add guarded CR3 camera-JPEG colour fitting with independent framing
validation, relative white balance and cache invalidation.

Validation: cargo xtask ci.
2026-10-08 22:22:13 +03:00
BFlat bf224c2a8d Merge pull request #361 from storytold/claude/fix-319-craft-fonts-pin
Fix #319: release builds pin craft-fonts with Noto Sans CJK SC (includes #203)
2026-10-09 02:19:59 +07:00
BFlat 16236ff779 Merge pull request #364 from storytold/claude/fix-293-undo-shows-photo
Fix #293: undo and redo show the photo they change
2026-10-09 02:19:56 +07:00
BFlat 2592ab47e9 Merge pull request #365 from storytold/claude/fix-sam-busy-after-reply
AI masks: the worker stops counting a job as pending before it replies
2026-10-09 02:19:49 +07:00
Claude 3e791905b9 Merge remote-tracking branch 'origin/main' into claude/fix-293-undo-shows-photo
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-08 19:19:32 +00:00
Claude 2897f85861 Merge remote-tracking branch 'origin/main' into claude/fix-319-craft-fonts-pin 2026-10-08 19:19:27 +00:00
Claude 3517606ffc Merge remote-tracking branch 'origin/main' into claude/fix-sam-busy-after-reply 2026-10-08 19:19:27 +00:00
BFlat 9e51ca0da8 Merge pull request #354 from storytold/claude/fix-341-date-keyword-rows
Fix #341: By Date and Keywords rows show their photos from All Photos
2026-10-09 02:19:21 +07:00
Claude 85e38c7342 Merge remote-tracking branch 'origin/main' into claude/fix-341-date-keyword-rows
# Conflicts:
#	crates/ui-egui/src/tests_panels.rs
2026-10-08 19:19:15 +00:00
BFlat 4836143f9e Merge pull request #353 from storytold/claude/fix-316-section-eye
Fix #316: the section eye switches Light, Color and Detail off, and can be clicked
2026-10-09 02:19:02 +07:00
Claude 57dc811a6a Merge remote-tracking branch 'origin/main' into claude/fix-316-section-eye
# Conflicts:
#	crates/ui-egui/src/headless.rs
2026-10-08 19:18:55 +00:00
BFlat 9e327b4772 Merge pull request #352 from storytold/claude/fix-322-type-slider-values
Fix #322: type an exact value into a slider
2026-10-09 02:18:43 +07:00
Claude f6fcef4d5c Merge remote-tracking branch 'origin/main' into claude/fix-322-type-slider-values 2026-10-08 19:18:31 +00:00
BFlat 3895cf6411 Merge pull request #363: community PRs, batch 3 (German + Russian UI, Fujifilm compressed RAF, raw fixes)
Integrate community PRs, batch 3: German + Russian UI, Fujifilm compressed RAF, raw fixes (stacked on #350)
2026-10-09 02:18:21 +07:00
BFlat 5cb74e7313 Merge pull request #350: community PRs, batch 2 (14 PRs + follow-ups)
Integrate community PRs, batch 2 (14 PRs + follow-ups; stacked on #349)
2026-10-09 02:18:09 +07:00
BFlat 4165904bd1 Merge pull request #349: community fixes, batch 1 (18 PRs + follow-ups)
Integrate community fixes, batch 1 (18 PRs + follow-ups)
2026-10-09 02:17:58 +07:00
Claude 6ea9b979c8 AI masks: the worker stops counting a job as pending before it replies
The pending counter was decremented when the job's guard dropped at the
end of the loop iteration, after the reply was sent, so a waiting caller
(CLI / MCP mask.add) could see the segmenter still busy right after its
answer arrived. Under a loaded test run that made
tests_segment::waiting_requests_with_a_damaged_model_are_errors fail on
assert!(!busy()). The guard is now dropped just before each reply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 19:02:44 +00:00
Claude e73d88a55f Merge PR #358
# Conflicts:
#	docs/parity.md
2026-10-08 18:57:38 +00:00
Claude d56d265044 Merge PR #282
# Conflicts:
#	ROADMAP.md
#	assets/ATTRIBUTION.md
#	crates/engine/src/camera_preview.rs
#	crates/raw/tests/corpus.rs
#	docs/camera-preview-colour.md
#	docs/parity.md
2026-10-08 18:55:07 +00:00
Adrian D'souza b4a1166f4b PR218: document optional-data and recovery warnings 2026-10-09 00:17:24 +05:30
Adrian D'souzaandCodex 5413c8bb3a PR218: make archive scale test exercise staging
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:13:49 +05:30
Claude c07395222f Merge PR #356
# Conflicts:
#	docs/parity.md
2026-10-08 18:43:38 +00:00
Claude 7b082660e7 Merge PR #339 2026-10-08 18:43:35 +00:00
Claude 3a9b6ccee4 Fix #293: undo and redo show the photo they change
After an undo or redo that changed one photo (its edit, rating, flag,
label, metadata or versions), that photo becomes the active one, as in
Lightroom Classic, so the change is on screen instead of happening to a
photo out of sight. It joins the selection if it isn't in it; steps that
touch several photos, albums or files, and photos outside the current
view, leave the selection alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:43:18 +00:00
Adrian D'souzaandCodex 07d9678209 PR218: cover optional payload cap warning
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:12:39 +05:30
Claude 8980c3bb0e Fix #341: clippy (no single-element loop in the test)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:42:11 +00:00
Adrian D'souzaandCodex 3c76902bbb PR218: cover unsupported schema entries
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:12:06 +05:30
Claude 1ebce934ea Fix #322: clippy (collapsible if)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:42:00 +00:00
Adrian D'souzaandCodex 7cf3719c63 PR218: localize complete import messages & scale prepared path
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:11:29 +05:30
Claude c3dce49c2e Format the gap-report test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:41:28 +00:00
Claude 3af89e6ed5 i18n: translate what #265 made translatable, and the formats gaps #264/#265 left
#265 wrapped about 460 more UI strings (command and control labels, rule
operators, built-in preset names, status messages) in tr()/tr_format!, and
filled the 54 new formats messages of the other catalogs with English
copies. These are now translated in ja, zh-hans, zh-hant and pt-br; the
Russian formats catalog gets the 58 messages it needs to build, and German
the strings batches 1-2 added. Release-note lines stay English outside
German.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:40:42 +00:00
Claude a7fcd81850 Fix #341: What's new
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:40:19 +00:00
Claude c3c30dae3f Fix #341: the test clicks the row's widget id
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:39:36 +00:00
Claude 47cf1ade6f Fix #341: the test window is tall enough to show the By Date rows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:38:10 +00:00
Claude 00388f3fd2 Fix #341: test from Recently Deleted (the demo has picks)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:37:25 +00:00
Claude 1b4518c202 Fix #341: By Date and Keywords rows show their photos from All Photos
The rows count every photo in the library, but choosing one filtered
whatever source was open (an album, a folder, Picks), so it often showed
nothing. Choosing a row now switches to All Photos first; clearing it
leaves the source alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:36:25 +00:00
Adrian D'souzaandCodex d16ab1f238 PR218: check SQLite local payload span
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:05:52 +05:30
Adrian D'souzaandCodex bbcfdf085b PR218: harden catalog import review fixes
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:05:35 +05:30
Adrian D'souzaandCodex 1e6035d9db test: encode SQLite fixture serial types & overflow offsets correctly
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:05:35 +05:30
Adrian D'souzaandCodex 37cfc46e0d PR218: place prepared-import regression tests after production items
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:05:35 +05:30
Adrian D'souzaandCodex b256b8b635 PR218: bound egui worker message size & simplify cancellation
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:05:35 +05:30
Adrian D'souzaandCodex e8c817ddea PR218: satisfy current CI clippy without changing import behavior
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:05:35 +05:30
Adrian D'souzaandCodex d367d83ee6 LRC-LIB-CATALOG-IMPORT: exercise actual concurrent develop edit
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:05:35 +05:30
Adrian D'souzaandCodex 6a05f865ee LRC-LIB-CATALOG-IMPORT: refresh rebased parity summary
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:05:35 +05:30
Adrian D'souzaandCodex 866d02e577 LRC-LIB-CATALOG-IMPORT: address SQLite, worker & archive review
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:05:35 +05:30
Orthic LabsandCodex 4a427894ed LRC-LIB-CATALOG-IMPORT: import Lightroom Classic catalogs
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:05:35 +05:30
Claude aaa46003d5 Fix #316: the header keeps its hover shade while the pointer is on the eye
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:34:35 +00:00
Claude a9cd761d52 Fix #316: the section eye stays while the pointer is on it
The eye was drawn only while the header itself was hovered; with the
pointer on the eye, egui reports the eye (on top) as hovered and not the
header, so the eye vanished under the pointer and could not be clicked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:33:25 +00:00
Claude 68267810b3 Fix #316: headless test of the section eye
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:30:43 +00:00
Claude 9b4ba1aa80 Fix #316: the section eye switches off Light, Color and Detail too
The pipeline honoured a section's eye only where it applies Effects,
Optics, Geometry and Calibration, so turning the eye off on Light, Color
or Detail changed nothing. DevelopSettings::effective() resets the
switched-off sections to their defaults (Light with the tone curve; Color
with white balance, mixers, grading and point colour; Detail), and both the
CPU and GPU renderers render those. Borrowed, so free, when every section
is on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:29:29 +00:00
Claude 0634782fbc i18n: German and Russian follow-ups (#265, #264)
- #265's German coverage test failed whenever any catalog key, command or
  control label, or line of What's New lacked German, so every feature PR
  would have had to ship German. It reports those gaps for every language
  now (display_label_gaps_are_reported), as the project's other gap test
  does; docs/localization.md says how release-note lines are matched and
  that an English copy in a formats catalog marks an untranslated message.
- Russian: docs/localization-ru.md, the language lists in
  docs/localization.md and docs/parity.md, ru tag tests.
- What's new: German and Russian UI. Removes a status sentence #265 put
  in parity.md's Top gaps list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:26:56 +00:00
Claude 9b5e771fd8 Merge PR #264
# Conflicts:
#	crates/ui-egui/src/i18n.rs
#	crates/ui-egui/src/menus.rs
2026-10-08 18:25:10 +00:00
Claude 69384ad430 Merge PR #265
# Conflicts:
#	crates/ui-egui/locales/ja-formats.json
#	crates/ui-egui/locales/zh-hans-formats.json
#	crates/ui-egui/locales/zh-hant-formats.json
#	crates/ui-egui/src/lib.rs
2026-10-08 18:25:01 +00:00
Claude acc85c4147 Fix #322: the value field draws in a child UI and leaves the slider rows in place
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:22:43 +00:00
radialmonster e2d35de867 LR-IMP-ADD-DIALOG: file dialogs match extensions in any letter case on Linux (#342)
rfd turns each extension into a *.ext glob that GTK and the desktop portal
match case-sensitively, so DSC_1234.NEF was hidden. One helper
(dialog_filter) gives every dialog filter the lower, UPPER and bracket
forms on Linux; the Add Photos filter uses import::EXTENSIONS.
2026-10-08 14:21:58 -04:00
Claude 0fbf20db51 Fix #322: select the old value once the field has the focus
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:21:10 +00:00
Claude 2b66eb4800 Fix #322: the typed-value field takes the keyboard on the frames after the click
The click's own release took the focus straight back from a field created
in that frame, so typing went nowhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:19:31 +00:00
Claude 92c7237c50 Fix #322: type an exact value into a slider
Click the value next to a slider's name to edit it: the old value starts
selected, Return or clicking away applies the typed number on the
control's steps and range as one undo step (like a keyboard nudge), and
Esc or text that isn't a number keeps the old value. A decimal comma,
a leading + and a % or K unit are accepted. The keys typed go to the
field, not to the shortcuts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:05:19 +00:00
radialmonster 34ecea4f24 LR-IMP-CAMERA-COVERAGE: read Canon ColorData stored as bytes, so PowerShot and EOS M CR2s get their as-shot white balance
Maker note tag 0x4001 is UNDEFINED bytes (ColorData versions -3 and -4) on
PowerShot and EOS M bodies. It was read as one number per byte, so no plausible
as-shot levels were found and the white balance was 1, 1, 1 (green cast, a* about
-13 on neutrals). Byte and undefined data is now paired into 16-bit words in the
maker note's byte order before the existing probe (word 71). No per-model table.

RENDER_CACHE_VERSION 11 -> 12.
2026-10-08 14:03:23 -04:00
radialmonster ff8d027766 LR-IMP-FORMATS: NEF lossy type 2 curve of version 0x44 0x40 spans a quarter of the sample range
Maker note 0x0096 of the D780, D6, Z 5, Z 6, Z 7 and Z 7_2 starts 0x44 0x40
and holds 257 points, but its first interval rises by 2^bits / 1024 where the
0x44 0x20 tables rise by 2^bits / 256: the curve is the identity in the dark
part and covers 2^bits / 4 codes, not 2^bits. The decoder spaced the points
2^bits / 256 codes apart for both, so these files decoded to a fraction of
their values, far below the black level.

parse_table now takes the span from the version (0x20: all codes, 0x40: a
quarter). Other versions decode to identical samples. A known-answer test
built from hand-coded table bytes and bit strings fails without the change.
Bumps RENDER_CACHE_VERSION to 12.
2026-10-08 14:02:01 -04:00
Claude 8b70d33d0f Fix #319: release builds pin craft-fonts with Noto Sans CJK SC
release.yml (five jobs) and freebsd.yml pinned craft-fonts abb83316, from
before Noto Sans CJK SC landed there, so v0.4.0 embedded only the Japanese
faces and most of the Simplified Chinese UI was tofu. The pin is now
8dcdacd5 (craft-fonts HEAD: Noto Sans CJK SC and Noto Sans Arabic), which
the Noto CJK exception merged from #203 allows. docs/releasing.md says when
to bump the pin and how to check it before a release.

flake.nix / flake.lock (community-maintained, not in CI) still pin the old
revision: the lock's narHash needs nix to recompute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 17:32:09 +00:00
Claude 5e2ece0cf3 Merge PR #203 (Noto CJK allowed via craft-fonts) 2026-10-08 17:31:36 +00:00
MarekSpodBiedry f2bcc9219c Issue #234: Windows opens links (Map button) in the browser, not File Explorer
explorer <url> opened File Explorer for links with a query or fragment, such as the OpenStreetMap link behind the Map button. Windows now opens https links with rundll32 url.dll,FileProtocolHandler, which hands the URL to the default browser without a shell.
2026-10-08 18:59:04 +02:00
sebastian pfluegelmeier b4ca9f7439 M11.4: harden RAF validation and camera profile fallback 2026-10-08 11:23:55 +02:00
sebastian pfluegelmeier cfcc169ff1 M11.4: calibrate Fujifilm colour with bundled X-H2S and X-T4 profiles 2026-10-08 10:49:55 +02:00
sebastian pfluegelmeier 5923be31d6 M11.1: decode Fujifilm lossless and lossy compressed RAF 2026-10-08 10:44:09 +02:00
MarcelandCodex 35dd973d91 M16: add German localization using the existing catalogs
Add German language selection and persisted locale support, complete German
label and format catalogs, and German release notes. Connect previously
untranslated UI text, preserve user names, and size translated buttons to
fit. Format cached import source facts in the active UI language.

Validate locale coverage, placeholder formats, language persistence,
German glyphs, button bounds and cached import language switching. Review
native and headless screenshots; run all seven cargo xtask ci gates.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
2026-10-08 07:27:32 +02:00
Wondarkus 49868693d5 Add Russian language support in i18n.rs 2026-10-08 08:23:30 +04:00
Wondarkus 79fbb6b464 Add Russian language support in i18n.rs 2026-10-08 08:21:31 +04:00
Wondarkus 95b57d3786 Add Russian language option to menus 2026-10-08 08:20:17 +04:00
Wondarkus 782ade6c39 Add files via upload 2026-10-08 08:16:54 +04:00
Wondarkus 11d6676984 Delete crates/ui-egui/locales/ru-formats.json 2026-10-08 08:15:15 +04:00
Wondarkus 841850d516 Delete crates/ui-egui/locales/ru.json 2026-10-08 08:14:58 +04:00
Wondarkus 6c7405ed3e Fix Russian translations in ru.json 2026-10-08 07:06:23 +04:00
Wondarkus 6a34a1f8b8 Add files for further editing 2026-10-08 06:56:08 +04:00
Brandon ThomasandClaude Opus 5.5 8148988eb8 Docs: allow Noto CJK (via craft-fonts) for Chinese text
Maintainer decision: Noto Sans/Serif CJK is Google-branded Source Han
(Adobe co-developed); nearly every OFL Chinese face derives from it, and the
Simplified/Traditional Chinese UI (#184, #198) uses Noto Sans CJK SC from
craft-fonts. Record it as a narrow, explicit exception to the Adobe-asset rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:16:43 -04:00
163 changed files with 28203 additions and 965 deletions

No files matched your search

+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
ref: 8dcdacd5153e64560d109541a47d806f26f048c0
path: craft-fonts
- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
+5 -5
View File
@@ -77,7 +77,7 @@ jobs:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
@@ -135,7 +135,7 @@ jobs:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
@@ -192,7 +192,7 @@ jobs:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- name: Install build and packaging tools
run: |
@@ -287,7 +287,7 @@ jobs:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- name: Build and package in a FreeBSD VM (tar.gz)
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
@@ -336,7 +336,7 @@ jobs:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
+5 -2
View File
@@ -63,7 +63,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
- **Never use any iconography, image, artwork, font, sound or other asset from Adobe products** (no Lightroom/Creative Cloud icons, no screenshots, no presets/profiles/LUTs, no UI bitmaps — not even as a temporary placeholder or "reference copy"). Observing Adobe's UI to imitate *layout and behaviour* is allowed; copying or tracing its assets is not.
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL);
Japanese fonts come from craft-fonts (below).
Japanese and Chinese fonts come from craft-fonts (below). **One exception (maintainer decision, 2026-10-07): Noto
Sans/Serif CJK** (Google-branded, OFL, co-developed with Adobe as Source Han) is allowed via craft-fonts for Chinese
text, because nearly every OFL Chinese face derives from it. Use it unmodified under its OFL; this does not open the
door to Source Han under Adobe's name or to any other Adobe asset.
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
- **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them.
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
@@ -72,7 +75,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI
(`theme::font_definitions`) and the export watermark renderer use its Japanese faces as fallbacks after Inter. Unset,
(`theme::font_definitions`) and the export watermark renderer use its CJK faces (picked by script) as fallbacks after Inter. Unset,
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build
with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip
without it; the FreeBSD CI job runs them with it. Rules: craftrules
+5 -2
View File
@@ -63,7 +63,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
- **Never use any iconography, image, artwork, font, sound or other asset from Adobe products** (no Lightroom/Creative Cloud icons, no screenshots, no presets/profiles/LUTs, no UI bitmaps — not even as a temporary placeholder or "reference copy"). Observing Adobe's UI to imitate *layout and behaviour* is allowed; copying or tracing its assets is not.
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL);
Japanese fonts come from craft-fonts (below).
Japanese and Chinese fonts come from craft-fonts (below). **One exception (maintainer decision, 2026-10-07): Noto
Sans/Serif CJK** (Google-branded, OFL, co-developed with Adobe as Source Han) is allowed via craft-fonts for Chinese
text, because nearly every OFL Chinese face derives from it. Use it unmodified under its OFL; this does not open the
door to Source Han under Adobe's name or to any other Adobe asset.
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
- **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them.
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
@@ -72,7 +75,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI
(`theme::font_definitions`) and the export watermark renderer use its Japanese faces as fallbacks after Inter. Unset,
(`theme::font_definitions`) and the export watermark renderer use its CJK faces (picked by script) as fallbacks after Inter. Unset,
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build
with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip
without it; the FreeBSD CI job runs them with it. Rules: craftrules
Generated
+2
View File
@@ -2892,6 +2892,7 @@ dependencies = [
"lightcraft-raw",
"lightcraft-scenes",
"lightcraft-segment",
"lightcraft-tiff",
"log",
"miniz_oxide 0.8.9",
"rayon",
@@ -3029,6 +3030,7 @@ dependencies = [
"proptest",
"rayon",
"serde",
"sha2",
"thiserror 2.0.21",
"zune-core",
"zune-jpeg",
+6 -6
View File
@@ -258,7 +258,7 @@ lightcraft --control 7980 ~/Pictures/trip
## Fast, native, private
- **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans,
- **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2/CR3, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans,
Panasonic RW2 / Leica RWL, Pentax PEF, Olympus ORF), our own colour science, our own pipeline. JPEG, PNG, TIFF, WebP,
PSD composites and JPEG XL open today.
- **Scene-referred & wide-gamut.** Linear Rec.2020 float internally, Bradford-adapted white balance, gamut mapping
@@ -288,8 +288,8 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
- **As a day-to-day Lightroom replacement we're nearer 60–70%.** It's great for JPEG/DNG and most Nikon / Sony /
older-Canon raws on one machine.
- **The biggest gaps:**
- **camera colour calibration:** raws other than DNG develop with a neutral colour matrix today, so colour is muted;
- **CR3 and compressed Fujifilm / Olympus raws:** these open as embedded previews only;
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
- **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
- **AI masks and denoise:** subject and sky selection are classical heuristics;
- **HDR, video and the Classic Print / Book / Map modules.**
- **What's next:** see [where we're going](ROADMAP.md#where-were-going).
@@ -302,9 +302,9 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) |
| Crop, straighten tool + auto straighten, flip, rotate, aspect ratios, overlays | ✅ |
| Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | ✅ |
| Camera colour: DNG files use their own matrices | ✅ · our own calibration for other raws ⬜ (top priority; ARW, NEF and RW2 start from a look fitted to their own JPEG, other raws from a neutral fallback) |
| Camera colour: DNG files use their own matrices | ✅ DNG · 🟡 own Sony/Fujifilm profiles; measured calibration database missing |
| Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ |
| RAW: DNG, CR2, ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | ✅ · CR3, compressed RAF/ORF decode ⬜ |
| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | 🟡 · CR3, compressed ORF decode ⬜ |
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ |
| Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 |
| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ · HDR export ⬜ |
@@ -353,7 +353,7 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this
repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語) or **Settings → General →
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Deutsch) or **Settings → General →
Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md).
**Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder
+36 -19
View File
@@ -6,7 +6,7 @@ projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revi
## Where we stand
*Honest assessment, 2026-10-05. Agents: read this before picking work. The checklist in
*Honest assessment, 2026-10-08. Agents: read this before picking work. The checklist in
[`docs/parity.md`](docs/parity.md) counts features that **exist**; this section is about whether a photographer can
**switch** from Lightroom. Update it when a gap below closes.*
@@ -23,13 +23,13 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
| Dimension | Estimate | What's true today | Biggest gaps |
|---|---:|---|---|
| **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, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/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 RAF/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 and RW2 have a guarded per-file embedded-JPEG colour estimate (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) |
| **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, 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 ⬜ |
| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese and Japanese UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese and German UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
### By kind of user
@@ -37,7 +37,8 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|---|---:|---|
| JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks |
| Nikon / Sony / Panasonic / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW, NEF and RW2 preview estimates are only a starting point); Panasonic compacts and kit zooms also miss their embedded distortion correction |
| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only (CR3: the full-size JPEG with full metadata) |
| Fujifilm raw shooter | ~65% | Uncompressed and lossless/lossy compressed RAF decoded; camera colour calibration remains missing. Verified on 13 bodies, including 14/16-bit GFX; see `docs/raf-compression.md` |
| Canon CR3 / Olympus compressed-raw shooter | ~45% | CR3 lossless Bayer and version 0x100/0x200 C-RAW develop from sensor data (M50, R100, R8 verified pixel for pixel); other CRX variants and compressed ORF open as embedded JPEG previews. Camera colour and broader model verification remain |
| Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering |
| Relies on AI (masks, denoise) | ~25% | Object / Describe masks via optional SAM 3 (download not yet hosted); no AI denoise |
@@ -46,9 +47,10 @@ 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 and Panasonic RW2 have the file-local fit
(matrix + tone curve from their own JPEG); generalise it to the other makes' raws (PEF, ORF…), then validate fidelity.
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed RAF / ORF, NEF
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.
3. **Verified camera coverage** (LR-IMP-CAMERA-COVERAGE, P0): a CC0 sample per model in the corpus, each decoded and
@@ -88,14 +90,14 @@ yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-
| M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) |
| M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ |
| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs ✅; JXL encode, HDR export ⬜) |
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF and RW2 preview fitting; measured database still missing), CR3, compressed ORF/RAF ⬜) |
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (CR3 lossless CRX and C-RAW, RAF uncompressed and lossless/lossy compressed, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF, RW2 and RAF preview fitting; measured database still missing), CR3, compressed ORF ⬜) |
| M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ |
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ |
| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ |
| M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) |
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese localisation 🟡; accessibility and further locales ⬜) |
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/German localisation 🟡; accessibility and further locales ⬜) |
## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02)
## Parity estimate (feature count updated 2026-10-08; effort estimate from 2026-10-02)
**By feature count** — from `docs/parity.md` (one row per Lightroom feature, menu item and shortcut; `cargo xtask
parity` prints this line on every run, so it stays current):
@@ -122,7 +124,7 @@ trip, slideshow, auto import…) in ≈ 12 h on 2026-10-02; four to six parallel
| Work package | Tracker rows | Agent-hours | Risk |
|---|---|---:|---|
| Remaining P0/P1 UI and library features (folder rename/move, keyword painter, people view…) | ≈ 8 | 5–10 | low |
| Raw codecs: CR3 (CRX), compressed ORF / RAF, NEF lossy-after-split, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
| Raw codecs: CR3 (CRX), compressed ORF, NEF lossy-after-split, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
| Lens-profile database of our own (calibration targets, fitting, data) | LR-EDIT-OPTICS-PROFILE | 15–30 | data collection |
| Video: playback, trim, edits, export (pure-Rust decode, ideally shared with FilmCraft) | R. Video | 20–40 | medium |
| AI: subject / sky / background / people / object masks, object-aware remove, AI denoise, super resolution, lens blur, people & faces, natural-language search, culling | ≈ 30 | 80–150 | **high** — permissively licensed weights, pure-Rust inference, maybe training |
@@ -169,31 +171,45 @@ The milestone estimates in the table above were made before work started and are
Sony ILCE-7M4 downsized lossless ARW (subsampled YCbCr 4:2:0 / 4:2:2 tiles) now decodes to linear RGB;
private 3:2 & 4:3 examples verified through native rendering & full-resolution 16-bit export.
CR3 lossless Bayer and version 0x100/0x200 C-RAW sensor decoding is verified pixel-exactly on Canon M50, R100 and R8; any other CR3, or one that fails to decode, opens from its embedded JPEG as before. See [`docs/cr3.md`](docs/cr3.md) for coding limits, reference provenance and colour limitations.
Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed,
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered
maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed (Bayer and
X-Trans), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8,
maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed and lossless/lossy compressed (Bayer and
X-Trans, 14/16-bit real-file verification), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8,
packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recovered by black-box analysis of 178 CC0 files from
118 bodies, `crates/raw/src/vendor/rw2.rs`), PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Every
supported container also yields its embedded JPEG preview (CR3 too), and the engine shows that preview for raw variants
it can't decode yet.
118 bodies, `crates/raw/src/vendor/rw2.rs`), PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Supported containers yield their embedded JPEG preview when present (including CR3). The engine uses it for RAW variants it cannot decode yet; HEVC-only CR3 previews are not decoded.
Not decoded yet — preview only (no permissively licensed description; black-box analysis incomplete):
- **Nikon "lossy after split" NEF** (non-zero split row in maker note `0x0096`, e.g. some D3400/D5000/D5200/D5500/D5600
files): rows above the split decode with the regular lossy table; from the split row on a different code is used
that our black-box analysis has not recovered yet (none of the four regular tables fits, also not with byte
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**, **Fujifilm compressed RAF**, **Canon CR3/CRX** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
**Camera colour matrices:** ARW, NEF and RW2 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.
## Lightroom Classic catalog migration
File → Import Lightroom Catalog… reads `.lrcat` plus committed WAL pages directly through a pure-Rust
SQLite reader. Originals stay in place; ratings, flags, labels, XMP metadata, hierarchical keywords,
collections/sets, virtual copies and supported develop settings migrate into LightCraft. Existing edits
are preserved by default, reimport identities are persistent, and source settings/history/snapshots are
archived before catalog mutation. Missing originals remain available for relinking.
Rendering is approximate: unsupported Adobe profiles/AI/process settings are reported and archived;
history/snapshots remain source data, and smart collections import current membership. The Lightroom
database is read-only; LightCraft owns subsequent edits. See `docs/lightroom-catalog-import.md`.
## Log
- 2026-09-30: roadmap created; M0 in progress; research docs (Lightroom reference, Rust imaging ecosystem) complete.
- 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG/CR2/ARW; README showcase. ≈ 8 h elapsed.
- 2026-10-02: parity estimate added (xtask parity prints weighted completion); milestone statuses refreshed.
- 2026-10-08: Fujifilm camera colour: guarded JPEG matching and relative WB, plus bundled X-H2S/X-T4 profiles from 201/545 photos. On 150 withheld files the pooled profiles improve mean ΔE slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); dark X-H2S samples 8.74 → 2.25. Measured calibration remains open.
- 2026-10-08: Fujifilm lossless/lossy compressed RAF: pure-Rust striped decoder; full sensor arrays verified against a black-box reference on 17 CC0 files and 7 supplied X-H2S / X-T4 files (13 camera bodies, 14/16-bit Bayer and X-Trans). Camera colour calibration remains a separate gap.
- 2026-10-01: RAW II formats: RAF (uncompressed Bayer + X-Trans), RW2 (packed), PEF (incl. Huffman), ORF (uncompressed); embedded previews for every container incl. CR3; raw corpus test with 37 CC0 samples.
- 2026-10-03 – 10-05: community PRs (#42–#51, #60) and 25+ issues landed: compressed NEF, Move import, folder templates,
Local roots and cleanup, 85k-photo grid and catalog performance, background compaction, failed-save errors, GPU
@@ -202,6 +218,7 @@ own chart-based calibration (M11.4). Adobe matrices are never used.
- 2026-10-07: Panasonic RW2 / Leica RWL / Panasonic RAW decode in every raw format (compressed 4/6/8, packed 2/5/7,
16-bit words), black levels, in-camera aspect crops and mapped-out defects established on 178 CC0 files from 118
bodies; RW2 gets the file-local camera look (accepted on 140 of 174 files with a preview).
- 2026-10-08: independent Rust CR3 lossless and version 0x100/0x200 C-RAW decoding, six exact full-sensor regressions on M50/R100/R8; any CR3 decode error falls back to the embedded JPEG; guarded CR3 camera-JPEG colour fitting with independent framing validation (`docs/cr3.md`). Unverified CRX variants and broader model verification remain.
## Chinese and Japanese interfaces, and text watermarks
+13 -2
View File
@@ -89,7 +89,7 @@ USAGE:
lightcraft-cli controls [--json] list every develop control id with its range
lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
Fit a colour profile per camera model from raw files and their embedded camera JPEGs
(Sony ARW, Nikon NEF): up to N files spread over the folders (default 300; 0 = all), pooled per
(Sony ARW, Nikon NEF, Fujifilm RAF): up to N files spread over the folders (default 300; 0 = all), pooled per
model, written as <model>.json to DIR (default: the profiles folder LightCraft reads,
<config>/camera-profiles, or $LIGHTCRAFT_CAMERA_PROFILES). Raws of a profiled model then
take their colour from the profile and only their tone from their own JPEG.
@@ -102,6 +102,14 @@ static ALLOC: dhat::Alloc = dhat::Alloc;
/// Why `--library DIR` can't be opened; for a library open in another program, how to work with
/// that one instead (issue #99).
/// Print the open library's warnings (an unlockable library, damaged settings files) on stderr:
/// stdout carries command results / MCP protocol messages only.
fn library_warnings(session: &mut Session, who: &str) {
for w in session.take_library_warnings() {
eprintln!("{who}: warning: {w}");
}
}
fn library_error(dir: &str, e: lightcraft_engine::EngineError) -> String {
match e {
lightcraft_engine::EngineError::LibraryInUse(why) => format!(
@@ -159,7 +167,8 @@ fn main() -> ExitCode {
/// Raw files below `path` (or `path` itself), skipping hidden and NAS metadata folders.
fn raw_files(path: &Path, out: &mut Vec<std::path::PathBuf>, depth: usize) {
let is_raw = |p: &Path| p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["arw", "nef", "nrw"].iter().any(|x| e.eq_ignore_ascii_case(x)));
let is_raw =
|p: &Path| p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["arw", "nef", "nrw", "raf"].iter().any(|x| e.eq_ignore_ascii_case(x)));
if path.is_file() {
if is_raw(path) {
out.push(path.to_path_buf());
@@ -316,6 +325,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
let mut h = Headless::default();
let r = h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
eprintln!("lightcraft-cli mcp: opened library {dir} ({r:?})");
library_warnings(&mut h.session, "lightcraft-cli mcp");
h
}
None if demo => Headless::demo(),
@@ -552,6 +562,7 @@ fn run(args: &[String]) -> Result<(), String> {
Some(dir) => {
let mut h = Headless::default();
h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
library_warnings(&mut h.session, "lightcraft-cli");
h
}
None if demo => Headless::demo(),
+19 -2
View File
@@ -19,6 +19,19 @@ fn gradient_png(path: &std::path::Path) {
std::fs::write(path, png).unwrap();
}
#[test]
fn calibrate_reads_raf_inputs_and_reports_bad_files() {
let input = tmp("invalid.RAF");
std::fs::write(&input, b"FUJIFILMCCD-RAW").unwrap();
let out = tmp("raf-profiles");
let result = Command::new(BIN).args(["calibrate", "--out"]).arg(&out).arg(&input).output().unwrap();
assert!(!result.status.success());
let stderr = String::from_utf8_lossy(&result.stderr);
assert!(stderr.contains("1 of 1 raw files"), "RAF was silently ignored: {stderr}");
assert!(stderr.contains("invalid.RAF") && stderr.contains("no profile written"), "{stderr}");
assert!(!out.join("X-T4.json").exists(), "bad input must not produce a profile");
}
fn mean(path: &std::path::Path) -> f64 {
let d = lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).unwrap();
let img = d.to_srgb8();
@@ -154,10 +167,14 @@ fn snapshot_subcommand_renders_the_ui_headlessly() {
std::fs::write(
&script,
format!(
"# comment\n{}\n{}\n{}\n{}\n",
"# comment\n{}\n{}\n{}\n{}\n{}\n{}\n",
json!({"method": "ui.set", "params": {"view": "photoGrid"}}),
// (settled: under load a grid shot taken before its thumbnails is darker than the
// dimmed export dialog over them, and the comparison below fails)
json!({"method": "ui.settle", "params": {"timeoutMs": 20000}}),
json!({"method": "ui.screenshot"}),
json!({"method": "engine.execute", "params": {"command": "dialog.export"}}),
json!({"method": "ui.settle", "params": {"timeoutMs": 20000}}),
json!({"method": "ui.screenshot", "params": {"path": b.to_str().unwrap()}}),
),
)
@@ -168,7 +185,7 @@ fn snapshot_subcommand_renders_the_ui_headlessly() {
.unwrap();
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
let replies: Vec<Value> = String::from_utf8_lossy(&o.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(replies.len(), 4);
assert_eq!(replies.len(), 6);
assert!(replies.iter().all(|r| r["ok"] == true), "{replies:?}");
for (p, dimmed) in [(&a, false), (&b, true)] {
let d = lightcraft_codecs::decode(&std::fs::read(p).unwrap(), Default::default()).unwrap();
+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
@@ -207,6 +208,7 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
// Preset files: browser pickers are asynchronous; not wired on the web yet.
pick_preset_files: None,
pick_tracklog: None,
pick_lightroom_catalog: None,
save_preset_file: None,
pick_curve_preset_files: None,
save_curve_preset_file: None,
+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()));
+145
View File
@@ -0,0 +1,145 @@
//! File-dialog filters that match extensions whatever their letter case.
//!
//! On Linux, `rfd` turns each extension into a `*.ext` glob for the GTK dialog and for the
//! desktop portal (`xdg-desktop-portal` FileChooser), and both match globs case-sensitively, so
//! a filter of `nef` hid `DSC_1234.NEF` (issue #342). Windows and macOS ignore case. Every
//! dialog filter of the app goes through [`FileDialogExt::add_filter_nocase`] (a test checks
//! that nothing else calls `add_filter`), so no extension can hit this.
use rfd::FileDialog;
/// The patterns to hand to `rfd` for `exts`, which are plain extensions without the dot.
///
/// Windows and macOS take the extension literally and ignore case: lower case, de-duplicated.
/// Linux (`linux`): `rfd` formats each entry as `*.{entry}`, which GTK 3 and the portal match
/// as a glob, so an entry may carry glob syntax. Each extension gives the lower-case form, the
/// UPPER-case form (kept in case a portal backend does not do `[...]` classes) and a bracket
/// form such as `[nN][eE][fF]` that matches every mix (`.Nef`, `.nEf`, ...).
pub fn dialog_extensions_for(exts: &[&str], linux: bool) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut push = |s: String| {
if !s.is_empty() && !out.contains(&s) {
out.push(s);
}
};
for ext in exts {
let ext = ext.trim_start_matches('.');
let lower = ext.to_lowercase();
push(lower.clone());
if !linux {
continue;
}
push(ext.to_uppercase());
// `[` `]` `*` `?` in an extension would already be glob syntax: leave those alone
if lower.chars().any(|c| c.is_alphabetic()) && !lower.contains(['[', ']', '*', '?', '\\']) {
push(
lower
.chars()
.map(|c| {
let up: String = c.to_uppercase().collect();
if up.chars().count() == 1 && up != c.to_string() { format!("[{c}{up}]") } else { c.to_string() }
})
.collect(),
);
}
}
out
}
/// [`dialog_extensions_for`] for the platform this is built for.
pub fn dialog_extensions(exts: &[&str]) -> Vec<String> {
dialog_extensions_for(exts, cfg!(all(unix, not(target_os = "macos"))))
}
/// `add_filter` that matches the extensions in any letter case.
pub trait FileDialogExt {
fn add_filter_nocase(self, name: impl Into<String>, exts: &[&str]) -> Self;
}
impl FileDialogExt for FileDialog {
fn add_filter_nocase(self, name: impl Into<String>, exts: &[&str]) -> Self {
self.add_filter(name, &dialog_extensions(exts))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn other_platforms_get_lower_case_once() {
assert_eq!(dialog_extensions_for(&["NEF", "nef", ".Jpg", "jpg", ""], false), ["nef", "jpg"]);
}
#[test]
fn linux_gets_lower_upper_and_any_mix() {
assert_eq!(dialog_extensions_for(&["nef"], true), ["nef", "NEF", "[nN][eE][fF]"]);
// digits have no case; a duplicate adds nothing
assert_eq!(dialog_extensions_for(&["mp4", "MP4"], true), ["mp4", "MP4", "[mM][pP]4"]);
assert_eq!(dialog_extensions_for(&["7z"], true), ["7z", "7Z", "7[zZ]"]);
assert_eq!(dialog_extensions_for(&["123"], true), ["123"]);
}
#[test]
fn linux_glob_syntax_is_not_wrapped_again() {
assert_eq!(dialog_extensions_for(&["[nN]ef"], true), ["[nn]ef", "[NN]EF"]);
}
/// The Linux patterns match like GTK's glob (`*.{entry}`): checked against a tiny matcher of
/// the subset used here (verified against real GTK 3.24 `GtkFileFilter` in the PR notes).
fn glob_ext(entry: &str, ext: &str) -> bool {
let mut pat = entry.chars().peekable();
let mut s = ext.chars();
while let Some(p) = pat.next() {
let Some(c) = s.next() else { return false };
if p == '[' {
let mut hit = false;
for q in pat.by_ref() {
if q == ']' {
break;
}
hit |= q == c;
}
if !hit {
return false;
}
} else if p != c {
return false;
}
}
s.next().is_none()
}
#[test]
fn linux_patterns_match_every_case_mix() {
let pats = dialog_extensions_for(&["nef", "cr2", "xmp"], true);
let matches = |name: &str| pats.iter().any(|p| glob_ext(p, name));
for ok in ["nef", "NEF", "Nef", "nEF", "CR2", "Cr2", "XMP", "xMp"] {
assert!(matches(ok), "{ok}");
}
for bad in ["nex", "ne", "nefs", "jpg", "cr3"] {
assert!(!matches(bad), "{bad}");
}
}
/// The app's own extension list (the one the folder scan uses) is covered whole.
#[test]
fn import_extensions_are_all_covered_in_both_cases() {
let pats = dialog_extensions_for(lightcraft_engine::import::EXTENSIONS, true);
for e in lightcraft_engine::import::EXTENSIONS {
assert!(pats.iter().any(|p| glob_ext(p, &e.to_uppercase())), "{e}");
assert!(pats.iter().any(|p| p == e), "{e}");
}
}
/// Every dialog filter goes through the helper: `main.rs` must not call rfd's own
/// `add_filter`.
#[test]
fn no_dialog_bypasses_the_helper() {
for (file, src) in [("main.rs", include_str!("main.rs")), ("control_server.rs", include_str!("control_server.rs"))] {
assert!(!src.contains(".add_filter("), "{file} calls rfd's add_filter; use add_filter_nocase");
}
assert!(include_str!("main.rs").contains(".add_filter_nocase("));
}
}
+158 -19
View File
@@ -24,10 +24,12 @@
mod alloc_release;
mod control_server;
mod dialog_filter;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
use dialog_filter::FileDialogExt as _;
use lightcraft_engine::Session;
use lightcraft_ui_egui::panels::library_problem::LibraryProblem;
use lightcraft_ui_egui::{LightcraftApp, Services, UiState};
@@ -269,8 +271,59 @@ impl PrefsWriter {
}
}
fn services() -> Services {
/// The Windows command that opens `url` in the default browser. `explorer <url>` opened File
/// Explorer instead for links with a query or fragment, like the Map button's OpenStreetMap link
/// (issue #234). The URL protocol handler hands the URL to the browser as is; no shell runs, so
/// `?`, `&` and `#` need no escaping.
fn windows_open_url_command(url: &str) -> std::process::Command {
let mut c = std::process::Command::new("rundll32");
c.args(["url.dll,FileProtocolHandler", url]);
c
}
/// The platform services; `ctx` repaints when a file dialog closes.
fn services(ctx: egui::Context) -> 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)
})),
pick_lightroom_catalog: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Import Lightroom Catalog"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Lightroom Classic Catalog"), &["lrcat"])
.pick_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_folder: Some(Box::new(|| {
rfd::FileDialog::new().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string())
})),
@@ -303,7 +356,7 @@ fn services() -> Services {
let status = if cfg!(target_os = "macos") {
std::process::Command::new("open").arg(url).status()
} else if cfg!(target_os = "windows") {
std::process::Command::new("explorer").arg(url).status()
windows_open_url_command(url).status()
} else {
std::process::Command::new("xdg-open").arg(url).status()
};
@@ -325,13 +378,7 @@ fn services() -> Services {
})),
pick_files: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter(
lightcraft_ui_egui::i18n::tr("Photos"),
&[
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw",
"pef", "psd", "jxl", "gif", "bmp",
],
)
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS)
.pick_files()
.unwrap_or_default()
.into_iter()
@@ -341,7 +388,7 @@ fn services() -> Services {
pick_preset_files: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Import Presets"))
.add_filter(
.add_filter_nocase(
lightcraft_ui_egui::i18n::tr("Presets & Profiles"),
&["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"],
)
@@ -354,7 +401,7 @@ fn services() -> Services {
pick_tracklog: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Auto-Tag from Tracklog"))
.add_filter(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"])
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"])
.pick_file()
.map(|p| vec![p.to_string_lossy().to_string()])
.unwrap_or_default()
@@ -362,7 +409,7 @@ fn services() -> Services {
save_preset_file: Some(Box::new(|name: &str| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Export Presets"))
.add_filter(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"])
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
@@ -370,7 +417,7 @@ fn services() -> Services {
pick_curve_preset_files: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets"))
.add_filter(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"])
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"])
.pick_files()
.unwrap_or_default()
.into_iter()
@@ -380,7 +427,7 @@ fn services() -> Services {
save_curve_preset_file: Some(Box::new(|name: &str| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Export Point Curve Presets"))
.add_filter(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"])
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
@@ -456,6 +503,31 @@ ENVIRONMENT:
<settings folder>/logs/lightcraft.log (previous runs: lightcraft.1.log, lightcraft.2.log)
";
/// The launch arguments' files and folders to import (absolute), and a notice for each one that
/// is not imported (see [`lightcraft_engine::import::launch_path`]; issue #374).
fn launch_imports(args: &[String]) -> (Vec<String>, Vec<String>) {
let cwd = std::env::current_dir().ok();
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(std::path::PathBuf::from);
launch_imports_in(args, cwd.as_deref(), home.as_deref())
}
fn launch_imports_in(args: &[String], cwd: Option<&std::path::Path>, home: Option<&std::path::Path>) -> (Vec<String>, Vec<String>) {
use lightcraft_engine::import::{LaunchPath, launch_path};
let (mut files, mut notices) = (Vec::new(), Vec::new());
for a in args {
match launch_path(a, cwd, home, lightcraft_engine::devices::dcim_in) {
Some(LaunchPath::Import(p)) => files.push(p),
Some(LaunchPath::Refused { path, why }) => {
log::warn!("not importing {path} given at launch: {why}");
notices
.push(format!("{path} was not imported: it is {why}. To import from it, choose a folder inside it with File ▸ Import Photos…"));
}
None => {}
}
}
(files, notices)
}
/// Where the log files live: `logs` in the settings folder, next to `ui.json` (see `logging`).
/// None with `LIGHTCRAFT_NO_PREFS` (tests, scripts), so those runs don't rotate away the user's logs.
fn log_dir() -> Option<std::path::PathBuf> {
@@ -513,18 +585,29 @@ fn main() -> eframe::Result {
}
// The log file: opened after the arguments, so `--version`, `--help` and a usage error leave
// no file behind. Records logged until now are written to it first.
let mut log_file = None;
if let Some(logger) = logger {
// a --memory session writes nothing (issue #164), a log file included
match log_dir().filter(|_| !in_memory) {
Some(dir) => match logger.attach_dir(&dir) {
Ok(path) => log::info!("LightCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
Ok(path) => {
log::info!("LightCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display());
log_file = Some(path);
}
// The file sink has given up by now, so this goes to standard error only.
Err(e) => log::warn!("no log file: {e}"),
},
None => logger.no_file(),
}
}
// what the arguments name, resolved now: an empty or relative argument never becomes a drive
// root, and a whole drive or home folder is not walked (issue #374)
let (files, launch_notices) = launch_imports(&files);
let (prefs, prefs_warning, keep_prefs_file) = load_prefs(in_memory);
// the saved language from the start, so a failed start is reported in it too
if let Some(ui) = &prefs {
lightcraft_ui_egui::i18n::set_language(ui.language);
}
// --library, else the library last opened from Settings, else the default location
let library_dir = library_dir.or_else(|| {
prefs
@@ -554,7 +637,7 @@ fn main() -> eframe::Result {
wgpu_options: window_wgpu_options(),
..Default::default()
};
eframe::run_native(
let started = eframe::run_native(
"LightCraft",
options,
Box::new(move |cc| {
@@ -565,13 +648,14 @@ 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()));
if let Some(ui) = prefs {
app.ui = ui;
}
lightcraft_ui_egui::i18n::set_language(app.ui.language);
app.integrated_titlebar = cfg!(target_os = "macos");
app.notices.extend(prefs_warning);
app.notices.extend(launch_notices);
// what's on disk now: only changes are written
let writer = PrefsWriter {
path: if in_memory { None } else { prefs_path() },
@@ -594,7 +678,12 @@ fn main() -> eframe::Result {
p.pending_import = files;
app.library_problem = Some(p);
} else if !files.is_empty() {
let _ = app.run("library.import", serde_json::json!({"paths": files}));
// listed and read on the import worker once the window shows (issue #374: never
// here, before the first frame, where a large folder kept the window from
// appearing)
if let Err(e) = lightcraft_ui_egui::import::start_paths(&mut app, files) {
log::warn!("import at launch: {e}");
}
app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid;
}
// native menu bar generated from the command registry (macOS; elsewhere the menus are
@@ -608,7 +697,28 @@ fn main() -> eframe::Result {
menu,
)))
}),
)
);
if let Err(e) = &started {
startup_failed(&e.to_string(), log_file.as_deref());
}
started
}
/// The window could not start (e.g. no usable graphics device): say so in the log and in a native
/// message box naming the log file, instead of exiting without a trace — the release builds have
/// no console on Windows, and an app started from Finder shows no standard error (issue #260).
fn startup_failed(error: &str, log_file: Option<&std::path::Path>) {
log::error!("LightCraft could not open its window: {error}");
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
return; // tests and scripts: no dialog to click away
}
let (title, text) = lightcraft_ui_egui::i18n::startup_failed_message(error, log_file.map(|p| p.display().to_string()).as_deref());
let _ = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title(title)
.set_description(text)
.set_buttons(rfd::MessageButtons::Ok)
.show();
}
#[cfg(test)]
@@ -623,6 +733,24 @@ mod tests {
d
}
/// Issue #374: launch arguments that would walk a whole drive or home folder are reported,
/// not imported; empty ones are ignored; the rest are imported as absolute paths.
#[test]
fn launch_arguments_never_walk_a_drive_or_the_home_folder() {
let home = dir("launch");
std::fs::create_dir_all(home.join("Trip")).unwrap();
let args: Vec<String> = ["", " ", ".", "Trip", &home.to_string_lossy()].iter().map(|s| s.to_string()).collect();
let (files, notices) = launch_imports_in(&args, Some(&home), Some(&home));
assert_eq!(files, [home.join("Trip").to_string_lossy().to_string()]);
assert_eq!(notices.len(), 2, "{notices:?}");
assert!(notices.iter().all(|n| n.contains("was not imported")), "{notices:?}");
// the working folder is a drive root (a launcher's choice): `.` is never the whole drive
let root = home.ancestors().last().unwrap().to_path_buf();
let (files, notices) = launch_imports_in(&[".".to_string()], Some(&root), None);
assert!(files.iter().all(|f| f.ends_with("DCIM")) && files.len() + notices.len() == 1, "{files:?} {notices:?}");
let _ = std::fs::remove_dir_all(&home);
}
/// Issue #103: a damaged ui.json (which holds the library location) is kept aside and
/// reported, not silently replaced by the defaults.
#[test]
@@ -744,6 +872,17 @@ mod tests {
assert!(!b.is_empty());
}
/// Issue #234: Windows opens links with the URL protocol handler (the default browser), not
/// File Explorer, and passes the whole map link, `?`, `&` and `#` included, as one argument.
#[test]
fn windows_links_open_in_the_browser() {
let url = "https://www.openstreetmap.org/?mlat=48.856600&mlon=2.352200#map=15/48.856600/2.352200";
let c = windows_open_url_command(url);
assert_eq!(c.get_program(), "rundll32");
let args: Vec<&std::ffi::OsStr> = c.get_args().collect();
assert_eq!(args, ["url.dll,FileProtocolHandler", url]);
}
#[test]
fn a_control_port_that_is_not_a_number_is_ignored() {
assert_eq!(control_port_from("--control", Some("7980".into())), Some(7980));
+12 -5
View File
@@ -54,6 +54,9 @@ pub struct NativeMenu {
/// Structure of the installed menu (ids, kinds, submenu names); a change rebuilds it.
structure: String,
text_focus: bool,
/// The keyboard shortcuts editor is recording a key: every accelerator is off so the key
/// press reaches it.
capturing: bool,
}
/// `Cmd+Shift+Z` → a muda accelerator (`None` for keys we leave to egui, e.g. Escape).
@@ -211,7 +214,7 @@ impl NativeMenu {
let _ = tx.send(e.id.0);
repaint.request_repaint();
}));
let mut m = NativeMenu { menu: Menu::new(), items: HashMap::new(), rx, structure: String::new(), text_focus: false };
let mut m = NativeMenu { menu: Menu::new(), items: HashMap::new(), rx, structure: String::new(), text_focus: false, capturing: false };
m.rebuild(app);
app.native_menu = true;
m
@@ -281,6 +284,7 @@ impl NativeMenu {
}
self.menu.init_for_nsapp();
self.text_focus = false;
self.capturing = false;
self.publish_shortcuts(app);
}
@@ -345,7 +349,8 @@ impl NativeMenu {
/// Tell the egui shortcut handler which shortcuts the menu bar currently owns.
fn publish_shortcuts(&self, app: &mut LightcraftApp) {
let installed = self.items.values().filter(|i| i.accel.is_some()).filter_map(|i| i.shortcut.as_deref());
app.native_shortcuts = owned_by_menu(installed, self.text_focus);
// (none while the keymap editor records a shortcut: every key goes to it)
app.native_shortcuts = if self.capturing { HashSet::new() } else { owned_by_menu(installed, self.text_focus) };
app.native_shortcuts.insert(SETTINGS_KEY.to_string());
}
@@ -407,14 +412,16 @@ impl NativeMenu {
walk(&bar.iter().flat_map(|(_, v)| v.clone()).collect::<Vec<_>>(), &mut self.items);
let focus = ctx.egui_wants_keyboard_input();
if focus != self.text_focus {
let capturing = app.recording_shortcut.is_some();
if focus != self.text_focus || capturing != self.capturing {
self.text_focus = focus;
self.capturing = capturing;
for it in self.items.values() {
let Some(sc) = it.shortcut.as_deref() else { continue };
if it.accel.is_none() || !yields_to_text(sc) {
if it.accel.is_none() {
continue;
}
let accel = if focus { None } else { it.accel };
let accel = if capturing || (focus && yields_to_text(sc)) { None } else { it.accel };
let _ = match &it.handle {
Handle::Plain(h) => h.set_accelerator(accel),
Handle::Check(h) => h.set_accelerator(accel),
+1
View File
@@ -12,6 +12,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` |
| `assets/camera-profiles/*.json` | Camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | ILCE-7M4: Harald Hoyer | original work: fitted by `lightcraft-cli calibrate` to the contributor's own raw photos and their in-camera JPEGs (597 ILCE-7M4 photos, 2026); aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-07 | none |
| `packaging/macos/dmg/` (`background.svg`, the `background.tiff` rendered from it by `generate.py`, and the `dmg-layout.DS_Store` layout file) | LightCraft macOS DMG window background | @XusBadia | original work for this repository, derived from the LightCraft app icon (`assets/app-icon/lightcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 | 2026-10-08 | none (original); used for the Finder window of the macOS DMG, `packaging/macos/dmg/generate.py` renders it to `background.tiff` |
| `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none |
| `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `docs/images/*-tetons.jpg`, `docs/images/grid-pd.jpg` (screenshots containing it) | "The Tetons and the Snake River" | Ansel Adams (1942), U.S. National Archives | https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+19 -5
View File
@@ -2,7 +2,7 @@
use std::sync::Arc;
use lightcraft_develop::DevelopSettings;
use lightcraft_develop::{DevelopSettings, WbMode};
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
@@ -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 and RW2 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"].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`]).
@@ -370,7 +371,12 @@ impl Photo {
}
/// Edited by the user: settings differ from what import gave the photo.
pub fn is_edited(&self) -> bool {
*self.develop != self.import_defaults() && !self.develop.is_unedited()
let mut defaults = self.import_defaults();
// As Shot reads the source's current WB; stored numbers can predate a decoder update.
if self.develop.wb.mode == WbMode::AsShot && defaults.wb.mode == WbMode::AsShot {
defaults.wb = self.develop.wb;
}
*self.develop != defaults && !self.develop.is_unedited()
}
/// Capture time if known, else import time (sort key).
pub fn date(&self) -> &str {
@@ -430,7 +436,7 @@ mod edited_tests {
}
#[test]
fn sony_and_nikon_raws_use_relative_white_balance() {
fn supported_raws_use_relative_white_balance() {
for (name, format, relative) in [
("a.arw", "ARW", true),
("a.nef", "NEF", true),
@@ -438,6 +444,8 @@ mod edited_tests {
("a.rw2", "RW2", true),
("a.rwl", "RWL", true),
("a.raw", "RAW", true),
("a.raf", "rAf", true),
("a.cr3", "cr3", true),
("a.dng", "DNG", false),
] {
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00");
@@ -446,6 +454,12 @@ mod edited_tests {
assert_eq!(p.relative_wb(), relative, "{format}");
let wb = p.camera_defaults().wb;
assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (5200.0, 4.0) }, "{format}");
p.develop = Arc::new(DevelopSettings::for_raw(5200.0, 4.0));
assert!(!p.is_edited(), "{format}: old As Shot values are not an edit");
let mut custom = (*p.develop).clone();
custom.wb.mode = WbMode::Custom;
p.develop = Arc::new(custom);
assert!(p.is_edited(), "{format}: Custom WB is still an edit");
// shown from the embedded preview: a rendered image, not a relative-WB raw
p.preview_only = Some("unsupported".into());
assert!(!p.relative_wb());
+11
View File
@@ -525,6 +525,17 @@ pub fn folder_within(path: &str, root: &str) -> bool {
key_within(&folder_key(path), &folder_key(root))
}
/// The names below folder `root` that lead to `path`, as the caller spelled them (`None` when
/// `path` is not at or inside `root`; empty when it is `root`). Compared by [`folder_key`], but
/// the names keep their case, which the key lowers on Windows.
pub fn folder_rest(path: &str, root: &str) -> Option<Vec<String>> {
if !key_within(&folder_key(path), &folder_key(root)) {
return None;
}
let depth = split_path(root, false).parts.len();
Some(split_path(path, false).parts.into_iter().skip(depth).collect())
}
/// Whether a library photo (not one only browsed in Local) lies in the folder whose
/// [`folder_key`] is `root`. A plain POSIX path (no `\`, `.`, `..`, repeated or trailing
/// separators) is its own identity, so it is read without building a key for it: the check runs
+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) };
+80
View File
@@ -108,6 +108,57 @@ 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 {
@@ -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,20 @@ 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 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.chunks_exact_mut(3).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.copy_from_slice(&[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.chunks_exact(2)) {
let x = u16::from_be_bytes([s[0], s[1]]);
*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.chunks_exact(4)) {
let x = f32::from_be_bytes([s[0], s[1], s[2], s[3]]);
*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:#?}");
}
+26
View File
@@ -68,6 +68,32 @@ impl DevelopSettings {
!self.disabled_sections.iter().any(|s| s == section)
}
/// The settings as rendered: an Edit-panel section whose eye is off contributes nothing (issue
/// #316). Light (with its tone curve), Color (white balance, presence, the mixers, colour grading
/// and point colour) and Detail are reset to their defaults here; Effects, Optics, Geometry and
/// Calibration the pipeline switches off where it applies them. Borrowed when every section is on.
pub fn effective(&self) -> std::borrow::Cow<'_, DevelopSettings> {
let off = |section: &str| !self.section_enabled(section);
if !(off("light") || off("color") || off("detail")) {
return std::borrow::Cow::Borrowed(self);
}
let mut d = self.clone();
if off("light") {
d.reset_section(Section::Light);
d.reset_section(Section::Curve);
}
if off("color") {
for section in [Section::Color, Section::Mixer, Section::BwMix, Section::Grading, Section::PointColor] {
d.reset_section(section);
}
d.point_colors.clear();
}
if off("detail") {
d.reset_section(Section::Detail);
}
std::borrow::Cow::Owned(d)
}
pub fn set_section_enabled(&mut self, section: &str, on: bool) {
self.disabled_sections.retain(|s| s != section);
if !on {
+1
View File
@@ -40,3 +40,4 @@ sam = ["dep:lightcraft-segment"]
[dev-dependencies]
libc = { workspace = true }
lightcraft-tiff = { workspace = true }
+304 -15
View File
@@ -1,5 +1,5 @@
//! Estimate the starting look of a raw without a camera colour matrix (Sony ARW, Nikon NEF, Panasonic
//! RW2) from its own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only,
//! RW2, Fujifilm RAF, Canon CR3) from its own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only,
//! never output pixels or a replacement for RAW editing.
//! A global matrix can't follow the camera's hue-dependent rendering (the best matrix rendered a
//! lime shirt olive that the camera kept lime): a hue/saturation table fitted to the residuals
@@ -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)
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> {
@@ -40,13 +43,13 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf
}
let (sensor, reference) = proxies(raw, bytes, transform, PROXY)?;
// A camera profile pooled from many photos knows colours this photo shows too little of;
// only the tone and chroma curves are fitted per photo (DRO and picture styles vary).
// try its colour first and fit tone/chroma per photo (DRO and picture styles vary).
let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get);
let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone())));
let look = fit_pairs_with(&sensor, &reference, colour)?;
if lightcraft_pipeline::profiling() {
eprintln!(
"[profile] {:?} camera look: {:?}, {:?}, hue/sat table {}, camera profile {}",
"[profile] {:?} camera look: {:?}, {:?}, hue/sat table {}, camera profile available {}",
raw.format,
look.matrix.0,
look.tone,
@@ -83,14 +86,161 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz
}
// Fixed, bounded proxy: the selected look cannot depend on thumbnail/export resolution.
let k = (crop.width.max(crop.height).div_ceil(edge as usize).max(2)).div_ceil(2) * 2;
let sensor = sensor_proxy(raw, k, edge as usize)?;
let mut sensor = sensor_proxy(raw, k, edge as usize)?;
let gain = 2f32.powf(transform.baseline_exposure as f32);
let to_working = |p: [f32; 3]| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain));
if raw.format == RawFormat::Cr3 {
// Orient the camera JPEG and the sensor identically before collecting pixel correspondences.
sensor = sensor.into_oriented(raw.orientation);
reference = reference.into_oriented(raw.orientation);
sensor.map_in_place(to_working);
// Canon's JPEG can be cropped differently from the sensor. Estimate only that common
// framing from edge directions, independently of the subsequent colour fit.
sensor = align_cr3_framing(sensor, &reference);
}
let mut sensor = fit(&sensor, size, size, Filter::Box);
let reference = fit(&reference, sensor.width, sensor.height, Filter::Box);
let gain = 2f32.powf(transform.baseline_exposure as f32);
sensor.map_in_place(|p| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain)));
if raw.format != RawFormat::Cr3 {
sensor.map_in_place(to_working);
}
Some((sensor, reference))
}
#[derive(Clone, Copy, Debug)]
struct Cr3Framing {
scale: f32,
/// Translation as a fraction of the oriented frame's width and height.
offset: [f32; 2],
}
impl Cr3Framing {
const IDENTITY: Self = Self { scale: 1.0, offset: [0.0; 2] };
fn source(self, x: f32, y: f32, w: usize, h: usize) -> (f32, f32) {
let (w, h) = (w as f32, h as f32);
((x - w * 0.5) * self.scale + w * (0.5 + self.offset[0]), (y - h * 0.5) * self.scale + h * (0.5 + self.offset[1]))
}
}
/// Register the JPEG's remaining framing on a fixed proxy. Edge directions survive different
/// white balance and monotone camera tone; no target RGB values train this three-parameter fit.
/// A separate third of the edges must confirm a substantial improvement and a close match.
/// A weak, flat or unrelated preview leaves the sensor framing unchanged.
fn estimate_cr3_framing(sensor: &Rgb32f, reference: &Rgb32f) -> Option<Cr3Framing> {
if sensor.width.checked_mul(sensor.height)? != sensor.data.len() || reference.width.checked_mul(reference.height)? != reference.data.len() {
return None;
}
let sensor = fit(sensor, PROXY, PROXY, Filter::Box);
let (w, h) = (sensor.width, sensor.height);
if w < 16 || h < 16 {
return None;
}
let reference = fit(reference, w, h, Filter::Box);
if (reference.width, reference.height) != (w, h) {
return None;
}
let mut gradients = Rgb32f::new(w, h);
let mut edges = Vec::new();
for y in 1..h - 1 {
for x in 1..w - 1 {
let derivative = |image: &Rgb32f| {
[
luminance_2020(image.data[y * w + x + 1]) - luminance_2020(image.data[y * w + x - 1]),
luminance_2020(image.data[(y + 1) * w + x]) - luminance_2020(image.data[(y - 1) * w + x]),
]
};
let d = derivative(&sensor);
gradients.data[y * w + x] = [d[0], d[1], 0.0];
let d = derivative(&reference);
let magnitude = d[0].hypot(d[1]);
let value = luminance_2020(reference.data[y * w + x]);
if (0.02..0.9).contains(&value) && magnitude.is_finite() && magnitude > 0.03 {
edges.push((x as f32 + 0.5, y as f32 + 0.5, d, magnitude, (x * 17 + y * 11) % 3 == 0));
}
}
}
if edges.len() < 192 {
return None;
}
let score = |framing: Cr3Framing, held_out: bool| -> Option<f64> {
let (mut agreement, mut weight, mut valid, mut total) = (0.0, 0.0, 0usize, 0usize);
for &(x, y, target, magnitude, holdout) in &edges {
if holdout != held_out {
continue;
}
total += 1;
let (sx, sy) = framing.source(x, y, w, h);
if sx < 1.5 || sy < 1.5 || sx > w as f32 - 1.5 || sy > h as f32 - 1.5 {
continue;
}
let sample = gradients.sample_bilinear(sx, sy);
let length = sample[0].hypot(sample[1]);
if !length.is_finite() || length <= 0.01 {
continue;
}
let k = f64::from(magnitude.min(0.25));
agreement += k * f64::from((sample[0] * target[0] + sample[1] * target[1]) / (length * magnitude));
weight += k;
valid += 1;
}
(valid >= 64 && valid * 5 >= total * 4 && weight > 0.0).then_some(agreement / weight)
};
let before = score(Cr3Framing::IDENTITY, false)?;
let mut best = (before, Cr3Framing::IDENTITY);
// At most 10% zoom and two proxy pixels of translation; a coarse search followed by one
// local refinement prevents unbounded optimisation on the photograph's contents.
for s in -10..=10 {
for tx in -4..=4 {
for ty in -4..=4 {
let framing = Cr3Framing { scale: 1.0 + s as f32 * 0.01, offset: [tx as f32 * 0.5 / w as f32, ty as f32 * 0.5 / h as f32] };
if let Some(value) = score(framing, false)
&& value > best.0
{
best = (value, framing);
}
}
}
}
let coarse = best.1;
for s in -5..=5 {
for tx in -4..=4 {
for ty in -4..=4 {
let framing = Cr3Framing {
scale: coarse.scale + s as f32 * 0.002,
offset: [coarse.offset[0] + tx as f32 * 0.125 / w as f32, coarse.offset[1] + ty as f32 * 0.125 / h as f32],
};
if !(0.9..=1.1).contains(&framing.scale) || framing.offset[0].abs() > 2.0 / w as f32 || framing.offset[1].abs() > 2.0 / h as f32 {
continue;
}
if let Some(value) = score(framing, false)
&& value > best.0
{
best = (value, framing);
}
}
}
}
let (held_before, held_after) = (score(Cr3Framing::IDENTITY, true)?, score(best.1, true)?);
if best.0 < 0.9 || best.0 < before + 0.04 || held_after < 0.9 || held_after < held_before + 0.04 {
return None;
}
if lightcraft_pipeline::profiling() {
eprintln!("[profile] CR3 JPEG framing {:?}, held-out edge agreement {held_before:.4} -> {held_after:.4}", best.1);
}
Some(best.1)
}
fn align_cr3_framing(sensor: Rgb32f, reference: &Rgb32f) -> Rgb32f {
let Some(framing) = estimate_cr3_framing(&sensor, reference) else { return sensor };
let (w, h) = (sensor.width, sensor.height);
let mut aligned = Rgb32f::new(w, h);
for (i, pixel) in aligned.data.iter_mut().enumerate() {
let (x, y) = framing.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h);
*pixel = if x >= 0.5 && y >= 0.5 && x <= w as f32 - 0.5 && y <= h as f32 - 0.5 { sensor.sample_bilinear(x, y) } else { [f32::NAN; 3] };
}
aligned
}
/// Colour training pairs of one raw for a camera profile: white-balanced camera RGB (with the
/// baseline exposure) → its camera JPEG (linear Rec.2020). `None` for formats with their own
/// colour matrices, other files or unusable previews.
@@ -101,7 +251,7 @@ pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option<Vec<([f64; 3
let transform = lightcraft_raw::color::camera_transform(raw, lightcraft_raw::color::as_shot_white_xy(raw));
let (sensor, reference) = proxies(raw, bytes, &transform, PROFILE_PROXY)?;
let to_camera = transform.matrix.inverse()?;
let (pairs, _) = collect_pairs(&sensor, &reference, None)?;
let (pairs, _) = collect_pairs(&sensor, &reference, 0.05, None)?;
Some(pairs.into_iter().map(|(x, y)| (to_camera.apply(x), y)).collect())
}
@@ -173,7 +323,7 @@ fn local_contrast(image: &Rgb32f, i: usize) -> f32 {
/// are left out: there a small geometric mismatch between the raw and its JPEG pairs unrelated
/// colours (see [`EDGE_CONTRAST`]).
type Pairs = Vec<([f64; 3], [f64; 3])>;
fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>) -> Option<(Pairs, Pairs)> {
fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option<f32>) -> Option<(Pairs, Pairs)> {
if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() {
return None;
}
@@ -197,7 +347,7 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>) -
}
let min = output.iter().copied().fold(f32::INFINITY, f32::min);
let max = output.iter().copied().fold(0.0, f32::max);
colour += usize::from(max - min > 0.05);
colour += usize::from(max - min > min_chroma);
pairs.push((input.map(f64::from), output.map(f64::from)));
}
(pairs.len() >= 256 && colour >= pairs.len() / 20).then_some((pairs, bright))
@@ -252,11 +402,22 @@ const EDGE_CONTRAST: f32 = 3.0;
/// it is tried once more on the pixels away from edges (same gates): there colour pairs stay
/// valid when the camera JPEG's geometry differs slightly from the raw's.
fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
let has_profile = colour.is_some();
// A different picture style can make a camera profile fail the gates. Preserve the
// photo's own colour fit before resorting to the neutral fallback.
fit_candidate(sensor, reference, colour).or_else(|| has_profile.then(|| fit_candidate(sensor, reference, None)).flatten())
}
/// One candidate colour model, fitted on all pixels and, when that fails the gates, once more on
/// the pixels away from edges (issue #232).
fn fit_candidate(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
fit_pairs_on(sensor, reference, colour.clone(), None).or_else(|| fit_pairs_on(sensor, reference, colour, Some(EDGE_CONTRAST)))
}
fn fit_pairs_on(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>, edge_limit: Option<f32>) -> Option<CameraLook> {
let (pairs, bright) = collect_pairs(sensor, reference, edge_limit)?;
// A known colour model needs enough signal for tone fitting, not a scene rich enough to
// learn a new colour matrix. Still reject monochrome references.
let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit)?;
let candidates = match colour {
Some(given) => vec![given],
None => {
@@ -778,7 +939,7 @@ mod tests {
let mut pool = Vec::new();
for seed in 0..4 {
let (sensor, reference) = photo(seed, 2.0 + seed as f32 * 0.5);
pool.extend(collect_pairs(&sensor, &reference, None).unwrap().0);
pool.extend(collect_pairs(&sensor, &reference, 0.05, None).unwrap().0);
}
let (matrix, _) = fit_profile(&pool).unwrap();
// the camera's colours (luminance-normalised: the tone curve sets brightness)
@@ -805,6 +966,48 @@ mod tests {
assert!(error.sqrt() < 0.02, "this photo's own tone is followed: RMS {}", error.sqrt());
}
#[test]
fn profile_fits_tone_without_learning_colour_from_a_dull_scene() {
let matrix = Mat3([[2.0, -0.02, 0.0], [0.0, 2.0, 0.0], [0.0, -0.03, 2.0]]);
let mut sensor = Rgb32f::new(64, 48);
let mut reference = sensor.clone();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
*src = [0.02 + (i % 997) as f32 * 0.00045; 3];
let p = matrix.apply_f32(*src);
let y = luminance_2020(p);
*dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y);
}
assert!(fit_pairs(&sensor, &reference).is_none(), "not enough colour to learn a matrix");
let look = fit_pairs_with(&sensor, &reference, Some((matrix, None))).unwrap();
assert_eq!(look.matrix, matrix);
reference.map_in_place(|p| [luminance_2020(p); 3]);
assert!(fit_pairs_with(&sensor, &reference, Some((matrix, None))).is_none(), "monochrome JPEGs remain rejected");
}
#[test]
fn rejected_profile_preserves_a_usable_per_photo_fit() {
let matrix = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
let wrong = Mat3([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]);
let mut sensor = Rgb32f::new(64, 64);
let mut reference = sensor.clone();
let mut seed = 0x2545_f491_u32;
for (src, dst) in sensor.data.iter_mut().zip(&mut reference.data) {
*src = std::array::from_fn(|_| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
0.05 + (seed % 1000) as f32 * 0.0003
});
let p = matrix.apply_f32(*src);
let y = luminance_2020(p);
*dst = p.map(|v| (v * (1.0 - (-2.5 * y).exp()) / y).clamp(0.0, 0.999));
}
let per_photo = fit_pairs(&sensor, &reference).unwrap();
let look = fit_pairs_with(&sensor, &reference, Some((wrong, None))).unwrap();
assert_eq!(look.matrix, per_photo.matrix, "a rejected camera profile must not suppress the per-photo fit");
assert_eq!(look.tone, per_photo.tone);
}
#[test]
fn hue_sat_passes_black_and_non_finite_pixels_through() {
let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
@@ -816,9 +1019,73 @@ mod tests {
}
#[test]
fn sony_nikon_and_panasonic_raws_get_a_file_local_look() {
assert!([RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2].into_iter().all(file_local_look));
assert!(![RawFormat::Dng, RawFormat::Cr2, RawFormat::Raf].into_iter().any(file_local_look));
fn supported_raws_get_a_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]
fn cr3_camera_framing_is_measured_independently_of_tone_and_rejects_unrelated_edges() {
let (w, h) = (192, 128);
let mut sensor = Rgb32f::new(w, h);
for (i, pixel) in sensor.data.iter_mut().enumerate() {
let (x, y) = ((i % w) as f32, (i / w) as f32);
let value = 0.35 + 0.13 * (x * 0.15 + y * 0.22).sin() + 0.1 * (x * 0.33 - y * 0.14).sin() + 0.07 * (x * 0.09 - y * 0.31).cos();
*pixel = [value; 3];
}
let expected = Cr3Framing { scale: 0.96, offset: [0.01, -0.006] };
let mut reference = Rgb32f::new(w, h);
for (i, pixel) in reference.data.iter_mut().enumerate() {
let (x, y) = expected.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h);
*pixel = sensor.sample_bilinear(x, y).map(|v| v.powf(0.7));
}
let measured = estimate_cr3_framing(&sensor, &reference).expect("coherent held-out edges locate camera framing despite nonlinear tone");
assert!((measured.scale - expected.scale).abs() < 0.006, "{measured:?}");
assert!((measured.offset[0] - expected.offset[0]).abs() < 0.003, "{measured:?}");
assert!((measured.offset[1] - expected.offset[1]).abs() < 0.003, "{measured:?}");
assert!(estimate_cr3_framing(&sensor, &sensor).is_none(), "already aligned pixels stay unchanged");
reference.data.fill([0.2; 3]);
assert!(estimate_cr3_framing(&sensor, &reference).is_none(), "flat previews cannot establish framing");
for (i, pixel) in reference.data.iter_mut().enumerate() {
let (x, y) = ((i % w) as f32, (i / w) as f32);
*pixel = [0.35 + 0.13 * (x * 0.41 - y * 0.07).sin() + 0.1 * (x * 0.04 + y * 0.39).cos(); 3];
}
assert!(estimate_cr3_framing(&sensor, &reference).is_none(), "unrelated edges cannot change the framing");
}
/// The guarded fit is assessed on real sensor data, never on the JPEG fallback. A corpus
/// download or an unfinished codec cannot make this test claim a successful RAW calibration.
#[test]
fn corpus_cr3_gets_a_framing_aligned_camera_look() {
let path = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw/cr3-canon-r100-raw.cr3");
let Ok(bytes) = std::fs::read(&path) else {
eprintln!("skip: {} absent", path.display());
return;
};
let raw = match lightcraft_raw::decode(&bytes) {
Ok(raw) => raw,
Err(lightcraft_raw::RawError::Unsupported(why)) => {
eprintln!("skip CR3 sensor decoder: {why}");
return;
}
Err(error) => panic!("CR3 corpus failed to decode: {error}"),
};
assert_eq!(raw.format, RawFormat::Cr3);
assert_eq!(raw.info().developed_size(), (6000, 4000));
let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
let look = fit_preview(&raw, &bytes, &transform);
eprintln!("R100 guarded colour fit accepted: {}", look.is_some());
assert!(look.is_some(), "this R100 corpus photo has enough matching colour after camera-framing alignment");
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
assert!(info.raw && info.relative_wb);
assert_eq!((info.as_shot_temp, info.as_shot_tint), (6500.0, 0.0));
assert_eq!(info.camera_tone.is_some(), look.is_some());
}
/// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white
@@ -918,6 +1185,28 @@ mod tests {
assert!(fit_pairs(&sensor, &reference).is_none());
}
/// X-Trans and 16-bit Bayer RAFs use the same fixed fit at thumbnail and export sizes.
/// The X-T20 fails the gates on all pixels (held-out RMS 0.114 against 0.10) and passes away
/// from edges (0.056, issue #232), so it is fitted too. Skips cleanly without these CC0
/// corpus files.
#[test]
fn corpus_raf_colour_and_white_balance() {
let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw");
for (name, accepted) in [("raf-fuji-xt2-865.raf", true), ("raf-fuji-gfx100s-4503.raf", true), ("raf-fuji-xt20-compressed.raf", true)] {
let Ok(bytes) = std::fs::read(dir.join(name)) else { continue };
let header = crate::files::probe_bytes(name, &bytes).unwrap();
assert_eq!(header.as_shot_wb, Some((6500.0, 0.0)), "{name}");
let (_, small) = crate::files::load_bytes(&bytes, 400).unwrap();
let (_, large) = crate::files::load_bytes(&bytes, 1200).unwrap();
assert_eq!(small.camera_tone.is_some(), accepted, "{name}");
assert_eq!(small.camera_tone, large.camera_tone, "{name}: resolution changed the fit");
assert!(small.raw && small.relative_wb && small.as_shot_temp == 6500.0 && small.as_shot_tint == 0.0, "{name}");
}
}
#[test]
fn rejects_monochrome_invalid_and_unrelated_previews() {
let mut sensor = Rgb32f::new(32, 32);
+8 -4
View File
@@ -1,6 +1,6 @@
//! Camera colour profiles of our own, pooled from many photos of one camera model.
//!
//! A raw file without colour matrices (Sony ARW, Nikon NEF) gets its look fitted to its own embedded camera
//! A raw file without colour matrices (Sony ARW, Nikon NEF, Fujifilm RAF) gets its look fitted to its own embedded camera
//! JPEG (`camera_preview`), but one photo shows too little of some colours: a lime shirt covering a
//! few dozen proxy pixels next to a hillside of foliage at the same hue. `lightcraft-cli calibrate`
//! pools the colour pairs of many photos per model and fits one matrix and hue/saturation/value
@@ -24,7 +24,11 @@ const MAX_FILE: u64 = 4 << 20;
/// Profiles built into LightCraft (`assets/camera-profiles/`, see `assets/ATTRIBUTION.md`):
/// `(model, JSON)`.
pub const BUNDLED: &[(&str, &str)] = &[("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json"))];
pub const BUNDLED: &[(&str, &str)] = &[
("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")),
("X-H2S", include_str!("../../../assets/camera-profiles/X-H2S.json")),
("X-T4", include_str!("../../../assets/camera-profiles/X-T4.json")),
];
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CameraProfile {
@@ -196,8 +200,8 @@ pub struct Pool {
const PAIRS_PER_FILE: usize = 4000;
impl Pool {
/// Add one raw file's colour pairs. `Ok(None)` when the file can't contribute (not an ARW or
/// NEF without colour matrices, no usable camera JPEG, too little colour).
/// Add one raw file's colour pairs. `Ok(None)` when the file can't contribute (not an ARW,
/// NEF or RAF without colour matrices, no usable camera JPEG, too little colour).
pub fn add(&mut self, bytes: &[u8]) -> Result<Option<String>, String> {
let mut raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?;
// colour only: geometric lens corrections would stop the sensor proxy from binning
+3 -7
View File
@@ -268,13 +268,9 @@ pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) {
/// the folder itself.
fn follow_library_folder(chosen: &mut Option<String>, from: &str, to: &str) {
let Some(c) = chosen.as_deref() else { return };
let (ck, fk) = (lightcraft_catalog::query::folder_key(c), lightcraft_catalog::query::folder_key(from));
if fk.is_empty() {
return;
}
if let Some(rest) = ck.strip_prefix(fk.as_str()).filter(|r| r.is_empty() || r.starts_with('/') || fk.ends_with('/')) {
let rest = rest.trim_start_matches('/');
*chosen = Some(if rest.is_empty() { to.to_string() } else { format!("{}/{rest}", to.trim_end_matches(['/', '\\'])) });
if let Some(rest) = lightcraft_catalog::query::folder_rest(c, from) {
// joined by the platform, so the result is spelled like `to` and like a path read from disk
*chosen = Some(rest.iter().fold(std::path::PathBuf::from(to), |p, name| p.join(name)).to_string_lossy().to_string());
}
}
+19
View File
@@ -1062,6 +1062,25 @@ pub fn specs() -> Vec<CommandSpec> {
ok()
}),
// ---- import
cmd!(query "library.inspectLightroom", "Inspect Lightroom Catalog", [], None,
"{path: .lrcat} → {photos, collections, missing, warnings}; read-only, no Lightroom required",
always, |_, p| {
let path = str_param(p,"path").ok_or_else(|| bad("library.inspectLightroom","missing path"))?;
let data = crate::lightroom_catalog::read(std::path::Path::new(path)).map_err(|e| bad("library.inspectLightroom",e))?;
Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !std::path::Path::new(&p.path).is_file()).map(|p|&p.path).collect::<Vec<_>>(),"warnings":data.warnings}))
}),
cmd!(
"library.importLightroom",
"Import Lightroom Catalog",
[],
None,
"{path: .lrcat, updateExisting?: false}; direct import of paths, ratings, flags, metadata, keywords, collections, virtual copies & mapped edits. Archives source settings/history first. Existing edited photos are preserved unless updateExisting=true; source catalog is read-only.",
always,
|s, p| {
let path = str_param(p, "path").ok_or_else(|| bad("library.importLightroom", "missing path"))?;
crate::lightroom_catalog::import(s, std::path::Path::new(path), bool_or(p, "updateExisting", false))
}
),
cmd!(
query "library.importPreview",
"Review Import",
+5 -16
View File
@@ -41,22 +41,11 @@ fn cube_files(paths: &[String]) -> Vec<(String, String, Option<String>)> {
for p in paths {
let path = Path::new(p);
if path.is_dir() {
let mut stack = vec![path.to_path_buf()];
while let Some(d) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&d) else { continue };
for e in rd.flatten() {
let ep = e.path();
if ep.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) {
continue;
}
if ep.is_dir() {
stack.push(ep);
} else if ep.extension().is_some_and(|x| x.eq_ignore_ascii_case("cube"))
&& let Ok(t) = std::fs::read_to_string(&ep)
{
let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string());
out.push((ep.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, group));
}
let cube = |f: &Path| f.extension().is_some_and(|x| x.eq_ignore_ascii_case("cube"));
for ep in crate::walk::files_in(path, None, crate::walk::Limits::default(), cube).files {
if let Ok(t) = std::fs::read_to_string(&ep) {
let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string());
out.push((ep.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, group));
}
}
} else if path.extension().is_some_and(|x| x.eq_ignore_ascii_case("zip")) {
+1 -1
View File
@@ -191,7 +191,7 @@ pub fn devices_in(parents: &[std::path::PathBuf], extra: Vec<std::path::PathBuf>
/// The volume's `DCIM` folder: the usual spellings are checked directly, so a volume's root is
/// only listed (other spellings on a case-sensitive volume) off Windows, whose drives aren't.
fn dcim_in(root: &std::path::Path) -> Option<std::path::PathBuf> {
pub fn dcim_in(root: &std::path::Path) -> Option<std::path::PathBuf> {
if let Some(p) = ["DCIM", "dcim"].iter().map(|n| root.join(n)).find(|p| p.is_dir()) {
return Some(p);
}
+192 -14
View File
@@ -92,16 +92,18 @@ 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);
let (meta, captured) = meta_of(&m);
if lightcraft_raw::probe(bytes).is_some() {
let raw = match lightcraft_raw::probe_info(bytes) {
let raw = match lightcraft_raw::probe_info(bytes).map_err(|e| preview_reason(bytes, e)) {
Ok(r) => r,
Err(lightcraft_raw::RawError::Unsupported(why)) => {
Err(Ok(why)) => {
// a raw variant we can't decode yet: describe it from its embedded preview
let (w, h) = embedded_preview_size(bytes).ok_or(format!("unsupported raw ({why}) without an embedded preview"))?;
return Ok(ProbeInfo {
@@ -119,7 +121,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
..Default::default()
});
}
Err(e) => return Err(e.to_string()),
Err(Err(e)) => return Err(e),
};
let (mut w, mut h) = (raw.crop.width.max(1) as u32, raw.crop.height.max(1) as u32);
if w <= 1 || h <= 1 {
@@ -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);
}
@@ -179,6 +182,21 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
})
}
/// Why a raw that failed to decode should show its embedded preview instead (`Ok`), or the error
/// to report (`Err`). Variants we can't decode yet always fall back. So does any CR3 error: the CRX
/// decoder is verified on few bodies, and every CR3 opened from its embedded JPEG before it existed.
/// So does any other failure of a recognised raw (damaged or oversized raw data, unreadable TIFF
/// structure): its preview may still be intact. Only a file that is not a raw reports an error.
fn preview_reason(bytes: &[u8], e: lightcraft_raw::RawError) -> Result<String, String> {
use lightcraft_raw::RawError;
match e {
RawError::Unsupported(why) => Ok(why),
e if lightcraft_raw::probe(bytes) == Some(lightcraft_raw::RawFormat::Cr3) => Ok(format!("CR3 {e}")),
RawError::NotRaw => Err(RawError::NotRaw.to_string()),
e => Ok(e.to_string()),
}
}
/// Sensor clip level (normalised) for highlight reconstruction.
const HIGHLIGHT_CLIP: f32 = 0.99;
@@ -213,13 +231,13 @@ pub fn load_vec(bytes: Vec<u8>, max_edge: usize) -> Result<(Rgb32f, SourceInfo),
fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> {
if lightcraft_raw::probe(&bytes).is_some() {
let mut raw = match lightcraft_raw::decode(&bytes) {
let mut raw = match lightcraft_raw::decode(&bytes).map_err(|e| preview_reason(&bytes, e)) {
Ok(r) => r,
Err(lightcraft_raw::RawError::Unsupported(why)) => {
Err(Ok(why)) => {
// show the camera's embedded JPEG (rendered, not raw) until the variant is supported
return load_embedded_preview(&bytes, max_edge).ok_or(format!("unsupported raw ({why}) without an embedded preview"));
}
Err(e) => return Err(e.to_string()),
Err(Err(e)) => return Err(e),
};
// Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in
// (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`).
@@ -318,10 +336,24 @@ fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation {
lightcraft_meta::extract(raw_bytes).orientation.unwrap_or(Orientation::Normal)
}
/// What stands in for a raw file we can't decode: its largest embedded JPEG, else, for a TIFF-based
/// raw, the file's own first image when the TIFF reader can show it (a reduced RGB copy: the only
/// preview some containers carry). Returns image bytes for [`lightcraft_codecs::decode`].
fn stand_in_image(bytes: &[u8]) -> Option<std::borrow::Cow<'_, [u8]>> {
if let Some(jpeg) = lightcraft_raw::embedded_preview(bytes) {
return Some(std::borrow::Cow::Owned(jpeg));
}
let undecodable = lightcraft_raw::probe(bytes).is_some_and(|f| !f.is_supported());
(undecodable && lightcraft_codecs::sniff(bytes).is_some_and(|f| f == lightcraft_codecs::Format::Tiff))
.then_some(std::borrow::Cow::Borrowed(bytes))
}
/// Shared colour interpretation for quick previews, unsupported RAW fallback and camera-look fitting.
pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOptions) -> Option<lightcraft_codecs::Decoded> {
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
match lightcraft_raw::embedded_preview_color_space(bytes) {
let jpeg = stand_in_image(bytes)?;
// the colour space is a property of the JPEG; a TIFF stand-in is read by the TIFF reader
let is_jpeg = matches!(jpeg, std::borrow::Cow::Owned(_));
match lightcraft_raw::embedded_preview_color_space(bytes).filter(|_| is_jpeg) {
Some(space) => {
let space = match space {
lightcraft_raw::PreviewColorSpace::Srgb => lightcraft_codecs::NamedSpace::Srgb,
@@ -335,7 +367,7 @@ pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOp
/// Oriented size of the embedded preview of a raw file.
fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> {
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
let jpeg = stand_in_image(bytes)?;
let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?;
let (mut w, mut h) = (d.source_width, d.source_height);
if preview_orientation(bytes, d.orientation).swaps_axes() {
@@ -413,6 +445,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> {
@@ -553,6 +616,43 @@ mod tests {
b
}
/// [`cr3_with_preview`] plus a full-size CRX raw track whose `CMP1` coding header is garbage:
/// the container parses, the raw data doesn't (`RawError::Corrupt`, not `Unsupported`).
fn cr3_with_corrupt_raw(w: u32, h: u32) -> Vec<u8> {
let bx = |kind: &[u8; 4], body: &[u8]| [&((body.len() + 8) as u32).to_be_bytes()[..], kind, body].concat();
let mut craw = vec![0u8; 82];
craw[24..26].copy_from_slice(&(w as u16).to_be_bytes());
craw[26..28].copy_from_slice(&(h as u16).to_be_bytes());
craw.extend(bx(b"CMP1", &[0xff; 4]));
let mut stsd = vec![0, 0, 0, 0, 0, 0, 0, 1];
stsd.extend(bx(b"CRAW", &craw));
let stsz = [0u32, 16, 1].map(u32::to_be_bytes).concat();
let co64 = [&[0u8, 0, 0, 0, 0, 0, 0, 1][..], &0u64.to_be_bytes()].concat();
let stbl = [bx(b"stsd", &stsd), bx(b"stsz", &stsz), bx(b"co64", &co64)].concat();
let trak = bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &stbl))));
let preview = cr3_with_preview(w, h);
[&preview[..24], &bx(b"moov", &trak), &preview[24..]].concat()
}
/// A CR3 whose raw data fails to decode, for any reason, still opens from its embedded JPEG:
/// CR3s did before the CRX decoder, which is verified on few bodies (PR #279 review).
#[test]
fn corrupt_cr3_falls_back_to_embedded_preview() {
let b = cr3_with_corrupt_raw(48, 32);
let err = lightcraft_raw::decode(&b).expect_err("the fixture's raw data is corrupt");
assert!(!matches!(err, lightcraft_raw::RawError::Unsupported(_)), "{err}");
let p = probe_bytes("x.cr3", &b).unwrap();
assert_eq!((p.width, p.height, p.kind, p.format.as_str()), (48, 32, MediaKind::Raw, "CR3"));
assert!(p.preview_only.is_some_and(|why| why.contains("CR3")));
let (img, src) = load_bytes(&b, 24).unwrap();
assert_eq!((img.width, img.height), (24, 16));
assert!(!src.raw);
// other formats still report a corrupt file as an error
let mut dng = crate::tests_xmp::synthetic_dng_with(None, Default::default());
dng.truncate(dng.len() / 2);
assert!(load_bytes(&dng, 24).is_err());
}
/// Issue #138: a DNG's own profile look (hue/saturation map, look table, tone curve) is
/// applied when it's developed — Lightroom-converted DNGs rendered flat and muted without it.
#[test]
@@ -656,6 +756,84 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// A raw whose TIFF shell holds only a small RGB image (IFD0) next to a private block, as Exif
/// declares a picture 25x its size.
fn tiff_shell(w: u32, h: u32, shell: bool) -> Vec<u8> {
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
let mut ifd0 = IfdBuilder::new();
ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8]));
ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3]));
ifd0.set(t::PHOTOMETRIC, Value::Short(vec![2]));
ifd0.set(t::COMPRESSION, Value::Short(vec![1]));
let px: Vec<u8> = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
ifd0.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![px] });
ifd0.set_child(
t::EXIF_IFD,
IfdBuilder::new().with(t::PIXEL_X_DIMENSION, Value::Long(vec![w * 25])).with(t::PIXEL_Y_DIMENSION, Value::Long(vec![h * 25])),
);
let mut b = TiffWriter::default().write(&[ifd0]).unwrap();
if shell {
b.extend(std::iter::repeat_n(0x5au8, (w * h * 625 / 8) as usize + 100));
}
b
}
/// A TIFF raw whose only image is a small thumbnail used to open as a plain image of that size
/// ("a 150 MP file renders 296x220"), with nothing saying so. Now it is recognised as a raw we can't
/// decode: preview only, with the reason, still showing that image.
#[test]
fn thumbnail_only_tiff_raw_is_preview_only_not_a_plain_image() {
let b = tiff_shell(24, 16, true);
let p = probe_bytes("IMG_0001.IIQ", &b).unwrap();
assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "IIQ", 24, 16));
let why = p.preview_only.expect("marked preview only");
assert!(why.contains("600x400") && why.contains("24x16"), "{why}");
let (img, src) = load_bytes(&b, 12).unwrap();
assert_eq!((img.width, img.height, src.raw), (12, 8, false));
assert!(embedded_preview_srgb(&b, 12).is_some());
// the same pixels without the private block are a small ordinary image
let plain = probe_bytes("small.tif", &tiff_shell(24, 16, false)).unwrap();
assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 24, 16));
assert!(lightcraft_raw::probe(&tiff_shell(24, 16, false)).is_none());
}
/// Recognised raw containers we don't decode (Minolta MRW here: the preview's first byte is
/// overwritten in the file) import as preview only with the JPEG's size, instead of failing.
#[test]
fn undecodable_container_with_a_preview_imports_as_preview_only() {
let px: Vec<u8> = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
let mut jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
jpeg[0] = 0x02;
let mut f = b"\0MRM\0\x01\0\0".to_vec();
f.extend(std::iter::repeat_n(0x11u8, 300));
f.extend_from_slice(&jpeg);
f.extend(std::iter::repeat_n(0x22u8, 300));
let p = probe_bytes("A.MRW", &f).unwrap();
assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "MRW", 40, 30));
assert!(p.preview_only.is_some_and(|w| w.contains("Mrw")));
let (img, src) = load_bytes(&f, 20).unwrap();
assert_eq!((img.width, img.height, src.raw), (20, 15, false));
// without a JPEG: a clear error
let e = probe_bytes("A.MRW", b"\0MRM\0\x01\0\0 nothing here").unwrap_err();
assert!(e.contains("without an embedded preview"), "{e}");
}
/// Any failure of a recognised raw (not only an unsupported variant) can fall back to its preview;
/// CR3 keeps its own wording.
#[test]
fn decode_failures_of_recognised_raws_may_fall_back_to_the_preview() {
use lightcraft_raw::RawError;
let cr3 = b"\0\0\0\x18ftypcrx \0\0\0\x01";
assert_eq!(preview_reason(b"x", RawError::Unsupported("x".into())), Ok("x".to_string()));
assert!(preview_reason(b"x", RawError::Corrupt("bad strip".into())).is_ok_and(|w| w.contains("bad strip")));
assert!(preview_reason(b"x", RawError::Limit("too big")).is_ok());
assert!(preview_reason(b"x", RawError::Tiff(lightcraft_tiff::TiffError::MissingTag(256))).is_ok());
assert!(preview_reason(b"x", RawError::NotRaw).is_err());
assert!(preview_reason(cr3, RawError::Corrupt("bad".into())).is_ok_and(|w| w.starts_with("CR3 ")));
}
#[test]
fn quick_jobs_show_embedded_previews_then_cached_renders() {
use crate::media::QuickSource;
+393 -73
View File
@@ -33,7 +33,8 @@ use crate::media::ProbeInfo;
/// File extensions LightCraft imports (lower case).
pub const EXTENSIONS: &[&str] = &[
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "psd", "jxl",
"gif", "bmp", "heic", "avif",
"gif", "bmp", "heic", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG)
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf",
];
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -317,32 +318,96 @@ pub fn is_supported(path: &Path) -> bool {
}
/// Expand files and folders (recursively) into supported files. `skip` (e.g. the library folder)
/// is never descended into.
/// is never descended into. Each folder's walk is bounded ([`crate::walk::Limits::default`]); a
/// walk that stops at a bound is logged.
pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec<String> {
fn walk(p: &Path, skip: Option<&Path>, out: &mut Vec<String>, top: bool) {
let hidden = p.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.'));
if (hidden && !top) || skip.is_some_and(|s| p == s) {
return;
expand_within(paths, skip, crate::walk::Limits::default()).0
}
/// [`expand`] with the walk bounded by `limits` → (files, whether a walk stopped at a bound).
pub fn expand_within(paths: &[String], skip: Option<&Path>, limits: crate::walk::Limits) -> (Vec<String>, bool) {
let mut out = Vec::new();
let mut truncated = false;
for p in paths {
let p = Path::new(p);
if skip.is_some_and(|s| p == s) {
continue;
}
if p.is_dir() {
let Ok(rd) = std::fs::read_dir(p) else { return };
let mut v: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
v.sort();
for c in v {
walk(&c, skip, out, false);
let w = crate::walk::files_in(p, skip, limits, is_supported);
if w.truncated {
log::warn!(
"import: stopped looking in {} after {} entries (limits: {} entries, {} folder levels)",
p.display(),
w.entries,
limits.max_entries,
limits.max_depth
);
}
} else if top || is_supported(p) {
truncated |= w.truncated;
out.extend(w.files.into_iter().map(|f| f.to_string_lossy().to_string()));
} else {
// explicitly named files are attempted even with an unknown extension (sniffed)
out.push(p.to_string_lossy().to_string());
}
}
let mut out = Vec::new();
for p in paths {
walk(Path::new(p), skip, &mut out, true);
}
let mut seen = std::collections::HashSet::new();
out.retain(|p| seen.insert(p.clone()));
out
(out, truncated)
}
/// What a path given on the command line at launch becomes ([`launch_path`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LaunchPath {
/// Import this (absolute) file or folder, in the background.
Import(String),
/// Not imported: `path` (absolute) is a whole drive or the whole home folder, which is never
/// walked without being chosen in the app (issue #374).
Refused { path: String, why: String },
}
/// Resolve a launch argument for importing (issue #374). An empty argument is ignored (`None`);
/// a relative one (`.`, `..`, `photos`; `/` or `\` on Windows, the current drive's root) is
/// resolved against `cwd` — the working folder a launcher picked, often a drive root or
/// `C:\Windows\System32` — and refused when that leaves no folder (`cwd` unknown). A drive
/// or file-system root is replaced by its camera folder (`DCIM`, from `dcim`: a memory card) or
/// refused, and so is the home folder `home`: either would walk most of the disk.
pub fn launch_path(arg: &str, cwd: Option<&Path>, home: Option<&Path>, dcim: impl Fn(&Path) -> Option<PathBuf>) -> Option<LaunchPath> {
if arg.trim().is_empty() {
return None;
}
let given = Path::new(arg);
let joined = if given.is_absolute() {
given.to_path_buf()
} else {
match cwd {
Some(c) => c.join(given),
None => return Some(LaunchPath::Refused { path: arg.to_string(), why: "a relative path, and the working folder is unknown".into() }),
}
};
// `.` and `..` resolved by name (no file-system access)
let mut abs = PathBuf::new();
for c in joined.components() {
match c {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
abs.pop();
}
other => abs.push(other.as_os_str()),
}
}
let shown = abs.to_string_lossy().to_string();
if abs.parent().is_none() {
return Some(match dcim(&abs) {
Some(d) => LaunchPath::Import(d.to_string_lossy().to_string()),
None => LaunchPath::Refused { path: shown, why: "a whole drive".into() },
});
}
let key = lightcraft_catalog::query::folder_key;
if home.is_some_and(|h| key(&h.to_string_lossy()) == key(&shown)) {
return Some(LaunchPath::Refused { path: shown, why: "the whole home folder".into() });
}
Some(LaunchPath::Import(shown))
}
/// Probe files (headers + content hash) as an import would.
@@ -365,7 +430,7 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress
};
#[cfg(not(target_arch = "wasm32"))]
{
let n = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8).min(paths.len().max(1));
let n = workers(paths.len());
if n > 1 {
let mut results: Vec<Option<Result<ProbeInfo, String>>> = vec![None; paths.len()];
let next = std::sync::atomic::AtomicUsize::new(0);
@@ -404,18 +469,16 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress
.collect()
}
/// Copy `src` into `root`/`folders` as `name` (default: its own name), made unique with -1, -2…;
/// returns the new path. The copy is verified ([`crate::import_move::copy_new`]: a new file,
/// synced and checked against `probe_hash`, the content hash the probe computed — or compared
/// byte for byte with the source when there is none); a bad one is removed and the photo
/// reported as failed.
fn copy_into(root: &Path, src: &str, folders: &[String], name: Option<&str>, probe_hash: Option<&str>) -> Result<String, String> {
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
/// Copy `src` into `dir` as `name` (default: its own name), made unique with -1, -2…; returns the
/// new path. The copy is verified ([`crate::import_move::copy_new`]: a new file, synced and
/// checked against `probe_hash`, the content hash the probe computed — or compared byte for byte
/// with the source when there is none); a bad one is removed and the photo reported as failed.
fn copy_into(dir: &Path, src: &str, name: Option<&str>, probe_hash: Option<&str>) -> Result<String, String> {
let name = name
.map(str::to_string)
.unwrap_or_else(|| Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into()));
let expect = probe_hash.and_then(lightcraft_preview::Hash128::parse);
crate::import_move::copy_new(Path::new(src), &dir, &name, expect).map(|p| p.to_string_lossy().to_string())
crate::import_move::copy_new(Path::new(src), dir, &name, expect).map(|p| p.to_string_lossy().to_string())
}
/// What a [`scan`] needs from the session, so it can run on another thread (a folder on a network
@@ -641,6 +704,50 @@ impl Prepared {
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
/// Recheck Add-only entries against the owner catalog immediately before commit.
/// Preparation may have run while another import added the same path or content.
pub fn revalidate_add(&mut self, s: &Session) {
let mut paths = HashMap::new();
let mut hashes = HashMap::new();
for photo in s.catalog.photos() {
if let Source::File { path } = &photo.source {
paths.insert(normalize_import_path(path), photo.id);
}
if let Some(hash) = &photo.content_hash {
hashes.insert(hash.clone(), photo.id);
}
}
let mut items = Vec::with_capacity(self.items.len());
for item in std::mem::take(&mut self.items) {
let PreparedItem::Ready(ready) = item else {
items.push(item);
continue;
};
let ReadyFile { path, stored, info, sidecar, placed } = *ready;
let existing_path = paths.get(&normalize_import_path(&stored)).copied().or_else(|| paths.get(&normalize_import_path(&path)).copied());
let existing_hash = info.content_hash.as_ref().and_then(|hash| hashes.get(hash).copied());
if let Some(existing) = existing_path.or(existing_hash) {
if let Some(placed) = &placed {
crate::import_move::rollback(placed);
}
items.push(PreparedItem::Duplicate {
path,
existing: Some(existing),
reason: if existing_path.is_some() { "path" } else { "content" },
hash: info.content_hash,
});
} else {
items.push(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed })));
}
}
self.items = items;
}
}
fn normalize_import_path(path: &str) -> String {
let path = path.replace('\\', "/");
if cfg!(windows) { path.to_lowercase() } else { path }
}
impl ImportJob {
@@ -699,8 +806,9 @@ impl ImportJob {
}
/// The file-system half of importing `paths`: expand folders, skip known paths, probe, skip
/// duplicates by content, read sidecars, copy or place (Move) the files. Nothing in the
/// catalog changes. Stops before the next file once `cancel` is set.
/// duplicates by content, read sidecars, copy or place (Move) the files — several at once,
/// with the names and outcomes of one after another. Nothing in the catalog changes. Stops
/// before the next file once `cancel` is set.
pub fn prepare(&mut self, paths: &[String], cancel: &std::sync::atomic::AtomicBool) -> Prepared {
let files = self.expand(paths);
self.prepare_files(files, cancel)
@@ -750,24 +858,39 @@ impl ImportJob {
let probed: Vec<Result<ProbeInfo, String>> =
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
crate::memory::release();
// Files are planned one by one, in order (duplicates, sidecars, names, folders); their
// copies or moves — the slow part, reading and writing every byte and verifying it — are
// collected and run on several threads (issue #367), then their outcomes take their places.
let mut plan = Plan::default();
for (path, info) in todo.into_iter().zip(probed) {
if cancel.load(Relaxed) {
break;
}
self.done.fetch_add(1, Relaxed);
let mut info = match info {
Ok(i) => i,
Err(e) => {
log::warn!("import {path}: {e}");
out.items.push(PreparedItem::Failed(path, e));
self.done.fetch_add(1, Relaxed);
plan.slots.push(Slot::Item(PreparedItem::Failed(path, e)));
continue;
}
};
// a copy of a file whose transfer is still pending: a duplicate only if that one succeeds
if !opts.local
&& let Some(h) = &info.content_hash
&& plan.transfers.iter().any(|t| t.info.content_hash.as_ref() == Some(h))
{
self.run_plan(&mut plan, &mut out, cancel);
if cancel.load(Relaxed) {
break;
}
}
if let Some(h) = &info.content_hash
&& !opts.local
&& let Some(existing) = self.by_hash.get(h)
{
out.items.push(PreparedItem::Duplicate { path, existing: *existing, reason: "content", hash: Some(h.clone()) });
self.done.fetch_add(1, Relaxed);
plan.slots.push(Slot::Item(PreparedItem::Duplicate { path, existing: *existing, reason: "content", hash: Some(h.clone()) }));
continue;
}
// the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files
@@ -786,7 +909,6 @@ impl ImportJob {
if info.captured.is_none() {
info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone());
}
let mut placed = None;
let stored = match (mode, &self.copy_root) {
(ImportMode::Copy | ImportMode::Move, Some(root))
if !crate::import_move::inside(Path::new(&path), root)
@@ -803,37 +925,35 @@ impl ImportJob {
n
});
let folders = opts.organize.folders(&q);
if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) {
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
let name = name.unwrap_or(own);
match crate::import_move::place(Path::new(&path), &dir, &name) {
Ok(pl) => {
let dst = pl.dst.to_string_lossy().to_string();
placed = Some(pl);
dst
}
Err(e) => {
out.items.push(PreparedItem::Failed(path, e));
continue;
}
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
// the name this file gets depends on what a pending transfer of a like-named file
// into the same folder ends up with: that one goes first
let as_named = name.as_deref().unwrap_or(if own.is_empty() { "photo" } else { own.as_str() });
let family = (dir.to_string_lossy().to_lowercase(), name_family(as_named));
if plan.transfers.iter().any(|t| t.family == family) {
self.run_plan(&mut plan, &mut out, cancel);
if cancel.load(Relaxed) {
break;
}
}
let how = if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) {
How::Place { name: name.unwrap_or(own) }
} else {
if mode == ImportMode::Move {
let reason = "a symbolic link: the file it points to was copied, the link and its target stay";
out.items.push(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }));
plan.slots.push(Slot::Item(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() })));
}
match copy_into(root, &path, &folders, name.as_deref(), info.content_hash.as_deref()) {
Ok(p) => p,
Err(e) => {
out.items.push(PreparedItem::Failed(path, e));
continue;
}
}
}
How::Copy { name }
};
// Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace)
let dng = opts.convert_dng && mode == ImportMode::Copy && raw && !info.format.eq_ignore_ascii_case("DNG");
plan.slots.push(Slot::Transfer);
plan.transfers.push(Transfer { path, info, sidecar, dir, how, dng, family });
continue;
}
(ImportMode::Move, _) => {
let reason = "already inside the destination or the library: added where it is";
out.items.push(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }));
plan.slots.push(Slot::Item(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() })));
path.clone()
}
_ => path.clone(),
@@ -841,27 +961,199 @@ impl ImportJob {
if let Some(h) = &info.content_hash {
self.by_hash.insert(h.clone(), None);
}
// Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace)
let mut stored = stored;
if opts.convert_dng
&& mode == ImportMode::Copy
&& stored != path
&& info.kind == MediaKind::Raw
&& !info.format.eq_ignore_ascii_case("DNG")
{
match crate::cmd::convert::write_dng_with(self.file_bytes.as_ref(), &stored, String::new()) {
Ok(dng) => {
let _ = std::fs::remove_file(&stored);
stored = dng;
info.format = "DNG".into();
}
Err(e) => log::warn!("import {path}: copy as DNG: {e}"),
}
}
out.items.push(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed })));
self.done.fetch_add(1, Relaxed);
plan.slots.push(Slot::Item(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed: None }))));
}
self.run_plan(&mut plan, &mut out, cancel);
out
}
/// Run the transfers `plan` collected (on several threads) and append its items to `out` in
/// file order. Transfers not started because `cancel` was set leave no item.
fn run_plan(&mut self, plan: &mut Plan, out: &mut Prepared, cancel: &std::sync::atomic::AtomicBool) {
let mut done = run_transfers(std::mem::take(&mut plan.transfers), self.file_bytes.as_ref(), cancel, &self.done).into_iter();
for slot in std::mem::take(&mut plan.slots) {
let item = match slot {
Slot::Item(it) => it,
Slot::Transfer => match done.next().flatten() {
Some(it) => it,
None => continue,
},
};
if let PreparedItem::Ready(r) = &item
&& let Some(h) = &r.info.content_hash
{
self.by_hash.insert(h.clone(), None);
}
out.items.push(item);
}
}
}
/// A batch's items in file order while its transfers are pending ([`ImportJob::run_plan`]).
#[derive(Default)]
struct Plan {
slots: Vec<Slot>,
transfers: Vec<Transfer>,
}
enum Slot {
Item(PreparedItem),
/// The outcome of the next of [`Plan::transfers`].
Transfer,
}
/// A file to copy or move into `dir`, planned in order and run on any thread ([`run_transfer`]).
struct Transfer {
path: String,
info: ProbeInfo,
sidecar: Option<crate::sidecar::SidecarData>,
dir: PathBuf,
how: How,
/// Copy as DNG.
dng: bool,
/// The folder (lowercase) and [`name_family`]: transfers of one family can take each other's
/// names, so they never run at the same time.
family: (String, String),
}
enum How {
/// A verified copy, under `name` (default: the file's own).
Copy { name: Option<String> },
/// Move: placed (linked or copied) without touching the source.
Place { name: String },
}
/// The names a file called `name` could end up with, as one key: the transfers number a taken name
/// `IMG-1.JPG`, `IMG-2.JPG`…, and Copy as DNG names its DNG `IMG.dng`, `IMG-2.dng`…, so `IMG.JPG`,
/// `IMG-1.jpg` and `img.dng` are all `img` (case-insensitive: so are some file systems).
fn name_family(name: &str) -> String {
let mut stem = match name.rsplit_once('.') {
Some((s, _)) if !s.is_empty() => s,
_ => name,
};
while let Some((head, tail)) = stem.rsplit_once('-')
&& !head.is_empty()
&& !tail.is_empty()
&& tail.bytes().all(|b| b.is_ascii_digit())
{
stem = head;
}
stem.to_lowercase()
}
/// Threads for `jobs` file reads (probes, copies) at once.
#[cfg(not(target_arch = "wasm32"))]
fn workers(jobs: usize) -> usize {
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8).min(jobs.max(1))
}
/// Files per batch for a caller that prepares an import in batches ([`ImportJob::prepare_files`]):
/// enough to keep every probe and copy thread busy (a batch waits for its slowest file), few
/// enough that each batch soon joins the catalog.
pub fn batch_size() -> usize {
#[cfg(not(target_arch = "wasm32"))]
{
workers(usize::MAX).saturating_mul(2).clamp(8, 16)
}
#[cfg(target_arch = "wasm32")]
{
8
}
}
/// Run `transfers` (several at once where threads exist); their outcomes in order, `None` for one
/// not started because `cancel` was set. `done` counts each one finished.
fn run_transfers(
transfers: Vec<Transfer>,
file_bytes: Option<&crate::merge::ByteReader>,
cancel: &std::sync::atomic::AtomicBool,
done: &std::sync::atomic::AtomicUsize,
) -> Vec<Option<PreparedItem>> {
use std::sync::atomic::Ordering::Relaxed;
let one = |t: Transfer| {
if cancel.load(Relaxed) {
return None;
}
let path = t.path.clone();
// last-resort guard: a panic is this file's failure, not the import's
let item = crate::guard::catch("import", || run_transfer(t, file_bytes)).unwrap_or_else(|e| PreparedItem::Failed(path, e));
done.fetch_add(1, Relaxed);
Some(item)
};
#[cfg(not(target_arch = "wasm32"))]
{
let n = workers(transfers.len());
if n > 1 {
let mut results: Vec<Option<PreparedItem>> = std::iter::repeat_with(|| None).take(transfers.len()).collect();
let queue = std::sync::Mutex::new(transfers.into_iter().enumerate());
let out = std::sync::Mutex::new(&mut results);
// (the tests' injected faults hold for the workers too)
#[cfg(test)]
let fault = crate::import_move::injected_fault();
let work = &|| {
#[cfg(test)]
crate::import_move::inject(fault);
loop {
let next = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner).next();
let Some((i, t)) = next else { break };
let r = one(t);
if let Some(slot) = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_mut(i) {
*slot = r;
}
}
};
std::thread::scope(|sc| {
let handles: Vec<_> =
(0..n).filter_map(|i| std::thread::Builder::new().name(format!("lc-import-copy-{i}")).spawn_scoped(sc, work).ok()).collect();
if handles.is_empty() {
// no thread to be had: copy here, one after another
work();
}
for h in handles {
if h.join().is_err() {
log::error!("import: a copy worker stopped unexpectedly");
}
}
});
return results;
}
}
transfers.into_iter().map(one).collect()
}
/// Copy or move one planned file (and Copy as DNG); its item.
fn run_transfer(t: Transfer, file_bytes: Option<&crate::merge::ByteReader>) -> PreparedItem {
let Transfer { path, mut info, sidecar, dir, how, dng, .. } = t;
let mut placed = None;
let mut stored = match how {
How::Place { name } => match crate::import_move::place(Path::new(&path), &dir, &name) {
Ok(pl) => {
let dst = pl.dst.to_string_lossy().to_string();
placed = Some(pl);
dst
}
Err(e) => return PreparedItem::Failed(path, e),
},
How::Copy { name } => match copy_into(&dir, &path, name.as_deref(), info.content_hash.as_deref()) {
Ok(p) => p,
Err(e) => return PreparedItem::Failed(path, e),
},
};
if dng {
// decoding the raw twice and encoding it takes several times the file's size in memory
let weight = usize::try_from(info.file_size).unwrap_or(usize::MAX).saturating_mul(6);
let _permit = crate::memory::work_gate().acquire(weight);
match crate::cmd::convert::write_dng_with(file_bytes, &stored, String::new()) {
Ok(dng) => {
let _ = std::fs::remove_file(&stored);
stored = dng;
info.format = "DNG".into();
}
Err(e) => log::warn!("import {path}: copy as DNG: {e}"),
}
}
PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed }))
}
/// The catalog half of an import: add the photos [`ImportJob::prepare`] readied (one undoable op;
@@ -1093,3 +1385,31 @@ impl Session {
self
}
}
#[cfg(test)]
mod prepared_tests {
use super::*;
#[test]
fn revalidate_add_only_converts_matching_ready_item() {
let mut s = Session::new();
let existing_path = std::env::temp_dir().join("lightcraft-revalidate-existing.jpg");
let other_path = std::env::temp_dir().join("lightcraft-revalidate-other.jpg");
let existing = Photo::new(PhotoId(1), Source::File { path: existing_path.to_string_lossy().into() }, "existing.jpg", "JPG", 1, 1, "now");
s.commit("existing", Op::AddPhoto { photo: Box::new(existing) }).unwrap();
let ready = |path: &Path| ReadyFile {
path: path.to_string_lossy().into(),
stored: path.to_string_lossy().into(),
info: ProbeInfo { format: "JPG".into(), ..ProbeInfo::default() },
sidecar: None,
placed: None,
};
let mut prepared = Prepared {
scanned: 2,
items: vec![PreparedItem::Ready(Box::new(ready(&existing_path))), PreparedItem::Ready(Box::new(ready(&other_path)))],
};
prepared.revalidate_add(&s);
assert!(matches!(prepared.items.first(), Some(PreparedItem::Duplicate { reason: "path", .. })));
assert!(matches!(prepared.items.get(1), Some(PreparedItem::Ready(_))));
}
}
+23
View File
@@ -56,6 +56,9 @@ pub(crate) enum Fault {
Corrupt,
/// Removing the source fails (as on a read-only card).
FailRemove,
/// Each copy takes a little longer and notes the thread it ran on ([`copy_threads`]), to see
/// an import's copies run side by side.
Slow,
}
#[cfg(test)]
@@ -69,6 +72,21 @@ pub(crate) fn inject(f: Fault) {
FAULT.with(|c| c.set(f));
}
/// The calling thread's injected fault (tests only), for the workers it starts to inherit.
#[cfg(test)]
pub(crate) fn injected_fault() -> Fault {
FAULT.with(|c| c.get())
}
/// The threads copies made under [`Fault::Slow`] ran on (tests only).
#[cfg(test)]
static COPY_THREADS: std::sync::Mutex<Vec<std::thread::ThreadId>> = std::sync::Mutex::new(Vec::new());
#[cfg(test)]
pub(crate) fn copy_threads() -> Vec<std::thread::ThreadId> {
COPY_THREADS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
}
fn injected(f: Fault) -> bool {
#[cfg(test)]
{
@@ -284,6 +302,11 @@ pub(crate) fn copy_verified(src: &Path, dst: &Path, expect: Option<lightcraft_pr
return Err(io::Error::other("write failed (injected)"));
}
io::copy(&mut inp, &mut out)?;
#[cfg(test)]
if injected(Fault::Slow) {
COPY_THREADS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(std::thread::current().id());
std::thread::sleep(std::time::Duration::from_millis(30));
}
if injected(Fault::Corrupt) {
out.write_all(b"!")?;
}
+49
View File
@@ -25,6 +25,10 @@ pub mod guard;
pub mod import;
mod import_move;
pub mod library;
mod lightroom_archive;
pub mod lightroom_catalog;
pub mod lightroom_job;
mod lightroom_sqlite;
pub mod logging;
pub mod media;
pub mod memory;
@@ -38,6 +42,7 @@ pub mod segment;
pub mod sidecar;
pub mod smart;
mod view;
pub mod walk;
use std::sync::Arc;
@@ -83,6 +88,27 @@ pub struct UndoEntry {
pub folder: Option<FolderMove>,
}
/// The one photo an undo step changes, when it changes exactly one through its edit, rating, flag,
/// label, metadata or versions (`depth` bounds nested batches).
fn single_photo(op: &Op, depth: usize) -> Option<PhotoId> {
match op {
Op::SetDevelop { id, .. }
| Op::SetRating { id, .. }
| Op::SetFlag { id, .. }
| Op::SetLabel { id, .. }
| Op::SetMeta { id, .. }
| Op::SetVersions { id, .. }
| Op::SetHistory { id, .. }
| Op::PushHistory { id, .. } => Some(*id),
Op::Batch { ops } if depth < 8 => {
let mut ids = ops.iter().map(|o| single_photo(o, depth + 1));
let first = ids.next()??;
ids.all(|id| id == Some(first)).then_some(first)
}
_ => None,
}
}
/// A folder renamed or moved on disk as part of an undo step.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FolderMove {
@@ -165,6 +191,8 @@ pub struct Session {
pub active_spot: Option<usize>,
/// The persistent library this session writes to (`None` = in-memory only).
pub library: Option<library::Library>,
/// Identity of current library opening; refreshed only after a successful open.
pub(crate) library_identity: Arc<()>,
/// XMP sidecar preferences (persisted with the library).
pub xmp: sidecar::XmpPrefs,
/// Parameters of the last export (`app.export` params, minus targets), persisted in prefs.json.
@@ -253,6 +281,7 @@ impl Session {
segmenter: segment::Segmenter::default(),
active_spot: None,
library: None,
library_identity: Arc::new(()),
xmp: sidecar::XmpPrefs::default(),
last_export: None,
export_presets: Vec::new(),
@@ -428,6 +457,7 @@ impl Session {
return Err(err);
}
};
self.show_undone(&e.op);
self.pending_log.push(e.op);
self.redo.push(UndoEntry { label: e.label.clone(), op: redo, folder: e.folder.as_ref().map(FolderMove::reversed) });
Ok(e.label)
@@ -442,11 +472,30 @@ impl Session {
return Err(err);
}
};
self.show_undone(&e.op);
self.pending_log.push(e.op);
self.undo.push(UndoEntry { label: e.label.clone(), op: undo, folder: e.folder.as_ref().map(FolderMove::reversed) });
Ok(e.label)
}
/// After an undo or redo that changed one photo (its edit, rating, flag, label, metadata or
/// versions), that photo becomes the active one, as in Lightroom Classic, so the change is on
/// screen (issue #293). It joins the selection if it isn't in it; nothing changes for steps
/// that touch several photos, albums or files, or for a photo not in the current view.
fn show_undone(&mut self, op: &Op) {
let Some(id) = single_photo(op, 0) else { return };
if self.selection.active == Some(id) || !self.visible().contains(&id) {
return;
}
if !self.selection.contains(id) {
self.selection = Selection { ids: vec![id], active: Some(id) };
} else {
self.selection.active = Some(id);
}
self.active_mask = None;
self.active_spot = None;
}
/// Apply an undo/redo op, first renaming the step's folder and moving the files its renames
/// imply (all or nothing). Files that can't be moved back after a failure are reported and the
/// library follows them (a logged change outside the undo history), so none goes missing; the
+21 -3
View File
@@ -395,7 +395,7 @@ impl Session {
forgot_local: None,
presets_written,
view_written,
settings_warnings: settings.warnings,
settings_warnings: unlocked_warning(lock.as_ref()).into_iter().chain(settings.warnings).collect(),
warnings_reported: 0,
blocked: settings.blocked,
lock: lock.take(),
@@ -412,6 +412,7 @@ impl Session {
lib.forgot_local = Some(plan);
}
}
self.library_identity = std::sync::Arc::new(());
Ok(())
}
@@ -504,12 +505,16 @@ impl Session {
lib.retry_at = None;
lib.last_error = None;
}
match (persisted, snapshot) {
let result = match (persisted, snapshot) {
(_, Ok(())) => Ok(()),
// neither the log nor the snapshot took the queued ops
(Err(EngineError::NotSaved(e)), Err(s)) if lib.journal.seq() == before => Err(EngineError::NotSaved(format!("{e}; snapshot: {s}"))),
(_, Err(s)) => Err(s.into()),
};
if result.is_ok() {
self.library_identity = std::sync::Arc::new(());
}
result
}
fn view_json(&self) -> Vec<u8> {
@@ -570,7 +575,8 @@ impl Session {
lib.files.write_atomic("prefs.json", &v).map_err(|e| EngineError::Other(format!("prefs: {e}")))
}
/// Settings-file warnings of the open library not handed out yet (the UI shows each once).
/// Warnings about the open library not handed out yet: its settings files, and a lock that
/// couldn't be taken (the UI shows each once; the CLI and MCP print them on stderr).
pub fn take_library_warnings(&mut self) -> Vec<String> {
let Some(lib) = self.library.as_mut() else { return vec![] };
let new = lib.settings_warnings[lib.warnings_reported..].to_vec();
@@ -625,3 +631,15 @@ impl Session {
Ok(())
}
}
/// The warning for a library opened without its one-program-at-a-time lock (issue #171): its
/// file system can't lock `catalog.lock` (some network shares). It opens anyway, as documented —
/// refusing would lock the user out — but the user must learn that a second program could open it.
fn unlocked_warning(lock: Option<&LibraryLock>) -> Option<String> {
lock.filter(|l| !l.held()).map(|_| {
"This library could not be locked (its catalog.lock file can't be locked where it is stored, e.g. on some network \
shares), so it is open without protection against a second program: use it in one LightCraft app or command at \
a time, or changes made in one of them can be lost."
.to_string()
})
}
+685
View File
@@ -0,0 +1,685 @@
//! Bounded, deduplicated persistence for Lightroom import source data.
//!
//! Archives use a deterministic JSON representation wrapped in zlib. The JSON
//! writer streams through a bounded compressor, so an import cannot first build
//! an unbounded archive buffer. Blob columns are omitted by the catalog reader;
//! decoded XMP,
//! settings, history, snapshots, identities, paths, collections & membership
//! remain available for recovery.
use std::collections::BTreeMap;
use std::fs::{self, File};
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use miniz_oxide::deflate::{
core::{CompressorOxide, create_comp_flags_from_zip_params},
stream::deflate,
};
use miniz_oxide::{MZFlush, MZStatus};
use serde::ser::{Serialize, SerializeMap, SerializeSeq, SerializeStruct, Serializer};
use serde_json::Value;
use crate::lightroom_catalog::{CatalogImport, CatalogPhoto};
/// Maximum size of one complete archive on disk, including format header.
pub const MAX_ARCHIVE_BYTES: u64 = 32 * 1024 * 1024;
/// Maximum total size retained in managed archives.
pub const MAX_TOTAL_ARCHIVE_BYTES: u64 = 128 * 1024 * 1024;
/// Maximum number of managed archives retained.
pub const MAX_MANAGED_ARCHIVES: usize = 8;
const FORMAT_VERSION: u8 = 1;
const HEADER: &[u8] = b"LC-LRARCH\0\x01Z";
const NAME_PREFIX: &str = "lightroom-import-";
const NAME_SUFFIX: &str = ".lca";
const MAX_SCAN: usize = 1024;
const IO_BUFFER: usize = 16 * 1024;
/// Store compact source data below `directory` and return its managed archive.
///
/// The operation is safe to call before catalog mutation. It rejects a
/// symlink directory, writes only `lightroom-import-<number>.lca`, compares the
/// complete bounded byte stream with recent managed archives, and retains at
/// most [`MAX_MANAGED_ARCHIVES`] and [`MAX_TOTAL_ARCHIVE_BYTES`]. Legacy
/// `lightroom-import-*.json` files are deliberately ignored and preserved for
/// an explicit caller warning.
pub fn store(directory: &Path, data: &CatalogImport) -> Result<PathBuf, String> {
prepare_directory(directory)?;
let mut managed = managed_files(directory)?;
managed.sort_by_key(|entry| entry.index);
let temporary = temporary_path(directory)?;
write_archive(&temporary, data).inspect_err(|_| {
let _ = remove_managed_file(&temporary);
})?;
let recent = managed.iter().rev().take(MAX_MANAGED_ARCHIVES);
for entry in recent {
let same = match same_bounded(&temporary, &entry.path) {
Ok(same) => same,
Err(error) => {
let _ = remove_managed_file(&temporary);
return Err(error.to_string());
}
};
if same {
remove_managed_file(&temporary).map_err(|e| e.to_string())?;
let matched = entry.path.clone();
let mut all = managed.clone();
prune_existing(directory, &mut all, Some(&matched))?;
return Ok(matched);
}
}
let index = managed.last().map_or(0, |entry| entry.index.saturating_add(1));
if managed.last().is_some_and(|entry| entry.index == u64::MAX) {
let _ = remove_managed_file(&temporary);
return Err("managed Lightroom archive index is exhausted".into());
}
let destination = match next_destination(directory, index) {
Ok(path) => path,
Err(error) => {
let _ = remove_managed_file(&temporary);
return Err(error);
}
};
let size = match fs::metadata(&temporary) {
Ok(meta) => meta.len(),
Err(error) => {
let _ = remove_managed_file(&temporary);
return Err(error.to_string());
}
};
let new_entry = Managed { index, path: destination.clone(), size };
let mut after = managed;
after.push(new_entry);
after.sort_by_key(|entry| entry.index);
let retirees = match retirement_set(&after) {
Ok(retirees) => retirees,
Err(error) => {
let _ = remove_managed_file(&temporary);
return Err(error);
}
};
let quarantined = match quarantine(&retirees) {
Ok(quarantined) => quarantined,
Err(error) => {
let _ = remove_managed_file(&temporary);
return Err(error);
}
};
if let Err(error) = publish_new(&temporary, &destination) {
rollback_quarantine(&quarantined);
let _ = remove_managed_file(&temporary);
return Err(error.to_string());
}
if let Err(error) = remove_managed_file(&temporary) {
return Err(format!("archive published but temporary cleanup failed: {error}"));
}
for (old, held) in quarantined {
if let Err(error) = remove_managed_file(&held) {
return Err(format!("archive published but could not retire {}: {error}", old.display()));
}
}
Ok(destination)
}
/// Store source data when possible. An archive that reaches its bounded size is
/// omitted with a warning so catalog import can still complete.
pub fn store_best_effort(directory: &Path, data: &CatalogImport) -> Result<Option<PathBuf>, String> {
match store(directory, data) {
Ok(path) => Ok(Some(path)),
Err(error) if error.contains("Lightroom archive exceeds 32 MiB") => Ok(None),
Err(error) => Err(error),
}
}
#[derive(Clone, Debug)]
struct Managed {
index: u64,
path: PathBuf,
size: u64,
}
fn prepare_directory(directory: &Path) -> Result<(), String> {
match fs::symlink_metadata(directory) {
Ok(meta) => {
if meta.file_type().is_symlink() || !meta.is_dir() {
return Err(format!("archive directory is not a real directory: {}", directory.display()));
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
fs::create_dir_all(directory).map_err(|e| e.to_string())?;
let meta = fs::symlink_metadata(directory).map_err(|e| e.to_string())?;
if meta.file_type().is_symlink() || !meta.is_dir() {
return Err(format!("archive directory is not a real directory: {}", directory.display()));
}
}
Err(error) => return Err(error.to_string()),
}
Ok(())
}
fn managed_files(directory: &Path) -> Result<Vec<Managed>, String> {
let mut files = Vec::new();
for (seen, result) in fs::read_dir(directory).map_err(|e| e.to_string())?.enumerate() {
if seen >= MAX_SCAN {
return Err(format!("too many entries in archive directory (limit {MAX_SCAN})"));
}
let entry = result.map_err(|e| e.to_string())?;
let Some(index) = parse_managed_name(&entry.file_name()) else { continue };
let meta = fs::symlink_metadata(entry.path()).map_err(|e| e.to_string())?;
if meta.file_type().is_symlink() || !meta.is_file() {
continue;
}
files.push(Managed { index, path: entry.path(), size: meta.len() });
}
Ok(files)
}
fn parse_managed_name(name: &std::ffi::OsStr) -> Option<u64> {
let name = name.to_str()?;
let digits = name.strip_prefix(NAME_PREFIX)?.strip_suffix(NAME_SUFFIX)?;
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
digits.parse().ok()
}
fn temporary_path(directory: &Path) -> Result<PathBuf, String> {
let pid = std::process::id();
for slot in 0..MAX_SCAN {
let path = directory.join(format!(".lightroom-import-{pid}-{slot}.tmp"));
match fs::symlink_metadata(&path) {
Ok(_) => continue,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(path),
Err(error) => return Err(error.to_string()),
}
}
Err("no safe temporary archive name available".into())
}
fn next_destination(directory: &Path, mut index: u64) -> Result<PathBuf, String> {
for _ in 0..MAX_SCAN {
let path = directory.join(format!("{NAME_PREFIX}{index}{NAME_SUFFIX}"));
match fs::symlink_metadata(&path) {
Ok(_) => index = index.checked_add(1).ok_or("managed archive index is exhausted")?,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(path),
Err(error) => return Err(error.to_string()),
}
}
Err("no safe managed archive name available".into())
}
fn write_archive(path: &Path, data: &CatalogImport) -> Result<(), String> {
lightcraft_catalog::safe_file::write_atomic_with(path, &mut |raw| {
let mut bounded = BoundedWriter { inner: raw, total: 0 };
bounded.write_all(HEADER)?;
let mut compressor = ZlibWriter::new(&mut bounded);
serde_json::to_writer(&mut compressor, &ArchiveRef(data)).map_err(json_io)?;
compressor.finish()?;
Ok(())
})
.map(|_| ())
.map_err(|error| format!("could not write Lightroom archive: {error}"))
}
fn json_io(error: serde_json::Error) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, error.to_string())
}
struct BoundedWriter<'a> {
inner: &'a mut dyn Write,
total: u64,
}
impl Write for BoundedWriter<'_> {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
let next = self.total.checked_add(bytes.len() as u64).ok_or_else(limit_error)?;
if next > MAX_ARCHIVE_BYTES {
return Err(limit_error());
}
self.inner.write_all(bytes)?;
self.total = next;
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}
fn limit_error() -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, "Lightroom archive exceeds 32 MiB")
}
struct ZlibWriter<'a, 'b> {
sink: &'a mut BoundedWriter<'b>,
compressor: CompressorOxide,
plain: u64,
}
impl<'a, 'b> ZlibWriter<'a, 'b> {
fn new(sink: &'a mut BoundedWriter<'b>) -> ZlibWriter<'a, 'b> {
let flags = create_comp_flags_from_zip_params(6, 15, 0);
ZlibWriter { sink, compressor: CompressorOxide::new(flags), plain: 0 }
}
fn run(&mut self, input: &[u8], flush: MZFlush) -> io::Result<()> {
let mut rest = input;
loop {
let mut output = [0u8; IO_BUFFER];
let result = deflate(&mut self.compressor, rest, &mut output, flush);
self.sink.write_all(&output[..result.bytes_written])?;
match result.status {
Ok(MZStatus::StreamEnd) => return Ok(()),
Ok(MZStatus::Ok) => {
let consumed = result.bytes_consumed;
if consumed > rest.len() {
return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid compressor progress"));
}
rest = &rest[consumed..];
if rest.is_empty() && flush == MZFlush::None {
return Ok(());
}
if consumed == 0 && result.bytes_written == 0 {
return Err(io::Error::new(io::ErrorKind::InvalidData, "compressor made no progress"));
}
}
Ok(status) => {
return Err(io::Error::new(io::ErrorKind::InvalidData, format!("unsupported compressor status: {status:?}")));
}
Err(error) => return Err(io::Error::new(io::ErrorKind::InvalidData, format!("compression failed: {error:?}"))),
}
}
}
fn finish(&mut self) -> io::Result<()> {
self.run(&[], MZFlush::Finish)
}
}
impl Write for ZlibWriter<'_, '_> {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
if bytes.is_empty() {
return Ok(0);
}
let next = self.plain.checked_add(bytes.len() as u64).ok_or_else(limit_error)?;
if next > MAX_ARCHIVE_BYTES {
return Err(limit_error());
}
self.run(bytes, MZFlush::None)?;
self.plain = next;
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn same_bounded(left: &Path, right: &Path) -> io::Result<bool> {
let left_meta = fs::symlink_metadata(left)?;
let right_meta = fs::symlink_metadata(right)?;
if left_meta.file_type().is_symlink() || right_meta.file_type().is_symlink() || !left_meta.is_file() || !right_meta.is_file() {
return Ok(false);
}
let left_len = left_meta.len();
let right_len = right_meta.len();
if left_len != right_len || left_len > MAX_ARCHIVE_BYTES {
return Ok(false);
}
let mut left = File::open(left)?;
let mut right = File::open(right)?;
let mut left_buf = [0u8; IO_BUFFER];
let mut right_buf = [0u8; IO_BUFFER];
loop {
let left_n = left.read(&mut left_buf)?;
let right_n = right.read(&mut right_buf)?;
if left_n != right_n {
return Ok(false);
}
if left_n == 0 {
return Ok(true);
}
if left_buf[..left_n] != right_buf[..right_n] {
return Ok(false);
}
}
}
fn retirement_set(entries: &[Managed]) -> Result<Vec<Managed>, String> {
retirement_set_protected(entries, None)
}
fn retirement_set_protected(entries: &[Managed], protected: Option<&Path>) -> Result<Vec<Managed>, String> {
let mut ordered = entries.to_vec();
ordered.sort_by_key(|entry| entry.index);
let mut total = ordered.iter().try_fold(0u64, |sum, entry| sum.checked_add(entry.size)).ok_or("archive size overflow")?;
let mut retirees = Vec::new();
while ordered.len() > MAX_MANAGED_ARCHIVES || total > MAX_TOTAL_ARCHIVE_BYTES {
let Some(position) = ordered.iter().position(|entry| protected.is_none_or(|path| entry.path != path)) else {
return Err("cannot satisfy archive retention limit".into());
};
let oldest = ordered.remove(position);
total = total.saturating_sub(oldest.size);
retirees.push(oldest);
}
Ok(retirees)
}
fn prune_existing(directory: &Path, entries: &mut [Managed], protected: Option<&Path>) -> Result<(), String> {
let retirees = retirement_set_protected(entries, protected)?;
let quarantined = quarantine(&retirees)?;
for (old, held) in quarantined {
if let Err(error) = remove_managed_file(&held) {
return Err(format!("could not retire {} from {}: {error}", old.display(), directory.display()));
}
}
Ok(())
}
fn quarantine(entries: &[Managed]) -> Result<Vec<(PathBuf, PathBuf)>, String> {
let mut moved = Vec::new();
let pid = std::process::id();
for (slot, entry) in entries.iter().enumerate() {
let held = entry.path.with_file_name(format!(".lightroom-retire-{pid}-{slot}.tmp"));
match fs::symlink_metadata(&held) {
Ok(_) => {
rollback_quarantine(&moved);
return Err(format!("retention temporary path already exists: {}", held.display()));
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => {
rollback_quarantine(&moved);
return Err(error.to_string());
}
}
let meta = fs::symlink_metadata(&entry.path).map_err(|error| {
rollback_quarantine(&moved);
error.to_string()
})?;
if meta.file_type().is_symlink() || !meta.is_file() {
rollback_quarantine(&moved);
return Err(format!("managed archive changed into non-file: {}", entry.path.display()));
}
if let Err(error) = fs::rename(&entry.path, &held) {
rollback_quarantine(&moved);
return Err(error.to_string());
}
moved.push((entry.path.clone(), held));
}
Ok(moved)
}
fn rollback_quarantine(moved: &[(PathBuf, PathBuf)]) {
for (original, held) in moved.iter().rev() {
let _ = fs::rename(held, original);
}
}
fn publish_new(temporary: &Path, destination: &Path) -> io::Result<()> {
match fs::hard_link(temporary, destination) {
Ok(()) => Ok(()),
Err(error) => Err(io::Error::new(error.kind(), format!("cannot atomically publish archive: {error}"))),
}
}
fn remove_managed_file(path: &Path) -> io::Result<()> {
let meta = fs::symlink_metadata(path)?;
if meta.file_type().is_symlink() || !meta.is_file() {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "refusing to remove non-regular archive path"));
}
fs::remove_file(path)
}
struct ArchiveRef<'a>(&'a CatalogImport);
impl Serialize for ArchiveRef<'_> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut out = serializer.serialize_struct("CatalogImportArchive", 7)?;
out.serialize_field("version", &FORMAT_VERSION)?;
out.serialize_field("source", &self.0.source)?;
out.serialize_field("photos", &PhotoSeq(&self.0.photos))?;
out.serialize_field("collections", &RowSeq(&self.0.collections))?;
out.serialize_field("members", &RowSeq(&self.0.members))?;
out.serialize_field("collection_content", &RowSeq(&self.0.collection_content))?;
out.serialize_field("warnings", &self.0.warnings)?;
out.end()
}
}
struct PhotoSeq<'a>(&'a [CatalogPhoto]);
impl Serialize for PhotoSeq<'_> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut out = serializer.serialize_seq(Some(self.0.len()))?;
for photo in self.0 {
out.serialize_element(&PhotoRef(photo))?;
}
out.end()
}
}
struct PhotoRef<'a>(&'a CatalogPhoto);
impl Serialize for PhotoRef<'_> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let p = self.0;
let mut out = serializer.serialize_struct("CatalogPhotoArchive", 8)?;
out.serialize_field("source_id", &p.source_id)?;
out.serialize_field("uuid", &p.uuid)?;
out.serialize_field("path", &p.path)?;
out.serialize_field("image", &RowRef(&p.image))?;
out.serialize_field("settings", &p.settings)?;
out.serialize_field("xmp", &p.xmp)?;
out.serialize_field("keywords", &p.keywords)?;
out.serialize_field("history", &RowSeq(&p.history))?;
out.serialize_field("snapshots", &RowSeq(&p.snapshots))?;
out.end()
}
}
struct RowSeq<'a>(&'a [BTreeMap<String, Value>]);
impl Serialize for RowSeq<'_> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut out = serializer.serialize_seq(Some(self.0.len()))?;
for row in self.0 {
out.serialize_element(&RowRef(row))?;
}
out.end()
}
}
struct RowRef<'a>(&'a BTreeMap<String, Value>);
impl Serialize for RowRef<'_> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut out = serializer.serialize_map(None)?;
for (key, value) in self.0 {
out.serialize_entry(key, value)?;
}
out.end()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lightroom_catalog::{CatalogImport, CatalogPhoto};
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(0);
fn tempdir() -> PathBuf {
let path = std::env::temp_dir().join(format!("lightcraft-lr-archive-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
fs::create_dir_all(&path).unwrap();
path
}
fn data(source: String) -> CatalogImport {
CatalogImport {
source,
photos: Vec::new(),
collections: Vec::new(),
members: Vec::new(),
collection_content: Vec::new(),
warnings: Vec::new(),
}
}
fn rich_data() -> CatalogImport {
let photo = CatalogPhoto {
source_id: 7,
uuid: "uuid-7".into(),
path: "/photos/one.raw".into(),
image: BTreeMap::from([("rating".into(), serde_json::json!(5)), ("opaque_blob".into(), serde_json::json!([1, 2, 3, 4]))]),
settings: "s = { Exposure2012 = 0.75 }".into(),
xmp: "<xmp>decoded</xmp>".into(),
keywords: vec!["Trips|Goa".into()],
history: vec![BTreeMap::from([("text".into(), serde_json::json!("step")), ("curve".into(), serde_json::json!([0, 128, 255]))])],
snapshots: vec![BTreeMap::from([("name".into(), serde_json::json!("before"))])],
};
CatalogImport {
source: "source.lrcat".into(),
photos: vec![photo],
collections: vec![BTreeMap::from([("name".into(), serde_json::json!("Keepers"))])],
members: vec![BTreeMap::from([("image".into(), serde_json::json!(7))])],
collection_content: vec![BTreeMap::from([("rule".into(), serde_json::json!("rating > 3"))])],
warnings: vec!["one warning".into()],
}
}
fn managed_count(path: &Path) -> usize {
fs::read_dir(path).unwrap().filter_map(Result::ok).filter(|entry| parse_managed_name(&entry.file_name()).is_some()).count()
}
#[test]
fn deduplicates_identical_deterministic_archives() {
let path = tempdir();
let input = data("catalog.lrcat".into());
let first = store(&path, &input).unwrap();
let second = store(&path, &input).unwrap();
assert_eq!(first, second);
assert_eq!(managed_count(&path), 1);
fs::remove_dir_all(path).unwrap();
}
#[test]
fn dedup_returns_archive_that_retention_keeps() {
let path = tempdir();
let input = data("catalog.lrcat".into());
let first = store(&path, &input).unwrap();
for index in 1..MAX_MANAGED_ARCHIVES {
let filler = path.join(format!("lightroom-import-{index}.lca"));
File::create(filler).unwrap().set_len(19 * 1024 * 1024).unwrap();
}
let reused = store(&path, &input).unwrap();
assert_eq!(reused, first);
assert!(reused.is_file());
assert!(managed_count(&path) <= MAX_MANAGED_ARCHIVES);
fs::remove_dir_all(path).unwrap();
}
#[test]
fn caps_managed_archive_count_and_preserves_unrelated_files() {
let path = tempdir();
let human = path.join("notes.txt");
let legacy = path.join("lightroom-import-0.json");
fs::write(&human, b"keep").unwrap();
fs::write(&legacy, b"legacy").unwrap();
for index in 0..(MAX_MANAGED_ARCHIVES + 3) {
let input = data(format!("catalog-{index}.lrcat"));
store(&path, &input).unwrap();
}
assert!(managed_count(&path) <= MAX_MANAGED_ARCHIVES);
assert_eq!(fs::read(&human).unwrap(), b"keep");
assert_eq!(fs::read(&legacy).unwrap(), b"legacy");
fs::remove_dir_all(path).unwrap();
}
#[test]
fn rejects_oversize_before_creating_archive() {
let path = tempdir();
let input = data("x".repeat((MAX_ARCHIVE_BYTES as usize) + 1));
assert!(store(&path, &input).is_err());
assert_eq!(managed_count(&path), 0);
fs::remove_dir_all(path).unwrap();
}
#[test]
fn large_catalog_history_is_best_effort_and_does_not_abort_import() {
let path = tempdir();
let mut input = data("catalog.lrcat".into());
input.photos = (0..5_000)
.map(|index| CatalogPhoto {
source_id: index,
uuid: format!("photo-{index}"),
path: format!("/photos/{index}.raw"),
image: BTreeMap::new(),
settings: String::new(),
xmp: String::new(),
keywords: Vec::new(),
history: vec![BTreeMap::from([("text".into(), serde_json::json!(format!("history-{index}-{}", "x".repeat(8 * 1024))))])],
snapshots: Vec::new(),
})
.collect();
assert_eq!(store_best_effort(&path, &input).unwrap(), None);
assert_eq!(managed_count(&path), 0);
fs::remove_dir_all(path).unwrap();
}
#[test]
fn archive_roundtrips_zlib_payload_and_preserves_numeric_arrays() {
let path = tempdir();
let archive = store(&path, &rich_data()).unwrap();
let bytes = fs::read(archive).unwrap();
assert!(bytes.starts_with(HEADER));
let json = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(&bytes[HEADER.len()..], MAX_ARCHIVE_BYTES as usize).unwrap();
let decoded: Value = serde_json::from_slice(&json).unwrap();
assert_eq!(decoded["version"], serde_json::json!(FORMAT_VERSION));
assert_eq!(decoded["source"], serde_json::json!("source.lrcat"));
assert_eq!(decoded["photos"].as_array().unwrap().len(), 1);
assert_eq!(decoded["photos"][0]["source_id"], serde_json::json!(7));
assert_eq!(decoded["photos"][0]["uuid"], serde_json::json!("uuid-7"));
assert_eq!(decoded["photos"][0]["path"], serde_json::json!("/photos/one.raw"));
assert_eq!(decoded["photos"][0]["settings"], serde_json::json!("s = { Exposure2012 = 0.75 }"));
assert_eq!(decoded["photos"][0]["xmp"], serde_json::json!("<xmp>decoded</xmp>"));
assert_eq!(decoded["photos"][0]["keywords"], serde_json::json!(["Trips|Goa"]));
assert_eq!(decoded["photos"][0]["image"]["rating"], serde_json::json!(5));
assert_eq!(decoded["photos"][0]["image"]["opaque_blob"], serde_json::json!([1, 2, 3, 4]));
assert_eq!(decoded["photos"][0]["history"].as_array().unwrap().len(), 1);
assert_eq!(decoded["photos"][0]["history"][0]["curve"], serde_json::json!([0, 128, 255]));
assert_eq!(decoded["photos"][0]["snapshots"].as_array().unwrap().len(), 1);
assert_eq!(decoded["collections"].as_array().unwrap().len(), 1);
assert_eq!(decoded["collections"][0]["name"], serde_json::json!("Keepers"));
assert_eq!(decoded["members"].as_array().unwrap().len(), 1);
assert_eq!(decoded["members"][0]["image"], serde_json::json!(7));
assert_eq!(decoded["collection_content"].as_array().unwrap().len(), 1);
assert_eq!(decoded["warnings"], serde_json::json!(["one warning"]));
fs::remove_dir_all(path).unwrap();
}
#[test]
fn retention_enforces_aggregate_byte_cap_with_small_synthetic_entries() {
let mut entries: Vec<_> = (0..MAX_MANAGED_ARCHIVES as u64)
.map(|index| Managed { index, path: PathBuf::from(format!("lightroom-import-{index}.lca")), size: 20 * 1024 * 1024 })
.collect();
entries.push(Managed { index: 8, path: PathBuf::from("lightroom-import-8.lca"), size: 20 * 1024 * 1024 });
let retirees = retirement_set(&entries).unwrap();
assert_eq!(retirees.len(), 3);
assert_eq!(retirees.iter().map(|entry| entry.index).collect::<Vec<_>>(), vec![0, 1, 2]);
}
#[test]
fn retention_failure_preserves_old_archive() {
let path = tempdir();
let old = path.join("lightroom-import-0.lca");
let held = path.join(format!(".lightroom-retire-{}-0.tmp", std::process::id()));
fs::write(&old, b"old archive").unwrap();
fs::write(&held, b"occupied").unwrap();
let entry = Managed { index: 0, path: old.clone(), size: 11 };
assert!(quarantine(&[entry]).is_err());
assert_eq!(fs::read(&old).unwrap(), b"old archive");
fs::remove_dir_all(path).unwrap();
}
}
File diff suppressed because it is too large. Load diff
+450
View File
@@ -0,0 +1,450 @@
//! Detached Lightroom catalog preparation and owner-thread commit.
//!
//! `LightroomJob::new` takes only an in-memory session snapshot. `prepare` owns all catalog
//! reading, probing and archive staging, while `commit_prepared` only applies catalog operations
//! to the session that created the job. Archive index bytes are returned for a worker to finish
//! after the owner has accepted the catalog operation.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
};
use serde_json::Value;
use crate::import::{ImportJob, ImportMode, ImportOptions, Prepared};
use crate::lightroom_catalog::{self, CatalogImport, ImportIndex};
const COMMAND: &str = "library.importLightroom";
const MAX_INDEX_BYTES: usize = 16 * 1024 * 1024;
fn error(message: impl Into<String>) -> crate::EngineError {
crate::EngineError::BadParams { cmd: COMMAND.into(), msg: message.into() }
}
fn stage_archive(archive_dir: Option<&Path>, data: &mut CatalogImport) -> crate::Result<Option<PathBuf>> {
let archive_path = archive_dir.map(|dir| crate::lightroom_archive::store_best_effort(dir, data).map_err(error)).transpose()?.flatten();
if archive_dir.is_some() && archive_path.is_none() {
data.warnings.push("Lightroom source archive exceeded 32 MiB; archive skipped".into());
}
Ok(archive_path)
}
/// Memory identity of the library which owns a prepared completion.
///
/// Each successful open/close refreshes session identity, including reopening the same path.
/// The owner check deliberately does not compare catalog revisions: ordinary edits while preparation
/// runs must survive and are merged by the owner commit.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LightroomLibraryToken {
library_dir: Option<PathBuf>,
identity: Arc<()>,
}
impl LightroomLibraryToken {
fn capture(s: &crate::Session) -> Self {
Self {
library_dir: s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.clone()),
identity: Arc::clone(&s.library_identity),
}
}
/// Whether `s` still refers to the library which started preparation.
pub fn matches(&self, s: &crate::Session) -> bool {
Arc::ptr_eq(&self.identity, &s.library_identity)
&& self.library_dir == s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.clone())
}
pub fn library_dir(&self) -> Option<&Path> {
self.library_dir.as_deref()
}
}
/// Filesystem-free result of catalog preparation.
pub struct PreparedLightroom {
pub(crate) data: CatalogImport,
pub(crate) normal: Prepared,
pub(crate) index: ImportIndex,
pub(crate) archive_path: Option<PathBuf>,
pub(crate) token: LightroomLibraryToken,
pub(crate) missing_sources: HashSet<String>,
pub(crate) update_existing: bool,
pub(crate) now: String,
cancelled: bool,
}
impl PreparedLightroom {
pub fn source(&self) -> &str {
&self.data.source
}
pub fn photo_count(&self) -> usize {
self.data.photos.len()
}
pub fn token(&self) -> &LightroomLibraryToken {
&self.token
}
pub fn is_cancelled(&self) -> bool {
self.cancelled
}
/// Roll back any normal-import filesystem placement held by this prepared value.
pub fn rollback(&self) {
self.normal.rollback();
}
}
/// Index write returned by owner commit. `finish` is intended for a worker after the owner has
/// returned, and performs the only archive-index filesystem operation in this pipeline.
pub struct LightroomArchiveFinalization {
path: Option<PathBuf>,
bytes: Option<Vec<u8>>,
}
impl LightroomArchiveFinalization {
pub fn finish(self) -> crate::Result<()> {
let (Some(path), Some(bytes)) = (self.path, self.bytes) else { return Ok(()) };
if bytes.len() > MAX_INDEX_BYTES {
return Err(error("Lightroom import index exceeds 16 MiB"));
}
lightcraft_catalog::safe_file::write_atomic(&path, &bytes).map_err(|e| error(e.to_string()))
}
pub fn path(&self) -> Option<&Path> {
self.path.as_deref()
}
}
/// Owner-thread catalog result. The report is ready for the UI immediately; index finalization
/// can remain on the worker while its task stays alive.
pub struct LightroomCompletion {
pub report: Value,
pub finalization: LightroomArchiveFinalization,
}
/// Alias used by hosts that call the result a commit.
pub type LightroomCommit = LightroomCompletion;
/// A Lightroom import detached from the owner thread.
pub struct LightroomJob {
path: PathBuf,
update_existing: bool,
import: ImportJob,
token: LightroomLibraryToken,
archive_dir: Option<PathBuf>,
}
impl LightroomJob {
/// Snapshot session state only. This constructor performs no filesystem work.
pub fn new(s: &mut crate::Session, path: PathBuf, update_existing: bool) -> crate::Result<Self> {
let opts = ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() };
let import = ImportJob::new(s, opts)?;
let archive_dir = s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.join("Interop"));
Ok(Self { path, update_existing, import, token: LightroomLibraryToken::capture(s), archive_dir })
}
pub fn total(&self) -> usize {
self.import.total.load(Ordering::Relaxed)
}
pub fn done(&self) -> usize {
self.import.done.load(Ordering::Relaxed)
}
pub fn progress(&self) -> (usize, usize) {
(self.done(), self.total())
}
pub fn token(&self) -> &LightroomLibraryToken {
&self.token
}
pub fn total_atomic(&self) -> std::sync::Arc<AtomicUsize> {
std::sync::Arc::clone(&self.import.total)
}
pub fn done_atomic(&self) -> std::sync::Arc<AtomicUsize> {
std::sync::Arc::clone(&self.import.done)
}
/// Finish an owner commit's deferred archive-index write on a worker.
pub fn finish_archive(finalization: LightroomArchiveFinalization) -> crate::Result<()> {
finalization.finish()
}
/// Read/probe/stage all source data off the owner thread.
pub fn prepare(&mut self, cancel: &AtomicBool) -> crate::Result<PreparedLightroom> {
if cancel.load(Ordering::Relaxed) {
return Err(error("Lightroom import cancelled"));
}
self.import.total.store(1, Ordering::Relaxed);
self.import.done.store(0, Ordering::Relaxed);
let mut data = lightroom_catalog::read_with_progress(&self.path, cancel, &self.import.total, &self.import.done).map_err(error)?;
lightroom_catalog::validate_for_job(&data).map_err(error)?;
if cancel.load(Ordering::Relaxed) {
return Err(error("Lightroom import cancelled"));
}
self.import.done.fetch_add(1, Ordering::Relaxed);
let paths: Vec<String> = data
.photos
.iter()
.filter(|photo| lightroom_catalog::number_for_job(&photo.image, "masterImage") == 0)
.map(|photo| photo.path.clone())
.collect();
let missing_sources = data
.photos
.iter()
.filter(|photo| lightroom_catalog::number_for_job(&photo.image, "masterImage") == 0)
.filter(|photo| !Path::new(&photo.path).is_file())
.map(|photo| normalize(&photo.path))
.collect();
let normal = self.import.prepare(&paths, cancel);
if cancel.load(Ordering::Relaxed) {
normal.rollback();
return Err(error("Lightroom import cancelled"));
}
let archive_path = stage_archive(self.archive_dir.as_deref(), &mut data)?;
let index_path = self.archive_dir.as_ref().map(|dir| dir.join("lightroom-index.json"));
let index = lightroom_catalog::load_index(index_path.as_deref())?;
Ok(PreparedLightroom {
data,
normal,
index,
archive_path,
token: self.token.clone(),
missing_sources,
update_existing: self.update_existing,
now: self.import.now().to_string(),
cancelled: cancel.load(Ordering::Relaxed),
})
}
}
/// Apply prepared data on the owner thread. No source, archive or index filesystem work occurs.
pub fn commit_prepared(s: &mut crate::Session, mut prepared: PreparedLightroom) -> crate::Result<LightroomCompletion> {
if prepared.cancelled {
prepared.normal.rollback();
return Err(error("Lightroom import cancelled"));
}
if !prepared.token.matches(s) {
prepared.normal.rollback();
return Err(error("Lightroom import belongs to a different open library"));
}
// A command-scoped Lightroom import must never rewrite source XMP sidecars. Direct depth-0
// callers do not set this transient flag, so they cannot affect a later command.
if s.depth > 0 {
s.skip_auto_write = true;
}
let undo0 = s.undo.len();
prepared.normal.revalidate_add(s);
let options = ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() };
let now = prepared.now.clone();
let files = crate::import::commit_prepared(s, &options, &now, prepared.normal)?;
let applied = lightroom_catalog::apply_prepared(
s,
prepared.data,
lightroom_catalog::ApplyPrepared {
update_existing: prepared.update_existing,
files,
index: &mut prepared.index,
archive_path: prepared.archive_path,
missing_sources: &prepared.missing_sources,
undo0,
},
)?;
let path = prepared.token.library_dir().map(|dir| dir.join("Interop/lightroom-index.json"));
let bytes = if path.is_some() { Some(serde_json::to_vec(&applied.index).map_err(|e| error(e.to_string()))?) } else { None };
Ok(LightroomCompletion { report: applied.report, finalization: LightroomArchiveFinalization { path, bytes } })
}
fn normalize(path: &str) -> String {
let path = path.replace('\\', "/");
if cfg!(windows) { path.to_lowercase() } else { path }
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_data() -> CatalogImport {
CatalogImport {
source: "fixture.lrcat".into(),
photos: Vec::new(),
collections: Vec::new(),
members: Vec::new(),
collection_content: Vec::new(),
warnings: Vec::new(),
}
}
fn cancelled_prepared(s: &crate::Session) -> PreparedLightroom {
PreparedLightroom {
data: empty_data(),
normal: Prepared::default(),
index: ImportIndex::default(),
archive_path: None,
token: LightroomLibraryToken::capture(s),
missing_sources: HashSet::new(),
update_existing: false,
now: "2026-01-01T00:00:00".into(),
cancelled: true,
}
}
fn prepared_for(s: &crate::Session, path: &str, rating: i64) -> PreparedLightroom {
let mut image = std::collections::BTreeMap::new();
image.insert("id_local".into(), serde_json::json!(1));
image.insert("rating".into(), serde_json::json!(rating));
image.insert("pick".into(), serde_json::json!(0));
image.insert("fileFormat".into(), serde_json::json!("JPG"));
image.insert("fileWidth".into(), serde_json::json!(1));
image.insert("fileHeight".into(), serde_json::json!(1));
PreparedLightroom {
data: CatalogImport {
source: "fixture.lrcat".into(),
photos: vec![lightroom_catalog::CatalogPhoto {
source_id: 1,
uuid: "fixture-photo".into(),
path: path.into(),
image,
settings: String::new(),
xmp: String::new(),
keywords: Vec::new(),
history: Vec::new(),
snapshots: Vec::new(),
}],
collections: Vec::new(),
members: Vec::new(),
collection_content: Vec::new(),
warnings: Vec::new(),
},
normal: Prepared::default(),
index: ImportIndex::default(),
archive_path: None,
token: LightroomLibraryToken::capture(s),
missing_sources: [normalize(path)].into_iter().collect(),
update_existing: false,
now: "2026-01-01T00:00:00".into(),
cancelled: false,
}
}
#[test]
fn cancellation_before_commit_leaves_owner_catalog_unchanged() {
let mut session = crate::Session::new();
let before = session.catalog.to_snapshot();
let prepared = cancelled_prepared(&session);
assert!(commit_prepared(&mut session, prepared).is_err());
assert_eq!(session.catalog.to_snapshot(), before);
assert!(session.undo.is_empty());
}
#[test]
fn library_identity_rejects_late_completion_after_reopen() {
let mut session = crate::Session::new();
let token = LightroomLibraryToken::capture(&session);
assert!(token.matches(&session));
session.library_identity = Arc::new(());
assert!(!token.matches(&session));
}
#[test]
fn prepared_reimport_preserves_current_edit_and_uses_one_undo_per_import() {
let mut session = crate::Session::new();
let path = "/missing/lightroom-job-edit.jpg";
let prepared = prepared_for(&session, path, 4);
let first = commit_prepared(&mut session, prepared).unwrap();
assert_eq!(first.report["mapping"]["1"].as_u64(), Some(1));
assert_eq!(session.undo.len(), 1);
let id = lightcraft_catalog::PhotoId(1);
let mut personal = lightcraft_develop::DevelopSettings::default();
personal.light.exposure = 1.5;
session.set_develop(id, personal, "Personal").unwrap();
let before = session.catalog.photo(id).map(|photo| photo.develop.clone());
let prepared = prepared_for(&session, path, 5);
commit_prepared(&mut session, prepared).unwrap();
assert_eq!(session.catalog.photo(id).map(|photo| photo.develop.clone()), before);
assert_eq!(session.catalog.photo(id).map(|photo| photo.rating), Some(5));
assert_eq!(session.undo.len(), 3);
}
#[test]
fn oversized_history_archive_keeps_prepared_import_successful_with_warning() {
let archive_dir = std::env::temp_dir().join(format!("lightcraft-lr-prepared-scale-{}", std::process::id()));
let mut data = empty_data();
data.photos = (0..5_000)
.map(|source_id| {
let image = std::collections::BTreeMap::from([
("id_local".into(), serde_json::json!(source_id)),
("fileFormat".into(), serde_json::json!("JPG")),
("fileWidth".into(), serde_json::json!(1)),
("fileHeight".into(), serde_json::json!(1)),
]);
lightroom_catalog::CatalogPhoto {
source_id,
uuid: format!("scale-{source_id}"),
path: format!("/missing/scale-{source_id}.jpg"),
image,
settings: String::new(),
xmp: String::new(),
keywords: Vec::new(),
history: vec![std::collections::BTreeMap::from([(
"text".into(),
serde_json::json!(format!("history-{source_id}-{}", "x".repeat(8 * 1024))),
)])],
snapshots: Vec::new(),
}
})
.collect();
let archive_path = stage_archive(Some(&archive_dir), &mut data).unwrap();
assert!(archive_path.is_none());
let mut session = crate::Session::new();
let token = LightroomLibraryToken::capture(&session);
let prepared = PreparedLightroom {
data,
normal: Prepared::default(),
index: ImportIndex::default(),
archive_path,
token,
missing_sources: HashSet::new(),
update_existing: false,
now: "2026-01-01T00:00:00".into(),
cancelled: false,
};
let completion = commit_prepared(&mut session, prepared).unwrap();
assert_eq!(completion.report["photos"], 5_000);
assert!(
completion.report["warnings"]
.as_array()
.is_some_and(|warnings| warnings.iter().any(|warning| warning == "Lightroom source archive exceeded 32 MiB; archive skipped"))
);
assert_eq!(session.catalog.len(), 5_000);
std::fs::remove_dir_all(archive_dir).unwrap();
}
#[test]
fn execute_fn_import_does_not_rewrite_source_sidecar() {
let dir = std::env::temp_dir().join(format!("lightcraft-lr-job-sidecar-{}", std::process::id()));
let path = dir.join("source.jpg");
let sidecar = dir.join("source.xmp");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(&path, b"fixture").unwrap();
std::fs::write(&sidecar, b"original sidecar").unwrap();
let original = std::fs::read(&sidecar).unwrap();
let mut session = crate::Session::new();
session.xmp.auto_write = true;
let result = session.execute_fn("library.importLightroom", |s| {
let prepared = prepared_for(s, &path.to_string_lossy(), 5);
let completion = commit_prepared(s, prepared)?;
Ok(completion.report)
});
assert!(result.is_ok());
assert_eq!(std::fs::read(&sidecar).unwrap(), original);
std::fs::remove_dir_all(dir).unwrap();
}
}
File diff suppressed because it is too large. Load diff
+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 = 12;
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();
+21 -2
View File
@@ -100,6 +100,7 @@ impl Lua {
struct LuaParser<'a> {
s: &'a [u8],
i: usize,
depth: usize,
}
impl LuaParser<'_> {
@@ -210,6 +211,15 @@ impl LuaParser<'_> {
std::str::from_utf8(&self.s[start..self.i]).ok().and_then(|t| t.parse().ok()).map_or_else(|| self.err("bad number"), Ok)
}
fn value(&mut self) -> Result<Lua, String> {
if self.depth >= 64 {
return self.err("Lua settings exceed nesting limit");
}
self.depth += 1;
let result = self.value_inner();
self.depth -= 1;
result
}
fn value_inner(&mut self) -> Result<Lua, String> {
self.skip_ws();
let Some(&c) = self.s.get(self.i) else { return self.err("unexpected end") };
match c {
@@ -315,7 +325,10 @@ impl LuaParser<'_> {
/// Parse a Lua table literal, optionally preceded by `name =` or `return`.
pub fn parse_lua(text: &str) -> Result<Lua, String> {
let mut p = LuaParser { s: text.as_bytes(), i: 0 };
if text.len() > 16 << 20 {
return Err("Lua settings exceed 16 MiB".into());
}
let mut p = LuaParser { s: text.as_bytes(), i: 0, depth: 0 };
p.skip_ws();
let save = p.i;
match p.ident().as_deref() {
@@ -347,6 +360,12 @@ pub fn lrtemplate_props(text: &str) -> Result<(Option<String>, Props, crate::crs
let root = parse_lua(text)?;
let title = root.get("title").or_else(|| root.get("internalName")).and_then(Lua::str).map(delocalize).filter(|t| !t.trim().is_empty());
let settings = root.get("value").and_then(|v| v.get("settings")).ok_or("no develop settings in this template")?;
let (props, values) = lua_settings_props(settings)?;
Ok((title, props, values))
}
/// Data-only Lua settings shared by preset and native catalog import. Never executes Lua.
pub(crate) fn lua_settings_props(settings: &Lua) -> Result<(Props, crate::crs_masks::Values), String> {
let Lua::Table(_, fields) = settings else { return Err("no develop settings in this template".into()) };
let mut props = Props::new();
let mut values = crate::crs_masks::Values::new();
@@ -374,7 +393,7 @@ pub fn lrtemplate_props(text: &str) -> Result<(Option<String>, Props, crate::crs
};
props.insert(key, vals);
}
Ok((title, props, values))
Ok((props, values))
}
// ------------------------------------------------------------------------------- XMP in files
+13 -19
View File
@@ -83,28 +83,22 @@ pub fn parse_preset_file(name: &str, bytes: &[u8]) -> Result<Vec<Preset>, String
Ok(out)
}
/// Expand files/folders (recursively) into preset files (`.lcpreset`, `.xmp`).
/// Expand files/folders (recursively, bounded: see [`crate::walk`]) into preset files
/// (`.lcpreset`, `.xmp`).
pub fn expand_preset_paths(paths: &[String]) -> Vec<String> {
fn walk(p: &Path, out: &mut Vec<String>, top: bool) {
if p.is_dir() {
let Ok(rd) = std::fs::read_dir(p) else { return };
let mut v: Vec<_> = rd.flatten().map(|e| e.path()).collect();
v.sort();
for c in v {
if !c.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) {
walk(&c, out, false);
}
}
} else {
let ext = p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
if top || ext == LCPRESET_EXT || ["xmp", "lrtemplate", "zip", "lmp", "mplumpack"].contains(&ext.as_str()) {
out.push(p.to_string_lossy().to_string());
}
}
}
let preset_file = |p: &Path| {
let ext = p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
ext == LCPRESET_EXT || ["xmp", "lrtemplate", "zip", "lmp", "mplumpack"].contains(&ext.as_str())
};
let mut out = Vec::new();
for p in paths {
walk(Path::new(p), &mut out, true);
let path = Path::new(p);
if path.is_dir() {
let w = crate::walk::files_in(path, None, crate::walk::Limits::default(), preset_file);
out.extend(w.files.into_iter().map(|f| f.to_string_lossy().to_string()));
} else {
out.push(p.clone());
}
}
out
}
+5 -1
View File
@@ -210,8 +210,11 @@ fn run(rx: &mpsc::Receiver<Job>, shared: &Shared, idle: Duration) {
}
let Some(mut job) = queue.pop_front() else { continue };
let is_detail = matches!(job.kind, Kind::Detail { .. });
let _done = Done(if is_detail { &shared.detail } else { &shared.pending });
// counts the job as pending until just before its reply goes out: a caller woken by the
// reply must not still see the worker busy with it
let done = Done(if is_detail { &shared.detail } else { &shared.pending });
if queue.iter().any(|later| supersedes(later, &job)) {
drop(done);
let _ = job.reply.send(Outcome { tag: job.tag, result: Ok(None), superseded: true });
continue;
}
@@ -229,6 +232,7 @@ fn run(rx: &mpsc::Receiver<Job>, shared: &Shared, idle: Duration) {
state.cache = None;
}
shared.loaded.store(state.model.is_some(), Ordering::SeqCst);
drop(done);
let _ = job.reply.send(Outcome { tag: job.tag, result, superseded: false });
}
}
+47 -18
View File
@@ -14,24 +14,27 @@ 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};
let mut s = demo();
let id = PhotoId(100);
let mut p = Photo::new(id, Source::File { path: "synthetic.arw".into() }, "synthetic.arw", "ARW", 16, 16, "");
// A catalog made by the old generic-matrix Kelvin inference.
p.as_shot_wb = Some((6829.0, -127.0));
p.develop = std::sync::Arc::new(DevelopSettings::for_raw(6829.0, -127.0));
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
s.execute("library.select", &json!({"ids": [100], "active": 100})).unwrap();
s.execute("develop.set", &json!({"control": "wb.temp", "value": 8000})).unwrap();
let d = active_dev(&s);
assert_eq!(d.wb.mode, WbMode::Custom);
assert_eq!(d.wb.temp, 8000.0);
assert_eq!(d.wb.tint, 0.0, "untouched tint comes from the current as-shot reference");
s.execute("develop.wb", &json!({"mode": "asShot"})).unwrap();
s.execute("develop.set", &json!({"control": "wb.tint", "value": 10})).unwrap();
assert_eq!(active_dev(&s).wb.temp, 6500.0);
s.execute("develop.reset", &json!({})).unwrap();
assert_eq!((active_dev(&s).wb.temp, active_dev(&s).wb.tint), (6500.0, 0.0));
for format in ["ARW", "NEF", "NRW", "RAF", "CR2", "PEF"] {
let mut s = demo();
let id = PhotoId(100);
let name = format!("synthetic.{format}");
let mut p = Photo::new(id, Source::File { path: name.clone() }, &name, format, 16, 16, "");
// A catalog made by the old generic-matrix Kelvin inference.
p.as_shot_wb = Some((6829.0, -127.0));
p.develop = std::sync::Arc::new(DevelopSettings::for_raw(6829.0, -127.0));
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
s.execute("library.select", &json!({"ids": [100], "active": 100})).unwrap();
s.execute("develop.set", &json!({"control": "wb.temp", "value": 8000})).unwrap();
let d = active_dev(&s);
assert_eq!(d.wb.mode, WbMode::Custom);
assert_eq!(d.wb.temp, 8000.0);
assert_eq!(d.wb.tint, 0.0, "{format}: untouched tint comes from the current as-shot reference");
s.execute("develop.wb", &json!({"mode": "asShot"})).unwrap();
s.execute("develop.set", &json!({"control": "wb.tint", "value": 10})).unwrap();
assert_eq!(active_dev(&s).wb.temp, 6500.0, "{format}");
s.execute("develop.reset", &json!({})).unwrap();
assert_eq!((active_dev(&s).wb.temp, active_dev(&s).wb.tint), (6500.0, 0.0), "{format}");
}
}
#[test]
@@ -922,3 +925,29 @@ fn saved_presets_with_unknown_keys_still_export() {
assert_eq!(o.watermark.map(|w| w.text), Some("©".to_string()));
assert!(ExportOptions::from_params(&s.export_params(&json!({"preset": "old", "qualty": 80})).unwrap()).is_err());
}
/// Undo and redo bring the photo they change on screen (issue #293): after editing one photo and
/// moving on, Undo makes it the active photo again; a step that changes several photos leaves the
/// selection alone.
#[test]
fn undo_and_redo_show_the_photo_they_change() {
let mut s = demo();
let vis = s.visible_cloned();
let (a, b) = (vis[0], vis[1]);
s.execute("library.select", &json!({"ids": [a.0]})).unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap();
s.execute("library.select", &json!({"ids": [b.0]})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.active(), Some(a), "undo shows the photo it changed");
assert_eq!(s.selection.ids, vec![a]);
s.execute("library.select", &json!({"ids": [b.0]})).unwrap();
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.active(), Some(a), "so does redo");
// rating both selected photos is one step over two photos: undoing it leaves the selection
s.execute("library.select", &json!({"ids": [a.0, b.0], "active": b.0})).unwrap();
s.execute("photo.rate", &json!({"rating": 3})).unwrap();
s.execute("library.select", &json!({"ids": [a.0, b.0], "active": a.0})).unwrap();
let before = s.selection.clone();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.selection, before, "a step over several photos keeps the selection");
}
+23 -8
View File
@@ -189,6 +189,12 @@ fn the_folder_filter_takes_only_a_text_path() {
assert_eq!(s.visible().len(), 0, "a path that names no folder shows nothing, not everything");
}
/// Folders are compared by identity: a path read back may be spelled with the platform's separator.
fn same_folder(actual: Option<&str>, expected: &str, why: &str) {
let key = lightcraft_catalog::query::folder_key;
assert_eq!(actual.map(key), Some(key(expected)), "{why}: {actual:?} vs {expected}");
}
#[test]
fn a_renamed_folder_stays_the_chosen_one_and_undo_follows_it_back() {
let dir = Scratch::new("rename");
@@ -200,10 +206,10 @@ fn a_renamed_folder_stays_the_chosen_one_and_undo_follows_it_back() {
s.execute("library.filter", &json!({"libraryFolder": trip})).unwrap();
assert_eq!(s.visible().len(), 2);
s.execute("folder.rename", &json!({"path": trip, "name": "holiday"})).unwrap();
assert_eq!(s.filter.library_folder.as_deref(), Some(renamed.as_str()), "the choice follows the folder");
same_folder(s.filter.library_folder.as_deref(), &renamed, "the choice follows the folder");
assert_eq!(s.visible().len(), 2, "and still shows its photos");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.filter.library_folder.as_deref(), Some(trip.as_str()));
same_folder(s.filter.library_folder.as_deref(), &trip, "the choice is the same folder");
assert_eq!(s.visible().len(), 2);
}
@@ -217,10 +223,10 @@ fn a_moved_folder_keeps_a_chosen_subfolder_chosen() {
add(&mut s, &format!("{day1}/b.jpg"));
s.execute("library.filter", &json!({"libraryFolder": day1})).unwrap();
s.execute("folder.move", &json!({"path": dir.path("trip"), "into": dir.path("archive")})).unwrap();
assert_eq!(s.filter.library_folder.as_deref(), Some(moved.as_str()), "the subfolder follows its parent");
same_folder(s.filter.library_folder.as_deref(), &moved, "the subfolder follows its parent");
assert_eq!(s.visible().len(), 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.filter.library_folder.as_deref(), Some(day1.as_str()));
same_folder(s.filter.library_folder.as_deref(), &day1, "the choice is the same folder");
}
#[test]
@@ -229,12 +235,21 @@ fn following_a_folder_reads_both_spellings_the_same_way() {
let mut s = Session::new();
s.filter.library_folder = Some("/a/x/../b/sub".into());
crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c");
assert_eq!(s.filter.library_folder.as_deref(), Some("/a/c/sub"), "a subfolder stays a subfolder, never widens to its parent");
same_folder(s.filter.library_folder.as_deref(), "/a/c/sub", "a subfolder stays a subfolder, never widens to its parent");
s.filter.library_folder = Some("/elsewhere/b".into());
crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c");
assert_eq!(s.filter.library_folder.as_deref(), Some("/elsewhere/b"), "other folders are left alone");
}
#[test]
fn following_a_folder_keeps_the_case_of_the_subfolder_names() {
// Windows compares folders in lower case; the name the user sees must not change with it
let mut s = Session::new();
s.filter.library_folder = Some("/A/Trip/Day1".into());
crate::cmd::browse::follow_folder(&mut s, "/A/Trip", "/B");
assert_eq!(s.filter.library_folder.as_deref(), Some(std::path::Path::new("/B").join("Day1").to_str().unwrap()));
}
#[test]
fn choosing_a_folder_replaces_the_source_the_way_an_album_does() {
let mut s = session();
@@ -296,10 +311,10 @@ fn a_renamed_folder_stays_the_one_shown_and_undo_follows_it_back() {
add(&mut s, &format!("{day1}/b.jpg"));
source_folder(&mut s, &day1);
s.execute("folder.rename", &json!({"path": trip, "name": "holiday"})).unwrap();
assert_eq!(s.library_folder.as_deref(), Some(format!("{renamed}/day1").as_str()), "the subfolder follows its parent");
same_folder(s.library_folder.as_deref(), &format!("{renamed}/day1"), "the subfolder follows its parent");
assert_eq!(s.visible().len(), 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.library_folder.as_deref(), Some(day1.as_str()));
same_folder(s.library_folder.as_deref(), &day1, "the choice is the same folder");
assert_eq!(s.visible().len(), 1);
}
@@ -308,7 +323,7 @@ fn the_shown_folder_follows_reading_both_spellings_the_same_way() {
let mut s = Session::new();
s.library_folder = Some("/a/x/../b/sub".into());
crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c");
assert_eq!(s.library_folder.as_deref(), Some("/a/c/sub"));
same_folder(s.library_folder.as_deref(), "/a/c/sub", "the shown folder reads both spellings alike");
}
#[test]
+98
View File
@@ -1007,3 +1007,101 @@ fn trashed_photo_ids(tag: &str) -> (Session, std::path::PathBuf, [u64; 1], u64)
let (s, src, id) = trashed_photo(tag);
(s, src, [id], 0)
}
/// Issue #374: launch arguments never turn into a walk of a whole drive or home folder. An empty
/// argument is ignored; `.`, `..` and (on Windows) `/` are resolved against the working folder a
/// launcher picked, which is often a drive root; a root is replaced by its memory card's DCIM
/// folder or refused, and so is the home folder.
#[test]
fn launch_paths_never_become_a_drive_root_or_the_home_folder() {
use crate::import::{LaunchPath, launch_path};
let root = if cfg!(windows) { Path::new("C:\\") } else { Path::new("/") };
let home = if cfg!(windows) { Path::new("C:\\Users\\me") } else { Path::new("/home/me") };
let none = |_: &Path| None;
let refused = |r: Option<LaunchPath>| matches!(r, Some(LaunchPath::Refused { .. }));
for empty in ["", " "] {
assert_eq!(launch_path(empty, Some(root), Some(home), none), None, "{empty:?}");
}
// the working folder is the drive root: `.` (and `..`) name the whole drive
for arg in [".", "..", "./", ".\\..", "/"] {
let arg = if cfg!(windows) { arg.to_string() } else { arg.replace('\\', "/") };
assert!(refused(launch_path(&arg, Some(root), Some(home), none)), "{arg:?}");
}
assert!(refused(launch_path("..", Some(&home.join("Pictures")), Some(home), none)), "up to the home folder");
assert!(refused(launch_path(&home.to_string_lossy(), None, Some(home), none)));
assert!(refused(launch_path(".", None, Some(home), none)), "relative, no working folder");
// a memory card's root: its camera folder
let dcim = |r: &Path| Some(r.join("DCIM"));
assert_eq!(launch_path(".", Some(root), Some(home), dcim), Some(LaunchPath::Import(root.join("DCIM").to_string_lossy().to_string())));
// ordinary folders and files resolve to absolute paths
let pics = home.join("Pictures");
assert_eq!(launch_path("Pictures", Some(home), Some(home), none), Some(LaunchPath::Import(pics.to_string_lossy().to_string())));
assert_eq!(
launch_path(&pics.join("a.jpg").to_string_lossy(), None, Some(home), none),
Some(LaunchPath::Import(pics.join("a.jpg").to_string_lossy().to_string()))
);
}
/// Issue #374: a folder import is bounded — a tree deeper or larger than the limits is not walked
/// to the end (it stops and says so), and what it found is still returned.
#[test]
fn expand_stops_at_the_walk_limits() {
let d = temp_dir("bounded");
let mut deep = d.clone();
for i in 0..8 {
deep = deep.join(format!("d{i}"));
std::fs::create_dir_all(&deep).unwrap();
std::fs::write(deep.join(format!("p{i}.jpg")), b"x").unwrap();
}
let top = [d.to_string_lossy().to_string()];
let (all, cut) = crate::import::expand_within(&top, None, crate::walk::Limits::default());
assert_eq!((all.len(), cut), (8, false));
let (some, cut) = crate::import::expand_within(&top, None, crate::walk::Limits { max_depth: 3, max_entries: 1000 });
assert_eq!((some.len(), cut), (2, true), "{some:?}");
let (few, cut) = crate::import::expand_within(&top, None, crate::walk::Limits { max_depth: 32, max_entries: 5 });
assert!(cut && few.len() < 8, "{few:?}");
let _ = std::fs::remove_dir_all(&d);
}
/// Raw containers LightCraft cannot decode but shows by their embedded JPEG are picked up from a folder
/// (the extensions are in `import::EXTENSIONS`; the files differ so none is a duplicate) and import as preview only.
#[test]
fn folder_import_picks_up_undecodable_raw_containers_as_preview_only() {
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
let src = temp_dir("preview-only-exts");
let px: Vec<u8> = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
let jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
// CRW / MRW / X3F: their magic, then the JPEG. IIQ / KDC / MOS / ERF: a TIFF whose IFD0 only points at the JPEG.
let tiff = {
let off = 8 + 2 + 24 + 4;
let mut f = b"II*\0\x08\0\0\0\x02\0".to_vec();
for (tag, v) in [(513u16, off as u32), (514, jpeg.len() as u32)] {
f.extend_from_slice(&tag.to_le_bytes());
f.extend_from_slice(&[4, 0, 1, 0, 0, 0]);
f.extend_from_slice(&v.to_le_bytes());
}
f.extend_from_slice(&[0, 0, 0, 0]);
f.extend_from_slice(&jpeg);
f
};
let with_magic = |magic: &[u8]| [magic, &[0x11u8; 64][..], &jpeg].concat();
let files: [(&str, Vec<u8>); 7] = [
("a.CRW", with_magic(b"II\x1a\0\0\0HEAPCCDR")),
("b.mrw", with_magic(b"\0MRM\0\x01\0\0")),
("c.x3f", with_magic(b"FOVb\x02\0\x02\0")),
("d.iiq", [&tiff[..], b"d"].concat()),
("e.kdc", [&tiff[..], b"e"].concat()),
("f.mos", [&tiff[..], b"f"].concat()),
("g.erf", [&tiff[..], b"g"].concat()),
];
for (name, bytes) in &files {
std::fs::write(src.join(name), bytes).unwrap();
}
let mut s = Session::new().with_fs();
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
assert_eq!(r["scanned"], 7, "{r}");
assert_eq!(ids(&r, "imported"), 7, "{r}");
assert_eq!(ids(&r, "failed"), 0, "{r}");
assert!(s.catalog.photos().all(|p| p.preview_only.is_some() && p.kind == lightcraft_catalog::MediaKind::Raw && (p.width, p.height) == (40, 30)));
let _ = std::fs::remove_dir_all(&src);
}
+67
View File
@@ -433,3 +433,70 @@ fn write_policy_timing() {
println!("copy {n} × {} MB: byte compare with the source {bytewise:?}, probe hash {hashed:?}", size >> 20);
let _ = std::fs::remove_dir_all(&base);
}
/// Issue #367: Import → Copy wrote and verified one file after another on one thread. The copies
/// now run side by side, with the names, order, duplicates and reports a one-by-one copy gives:
/// like-named files take -1, -2… in file order (also against a file already there and a literal
/// `IMG_1-1`), and a file with the content of one copied earlier in the batch is its duplicate
/// (also while that copy is still under way).
#[test]
fn copies_run_side_by_side_with_the_same_outcome() {
let base = temp_dir("parallel-copy");
let (card, dest) = (base.join("card"), base.join("out"));
write_png(&card.join("a/IMG_1.png"), 1);
write_png(&card.join("a/IMG_2.png"), 2);
write_png(&card.join("b/IMG_1.png"), 3);
write_png(&card.join("b/IMG_1-1.png"), 4);
write_png(&card.join("c/IMG_1.png"), 5);
for i in 0..8u8 {
write_png(&card.join(format!("d/p{i}.png")), 10 + i);
}
// the content of a file whose copy is still under way
std::fs::create_dir_all(card.join("e")).unwrap();
std::fs::copy(card.join("d/p3.png"), card.join("e/dup.png")).unwrap();
std::fs::create_dir_all(&dest).unwrap();
std::fs::write(dest.join("IMG_2.png"), b"someone else's file").unwrap();
let mut s = session();
inject(Fault::Slow);
let r = s
.execute(
"library.import",
&json!({"paths": [card.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "flat"}),
)
.unwrap();
inject(Fault::None);
let threads: std::collections::HashSet<_> = crate::import_move::copy_threads().into_iter().collect();
if std::thread::available_parallelism().map_or(1, |n| n.get()) > 1 {
assert!(threads.len() > 1, "copies ran on {} thread(s)", threads.len());
}
assert_eq!(len(&r, "imported"), 13, "{r}");
assert_eq!(len(&r, "failed"), 0, "{r}");
assert_eq!(len(&r, "duplicates"), 1, "{r}");
assert_eq!(r["duplicates"][0]["reason"], "content");
assert!(r["duplicates"][0]["path"].as_str().unwrap().ends_with("dup.png"), "{r}");
// the order of a one-by-one copy (files in name order), each verified
let expect = [
("a/IMG_1.png", "IMG_1.png"),
("a/IMG_2.png", "IMG_2-1.png"),
("b/IMG_1-1.png", "IMG_1-1.png"),
("b/IMG_1.png", "IMG_1-2.png"),
("c/IMG_1.png", "IMG_1-3.png"),
];
for (src, dst) in expect {
assert_eq!(std::fs::read(dest.join(dst)).unwrap(), std::fs::read(card.join(src)).unwrap(), "{src} → {dst}");
}
for i in 0..8 {
assert_eq!(std::fs::read(dest.join(format!("p{i}.png"))).unwrap(), std::fs::read(card.join(format!("d/p{i}.png"))).unwrap());
}
assert_eq!(std::fs::read(dest.join("IMG_2.png")).unwrap(), b"someone else's file");
assert_eq!(files_under(&dest).len(), 14, "{:?}", files_under(&dest));
// the duplicate names the photo copied from the same content (d/p3); ids follow the file order
let ids: Vec<u64> = r["imported"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
assert!(ids.windows(2).all(|w| w[0] < w[1]), "{ids:?}");
assert_eq!(r["duplicates"][0]["existing"], ids[8], "{r}");
let first = s.catalog.photo(lightcraft_catalog::PhotoId(ids[0])).unwrap();
assert_eq!(first.file_name, "IMG_1.png");
let _ = std::fs::remove_dir_all(&base);
}
+25
View File
@@ -95,3 +95,28 @@ fn unreadable_settings_are_not_overwritten() {
assert_eq!(files.files.get("presets.json").unwrap(), presets);
assert_eq!(files.files.get("prefs.json").unwrap(), prefs);
}
/// Issue #171: a library whose `catalog.lock` can't be locked (stood in for here by a directory
/// of that name, which takes the same path as a file system without locks) still opens, and the
/// user is told it is unprotected — in `library.info` and once through the library warnings the
/// app's notices and the CLI show. A normally locked library says nothing.
#[test]
fn an_unlockable_library_opens_with_a_warning() {
let lib = temp_dir("unlocked");
std::fs::create_dir_all(lib.join("catalog.lock")).unwrap();
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
let info = s.execute("library.info", &json!({})).unwrap();
let listed = info["settingsWarnings"].as_array().cloned().unwrap_or_default();
assert!(listed.iter().any(|w| w.as_str().is_some_and(|w| w.contains("without protection against a second program"))), "{info}");
let warnings = s.take_library_warnings();
assert_eq!(warnings.len(), 1, "{warnings:?}");
drop(s);
// the same library with a lockable lock file: no warning
std::fs::remove_dir_all(lib.join("catalog.lock")).unwrap();
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
assert!(s.take_library_warnings().is_empty());
drop(s);
let _ = std::fs::remove_dir_all(&lib);
}
+183
View File
@@ -0,0 +1,183 @@
//! Bounded folder walks (issue #374): every recursive listing of the disk — import, Local's
//! Include Subfolders, Find Missing Photos, preset and LUT folders, MCP — goes through
//! [`files_in`], which stops at a depth and a number of directory entries instead of walking a
//! whole drive, follows a symbolic link or junction to a folder at most once (no loops), and
//! decides what is a folder from the directory listing itself (no extra open per entry, which
//! is what makes a large walk slow on Windows).
//!
//! It never chooses where to walk: callers pass a folder the user named. Nothing in the app walks
//! at startup or on the UI thread.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
/// How far a walk may go.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Limits {
/// Folder levels below the starting folder that are listed (its own entries are level 1).
pub max_depth: usize,
/// Directory entries looked at in all (files and folders, hidden ones included).
pub max_entries: usize,
}
impl Default for Limits {
fn default() -> Self {
Limits { max_depth: MAX_DEPTH, max_entries: MAX_ENTRIES }
}
}
/// Folder levels a walk descends at most; photo trees are a handful of levels deep.
pub const MAX_DEPTH: usize = 32;
/// Directory entries a walk looks at at most: far more than any photo folder holds, far fewer
/// than a whole system drive.
pub const MAX_ENTRIES: usize = 1_000_000;
/// What a walk found.
#[derive(Debug, Default, PartialEq)]
pub struct Walked {
/// The files `keep` accepted, in path order, folder by folder (depth first).
pub files: Vec<PathBuf>,
/// The walk stopped at a [`Limits`] bound: there may be more files.
pub truncated: bool,
/// Directory entries looked at.
pub entries: usize,
}
fn hidden(p: &Path) -> bool {
p.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.'))
}
/// The files below folder `root` that `keep` accepts. Hidden entries (names starting with `.`)
/// are left out, the folder `skip` (e.g. the library's own) is never entered, and the walk stops
/// at `limits` (see [`Walked::truncated`]). Unreadable folders are skipped.
pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl FnMut(&Path) -> bool) -> Walked {
let mut out = Walked::default();
// canonical folders reached through a link, so a link loop is walked at most once
let mut linked: HashSet<PathBuf> = HashSet::new();
if let Ok(c) = std::fs::canonicalize(root) {
linked.insert(c);
}
// (path, depth of the folder it is in, is a folder); popped in path order
let mut stack: Vec<(PathBuf, usize, bool)> = vec![(root.to_path_buf(), 0, true)];
while let Some((path, depth, is_dir)) = stack.pop() {
if !is_dir {
if keep(&path) {
out.files.push(path);
}
continue;
}
if skip.is_some_and(|s| path == s) {
continue;
}
if depth >= limits.max_depth {
out.truncated = true;
continue;
}
let Ok(rd) = std::fs::read_dir(&path) else { continue };
let mut children: Vec<(PathBuf, bool)> = Vec::new();
for e in rd.flatten() {
if out.entries >= limits.max_entries {
out.truncated = true;
break;
}
out.entries = out.entries.saturating_add(1);
let p = e.path();
if hidden(&p) {
continue;
}
let Ok(ft) = e.file_type() else { continue };
let dir = if ft.is_symlink() {
// a link (or a Windows junction): a folder is entered once, a file is a file
match std::fs::metadata(&p) {
Ok(m) if m.is_dir() => {
let Ok(c) = std::fs::canonicalize(&p) else { continue };
if !linked.insert(c) {
continue;
}
true
}
_ => false,
}
} else {
ft.is_dir()
};
children.push((p, dir));
}
children.sort();
let below = depth.saturating_add(1);
stack.extend(children.into_iter().rev().map(|(p, d)| (p, below, d)));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-walk-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn files_come_in_path_order_without_hidden_ones_or_the_skipped_folder() {
let d = tmp("order");
for f in ["b/2.jpg", "a/1.jpg", "a/x/0.jpg", "c.jpg", ".hidden/h.jpg", "a/.h.jpg", "lib/l.jpg", "a/n.txt"] {
let p = d.join(f);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, b"x").unwrap();
}
let lib = d.join("lib");
let w = files_in(&d, Some(&lib), Limits::default(), |p| p.extension().is_some_and(|e| e == "jpg"));
let rel: Vec<String> = w.files.iter().map(|p| p.strip_prefix(&d).unwrap().to_string_lossy().replace('\\', "/")).collect();
assert_eq!(rel, ["a/1.jpg", "a/x/0.jpg", "b/2.jpg", "c.jpg"]);
assert!(!w.truncated);
let _ = std::fs::remove_dir_all(&d);
}
/// Issue #374: a walk never runs on without end — a tree deeper than the limit, or with more
/// entries than the limit, stops and says so.
#[test]
fn deep_and_wide_trees_stop_at_the_limits() {
let d = tmp("bounds");
let mut deep = d.join("deep");
for i in 0..12 {
deep = deep.join(format!("l{i}"));
}
std::fs::create_dir_all(&deep).unwrap();
std::fs::write(deep.join("bottom.jpg"), b"x").unwrap();
let wide = d.join("wide");
std::fs::create_dir_all(&wide).unwrap();
for i in 0..50 {
std::fs::write(wide.join(format!("{i:02}.jpg")), b"x").unwrap();
}
let all = |_: &Path| true;
let w = files_in(&d.join("deep"), None, Limits { max_depth: 5, max_entries: 1000 }, all);
assert!(w.files.is_empty() && w.truncated, "{w:?}");
let w = files_in(&d.join("deep"), None, Limits { max_depth: 13, max_entries: 1000 }, all);
assert_eq!((w.files.len(), w.truncated), (1, false), "{w:?}");
let w = files_in(&wide, None, Limits { max_depth: 5, max_entries: 20 }, all);
assert_eq!((w.files.len(), w.entries, w.truncated), (20, 20, true));
let w = files_in(&wide, None, Limits::default(), all);
assert_eq!((w.files.len(), w.truncated), (50, false));
let _ = std::fs::remove_dir_all(&d);
}
/// A link back up the tree (a loop) is followed at most once: the walk ends.
#[cfg(unix)]
#[test]
fn link_loops_end() {
let d = tmp("loop");
std::fs::create_dir_all(d.join("a/b")).unwrap();
std::fs::write(d.join("a/b/p.jpg"), b"x").unwrap();
std::os::unix::fs::symlink(&d, d.join("a/b/up")).unwrap();
std::os::unix::fs::symlink(d.join("a"), d.join("alias")).unwrap();
let w = files_in(&d, None, Limits::default(), |_| true);
assert!(!w.truncated, "{w:?}");
assert!(w.files.len() <= 2, "{w:?}");
assert!(w.files.iter().any(|p| p.ends_with("a/b/p.jpg")), "{w:?}");
let _ = std::fs::remove_dir_all(&d);
}
}
+30
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"))]
@@ -294,6 +321,9 @@ pub fn stage_bytes(stages: &StageCache) -> usize {
pub fn render(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>) -> Option<Rendered> {
#[cfg(not(target_arch = "wasm32"))]
{
// sections switched off with their eye render as if at their defaults (issue #316)
let effective = s.effective();
let s: &DevelopSettings = &effective;
// the kernel writes 8-bit output: high-bit-depth exports (and soft proofs) render on the CPU
if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 || req.proof.is_some() {
return None;
+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);
}
+9 -19
View File
@@ -13,7 +13,8 @@ use crate::backend::Backend;
/// File extensions recognised as photos when expanding folders.
pub const PHOTO_EXTENSIONS: &[&str] = &[
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "psd", "jxl", "gif",
"bmp", "avif",
"bmp", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG)
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf",
];
/// Headless backend: a [`Session`] with filesystem hooks.
@@ -139,31 +140,20 @@ impl Backend for Headless {
}
}
/// Expand folders (recursively, sorted) into photo files and make paths absolute.
/// Expand folders (recursively, sorted; bounded, see `lightcraft_engine::walk`) into photo files
/// and make paths absolute.
pub fn expand_paths(paths: &[String]) -> Vec<String> {
fn walk(p: &Path, out: &mut Vec<String>) {
if p.is_dir() {
if let Ok(rd) = std::fs::read_dir(p) {
let mut v: Vec<_> = rd.flatten().map(|e| e.path()).collect();
v.sort();
for c in v {
if c.file_name().is_some_and(|n| !n.to_string_lossy().starts_with('.')) {
walk(&c, out);
}
}
}
} else if p.extension().is_some_and(|e| PHOTO_EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str())) {
out.push(std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf()).to_string_lossy().to_string());
}
}
let absolute = |p: &Path| std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf()).to_string_lossy().to_string();
let photo = |p: &Path| p.extension().is_some_and(|e| PHOTO_EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str()));
let mut out = Vec::new();
for p in paths {
let path = Path::new(p);
if path.is_dir() {
walk(path, &mut out);
let w = lightcraft_engine::walk::files_in(path, None, lightcraft_engine::walk::Limits::default(), photo);
out.extend(w.files.iter().map(|f| absolute(f)));
} else {
// Explicit files are kept even with unknown extensions (the probe decides).
out.push(std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()).to_string_lossy().to_string());
out.push(absolute(path));
}
}
out
+405 -12
View File
@@ -17,7 +17,13 @@ pub enum Cr3TrackKind {
/// `CRAW` with a `JPEG` child: the full-size JPEG.
Jpeg,
/// `CRAW` raw image: dimensions and the `CMP1` coding header (byte range in the file).
Raw { width: u16, height: u16, cmp1: Option<(usize, usize)> },
Raw {
width: u16,
height: u16,
cmp1: Option<(usize, usize)>,
/// `CDI1/IAD1` image-area descriptor (byte range in the file).
iad1: Option<(usize, usize)>,
},
/// Anything else (`CTMD` timed metadata, unknown entries).
Other([u8; 4]),
}
@@ -30,6 +36,134 @@ pub struct Cr3Track {
pub data: Option<(usize, usize)>,
}
/// Canon's `CMP1` image coding descriptor, separate from the encoded track sample.
///
/// Parsed from the payload (after the eight-byte box header). Geometry is the full sensor mosaic,
/// including optically masked borders. Unsupported encoding modes are retained for the decoder to
/// report explicitly, rather than confused with the full-size embedded JPEG.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Cr3Compression {
pub version: u16,
pub width: u32,
pub height: u32,
pub tile_width: u32,
pub tile_height: u32,
pub bit_depth: u8,
pub planes: u8,
/// 0 = RGGB, 1 = GRBG, 2 = GBRG, 3 = BGGR.
pub cfa_pattern: u8,
pub encoding: u8,
pub levels: u8,
pub tile_flags: u8,
/// Number of bytes at the start of the sample before its entropy-coded data.
pub header_size: u32,
pub median_bit_depth: Option<u8>,
}
impl Cr3Compression {
/// Read the public `CMP1` field layout. Truncated or impossible descriptors return `None`.
pub fn parse(payload: &[u8]) -> Option<Self> {
// The first word is reserved in the public notes; real files use FF00, although older
// descriptions guessed FFFF. It is not a reliable format signature.
let header_len = usize::from(be16(payload, 2)?).checked_add(4)?;
if header_len < 52 || header_len > payload.len() {
return None;
}
let packed = *payload.get(25)?;
let coding = *payload.get(26)?;
let mut out = Self {
version: be16(payload, 4)?,
width: be32(payload, 8)?,
height: be32(payload, 12)?,
tile_width: be32(payload, 16)?,
tile_height: be32(payload, 20)?,
bit_depth: *payload.get(24)?,
planes: packed >> 4,
cfa_pattern: packed & 15,
encoding: coding >> 4,
levels: coding & 15,
tile_flags: *payload.get(27)?,
header_size: be32(payload, 28)?,
median_bit_depth: None,
};
if out.width == 0
|| out.height == 0
|| out.tile_width == 0
|| out.tile_height == 0
|| out.tile_width > out.width
|| out.tile_height > out.height
|| !(1..=16).contains(&out.bit_depth)
|| !(1..=4).contains(&out.planes)
|| (out.planes > 1 && out.cfa_pattern > 3)
|| out.header_size == 0
{
return None;
}
if payload.get(32).is_some_and(|flags| flags & 0x80 != 0) {
// Extended CMP1: a four-byte length, flags, reserved bytes and six words. The optional
// median precision is used by type-3 burst encodings, not ordinary Bayer raw/craw.
let extension_len = usize::try_from(be32(payload, 52)?).ok()?;
let extension_end = 52usize.checked_add(extension_len)?;
if extension_len < 32 || extension_end > payload.len() {
return None;
}
if payload.get(56).is_some_and(|flags| flags & 0x40 != 0) {
let precision = *payload.get(84).filter(|_| extension_end > 84)?;
if !(1..=16).contains(&precision) {
return None;
}
out.median_bit_depth = Some(precision);
}
}
Some(out)
}
}
/// `IAD1` geometry. Coordinates are inclusive sensor offsets: `[left, top, right, bottom]`.
/// A full raw descriptor distinguishes valid sensor pixels from the recommended final crop.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Cr3ImageArea {
pub width: u16,
pub height: u16,
pub crop: [u16; 4],
pub active: Option<[u16; 4]>,
pub masked_left: [u16; 4],
pub masked_top: Option<[u16; 4]>,
}
impl Cr3ImageArea {
pub fn parse(payload: &[u8]) -> Option<Self> {
let bounds = |at: usize| Some([be16(payload, at)?, be16(payload, at + 2)?, be16(payload, at + 4)?, be16(payload, at + 6)?]);
let width = be16(payload, 4)?;
let height = be16(payload, 6)?;
if width == 0 || height == 0 {
return None;
}
let full = be16(payload, 10)? == 2;
Some(Self {
width,
height,
crop: bounds(16)?,
masked_left: bounds(24)?,
masked_top: if full { Some(bounds(32)?) } else { None },
active: if full { Some(bounds(40)?) } else { None },
})
}
}
impl Cr3Track {
/// Decode this track's image coding descriptor without reading its pixel data.
pub fn compression(&self, bytes: &[u8]) -> Option<Cr3Compression> {
let Cr3TrackKind::Raw { cmp1: Some((at, len)), .. } = self.kind else { return None };
Cr3Compression::parse(bytes.get(at..at.checked_add(len)?)?)
}
pub fn image_area(&self, bytes: &[u8]) -> Option<Cr3ImageArea> {
let Cr3TrackKind::Raw { iad1: Some((at, len)), .. } = self.kind else { return None };
Cr3ImageArea::parse(bytes.get(at..at.checked_add(len)?)?)
}
}
/// The parts of a CR3 file LightCraft reads.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Cr3<'a> {
@@ -40,6 +174,57 @@ pub struct Cr3<'a> {
pub tracks: Vec<Cr3Track>,
}
/// One independent TIFF block from Canon timed metadata. Its offsets remain relative to this block.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Cr3TimedExif<'a> {
/// CTMD record type (7, 8 or 9).
pub record_type: u16,
/// Exif IFD (`0x8769`) or Canon maker note (`0x927c`).
pub tag: u32,
pub data: &'a [u8],
}
impl Cr3<'_> {
/// Read TIFF blocks in the first samples of CTMD tracks. As-shot white balance and sensor
/// levels often live here rather than in the static `CMT3` maker note. Both record layers use
/// little-endian byte lengths; TIFF blocks retain their own byte order and offset origin.
pub fn timed_exif<'a>(&self, bytes: &'a [u8]) -> Vec<Cr3TimedExif<'a>> {
let mut out = Vec::new();
let mut budget = MAX_BOXES;
for track in &self.tracks {
if track.kind != Cr3TrackKind::Other(*b"CTMD") {
continue;
}
let Some(sample) = track.data.and_then(|(at, len)| at.checked_add(len).and_then(|end| bytes.get(at..end))) else { continue };
let mut at = 0usize;
while budget > 0 {
budget -= 1;
let Some(len) = le32(sample, at).and_then(|v| usize::try_from(v).ok()) else { break };
let Some(end) = at.checked_add(len).filter(|&end| len >= 12 && end <= sample.len()) else { break };
let Some(record) = sample.get(at..end) else { break };
let Some(kind) = le16(record, 4) else { break };
if matches!(kind, 7..=9) {
let mut item_at = 12usize;
while budget > 0 {
budget -= 1;
let Some(item_len) = le32(record, item_at).and_then(|v| usize::try_from(v).ok()) else { break };
let Some(item_end) = item_at.checked_add(item_len).filter(|&end| item_len >= 8 && end <= record.len()) else { break };
let Some(item) = record.get(item_at..item_end) else { break };
if let Some(tag @ (0x8769 | 0x927c)) = le32(item, 4)
&& let Some(data) = item.get(8..)
{
out.push(Cr3TimedExif { record_type: kind, tag, data });
}
item_at = item_end;
}
}
at = end;
}
}
out
}
}
const CANON_UUID: [u8; 16] = [0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a, 0x48];
const XMP_UUID: [u8; 16] = [0xbe, 0x7a, 0xcf, 0xcb, 0x97, 0xa9, 0x42, 0xe8, 0x9c, 0x71, 0x99, 0x94, 0x91, 0xe3, 0xaf, 0xac];
const MAX_DEPTH: usize = 12;
@@ -158,19 +343,21 @@ fn trak(bytes: &[u8], t: &BoxRef, budget: &mut usize) -> Option<Cr3Track> {
let stbl = stbl?;
let (mut kind, mut size, mut offset) = (None, None, None);
for b in boxes(bytes, stbl.body, stbl.end, budget) {
let body = bytes.get(b.body..b.end)?;
match &b.kind {
// full box: version/flags, entry count, entries
b"stsd" => kind = boxes(bytes, b.body + 8, b.end, budget).first().map(|e| sample_entry(bytes, e, budget)),
b"stsd" if be32(body, 4)? > 0 => kind = boxes(bytes, b.body + 8, b.end, budget).first().map(|e| sample_entry(bytes, e, budget)),
// version/flags, sample_size (0 = per-sample table), count, sizes
b"stsz" => {
size = match be32(bytes, b.body + 4)? {
0 if be32(bytes, b.body + 8)? > 0 => be32(bytes, b.body + 12),
size = match be32(body, 4)? {
0 if be32(body, 8)? > 0 => be32(body, 12),
0 => None,
n => Some(n),
n if be32(body, 8)? > 0 => Some(n),
_ => None,
}
}
b"co64" if be32(bytes, b.body + 4)? > 0 => offset = be64(bytes, b.body + 8).and_then(|v| usize::try_from(v).ok()),
b"stco" if be32(bytes, b.body + 4)? > 0 => offset = be32(bytes, b.body + 8).map(|v| v as usize),
b"co64" if be32(body, 4)? > 0 => offset = be64(body, 8).and_then(|v| usize::try_from(v).ok()),
b"stco" if be32(body, 4)? > 0 => offset = be32(body, 8).map(|v| v as usize),
_ => {}
}
}
@@ -185,18 +372,37 @@ fn sample_entry(bytes: &[u8], e: &BoxRef, budget: &mut usize) -> Cr3TrackKind {
if &e.kind != b"CRAW" {
return Cr3TrackKind::Other(e.kind);
}
let (Some(width), Some(height)) = (be16(bytes, e.body + 24), be16(bytes, e.body + 26)) else {
let Some(body) = bytes.get(e.body..e.end) else { return Cr3TrackKind::Other(e.kind) };
let (Some(width), Some(height)) = (be16(body, 24), be16(body, 26)) else {
return Cr3TrackKind::Other(e.kind);
};
let mut cmp1 = None;
let (mut cmp1, mut iad1, mut rendered) = (None, None, None);
for c in boxes(bytes, e.body.saturating_add(CRAW_CHILDREN_AT), e.end, budget) {
match &c.kind {
b"JPEG" => return Cr3TrackKind::Jpeg,
b"JPEG" => rendered = Some(Cr3TrackKind::Jpeg),
// HDR-capable cameras also put an HEVC preview in a CRAW entry,
// often with the same displayed dimensions as the main raw track.
b"HEVC" => rendered = Some(Cr3TrackKind::Other(c.kind)),
b"CMP1" => cmp1 = Some((c.body, c.end - c.body)),
b"CDI1" => {
for area in boxes(bytes, c.body.saturating_add(4), c.end, budget) {
if &area.kind == b"IAD1" {
iad1 = Some((area.body, area.end - area.body));
}
}
}
_ => {}
}
}
Cr3TrackKind::Raw { width, height, cmp1 }
// A present, malformed CMP1 must remain a raw descriptor so its corruption
// is reported. Only an explicitly identified rendered codec can establish
// that a CRAW entry without CMP1 is a preview rather than a broken raw.
if cmp1.is_none()
&& let Some(kind) = rendered
{
return kind;
}
Cr3TrackKind::Raw { width, height, cmp1, iad1 }
}
fn be16(b: &[u8], at: usize) -> Option<u16> {
@@ -212,6 +418,16 @@ fn be64(b: &[u8], at: usize) -> Option<u64> {
Some(u64::from_be_bytes(a))
}
fn le16(b: &[u8], at: usize) -> Option<u16> {
let s = b.get(at..at.checked_add(2)?)?;
Some(u16::from_le_bytes([s[0], s[1]]))
}
fn le32(b: &[u8], at: usize) -> Option<u32> {
let s = b.get(at..at.checked_add(4)?)?;
Some(u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
}
/// Merge `CMT1` (IFD0), `CMT2` (Exif) and `CMT4` (GPS) into one TIFF stream, so the Exif readers see a CR3
/// like any TIFF-based raw. The maker note (`CMT3`) is left out: its offsets are relative to its own block.
pub fn merged_exif(c: &Cr3<'_>) -> Option<Vec<u8>> {
@@ -308,12 +524,50 @@ mod tests {
assert_eq!(c.xmp, Some(b"<x:xmpmeta/>".as_slice()));
assert_eq!(c.tracks.len(), 2);
assert_eq!(c.tracks[0], Cr3Track { kind: Cr3TrackKind::Jpeg, data: Some((4096, 16)) });
let Cr3TrackKind::Raw { width, height, cmp1: Some((at, len)) } = c.tracks[1].kind else { panic!("{:?}", c.tracks[1]) };
let Cr3TrackKind::Raw { width, height, cmp1: Some((at, len)), .. } = c.tracks[1].kind else { panic!("{:?}", c.tracks[1]) };
assert_eq!((width, height, len), (60, 40, 4));
assert_eq!(&f[at..at + 2], &[0xff, 0x10]);
assert_eq!(c.tracks[1].data, Some((4112, 32)));
}
#[test]
fn hevc_preview_is_distinct_from_an_equal_size_raw_track() {
let preview = trak(craw(6000, 4000, &bx(b"HEVC", &[0; 4])), 4096, 16);
let reduced = trak(craw(1624, 1080, &bx(b"CMP1", &compression_payload(0))), 4112, 16);
let sensor = trak(craw(6000, 4000, &bx(b"CMP1", &compression_payload(0))), 4128, 16);
let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom");
file.extend(bx(b"moov", &[preview, reduced, sensor].concat()));
file.resize(4144, 0);
let container = parse_cr3(&file).unwrap();
assert_eq!(container.tracks[0].kind, Cr3TrackKind::Other(*b"HEVC"));
assert!(container.tracks[0].compression(&file).is_none());
assert!(matches!(container.tracks[2].kind, Cr3TrackKind::Raw { width: 6000, height: 4000, cmp1: Some(_), .. }));
assert_eq!(container.tracks[2].compression(&file).unwrap().width, 6888);
}
#[test]
fn rendered_child_does_not_hide_a_malformed_raw_descriptor() {
for codec in [b"JPEG", b"HEVC"] {
for first in [true, false] {
let rendered = bx(codec, &[0; 4]);
let corrupt = bx(b"CMP1", &[0xff, 0]);
let children = if first { [rendered, corrupt] } else { [corrupt, rendered] }.concat();
let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom");
file.extend(bx(b"moov", &trak(craw(6000, 4000, &children), 4096, 16)));
file.resize(4112, 0);
let container = parse_cr3(&file).unwrap();
assert!(matches!(container.tracks[0].kind, Cr3TrackKind::Raw { cmp1: Some(_), .. }));
assert!(container.tracks[0].compression(&file).is_none());
}
}
// Absence alone is not evidence of a preview: retain a broken raw so
// main-track selection cannot quietly fall back to a reduced image.
let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom");
file.extend(bx(b"moov", &trak(craw(6000, 4000, &bx(b"free", &[])), 4096, 16)));
file.resize(4112, 0);
assert!(matches!(parse_cr3(&file).unwrap().tracks[0].kind, Cr3TrackKind::Raw { cmp1: None, .. }));
}
#[test]
fn merged_exif_reads_like_a_tiff_raw() {
let f = sample();
@@ -322,6 +576,145 @@ mod tests {
assert_eq!(m.iso, Some(400));
}
fn compression_payload(levels: u8) -> Vec<u8> {
let mut b = vec![0u8; 52];
b[0..2].copy_from_slice(&0xff00u16.to_be_bytes());
b[2..4].copy_from_slice(&48u16.to_be_bytes());
b[4..6].copy_from_slice(&0x100u16.to_be_bytes());
for (at, value) in [(8, 6888u32), (12, 4546), (16, 3444), (20, 4546), (28, 216)] {
b[at..at + 4].copy_from_slice(&value.to_be_bytes());
}
b[24] = 14;
b[25] = 0x40;
b[26] = levels;
b
}
#[test]
fn compression_descriptor_retains_sensor_geometry_and_cfa() {
let b = compression_payload(0);
let h = Cr3Compression::parse(&b).unwrap();
assert_eq!((h.width, h.height, h.tile_width, h.tile_height), (6888, 4546, 3444, 4546));
assert_eq!((h.version, h.bit_depth, h.planes, h.cfa_pattern, h.encoding, h.levels, h.header_size), (0x100, 14, 4, 0, 0, 0, 216));
let mut craw = compression_payload(3);
craw[25] = 0x41;
craw[27] = 0x80;
let h = Cr3Compression::parse(&craw).unwrap();
assert_eq!((h.cfa_pattern, h.levels, h.tile_flags), (1, 3, 0x80));
for len in 0..b.len() {
assert!(Cr3Compression::parse(&b[..len]).is_none());
}
for (at, value) in [(24, 0), (24, 17), (25, 0), (25, 0x44)] {
let mut bad = b.clone();
bad[at] = value;
assert!(Cr3Compression::parse(&bad).is_none());
}
}
#[test]
fn extended_descriptor_checks_optional_median_precision() {
let mut b = compression_payload(0x30);
b[32] = 0x80;
b.extend_from_slice(&33u32.to_be_bytes());
b.resize(85, 0);
b[56] = 0xc0;
b[84] = 14;
assert_eq!(Cr3Compression::parse(&b).unwrap().median_bit_depth, Some(14));
assert!(Cr3Compression::parse(&b[..84]).is_none());
b[84] = 0;
assert!(Cr3Compression::parse(&b).is_none());
}
#[test]
fn raw_track_reads_distinct_active_area_and_default_crop() {
let mut area = vec![0u8; 48];
for (at, value) in [
(4, 6888u16),
(6, 4546),
(10, 2),
(16, 156),
(18, 158),
(20, 6875),
(22, 4537),
(24, 0),
(26, 0),
(28, 143),
(30, 4545),
(32, 144),
(34, 0),
(36, 6887),
(38, 45),
(40, 144),
(42, 46),
(44, 6887),
(46, 4545),
] {
area[at..at + 2].copy_from_slice(&value.to_be_bytes());
}
let cmp1 = bx(b"CMP1", &compression_payload(0));
let iad1 = full(b"CDI1", &bx(b"IAD1", &area));
let track = trak(craw(6888, 4546, &[cmp1, iad1].concat()), 4096, 32);
let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom");
file.extend(bx(b"moov", &track));
file.resize(4128, 0);
let c = parse_cr3(&file).unwrap();
assert_eq!(c.tracks[0].compression(&file).unwrap().width, 6888);
let geometry = c.tracks[0].image_area(&file).unwrap();
assert_eq!(geometry.crop, [156, 158, 6875, 4537]);
assert_eq!(geometry.active, Some([144, 46, 6887, 4545]));
assert_eq!(geometry.masked_left, [0, 0, 143, 4545]);
for len in 0..area.len() {
assert!(Cr3ImageArea::parse(&area[..len]).is_none());
}
}
#[test]
fn truncated_sample_table_does_not_read_the_next_box() {
let stbl = [
full(b"stsd", &[1u32.to_be_bytes().to_vec(), craw(16, 16, &bx(b"CMP1", &compression_payload(0)))].concat()),
bx(b"stsz", &[]),
full(b"co64", &[1u32.to_be_bytes().to_vec(), 256u64.to_be_bytes().to_vec()].concat()),
]
.concat();
let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom");
file.extend(bx(b"moov", &bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &stbl))))));
file.resize(512, 0);
assert!(parse_cr3(&file).unwrap().tracks.is_empty());
}
#[test]
fn timed_metadata_exposes_the_dynamic_maker_note() {
use lightcraft_tiff::{Value, tags as t};
let exif = tiff_with(t::ISO_SPEED, Value::Short(vec![800]));
let maker = tiff_with(0x4001, Value::Short(vec![48, 100, 200]));
let item = |tag: u32, data: &[u8]| {
let mut b = ((data.len() + 8) as u32).to_le_bytes().to_vec();
b.extend_from_slice(&tag.to_le_bytes());
b.extend_from_slice(data);
b
};
let items = [item(0x8769, &exif), item(0x927c, &maker)].concat();
let mut sample = vec![0u8; 12];
sample[0..4].copy_from_slice(&((12 + items.len()) as u32).to_le_bytes());
sample[4..6].copy_from_slice(&8u16.to_le_bytes());
sample.extend(items);
let container = Cr3 { tracks: vec![Cr3Track { kind: Cr3TrackKind::Other(*b"CTMD"), data: Some((0, sample.len())) }], ..Default::default() };
let parts = container.timed_exif(&sample);
assert_eq!(parts.len(), 2);
assert_eq!((parts[0].record_type, parts[0].tag, parts[0].data), (8, 0x8769, exif.as_slice()));
assert_eq!((parts[1].record_type, parts[1].tag, parts[1].data), (8, 0x927c, maker.as_slice()));
for len in 0..sample.len() {
assert!(container.timed_exif(&sample[..len]).is_empty());
}
for at in [0, 12] {
let mut corrupt = sample.clone();
corrupt[at..at + 4].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(container.timed_exif(&corrupt).is_empty());
corrupt[at..at + 4].copy_from_slice(&0u32.to_le_bytes());
assert!(container.timed_exif(&corrupt).is_empty());
}
}
#[test]
fn hostile_input_never_panics() {
let f = sample();
+2 -2
View File
@@ -5,8 +5,8 @@ proptest! {
#![proptest_config(ProptestConfig { cases: 1500, .. ProptestConfig::default() })]
#[test]
fn random_containers_never_panic(mut data in proptest::collection::vec(any::<u8>(), 0..600), kind in 0usize..5) {
let heads: [&[u8]; 5] = [b"\xff\xd8\xff\xe1", b"\x89PNG\r\n\x1a\n", b"RIFF\0\0\0\0WEBP", b"II*\0", b"MM\0*"];
fn random_containers_never_panic(mut data in proptest::collection::vec(any::<u8>(), 0..600), kind in 0usize..6) {
let heads: [&[u8]; 6] = [b"\xff\xd8\xff\xe1", b"\x89PNG\r\n\x1a\n", b"RIFF\0\0\0\0WEBP", b"II*\0", b"MM\0*", b"\0\0\0\x18ftypcrx "];
let h = heads[kind];
if data.len() >= h.len() {
data[..h.len()].copy_from_slice(h);
+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] {
+86 -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).
@@ -365,6 +429,9 @@ enum Src<'a> {
}
fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, cache: Option<&StageCache>) -> Rendered {
// sections switched off with their eye render as if at their defaults (issue #316)
let effective = s.effective();
let s: &DevelopSettings = &effective;
// `Instant::now()` panics on wasm32-unknown-unknown: only read the clock when profiling.
let lap = |what: &str, t: &mut Option<std::time::Instant>| {
if let Some(t) = t {
@@ -398,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)
}
};
@@ -407,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 });
@@ -421,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 }
}
@@ -507,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));
}
}
+29
View File
@@ -237,6 +237,35 @@ fn exposure_after_spatial_filters_equals_exposing_the_source() {
assert!(d <= 2, "max difference {d}");
}
/// A section whose eye is off renders as if it were at its defaults (issue #316): Light, Color
/// and Detail, which the pipeline doesn't switch off where it applies them.
#[test]
fn sections_switched_off_render_as_defaults() {
let src = Rgb32f::from_fn(32, 24, |x, y| [0.05 + x as f32 / 40.0, 0.1 + y as f32 / 30.0, 0.3]);
let info = SourceInfo::default();
let req = RenderRequest::fit(32, 24);
let plain = render(&src, &info, &DevelopSettings::default(), &req).image;
let mut s = DevelopSettings::default();
s.light.exposure = 1.5;
s.light.contrast = 40.0;
s.color.saturation = -60.0;
s.color.vibrance = 30.0;
s.detail.sharpen_amount = 120.0;
let edited = render(&src, &info, &s, &req).image;
assert_ne!(edited.data, plain.data);
for section in ["light", "color", "detail"] {
s.set_section_enabled(section, false);
}
assert_eq!(render(&src, &info, &s, &req).image.data, plain.data, "every edited section off = the unedited photo");
// one section back on brings back only its own adjustments
s.set_section_enabled("light", true);
let mut light_only = DevelopSettings::default();
light_only.light.exposure = 1.5;
light_only.light.contrast = 40.0;
assert_eq!(render(&src, &info, &s, &req).image.data, render(&src, &info, &light_only, &req).image.data);
assert!(matches!(DevelopSettings::default().effective(), std::borrow::Cow::Borrowed(_)), "nothing is copied when every section is on");
}
#[test]
fn calibration_shifts_colours_but_keeps_greys() {
let info = SourceInfo { raw: true, ..Default::default() };
+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);
}
+2
View File
@@ -24,3 +24,5 @@ zune-core = { workspace = true }
[dev-dependencies]
jpeg-encoder = { workspace = true }
proptest = { workspace = true }
# Pinned CC0 corpus identities; not a product dependency.
sha2 = { version = "0.10", default-features = false, features = ["std"] }
+253 -10
View File
@@ -12,11 +12,11 @@
//! look tables and tone curve.
//!
//! Formats: DNG (uncompressed, lossless JPEG, lossy JPEG (Smart Previews), Deflate incl. floating point, tiled/stripped, CFA and LinearRaw),
//! Canon CR2, Nikon NEF/NRW (uncompressed, Huffman lossless / lossy compressed), Sony ARW (uncompressed, ARW2, lossless), Fujifilm RAF (uncompressed Bayer
//! and X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format: compressed 4 and 6, the prefix-coded strips of 8,
//! Canon CR2 / CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), Nikon NEF/NRW (uncompressed, Huffman lossless / lossy compressed), Sony ARW (uncompressed, ARW2, lossless), Fujifilm RAF (uncompressed Bayer
//! and X-Trans, lossless and lossy compressed), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format: compressed 4 and 6, the prefix-coded strips of 8,
//! packed 2/5/7, the 16-bit words of the oldest bodies), Pentax PEF (uncompressed, Huffman), Olympus ORF (uncompressed).
//! [`embedded_preview`] covers all of them plus CR3. Variants we can't decode yet (Nikon "lossy after split" NEF,
//! compressed ORF/RAF, CR3) return [`RawError::Unsupported`]; each vendor module documents its sources
//! [`embedded_preview`] covers these containers' JPEG previews. Variants we can't decode yet (Nikon "lossy after split" NEF,
//! compressed ORF, CR3 unverified marker families / C-RAW configurations) return [`RawError::Unsupported`]; each vendor module documents its sources
//! (public specifications, tag-name documentation, black-box analysis of CC0 samples) and gaps. Non-DNG files carry no
//! colour matrix: [`color`] falls back to a documented neutral model. The decoders never panic on malformed input.
#![forbid(unsafe_code)]
@@ -83,6 +83,12 @@ pub enum RawFormat {
Rw2,
Pef,
Srw,
/// Canon CRW (CIFF heap), the bodies before the CR2 era.
Crw,
/// Minolta MRW (`\0MRM` block container).
Mrw,
/// Sigma / Foveon X3F (`FOVb`).
X3f,
/// Another TIFF-based raw (3FR, IIQ, ERF, KDC, DCR, MOS, …).
OtherTiff,
}
@@ -92,7 +98,15 @@ impl RawFormat {
pub fn is_supported(self) -> bool {
matches!(
self,
RawFormat::Dng | RawFormat::Cr2 | RawFormat::Nef | RawFormat::Nrw | RawFormat::Arw | RawFormat::Raf | RawFormat::Rw2 | RawFormat::Pef
RawFormat::Dng
| RawFormat::Cr2
| RawFormat::Cr3
| RawFormat::Nef
| RawFormat::Nrw
| RawFormat::Arw
| RawFormat::Raf
| RawFormat::Rw2
| RawFormat::Pef
)
}
}
@@ -111,6 +125,15 @@ pub fn probe(bytes: &[u8]) -> Option<RawFormat> {
if bytes.starts_with(b"IIU\0") {
return Some(RawFormat::Rw2);
}
if bytes.starts_with(b"\0MRM") {
return Some(RawFormat::Mrw);
}
if bytes.starts_with(b"FOVb") {
return Some(RawFormat::X3f);
}
if bytes.len() >= 14 && bytes.starts_with(b"II") && &bytes[6..14] == b"HEAPCCDR" {
return Some(RawFormat::Crw);
}
if bytes.len() >= 10 && bytes.starts_with(b"II*\0") && &bytes[8..10] == b"CR" {
return Some(RawFormat::Cr2);
}
@@ -122,10 +145,7 @@ pub fn probe(bytes: &[u8]) -> Option<RawFormat> {
return Some(RawFormat::Dng);
}
let make = t.find(lightcraft_tiff::tags::MAKE).and_then(|e| e.value.as_str()).unwrap_or_default().to_ascii_uppercase();
let has_cfa = t.all_ifds().iter().any(|i| {
i.u16(lightcraft_tiff::tags::PHOTOMETRIC) == Some(lightcraft_tiff::tags::photometric::CFA)
|| i.u16(lightcraft_tiff::tags::COMPRESSION).is_some_and(|c| c == 34713 || c == 32767 || c == 32769 || c == 32770)
});
let has_cfa = has_raw_ifd(&t);
if make.starts_with("CANON") && t.ifds.len() >= 4 && t.ifds[3].u16(lightcraft_tiff::tags::COMPRESSION) == Some(6) {
return Some(RawFormat::Cr2);
}
@@ -141,12 +161,103 @@ pub fn probe(bytes: &[u8]) -> Option<RawFormat> {
if make.starts_with("SAMSUNG") {
return Some(RawFormat::Srw);
}
if has_cfa {
if has_cfa || thumbnail_shell(&t, bytes.len()).is_some() || is_preview_container(ifd0) {
return Some(RawFormat::OtherTiff);
}
None
}
/// Whether some IFD is marked as raw: CFA photometric, or a raw-only compression value (99 is not a
/// registered TIFF compression; Leaf MOS files use it for their tiled 16-bit lossless-JPEG raw).
fn has_raw_ifd(t: &Tiff) -> bool {
t.all_ifds().iter().any(|i| {
i.u16(lightcraft_tiff::tags::PHOTOMETRIC) == Some(lightcraft_tiff::tags::photometric::CFA)
|| i.u16(lightcraft_tiff::tags::COMPRESSION).is_some_and(|c| c == 34713 || c == 32767 || c == 32769 || c == 32770 || c == 99)
})
}
/// IFD0 that is not an image at all: no `ImageWidth` / `ImageLength`, but a JPEG preview pointer or
/// `SubIFDs` (the layout of Kodak KDC files, whose pictures sit in private tags). A plain TIFF reader
/// rejects such a file as malformed even though it carries a preview.
fn is_preview_container(ifd0: &lightcraft_tiff::Ifd) -> bool {
use lightcraft_tiff::tags::{IMAGE_LENGTH, IMAGE_WIDTH, JPEG_INTERCHANGE_FORMAT, SUB_IFDS};
!ifd0.contains(IMAGE_WIDTH) && !ifd0.contains(IMAGE_LENGTH) && (ifd0.contains(JPEG_INTERCHANGE_FORMAT) || ifd0.contains(SUB_IFDS))
}
/// A TIFF whose first image is only a reduced copy of a picture stored somewhere else.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ThumbnailShell {
/// Size of the first image (IFD0).
pub thumb: (u32, u32),
/// Size of the picture it stands in for.
pub full: (u32, u32),
}
/// Some raw files are a TIFF "shell" around a private block: IFD0 holds a small RGB preview and nothing
/// in the TIFF structure marks the real image as raw (no CFA photometric, no raw compression). The
/// structure still gives them away, with a generic rule that names no maker or model: the file
/// declares a picture at least 4x larger than IFD0 in both directions, and either
/// - a `SubIFDs` child of IFD0 is that bigger image (an 8-bit grey mosaic in one such file), or
/// - the Exif `PixelXDimension` / `PixelYDimension` say so *and* the file holds enough bytes outside
/// every IFD's strips and tiles to carry that many pixels at one bit each (a downsized copy that kept
/// its old Exif size has no such block).
///
/// A multi-page TIFF is not caught (its pages are the IFD chain, not `SubIFDs`), nor a pyramid whose
/// IFD0 is the full image.
fn thumbnail_shell(t: &Tiff, len: usize) -> Option<ThumbnailShell> {
use lightcraft_tiff::tags::{IMAGE_LENGTH, IMAGE_WIDTH, PIXEL_X_DIMENSION, PIXEL_Y_DIMENSION};
let ifd0 = t.ifds.first()?;
let (w0, h0) = (ifd0.u32(IMAGE_WIDTH)?, ifd0.u32(IMAGE_LENGTH)?);
if w0 == 0 || h0 == 0 {
return None;
}
let much_bigger = |(w, h): (u32, u32)| u64::from(w) >= 4 * u64::from(w0) && u64::from(h) >= 4 * u64::from(h0);
let shell = |full| Some(ThumbnailShell { thumb: (w0, h0), full });
let mut best: Option<(u32, u32)> = None;
for s in &ifd0.sub_ifds {
if s.image().is_ok()
&& let (Some(w), Some(h)) = (s.u32(IMAGE_WIDTH), s.u32(IMAGE_LENGTH))
&& much_bigger((w, h))
&& best.is_none_or(|(bw, bh)| u64::from(w) * u64::from(h) > u64::from(bw) * u64::from(bh))
{
best = Some((w, h));
}
}
if let Some(full) = best {
return shell(full);
}
let exif = t.exif()?;
let full = (exif.u32(PIXEL_X_DIMENSION)?, exif.u32(PIXEL_Y_DIMENSION)?);
if !much_bigger(full) {
return None;
}
// bytes covered by image data (strips and tiles shared between IFDs, e.g. IFD0 and its thumbnail, count once)
let mut seen = std::collections::BTreeMap::new();
for ifd in t.all_ifds() {
if let Ok(info) = ifd.image() {
for c in info.chunks(len as u64) {
seen.insert(c.offset, c.len);
}
}
}
let covered = seen.values().fold(0u64, |a, &l| a.saturating_add(l));
let spare = (len as u64).saturating_sub(covered);
(spare.saturating_mul(8) >= u64::from(full.0) * u64::from(full.1)).then_some(())?;
shell(full)
}
/// Why [`decode`] gives up on a file [`probe`] called [`RawFormat::OtherTiff`].
fn other_tiff_reason(bytes: &[u8]) -> String {
let t = Tiff::parse_with(bytes, &lightcraft_tiff::ParseOptions { max_ifds: 256, ..Default::default() }).ok();
match t.as_ref().filter(|t| !has_raw_ifd(t)).and_then(|t| thumbnail_shell(t, bytes.len())) {
Some(s) => format!(
"{}x{} raw image in a private block, not decoded yet (the file's first image is a {}x{} reduced copy)",
s.full.0, s.full.1, s.thumb.0, s.thumb.1
),
None => "OtherTiff files are not decoded yet".to_string(),
}
}
/// Decode a raw file.
pub fn decode(bytes: &[u8]) -> Result<RawImage> {
decode_with(bytes, Mode::Full)
@@ -180,12 +291,14 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result<RawImage> {
match probe(bytes).ok_or(RawError::NotRaw)? {
RawFormat::Dng => dng::decode(bytes, mode),
RawFormat::Cr2 => vendor::cr2::decode(bytes, mode),
RawFormat::Cr3 => vendor::cr3::decode(bytes, mode),
RawFormat::Nef | RawFormat::Nrw => vendor::nef::decode(bytes),
RawFormat::Arw => vendor::arw::decode(bytes, mode),
RawFormat::Raf => vendor::raf::decode(bytes, mode),
RawFormat::Rw2 => vendor::rw2::decode(bytes, mode),
RawFormat::Pef => vendor::pef::decode(bytes, mode),
RawFormat::Orf => vendor::orf::decode(bytes, mode),
RawFormat::OtherTiff => Err(RawError::Unsupported(other_tiff_reason(bytes))),
other => Err(RawError::Unsupported(format!("{other:?} files are not decoded yet"))),
}
}
@@ -614,6 +727,136 @@ mod tests {
assert_eq!(BlackLevel::uniform(7.0).at(9, 9, 0, 3), 7.0);
}
// --- TIFF shells around a private raw block (thumbnail-only IFD0) ---
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
/// An uncompressed 8-bit RGB image IFD.
fn rgb_ifd(w: u32, h: u32) -> IfdBuilder {
let mut ifd = IfdBuilder::new();
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8]));
ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3]));
ifd.set(t::PHOTOMETRIC, Value::Short(vec![2]));
ifd.set(t::COMPRESSION, Value::Short(vec![1]));
ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![vec![90u8; (w * h * 3) as usize]] });
ifd
}
fn exif_size(w: u32, h: u32) -> IfdBuilder {
IfdBuilder::new().with(t::PIXEL_X_DIMENSION, Value::Long(vec![w])).with(t::PIXEL_Y_DIMENSION, Value::Long(vec![h]))
}
fn write(chain: &[IfdBuilder]) -> Vec<u8> {
TiffWriter::default().write(chain).unwrap()
}
const SHELL_REASON: &str = "raw image in a private block";
/// A bigger image in a `SubIFDs` child of a small IFD0 (the layout of an 8-bit-mosaic TIFF raw).
#[test]
fn thumbnail_with_a_bigger_sub_ifd_is_an_undecodable_raw() {
let mut ifd0 = rgb_ifd(16, 12);
ifd0.add_sub_ifd(rgb_ifd(64, 48));
let bytes = write(&[ifd0]);
assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff));
let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") };
assert!(why.contains("64x48") && why.contains("16x12") && why.contains(SHELL_REASON), "{why}");
// a sub-IFD that is not much bigger (a pyramid level) does not count
let mut ifd0 = rgb_ifd(16, 12);
ifd0.add_sub_ifd(rgb_ifd(32, 24));
assert_eq!(probe(&write(&[ifd0])), None);
}
/// Exif says the picture is far bigger than IFD0 and the file holds a block that could carry it.
#[test]
fn thumbnail_with_a_private_block_behind_the_exif_size_is_an_undecodable_raw() {
let mut ifd0 = rgb_ifd(16, 12);
ifd0.set_child(t::EXIF_IFD, exif_size(400, 300));
let mut bytes = write(&[ifd0.clone()]);
// no private block: a downsized copy that kept its old Exif size is an ordinary image
assert_eq!(probe(&bytes), None);
// 400 x 300 pixels at one bit each is 15000 bytes: 14000 is not enough, 16000 is
bytes.extend(std::iter::repeat_n(7u8, 14_000));
assert_eq!(probe(&bytes), None);
bytes.extend(std::iter::repeat_n(7u8, 2_000));
assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff));
let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") };
assert!(why.contains("400x300") && why.contains("16x12") && why.contains(SHELL_REASON), "{why}");
// truncating it never panics
for n in (0..bytes.len()).step_by(37) {
let _ = probe(&bytes[..n]);
let _ = decode(&bytes[..n]);
}
}
/// Ordinary TIFFs stay images: the full picture first, equal Exif size, or later pages that are bigger.
#[test]
fn ordinary_tiffs_are_not_shells() {
let padded = |chain: &[IfdBuilder]| {
let mut b = write(chain);
b.extend(std::iter::repeat_n(0u8, 100_000));
b
};
let mut full = rgb_ifd(400, 300);
full.set_child(t::EXIF_IFD, exif_size(400, 300));
assert_eq!(probe(&padded(&[full])), None);
// the Exif size is much bigger than IFD0, with spare bytes, but IFD0 is not tiny next to it
let mut near = rgb_ifd(120, 90);
near.set_child(t::EXIF_IFD, exif_size(400, 300));
assert_eq!(probe(&padded(&[near])), None);
// a multi-page TIFF whose first page is small
assert_eq!(probe(&padded(&[rgb_ifd(16, 12), rgb_ifd(400, 300)])), None);
// no dimensions at all
assert_eq!(probe(&padded(&[IfdBuilder::new().with(t::MAKE, Value::Ascii("X".into()))])), None);
}
// --- containers that are recognised but not decoded, with a preview ---
#[test]
fn private_containers_are_recognised_by_their_magic() {
assert_eq!(probe(b"II\x1a\0\0\0HEAPCCDR\0\0"), Some(RawFormat::Crw));
assert_eq!(probe(b"\0MRM\0\x01\0\0"), Some(RawFormat::Mrw));
assert_eq!(probe(b"FOVb\x02\0\x02\0"), Some(RawFormat::X3f));
for f in [RawFormat::Crw, RawFormat::Mrw, RawFormat::X3f] {
assert!(!f.is_supported());
}
assert!(matches!(decode(b"\0MRM\0\x01\0\0"), Err(RawError::Unsupported(w)) if w.contains("Mrw")));
// too short for the CIFF signature
assert_eq!(probe(b"II\x1a\0\0\0HEAP"), None);
}
/// An IFD0 with a JPEG pointer but no `ImageWidth` / `ImageLength` (the Kodak KDC layout) is a raw
/// container with a preview; a TIFF reader would call it malformed.
#[test]
fn ifd0_without_dimensions_but_with_a_jpeg_is_a_preview_container() {
let jpeg: Vec<u8> = [&[0xffu8, 0xd8, 0xff, 0xc0, 0, 0x0b, 8, 0, 1, 0, 1, 1, 1, 0x11, 0][..], &[0x55; 40], &[0xff, 0xd9]].concat();
let off = 8 + 2 + 24 + 4;
let mut f = b"II*\0\x08\0\0\0\x02\0".to_vec();
for (tag, v) in [(513u16, off as u32), (514, jpeg.len() as u32)] {
f.extend_from_slice(&tag.to_le_bytes());
f.extend_from_slice(&[4, 0, 1, 0, 0, 0]);
f.extend_from_slice(&v.to_le_bytes());
}
f.extend_from_slice(&[0, 0, 0, 0]);
f.extend_from_slice(&jpeg);
assert_eq!(probe(&f), Some(RawFormat::OtherTiff));
assert!(matches!(decode(&f), Err(RawError::Unsupported(_))));
assert_eq!(embedded_preview(&f), Some(jpeg));
// no pointer and no sub-IFDs: just a broken TIFF
let g = write(&[IfdBuilder::new().with(t::MAKE, Value::Ascii("KODAK".into()))]);
assert_eq!(probe(&g), None);
}
/// Compression 99 (not a registered TIFF value; Leaf MOS tiles) marks a raw even without a CFA tag.
#[test]
fn private_compression_99_is_a_raw() {
let mut ifd = rgb_ifd(16, 12);
ifd.set(t::COMPRESSION, Value::Short(vec![99]));
assert_eq!(probe(&write(&[ifd])), Some(RawFormat::OtherTiff));
}
#[test]
fn probe_rejects_junk() {
assert_eq!(probe(b""), None);
+173 -2
View File
@@ -91,9 +91,13 @@ pub fn embedded_preview(bytes: &[u8]) -> Option<Vec<u8>> {
let j = crate::vendor::raf::header(bytes).ok()?.jpeg?;
return is_dct_jpeg(j).then(|| trim_eoi(j).to_vec());
}
if crate::probe(bytes) == Some(crate::RawFormat::Cr3) {
let format = crate::probe(bytes);
if format == Some(crate::RawFormat::Cr3) {
return cr3_preview(bytes).map(|j| trim_eoi(j).to_vec());
}
if matches!(format, Some(crate::RawFormat::Crw | crate::RawFormat::Mrw | crate::RawFormat::X3f)) {
return scan_for_jpeg(bytes);
}
let tiff = Tiff::parse(bytes).ok()?;
let mut found: Vec<&[u8]> = Vec::new();
for ifd in tiff.all_ifds() {
@@ -117,7 +121,112 @@ pub fn embedded_preview(bytes: &[u8]) -> Option<Vec<u8>> {
if let Some(p) = crate::vendor::orf::preview(bytes) {
found.push(p);
}
found.into_iter().filter(|s| is_dct_jpeg(s)).max_by_key(|s| s.len()).map(|s| trim_eoi(s).to_vec())
let best = found.into_iter().filter(|s| is_dct_jpeg(s)).max_by_key(|s| s.len()).map(|s| trim_eoi(s).to_vec());
// a TIFF raw we can't decode whose preview no tag points to (Leaf MOS, Epson ERF)
best.or_else(|| format.filter(|f| !f.is_supported()).and_then(|_| scan_for_jpeg(bytes)))
}
/// The end of the DCT JPEG whose SOI is at `soi` (exclusive index after its EOI) and its component count,
/// walking the marker segments and the entropy-coded data. `None` when it is not a complete, displayable
/// (baseline or progressive) JPEG, so a stray `D8 FF` in other data is rejected. `*reach` is left at
/// the furthest byte looked at (what the walk cost; see [`scan_for_jpeg`]).
fn jpeg_extent(b: &[u8], soi: usize, reach: &mut usize) -> Option<(usize, u8)> {
let mut i = soi + 2;
let r = walk_jpeg(b, &mut i);
*reach = i;
r
}
fn walk_jpeg(b: &[u8], i: &mut usize) -> Option<(usize, u8)> {
let mut components = None;
for _ in 0..4096 {
if *b.get(*i)? != 0xff {
return None;
}
while *b.get(*i)? == 0xff {
*i += 1;
}
let m = *b.get(*i)?;
*i += 1;
match m {
0xd9 => return components.map(|c| (*i, c)),
0x01 | 0xd0..=0xd8 => continue,
_ => {}
}
let len = usize::from(u16::from_be_bytes([*b.get(*i)?, *b.get(*i + 1)?]));
if len < 2 {
return None;
}
match m {
0xc0..=0xc2 => {
// precision, height, width, component count
let h = u16::from_be_bytes([*b.get(*i + 3)?, *b.get(*i + 4)?]);
let w = u16::from_be_bytes([*b.get(*i + 5)?, *b.get(*i + 6)?]);
if h == 0 || w == 0 || components.is_some() {
return None;
}
components = Some(*b.get(*i + 7)?);
}
0xc3 | 0xc5..=0xc7 | 0xc9..=0xcb | 0xcd..=0xcf => return None, // lossless, arithmetic, hierarchical
_ => {}
}
*i += len;
if m == 0xda {
// entropy-coded data up to the next marker that is not a stuffed 0xff or a restart
loop {
*i += b.get(*i..)?.iter().position(|&x| x == 0xff)?;
match *b.get(*i + 1)? {
0x00 | 0xd0..=0xd7 => *i += 2,
0xff => *i += 1,
_ => break,
}
}
}
}
None
}
/// Last resort for the containers whose structure is not walked (Canon CRW, Minolta MRW, Sigma X3F, TIFF raws
/// with private blocks such as Leaf MOS or Epson ERF): the largest complete colour DCT JPEG stored as a plain
/// byte run in the file. A one-component JPEG is skipped (a Canon PowerShot CRW stores its sensor mosaic as one).
///
/// Evidence: in the Minolta and Epson files the first byte of the stored preview is not `ff` (it is `00`, `02` or
/// `ee`) while the rest is a regular JPEG, so a run is found by its `d8 ff` and starts one byte earlier,
/// with that byte restored to `ff`.
///
/// A crafted file can hold millions of JPEG-like starts whose walks each run to its end: the walks
/// share a budget of eight times the file's size, so the scan stays linear (a real file's candidates
/// fail within a few bytes, or are the preview itself).
fn scan_for_jpeg(data: &[u8]) -> Option<Vec<u8>> {
let mut best: Option<(usize, usize)> = None;
let mut at = 1;
let mut budget = data.len().saturating_mul(8).max(1 << 20);
while let Some(off) = data.get(at..).and_then(|s| s.iter().position(|&b| b == 0xd8)) {
let d8 = at + off;
at = d8 + 1;
if data.get(d8 + 1) != Some(&0xff) || !matches!(data.get(d8 + 2), Some(0xc0..=0xc4 | 0xdb | 0xe0..=0xef | 0xfe)) {
continue;
}
let start = d8 - 1; // `at` starts at 1, so d8 >= 1
let mut reach = start;
let found = jpeg_extent(data, start, &mut reach);
budget = budget.saturating_sub(reach.saturating_sub(start));
if budget == 0 {
break;
}
if let Some((end, 3)) = found {
if best.is_none_or(|(s, e)| end - start > e - s) {
best = Some((start, end));
}
at = end;
}
}
let (start, end) = best?;
let mut jpeg = data.get(start..end)?.to_vec();
if let Some(first) = jpeg.first_mut() {
*first = 0xff;
}
Some(jpeg)
}
/// Canon CR3: the full-size JPEG track (see [`lightcraft_meta::cr3`]), else the `PRVW` / `THMB` boxes.
@@ -242,4 +351,66 @@ mod tests {
file.truncate(at as usize + 100);
assert_eq!(embedded_preview(&file).unwrap(), small);
}
/// A structurally complete DCT JPEG (no real image data): `nc` components, `n` entropy bytes.
fn scan_jpeg(nc: u8, n: usize) -> Vec<u8> {
let mut j = vec![0xff, 0xd8, 0xff, 0xdb, 0x00, 0x43, 0x00];
j.extend(std::iter::repeat_n(8u8, 64));
j.extend_from_slice(&[0xff, 0xc0, 0x00, 8 + 3 * nc, 8, 0, 16, 0, 24, nc]);
for c in 0..nc {
j.extend_from_slice(&[c + 1, 0x11, 0]);
}
j.extend_from_slice(&[0xff, 0xda, 0x00, 6 + 2 * nc, nc]);
for c in 0..nc {
j.extend_from_slice(&[c + 1, 0]);
}
j.extend_from_slice(&[0, 63, 0]);
j.extend(std::iter::repeat_n(0x55u8, n));
j.extend_from_slice(&[0xff, 0x00, 0x12, 0xff, 0xd0, 0x34, 0xff, 0xd9]);
j
}
/// A crafted container full of JPEG-like starts (each walk running on through the ones after it)
/// is scanned in linear time: the walks share a budget (without it this 40 MB file takes about a minute).
#[test]
fn a_scan_through_many_fake_jpegs_stays_linear() {
let mut unit = vec![0u8, 0xd8, 0xff, 0xdb, 0x00, 0x02, 0xff, 0xda, 0x00, 0x02];
unit.extend(std::iter::repeat_n(0x55u8, 190));
let mut f = b"\0MRM\0\x01\0\0".to_vec();
for _ in 0..200_000 {
f.extend_from_slice(&unit);
}
let t = std::time::Instant::now();
assert_eq!(scan_for_jpeg(&f), None);
assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed());
}
/// Containers whose structure we do not walk give up their largest colour JPEG, found by scanning.
#[test]
fn scanned_containers_give_the_largest_colour_jpeg() {
let (small, big, mosaic) = (scan_jpeg(3, 100), scan_jpeg(3, 900), scan_jpeg(1, 5000));
let heads: [&[u8]; 3] = [b"II\x1a\0\0\0HEAPCCDR", b"\0MRM\0\x01\0\0", b"FOVb\x02\0\x02\0"];
for head in heads {
let mut f = head.to_vec();
for part in [&small, &mosaic, &big] {
f.extend_from_slice(&[0xff, 0xd8, 0xff, 0x00, 0x00]); // a stray SOI-like run: not a JPEG
f.extend_from_slice(part);
f.extend_from_slice(&[0u8; 7]);
}
assert!(crate::probe(&f).is_some_and(|p| !p.is_supported()), "{head:?}");
assert_eq!(embedded_preview(&f).as_deref(), Some(&big[..]), "{head:?}");
for n in (0..f.len()).step_by(11) {
let _ = embedded_preview(&f[..n]);
}
}
// a preview whose first byte was overwritten (Minolta and Epson files store 00, 02 or ee there) is restored
let mut f = b"\0MRM\0\x01\0\0".to_vec();
f.push(0x02);
f.extend_from_slice(&big[1..]);
assert_eq!(embedded_preview(&f).as_deref(), Some(&big[..]));
// only a one-component JPEG, or none: nothing
let mut f = b"FOVb\x02\0\x02\0".to_vec();
f.extend_from_slice(&mosaic);
assert_eq!(embedded_preview(&f), None);
}
}
+81 -3
View File
@@ -15,14 +15,15 @@
//! - `SensorInfo` (maker note `0x00e0`): sensor width/height and the left/top/right/bottom borders of the image
//! area; the masked columns left of it give the black level.
//! - `ColorBalance` (maker note `0x4001`): as-shot `RGGB` levels at a model-dependent offset; we probe the known
//! offsets and accept the first plausible quadruple.
//! offsets and accept the first plausible quadruple. The array is 16-bit words; some models store it as UNDEFINED
//! bytes (ColorData versions -3 and -4), which are paired up in the maker note's byte order first.
//! - sRAW / mRAW (YCbCr, subsampled) are not supported yet.
use super::{black_from_columns, white_from_data};
use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg};
use lightcraft_geom::Orientation;
use lightcraft_tiff::image::chunk_bytes;
use lightcraft_tiff::{Ifd, Tiff, makernote, tags as t};
use lightcraft_tiff::{ByteOrder, Ifd, Tiff, Value, makernote, tags as t};
const CR2_SLICE: u16 = 0xc640;
const SRAW_TYPE: u16 = 0xc6c5;
@@ -46,6 +47,17 @@ fn cfa_from_tag(v: Option<u64>) -> Option<Cfa> {
Some(Cfa::bayer_static(name))
}
/// The 16-bit words of a maker-note array. Canon writes `ColorData` as SHORT in most models, but as UNDEFINED
/// bytes (count = byte length, e.g. 5120) in the PowerShot / EOS M models with ColorData versions -3 and -4;
/// those bytes are the same 16-bit words in the maker note's byte order, so they are paired up, not read one by
/// one. Other types are returned value by value.
fn words(v: &Value, order: ByteOrder) -> Vec<u64> {
match v {
Value::Byte(b) | Value::Undefined(b) => b.as_chunks::<2>().0.iter().map(|c| u64::from(order.u16(*c))).collect(),
_ => v.to_u64_vec(),
}
}
/// As-shot WB multipliers (R, G, B; G = 1) from the ColorBalance array.
fn wb_from_color_balance(v: &[u64]) -> Option<[f32; 3]> {
for off in [63usize, 25, 24, 34, 71, 85, 105, 69, 77] {
@@ -134,7 +146,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
active = Rect::new(l, tp, r - l + 1, b - tp + 1);
}
}
let wb = mn.as_ref().and_then(|m| m.ifd.u64s(COLOR_BALANCE)).and_then(|v| wb_from_color_balance(&v));
let wb = mn.as_ref().and_then(|m| m.ifd.value(COLOR_BALANCE).map(|v| words(v, m.order))).and_then(|v| wb_from_color_balance(&v));
let cfa = cfa_from_tag(raw.u64(CR2_CFA_PATTERN)).unwrap_or_else(|| Cfa::bayer_static("RGGB"));
let black = if active.x >= 8 {
black_from_columns(&data, width, 2..active.x - 2, active.y..active.y + active.height, active)
@@ -175,6 +187,11 @@ pub(crate) mod tests {
/// Build a synthetic CR2: IFD0..IFD2 placeholders + IFD3 holding a sliced 2-component LJ92 frame.
pub(crate) fn synthetic_cr2(w: usize, h: usize, slices: Option<[u16; 3]>) -> (Vec<u8>, Vec<u16>) {
synthetic_cr2_note(w, h, slices, None)
}
/// Like [`synthetic_cr2`], with `note` as the Exif maker note (bytes as stored).
fn synthetic_cr2_note(w: usize, h: usize, slices: Option<[u16; 3]>, note: Option<Vec<u8>>) -> (Vec<u8>, Vec<u16>) {
let img: Vec<u16> = (0..w * h).map(|i| 1024 + ((i * 7919) % 12000) as u16).collect();
let widths: Vec<usize> = match slices {
Some([n, sw, last]) => {
@@ -200,6 +217,9 @@ pub(crate) mod tests {
// maker note: SensorInfo + ColorBalance, plain IFD with offsets relative to the TIFF header
let mut exif = IfdBuilder::new();
exif.set(t::ISO_SPEED, Value::Short(vec![400]));
if let Some(n) = note {
exif.set(t::MAKER_NOTE, Value::Undefined(n));
}
ifd0.set_child(t::EXIF_IFD, exif);
let mut ifd3 = IfdBuilder::new();
ifd3.set(t::COMPRESSION, Value::Short(vec![6]));
@@ -251,4 +271,62 @@ pub(crate) mod tests {
assert!((wb[0] - 2000.0 / 1025.0).abs() < 1e-4 && (wb[2] - 1500.0 / 1025.0).abs() < 1e-4);
assert!(wb_from_color_balance(&[0; 10]).is_none());
}
#[test]
fn wb_from_undefined_bytes() {
// hand-coded: ColorData of an S110-like file, UNDEFINED bytes, little-endian words; word 71.. = 1988 745 745 1641
let mut b = vec![0u8; 2 * 120];
b[0..2].copy_from_slice(&[0xfd, 0xff]); // version -3
b[142..150].copy_from_slice(&[0xc4, 0x07, 0xe9, 0x02, 0xe9, 0x02, 0x69, 0x06]);
let le = words(&Value::Undefined(b.clone()), ByteOrder::Little);
assert_eq!(&le[71..75], &[1988, 745, 745, 1641]);
let wb = wb_from_color_balance(&le).unwrap();
assert!((wb[0] - 1988.0 / 745.0).abs() < 1e-5 && wb[1] == 1.0 && (wb[2] - 1641.0 / 745.0).abs() < 1e-5);
// read as single bytes (the old behaviour) nothing is plausible
assert!(wb_from_color_balance(&b.iter().map(|&x| u64::from(x)).collect::<Vec<_>>()).is_none());
// big-endian note: same words, bytes swapped
let mut be = b.clone();
be.as_chunks_mut::<2>().0.iter_mut().for_each(|c| c.swap(0, 1));
assert_eq!(words(&Value::Undefined(be), ByteOrder::Big), le);
// SHORT values pass through, an odd trailing byte is ignored
assert_eq!(words(&Value::Short(vec![1, 2, 3]), ByteOrder::Little), vec![1, 2, 3]);
assert_eq!(words(&Value::Undefined(vec![1, 0, 2]), ByteOrder::Little), vec![1]);
}
/// A maker note holding only `ColorData` (`0x4001`) as `ty` (3 = SHORT, 7 = UNDEFINED) with `n` values, a plain
/// IFD whose value offset is relative to the TIFF header (patched once the note's position is known).
fn note_with_color_data(ty: u16, data: &[u8]) -> Vec<u8> {
let count = if ty == 3 { data.len() / 2 } else { data.len() } as u32;
let mut note = Vec::new();
note.extend_from_slice(&1u16.to_le_bytes());
note.extend_from_slice(&COLOR_BALANCE.to_le_bytes());
note.extend_from_slice(&ty.to_le_bytes());
note.extend_from_slice(&count.to_le_bytes());
note.extend_from_slice(&0u32.to_le_bytes()); // value offset, patched below
note.extend_from_slice(&0u32.to_le_bytes()); // next IFD
note.extend_from_slice(data);
note
}
fn decode_with_color_data(ty: u16, data: &[u8]) -> RawImage {
let (mut bytes, _) = synthetic_cr2_note(64, 10, None, Some(note_with_color_data(ty, data)));
let tiff = Tiff::parse(&bytes).unwrap();
let e = tiff.exif().unwrap().get(t::MAKER_NOTE).unwrap();
let off = e.offset as usize;
bytes[off + 10..off + 14].copy_from_slice(&(e.offset as u32 + 18).to_le_bytes());
crate::decode(&bytes).unwrap()
}
/// `ColorData` stored as UNDEFINED bytes (versions -3 / -4: PowerShot, EOS M) gives the as-shot WB from word 71,
/// exactly as the same words stored as SHORT do.
#[test]
fn color_data_stored_as_bytes() {
let mut bytes = vec![0u8; 2 * 120];
bytes[0..2].copy_from_slice(&[0xfd, 0xff]); // version -3
bytes[142..150].copy_from_slice(&[0xc4, 0x07, 0xe9, 0x02, 0xe9, 0x02, 0x69, 0x06]); // 1988 745 745 1641
for ty in [7, 3] {
let wb = decode_with_color_data(ty, &bytes).wb_multipliers.unwrap_or_else(|| panic!("type {ty}: no WB"));
assert!((wb[0] - 1988.0 / 745.0).abs() < 1e-5 && wb[1] == 1.0 && (wb[2] - 1641.0 / 745.0).abs() < 1e-5, "type {ty}: {wb:?}");
}
}
}
+470
View File
@@ -0,0 +1,470 @@
//! Canon CR3 container, camera metadata and sensor geometry.
//!
//! Sources: Laurent Clévy's public CR3 format description (`CMP1`, `CDI1/IAD1`, `CMT3`),
//! <https://github.com/lclevy/canon_cr3/blob/master/readme.md>, and ExifTool's published Canon
//! tag-name tables (`SensorInfo`, `ColorData9`…`ColorData12`, `LensModel`),
//! <https://exiftool.org/TagNames/Canon.html>. No third-party decoder source was consulted.
//!
//! `IAD1` stores the valid sensor area separately from the recommended image crop. Both use inclusive
//! sensor offsets. The CFA is anchored at the full sensor's origin; cropping shifts its phase later.
//! Unknown camera metadata versions are ignored rather than assigned a guessed white balance.
use super::{black_from_columns, crx, white_from_data};
use crate::{BlackLevel, Cfa, ColorData, MAX_SAMPLES, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result};
use lightcraft_meta::cr3::{Cr3ImageArea, Cr3Track, Cr3TrackKind, parse_cr3};
use lightcraft_tiff::{Ifd, Tiff};
const SENSOR_INFO: u16 = 0x00e0;
const COLOR_DATA: u16 = 0x4001;
const LENS_MODEL: u16 = 0x0095;
/// Pick the first largest raw track. Equal-sized later tracks can be the Dual Pixel delta, which
/// must never replace the main sensor image. An invalid full-size descriptor is reported, not
/// silently replaced with the reduced raw preview.
fn main_track(tracks: &[Cr3Track]) -> Option<&Cr3Track> {
let mut best = None;
let mut best_area = 0u64;
for track in tracks {
if let Cr3TrackKind::Raw { width, height, .. } = track.kind {
let area = u64::from(width) * u64::from(height);
if area > best_area {
best = Some(track);
best_area = area;
}
}
}
best
}
fn cfa(pattern: u8) -> Option<Cfa> {
let name = match pattern {
0 => "RGGB",
1 => "GRBG",
2 => "GBRG",
3 => "BGGR",
_ => return None,
};
Some(Cfa::bayer_static(name))
}
/// Inclusive offsets, clipped to the sample dimensions (some Canon crop-mode files record right
/// and bottom borders a few pixels beyond the encoded image).
fn inclusive_rect(bounds: [u16; 4], width: usize, height: usize) -> Option<Rect> {
let [left, top, right, bottom] = bounds.map(usize::from);
if left >= width || top >= height || right < left || bottom < top {
return None;
}
let end_x = right.checked_add(1)?.min(width);
let end_y = bottom.checked_add(1)?.min(height);
Some(Rect::new(left, top, end_x.checked_sub(left)?, end_y.checked_sub(top)?))
}
fn relative_crop(crop: Rect, active: Rect) -> Option<Rect> {
let x = crop.x.checked_sub(active.x)?;
let y = crop.y.checked_sub(active.y)?;
if crop.width < 2 || crop.height < 2 || x.checked_add(crop.width)? > active.width || y.checked_add(crop.height)? > active.height {
return None;
}
Some(Rect::new(x, y, crop.width, crop.height))
}
fn sensor_crop(maker: Option<&Ifd>, width: usize, height: usize) -> Option<Rect> {
let data = maker?.u64s(SENSOR_INFO)?;
let bounds = data.get(5..9)?;
inclusive_rect(
[
u16::try_from(*bounds.first()?).ok()?,
u16::try_from(*bounds.get(1)?).ok()?,
u16::try_from(*bounds.get(2)?).ok()?,
u16::try_from(*bounds.get(3)?).ok()?,
],
width,
height,
)
}
fn geometry(area: Option<&Cr3ImageArea>, maker: Option<&Ifd>, width: usize, height: usize) -> (Rect, Rect) {
let area = area.filter(|a| usize::from(a.width) == width && usize::from(a.height) == height);
let sensor = sensor_crop(maker, width, height);
let active = area.and_then(|a| a.active).and_then(|b| inclusive_rect(b, width, height)).or(sensor).unwrap_or(Rect::new(0, 0, width, height));
let crop = area.and_then(|a| inclusive_rect(a.crop, width, height)).or(sensor).and_then(|c| relative_crop(c, active)).unwrap_or(Rect::new(
0,
0,
active.width,
active.height,
));
(active, crop)
}
#[derive(Default)]
struct CameraLevels {
wb: Option<[f32; 3]>,
black: Option<[f32; 4]>,
white: Option<f32>,
}
/// Canon's versioned word arrays. Offsets are word indices from the published tag tables, not
/// byte offsets. The four as-shot WB values are R, G, G, B, independent of the CFA layout.
fn camera_levels(maker: Option<&Ifd>, bits: u8) -> CameraLevels {
let Some(values) = maker.and_then(|m| m.u64s(COLOR_DATA)) else { return CameraLevels::default() };
let offsets = match values.first().copied() {
Some(16..=19) => (71, 329, 797),
Some(32 | 33) => (85, 343, 811),
Some(34 | 48) => (105, 363, 641),
Some(64 | 65) => (105, 383, 661),
_ => return CameraLevels::default(),
};
let full = ((1u32 << u32::from(bits.clamp(1, 16))) - 1) as f32;
let quadruple = |at: usize| -> Option<[f32; 4]> {
let v = values.get(at..at + 4)?;
Some([*v.first()? as f32, *v.get(1)? as f32, *v.get(2)? as f32, *v.get(3)? as f32])
};
let wb = quadruple(offsets.0).and_then(|[r, g1, g2, b]| {
let green = (g1 + g2) * 0.5;
if !(64.0..=32767.0).contains(&green) || (g1 - g2).abs() > green * 0.1 {
return None;
}
let red = r / green;
let blue = b / green;
((0.1..8.0).contains(&red) && (0.1..8.0).contains(&blue)).then_some([red, 1.0, blue])
});
let black = quadruple(offsets.1).filter(|v| v.iter().any(|&b| b > 0.0) && v.iter().all(|&b| b >= 0.0 && b < full * 0.5));
let max_black = black.map(|v| v.into_iter().fold(0.0f32, f32::max)).unwrap_or(0.0);
let white = values.get(offsets.2).map(|&v| v as f32).filter(|&v| v > max_black && v >= full * 0.5 && v <= full);
CameraLevels { wb, black, white }
}
fn black_at_active(levels: [f32; 4], pattern: u8, active: Rect) -> BlackLevel {
// Convert Canon's channel order to full-sensor site order, then shift to the active origin.
let sites = match pattern {
1 => [1, 0, 3, 2],
2 => [2, 3, 0, 1],
3 => [3, 2, 1, 0],
_ => [0, 1, 2, 3],
};
let values = (0..4)
.map(|i| {
let x = (active.x + i % 2) % 2;
let y = (active.y + i / 2) % 2;
levels.get(*sites.get(y * 2 + x).unwrap_or(&0)).copied().unwrap_or(0.0)
})
.collect();
BlackLevel { repeat_rows: 2, repeat_cols: 2, values, delta_h: vec![], delta_v: vec![] }
}
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
let container = parse_cr3(bytes).ok_or(RawError::NotRaw)?;
let track = main_track(&container.tracks).ok_or_else(|| RawError::Unsupported("CR3 without a Bayer raw track".into()))?;
let coding = track.compression(bytes).ok_or_else(|| RawError::Corrupt("CR3 raw track has an invalid CMP1 descriptor".into()))?;
if coding.planes != 4 || coding.encoding != 0 {
return Err(RawError::Unsupported(format!("Canon CR3 encoding {} with {} planes", coding.encoding, coding.planes)));
}
let (width, height) = (coding.width as usize, coding.height as usize);
let samples = width.checked_mul(height).filter(|&n| n > 0 && n <= MAX_SAMPLES).ok_or(RawError::Limit("CR3 sensor dimensions"))?;
let cfa = cfa(coding.cfa_pattern).ok_or_else(|| RawError::Unsupported(format!("Canon CR3 CFA {}", coding.cfa_pattern)))?;
let (offset, len) = track.data.ok_or_else(|| RawError::Corrupt("CR3 raw sample outside file".into()))?;
let sample =
offset.checked_add(len).and_then(|end| bytes.get(offset..end)).ok_or_else(|| RawError::Corrupt("CR3 raw sample outside file".into()))?;
if coding.header_size as usize > sample.len() {
return Err(RawError::Corrupt("CR3 coding header outside sample".into()));
}
crx::validate(sample, &coding)?;
let data = match mode {
Mode::Full => {
let data = crx::decode(sample, &coding)?;
if data.len() != samples {
return Err(RawError::Corrupt("CR3 decoded sample count does not match CMP1".into()));
}
data
}
Mode::Header => vec![],
};
let static_maker = container.cmt.get(2).and_then(|b| *b).and_then(|b| Tiff::parse(b).ok());
let maker = static_maker.as_ref().and_then(|m| m.ifds.first());
// Retain only the first useful timed maker note: repeated CTMD records must not accumulate
// independent copies of every bounded TIFF value in memory.
let timed_maker = container
.timed_exif(bytes)
.into_iter()
.filter(|b| b.tag == 0x927c)
.filter_map(|b| Tiff::parse(b.data).ok())
.find(|t| t.ifds.first().is_some_and(|m| m.contains(COLOR_DATA)));
let color_maker = timed_maker.as_ref().and_then(|t| t.ifds.first()).or(maker);
let area = track.image_area(bytes);
let (active, crop) = geometry(area.as_ref(), maker, width, height);
let levels = camera_levels(color_maker, coding.bit_depth);
let black = match levels.black {
Some(levels) => black_at_active(levels, coding.cfa_pattern, active),
None if active.x >= 8 => black_from_columns(&data, width, 2..active.x - 2, active.y..active.y + active.height, active),
None => BlackLevel::uniform(0.0),
};
let white = levels.white.unwrap_or_else(|| white_from_data(&data, u32::from(coding.bit_depth)));
let mut metadata = lightcraft_meta::extract(bytes);
if metadata.lens_model.is_none() {
metadata.lens_model = maker.and_then(|m| m.string(LENS_MODEL)).map(|s| s.trim().to_owned()).filter(|s| !s.is_empty());
}
metadata.width = Some(crop.width as u32);
metadata.height = Some(crop.height as u32);
let image = RawImage {
format: RawFormat::Cr3,
width,
height,
cpp: 1,
data: RawData::U16(data),
cfa: Some(cfa),
bits: u32::from(coding.bit_depth),
black,
white: vec![white],
active_area: active,
crop,
orientation: metadata.orientation.unwrap_or_default(),
color: ColorData::default(),
wb_multipliers: levels.wb,
linearized: false,
opcodes: OpcodeLists::default(),
metadata,
};
image.validate_for(mode)?;
Ok(image)
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_tiff::{IfdBuilder, Value};
fn maker(version: u16, wb_at: usize, black_at: usize, white_at: usize) -> Ifd {
let mut words = vec![0u16; white_at + 1];
words[0] = version;
words[wb_at..wb_at + 4].copy_from_slice(&[2048, 1024, 1024, 1536]);
words[black_at..black_at + 4].copy_from_slice(&[100, 101, 102, 103]);
words[white_at] = 15000;
let b = IfdBuilder::new().with(COLOR_DATA, Value::Short(words));
let bytes = lightcraft_tiff::TiffWriter::new(lightcraft_tiff::ByteOrder::Little, false).write(&[b]).unwrap();
Tiff::parse(&bytes).unwrap().ifds.remove(0)
}
fn box_bytes(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
let mut b = ((body.len() + 8) as u32).to_be_bytes().to_vec();
b.extend_from_slice(kind);
b.extend_from_slice(body);
b
}
fn track(entry: &[u8], offset: u64, size: u32) -> Vec<u8> {
let mut stsd = vec![0u8; 4];
stsd.extend(1u32.to_be_bytes());
stsd.extend(entry);
let mut stsz = vec![0u8; 4];
stsz.extend(size.to_be_bytes());
stsz.extend(1u32.to_be_bytes());
let mut co64 = vec![0u8; 4];
co64.extend(1u32.to_be_bytes());
co64.extend(offset.to_be_bytes());
let tables = [box_bytes(b"stsd", &stsd), box_bytes(b"stsz", &stsz), box_bytes(b"co64", &co64)].concat();
box_bytes(b"trak", &box_bytes(b"mdia", &box_bytes(b"minf", &box_bytes(b"stbl", &tables))))
}
fn synthetic_header() -> Vec<u8> {
use lightcraft_tiff::{ByteOrder, TiffWriter, tags as t};
let tiff = |b: IfdBuilder| TiffWriter::new(ByteOrder::Little, false).write(&[b]).unwrap();
let cmt1 = tiff(
IfdBuilder::new()
.with(t::MAKE, Value::Ascii("Canon".into()))
.with(t::MODEL, Value::Ascii("EOS Test".into()))
.with(t::ORIENTATION, Value::Short(vec![6])),
);
let cmt2 = tiff(IfdBuilder::new().with(t::ISO_SPEED, Value::Short(vec![800])));
let cmt3 = tiff(
IfdBuilder::new()
.with(SENSOR_INFO, Value::Short(vec![34, 64, 48, 1, 1, 6, 4, 61, 45]))
.with(LENS_MODEL, Value::Ascii("RF-S18-45mm F4.5-6.3 IS STM".into())),
);
let mut canon = vec![0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a, 0x48];
canon.extend([box_bytes(b"CMT1", &cmt1), box_bytes(b"CMT2", &cmt2), box_bytes(b"CMT3", &cmt3)].concat());
let mut coding = vec![0u8; 52];
coding[0..2].copy_from_slice(&0xff00u16.to_be_bytes());
coding[2..4].copy_from_slice(&48u16.to_be_bytes());
coding[4..6].copy_from_slice(&0x100u16.to_be_bytes());
for (at, value) in [(8, 64u32), (12, 48), (16, 32), (20, 48), (28, 216)] {
coding[at..at + 4].copy_from_slice(&value.to_be_bytes());
}
coding[24] = 14;
coding[25] = 0x40;
let mut area = vec![0u8; 48];
for (at, value) in [
(4, 64u16),
(6, 48),
(10, 2),
(16, 6),
(18, 4),
(20, 61),
(22, 45),
(28, 3),
(30, 47),
(32, 4),
(36, 63),
(38, 1),
(40, 4),
(42, 2),
(44, 63),
(46, 47),
] {
area[at..at + 2].copy_from_slice(&value.to_be_bytes());
}
let mut craw = vec![0u8; 82];
craw[24..26].copy_from_slice(&64u16.to_be_bytes());
craw[26..28].copy_from_slice(&48u16.to_be_bytes());
craw.extend(box_bytes(b"CMP1", &coding));
craw.extend(box_bytes(b"CDI1", &[vec![0u8; 4], box_bytes(b"IAD1", &area)].concat()));
let levels = maker(48, 105, 363, 641).value(COLOR_DATA).unwrap().clone();
let dynamic_maker = tiff(IfdBuilder::new().with(COLOR_DATA, levels));
let mut item = ((dynamic_maker.len() + 8) as u32).to_le_bytes().to_vec();
item.extend(0x927cu32.to_le_bytes());
item.extend(dynamic_maker);
let mut timed = vec![0u8; 12];
timed[0..4].copy_from_slice(&((12 + item.len()) as u32).to_le_bytes());
timed[4..6].copy_from_slice(&8u16.to_le_bytes());
timed.extend(item);
// Two tiles, each with four one-byte lossless plane streams. Header
// probing checks the complete marker layout without decoding entropy.
let mut sample = Vec::new();
let marker = |out: &mut Vec<u8>, code: u16, size: u32, flags: u32| {
out.extend(code.to_be_bytes());
out.extend(8u16.to_be_bytes());
out.extend(size.to_be_bytes());
out.extend(flags.to_be_bytes());
};
for tile in 0..2 {
marker(&mut sample, 0xff01, 4, tile << 16);
for plane in 0..4 {
marker(&mut sample, 0xff02, 1, (plane << 28) | 0x0800_0000);
marker(&mut sample, 0xff03, 1, 0x0020_0000);
}
}
assert_eq!(sample.len(), 216);
sample.extend([0x1e; 8]);
let moov =
[box_bytes(b"uuid", &canon), track(&box_bytes(b"CRAW", &craw), 4096, 224), track(&box_bytes(b"CTMD", &[]), 4320, timed.len() as u32)]
.concat();
let mut file = box_bytes(b"ftyp", b"crx \0\0\0\x01crx isom");
file.extend(box_bytes(b"moov", &moov));
assert!(file.len() < 4096);
file.resize(4096, 0);
file.extend(sample);
file.extend(timed);
file
}
#[test]
fn header_reads_timed_white_balance_and_distinct_geometry() {
let bytes = synthetic_header();
let image = decode(&bytes, Mode::Header).unwrap();
assert_eq!((image.width, image.height, image.bits), (64, 48, 14));
assert_eq!(image.active_area, Rect::new(4, 2, 60, 46));
assert_eq!(image.crop, Rect::new(2, 2, 56, 42));
assert_eq!(image.wb_multipliers, Some([2.0, 1.0, 1.5]));
assert_eq!(image.black.values, vec![100.0, 101.0, 102.0, 103.0]);
assert_eq!(image.white, vec![15000.0]);
assert_eq!(image.metadata.iso, Some(800));
assert_eq!(image.metadata.lens_model.as_deref(), Some("RF-S18-45mm F4.5-6.3 IS STM"));
assert_eq!((image.metadata.width, image.metadata.height), (Some(56), Some(42)));
assert_eq!(image.orientation, crate::Orientation::Rotate90);
assert!(image.data.is_empty());
assert!(decode(&bytes[..4200], Mode::Header).is_err());
}
fn prepend_tracks(bytes: &[u8], tracks: &[u8]) -> Vec<u8> {
let ftyp_end = u32::from_be_bytes(bytes[..4].try_into().unwrap()) as usize;
let moov_len = u32::from_be_bytes(bytes[ftyp_end..ftyp_end + 4].try_into().unwrap()) as usize;
assert_eq!(&bytes[ftyp_end + 4..ftyp_end + 8], b"moov");
let moov = [tracks, &bytes[ftyp_end + 8..ftyp_end + moov_len]].concat();
let mut out = [bytes[..ftyp_end].to_vec(), box_bytes(b"moov", &moov)].concat();
assert!(out.len() < 4096);
out.resize(4096, 0);
out.extend_from_slice(&bytes[4096..]);
out
}
#[test]
fn hevc_preview_with_equal_dimensions_does_not_replace_the_sensor_track() {
let bytes = synthetic_header();
let mut preview = vec![0u8; 82];
preview[24..26].copy_from_slice(&64u16.to_be_bytes());
preview[26..28].copy_from_slice(&48u16.to_be_bytes());
preview.extend(box_bytes(b"HEVC", &[0; 4]));
let bytes = prepend_tracks(&bytes, &track(&box_bytes(b"CRAW", &preview), 4096, 224));
let container = parse_cr3(&bytes).unwrap();
assert_eq!(container.tracks[0].kind, Cr3TrackKind::Other(*b"HEVC"));
let image = decode(&bytes, Mode::Header).unwrap();
assert_eq!((image.width, image.height, image.bits), (64, 48, 14));
assert_eq!(image.active_area, Rect::new(4, 2, 60, 46));
}
#[test]
fn corrupt_main_descriptor_is_not_replaced_by_the_reduced_raw() {
let bytes = synthetic_header();
let container = parse_cr3(&bytes).unwrap();
let Cr3TrackKind::Raw { cmp1: Some((at, len)), .. } = main_track(&container.tracks).unwrap().kind else { panic!("raw fixture") };
let mut reduced = vec![0u8; 82];
reduced[24..26].copy_from_slice(&32u16.to_be_bytes());
reduced[26..28].copy_from_slice(&24u16.to_be_bytes());
reduced.extend(box_bytes(b"CMP1", &bytes[at..at + len]));
let bytes = prepend_tracks(&bytes, &track(&box_bytes(b"CRAW", &reduced), 4096, 224));
let container = parse_cr3(&bytes).unwrap();
let Cr3TrackKind::Raw { cmp1: Some((at, len)), .. } = main_track(&container.tracks).unwrap().kind else { panic!("main raw fixture") };
let mut malformed = bytes.clone();
malformed[at..at + len].fill(0);
assert!(matches!(decode(&malformed, Mode::Header), Err(RawError::Corrupt(_))));
let mut missing = bytes;
missing[at - 4..at].copy_from_slice(b"free");
assert!(matches!(decode(&missing, Mode::Header), Err(RawError::Corrupt(_))));
}
#[test]
fn color_data_uses_versioned_as_shot_offsets() {
for (version, wb_at, black_at, white_at) in
[(16, 71, 329, 797), (19, 71, 329, 797), (33, 85, 343, 811), (48, 105, 363, 641), (64, 105, 383, 661)]
{
let m = maker(version, wb_at, black_at, white_at);
let levels = camera_levels(Some(&m), 14);
assert_eq!(levels.wb, Some([2.0, 1.0, 1.5]));
assert_eq!(levels.black, Some([100.0, 101.0, 102.0, 103.0]));
assert_eq!(levels.white, Some(15000.0));
}
assert!(camera_levels(Some(&maker(255, 71, 329, 797)), 14).wb.is_none());
assert!(camera_levels(None, 14).black.is_none());
}
#[test]
fn crop_is_relative_to_the_valid_sensor_area() {
let area = Cr3ImageArea {
width: 6888,
height: 4546,
crop: [156, 158, 6875, 4537],
active: Some([144, 46, 6887, 4545]),
masked_left: [0, 0, 143, 4545],
masked_top: None,
};
assert_eq!(geometry(Some(&area), None, 6888, 4546), (Rect::new(144, 46, 6744, 4500), Rect::new(12, 112, 6720, 4380)));
assert_eq!(inclusive_rect([152, 46, 4353, 2851], 4352, 2850), Some(Rect::new(152, 46, 4200, 2804)));
assert_eq!(inclusive_rect([100, 100, 50, 50], 200, 200), None);
assert_eq!(relative_crop(Rect::new(0, 0, 100, 100), Rect::new(4, 4, 92, 92)), None);
}
#[test]
fn first_full_size_track_wins_over_dual_pixel_delta() {
let raw = |w, h, offset| Cr3Track { kind: Cr3TrackKind::Raw { width: w, height: h, cmp1: None, iad1: None }, data: Some((offset, 100)) };
let tracks = [raw(1624, 1080, 1), raw(6888, 4546, 2), raw(6888, 4546, 3)];
assert_eq!(main_track(&tracks).unwrap().data, Some((2, 100)));
}
#[test]
fn cfa_and_black_follow_the_full_sensor_origin() {
for (code, name) in [(0, "RGGB"), (1, "GRBG"), (2, "GBRG"), (3, "BGGR")] {
assert_eq!(cfa(code).unwrap().name(), name);
}
let b = black_at_active([100.0, 101.0, 102.0, 103.0], 0, Rect::new(3, 1, 32, 20));
assert_eq!(b.values, vec![103.0, 102.0, 101.0, 100.0]);
}
}
+1088
View File
File diff suppressed because it is too large. Load diff
+338
View File
@@ -0,0 +1,338 @@
//! Reversible integer Le Gall 5/3 synthesis for Canon CRX subbands.
//!
//! Independent implementation of the lifting equations in ITU-T T.800, Annex F;
//! the equations are also printed in US20030031370A1, paragraph 0047.
//! Each tile level has even local origin. Internal boundaries retain the
//! neighbouring low/high coefficients; only outer image edges are symmetric.
use crate::{RawError, Result};
#[derive(Clone, Debug)]
pub(crate) struct Band {
pub width: usize,
pub height: usize,
pub data: Vec<i32>,
}
fn corrupt() -> RawError {
RawError::Corrupt("invalid CRX wavelet subband geometry".into())
}
fn checked_sample(v: i64) -> Result<i32> {
i32::try_from(v).map_err(|_| RawError::Corrupt("CRX wavelet coefficient overflow".into()))
}
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct TileEdges {
pub left: bool,
pub right: bool,
pub top: bool,
pub bottom: bool,
}
fn sizes(length: usize, before: bool, after: bool) -> (usize, usize) {
(length.div_ceil(2) + usize::from(after && length.is_multiple_of(2)), length / 2 + usize::from(before) + usize::from(after))
}
fn inverse_line(low: &[i32], high: &[i32], out: &mut [i32], before: bool, after: bool) -> Result<()> {
let (low_count, high_count) = sizes(out.len(), before, after);
if out.is_empty() || low.len() != low_count || high.len() != high_count {
return Err(corrupt());
}
if high.is_empty() {
let value = low.first().copied().ok_or_else(corrupt)?;
*out.first_mut().ok_or_else(corrupt)? = value;
return Ok(());
}
// The high-pass storage starts at -1 when there is a preceding tile.
// Only absent external neighbours are reflected at the image boundary.
let high_at = |index: usize| -> Result<i32> { high.get(index.min(high.len().saturating_sub(1))).copied().ok_or_else(corrupt) };
let even = |i: usize| -> Result<i32> {
let left = high_at((i + usize::from(before)).saturating_sub(1))?;
let right = high_at(i + usize::from(before))?;
let update = (i64::from(left) + i64::from(right) + 2).div_euclid(4);
checked_sample(i64::from(*low.get(i).ok_or_else(corrupt)?) - update)
};
for i in 0..out.len().div_ceil(2) {
*out.get_mut(i * 2).ok_or_else(corrupt)? = even(i)?;
}
for i in 0..out.len() / 2 {
let left = out.get(i * 2).copied().ok_or_else(corrupt)?;
let right = match out.get(i * 2 + 2).copied() {
Some(value) => value,
None if after => even(i + 1)?,
None => left,
};
let prediction = (i64::from(left) + i64::from(right)).div_euclid(2);
let value = high_at(i + usize::from(before))?;
*out.get_mut(i * 2 + 1).ok_or_else(corrupt)? = checked_sample(i64::from(value) + prediction)?;
}
Ok(())
}
fn valid_band(band: &Band, width: usize, height: usize) -> bool {
band.width == width && band.height == height && width.checked_mul(height) == Some(band.data.len())
}
/// A zeroed buffer, or a limit error instead of an abort when the allocation fails.
fn zeroed(count: usize) -> Result<Vec<i32>> {
let mut v = Vec::new();
v.try_reserve_exact(count).map_err(|_| RawError::Limit("CRX wavelet allocation"))?;
v.resize(count, 0);
Ok(v)
}
/// One inverse level. `HL` is horizontally high-pass, `LH` vertically high-pass.
pub(crate) fn inverse_level(ll: &Band, hl: &Band, lh: &Band, hh: &Band, width: usize, height: usize, edges: TileEdges) -> Result<Band> {
let count = width.checked_mul(height).filter(|n| *n > 0).ok_or_else(corrupt)?;
if count > crate::MAX_SAMPLES {
return Err(RawError::Limit("CRX wavelet image size"));
}
let (lw, hw) = sizes(width, edges.left, edges.right);
let (lhgt, hhgt) = sizes(height, edges.top, edges.bottom);
if !valid_band(ll, lw, lhgt) || !valid_band(hl, hw, lhgt) || !valid_band(lh, lw, hhgt) || !valid_band(hh, hw, hhgt) {
return Err(corrupt());
}
let intermediate_count = width.checked_mul(lhgt + hhgt).filter(|n| *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("CRX wavelet image size"))?;
let mut temporary = zeroed(intermediate_count)?;
// CRX analysis is vertical then horizontal. Integer rounding means that
// synthesis must reverse that order, including for quantized subbands.
for y in 0..lhgt + hhgt {
let (lo, hi, by) = if y < lhgt { (ll, hl, y) } else { (lh, hh, y - lhgt) };
inverse_line(
lo.data.get(by * lw..(by + 1) * lw).ok_or_else(corrupt)?,
hi.data.get(by * hw..(by + 1) * hw).ok_or_else(corrupt)?,
temporary.get_mut(y * width..(y + 1) * width).ok_or_else(corrupt)?,
edges.left,
edges.right,
)?;
}
let mut data = zeroed(count)?;
let mut low = zeroed(lhgt)?;
let mut high = zeroed(hhgt)?;
let mut column = zeroed(height)?;
for x in 0..width {
for (y, value) in low.iter_mut().enumerate() {
*value = temporary.get(y * width + x).copied().ok_or_else(corrupt)?;
}
for (y, value) in high.iter_mut().enumerate() {
*value = temporary.get((y + lhgt) * width + x).copied().ok_or_else(corrupt)?;
}
inverse_line(&low, &high, &mut column, edges.top, edges.bottom)?;
for (y, &value) in column.iter().enumerate() {
*data.get_mut(y * width + x).ok_or_else(corrupt)? = value;
}
}
Ok(Band { width, height, data })
}
/// Subbands in CRX order: LLn, HLn, LHn, HHn, ... HL1, LH1, HH1.
pub(crate) fn reconstruct(bands: &[Band], width: usize, height: usize, levels: u8, edges: TileEdges) -> Result<Vec<i32>> {
if levels > 3 || bands.len() != 1 + usize::from(levels) * 3 || width == 0 || height == 0 {
return Err(corrupt());
}
let count = width.checked_mul(height).ok_or_else(corrupt)?;
if count > crate::MAX_SAMPLES {
return Err(RawError::Limit("CRX wavelet image size"));
}
let mut dimensions = vec![(width, height)];
for _ in 0..levels {
let &(w, h) = dimensions.last().ok_or_else(corrupt)?;
dimensions.push((sizes(w, edges.left, edges.right).0, sizes(h, edges.top, edges.bottom).0));
}
let &(low_width, low_height) = dimensions.last().ok_or_else(corrupt)?;
if !bands.first().is_some_and(|b| valid_band(b, low_width, low_height)) {
return Err(corrupt());
}
let mut ll = bands.first().cloned().ok_or_else(corrupt)?;
for step in 0..usize::from(levels) {
let &(w, h) = dimensions.get(usize::from(levels) - step - 1).ok_or_else(corrupt)?;
let index = 1 + step * 3;
ll = inverse_level(
&ll,
bands.get(index).ok_or_else(corrupt)?,
bands.get(index + 1).ok_or_else(corrupt)?,
bands.get(index + 2).ok_or_else(corrupt)?,
w,
h,
edges,
)?;
}
if !valid_band(&ll, width, height) {
return Err(corrupt());
}
Ok(ll.data)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn integer_lifting_preserves_signed_rounding_and_symmetric_ends() {
let mut out = [0; 9];
inverse_line(&[6, 11, 8, 6, 3], &[2, 2, -1, -2], &mut out, false, false).unwrap();
assert_eq!(out, [5, 9, 10, 11, 8, 6, 7, 3, 4]);
let mut out = [0; 2];
inverse_line(&[25], &[20], &mut out, false, false).unwrap();
assert_eq!(out, [15, 35]);
let mut out = [0];
inverse_line(&[-27], &[], &mut out, false, false).unwrap();
assert_eq!(out, [-27]);
}
#[test]
fn two_dimensional_subband_order() {
let band = |value| Band { width: 1, height: 1, data: vec![value] };
let out = reconstruct(&[band(25), band(10), band(20), band(0)], 2, 2, 1, TileEdges::default()).unwrap();
assert_eq!(out, [10, 20, 30, 40]);
}
#[test]
fn invalid_geometry_and_overflow_are_errors() {
let mut out = [0; 2];
assert!(inverse_line(&[], &[0], &mut out, false, false).is_err());
assert!(inverse_line(&[i32::MAX], &[i32::MIN], &mut out, false, false).is_err());
let b = Band { width: 1, height: 1, data: vec![0] };
assert!(inverse_level(&b, &b, &b, &b, usize::MAX, 2, TileEdges::default()).is_err());
assert!(reconstruct(&[b], 2, 2, 0, TileEdges::default()).is_err());
}
// Independent forward lifting in tests checks all odd/even boundary
// combinations; hand-computed vectors above also establish sign/order.
fn forward_line(input: &[i32]) -> (Vec<i32>, Vec<i32>) {
let high: Vec<_> = (0..input.len() / 2)
.map(|i| input[i * 2 + 1] - (input[i * 2] + input.get(i * 2 + 2).copied().unwrap_or(input[i * 2])).div_euclid(2))
.collect();
let low = input
.iter()
.step_by(2)
.enumerate()
.map(|(i, &v)| {
if high.is_empty() {
v
} else {
v + (high[i.saturating_sub(1)] + high.get(i).copied().unwrap_or(high[high.len() - 1]) + 2).div_euclid(4)
}
})
.collect();
(low, high)
}
#[test]
fn every_boundary_parity_round_trips_signed_coefficients() {
for width in 1..=17usize {
for height in 1..=17usize {
let input: Vec<i32> = (0..width * height).map(|i| ((i * 193 + i * i * 17) % 1001) as i32 - 500).collect();
let (lw, hw, lhgt, hhgt) = (width.div_ceil(2), width / 2, height.div_ceil(2), height / 2);
let mut vertical = vec![0; width * height];
for x in 0..width {
let column: Vec<_> = (0..height).map(|y| input[y * width + x]).collect();
let (lo, hi) = forward_line(&column);
for (y, value) in lo.into_iter().chain(hi).enumerate() {
vertical[y * width + x] = value;
}
}
let mut bands = [
Band { width: lw, height: lhgt, data: vec![0; lw * lhgt] },
Band { width: hw, height: lhgt, data: vec![0; hw * lhgt] },
Band { width: lw, height: hhgt, data: vec![0; lw * hhgt] },
Band { width: hw, height: hhgt, data: vec![0; hw * hhgt] },
];
for y in 0..height {
let (lo, hi) = forward_line(&vertical[y * width..(y + 1) * width]);
let (b, by) = if y < lhgt { (0, y) } else { (2, y - lhgt) };
bands[b].data[by * lw..(by + 1) * lw].copy_from_slice(&lo);
bands[b + 1].data[by * hw..(by + 1) * hw].copy_from_slice(&hi);
}
assert_eq!(reconstruct(&bands, width, height, 1, TileEdges::default()).unwrap(), input, "{width}x{height}");
}
}
}
// Independently generated from ITU-T T.800 integer 5/3 forward equations.
// Original40x9 samples: ((x*23+y*37+x*y*7)%101)-50.
// Tile width20 creates the same level3phase4 mismatch as R1001572.
fn left_halo_fixture() -> Vec<Band> {
vec![
Band { width: 4, height: 2, data: vec![1, 1, -4, 1, 20, -2, -1, 2] },
Band { width: 4, height: 2, data: vec![35, 21, -2, 19, -24, -10, -7, -2] },
Band { width: 4, height: 1, data: vec![31, -16, -19, -7] },
Band { width: 4, height: 1, data: vec![22, 9, 20, 18] },
Band { width: 6, height: 3, data: vec![24, -34, -55, -21, 2, 7, 25, 5, -3, 14, 31, 14, 24, 37, 55, 19, -42, -41] },
Band { width: 6, height: 2, data: vec![9, -21, 20, -4, 31, -6, -32, -3, 7, 31, 14, -11] },
Band { width: 6, height: 2, data: vec![3, 62, 40, 50, 50, 51, -3, -62, -38, -45, 50, 35] },
Band {
width: 11,
height: 5,
data: vec![
13, -38, -12, 13, -50, -13, 13, 39, 25, -38, 38, -44, 6, 0, -44, -50, 57, 0, -6, 38, 19, -13, 26, 26, 39, -37, -62, -37, -37,
-25, -25, -44, -44, 6, -44, -12, 57, 39, -44, 13, 32, -18, 0, 57, -38, 13, 13, -13, 51, 13, -12, -38, 13, 13, -13,
],
},
Band {
width: 11,
height: 4,
data: vec![
13, 39, 0, -25, 6, -6, 26, 0, -43, 20, -25, -50, 26, 7, -19, 0, 13, 32, -6, -25, 38, 32, 1, 26, -44, 19, 0, -12, -6, 45, -25, -6,
32, -37, 39, -12, 26, -6, 6, -25, 0, 39, -12, 26,
],
},
Band {
width: 11,
height: 4,
data: vec![
26, -75, 76, 25, 0, -25, -75, 76, -50, -75, -25, 0, -101, -75, 0, 0, 51, 76, 101, 0, -51, -25, -101, 0, 25, 51, -50, 0, -25, 0,
101, 76, 51, -75, 26, -75, -25, 0, 25, 76, -75, 26, -75, -25,
],
},
]
}
fn right_halo_fixture() -> Vec<Band> {
vec![
Band { width: 3, height: 2, data: vec![-12, 8, -5, 0, 4, -2] },
Band { width: 3, height: 2, data: vec![-10, -5, -15, 8, 6, 5] },
Band { width: 3, height: 1, data: vec![-10, -14, -10] },
Band { width: 3, height: 1, data: vec![-17, -14, -25] },
Band { width: 6, height: 3, data: vec![2, 7, -4, 7, 26, 38, 31, 14, 6, -12, -2, -22, -42, -41, -47, -7, 10, 44] },
Band { width: 5, height: 2, data: vec![-6, 15, 31, -7, 12, -11, 6, 9, 5, -5] },
Band { width: 6, height: 2, data: vec![50, 51, -46, -36, -58, -61, 50, 35, 69, 18, -36, -21] },
Band {
width: 11,
height: 5,
data: vec![
-38, 38, -13, 13, -38, 39, -25, -38, 63, 13, 49, 19, -13, -57, 70, 25, -6, -12, 32, 0, -44, -51, -44, -44, -50, -37, -37, -25, 1,
-31, -44, -50, -50, 0, 57, -37, -44, 13, 32, -12, 6, 57, -37, -36, 13, -13, 77, 13, -12, -38, -25, -25, -13, 77, -22,
],
},
Band {
width: 10,
height: 4,
data: vec![
-25, 39, 0, -37, 25, -18, 45, -12, -37, 19, 32, -6, 13, 32, -6, 1, -25, 45, -6, 0, 32, 13, 0, -6, 45, -25, -6, 32, 13, 0, 26, 7,
19, -25, 0, 45, -50, 32, 7, 13,
],
},
Band {
width: 11,
height: 4,
data: vec![
-75, -25, -25, 25, 25, 76, 51, -75, 25, 25, 51, -51, -25, 0, 51, 76, 101, 101, 0, -25, 0, 0, 76, 51, 0, 0, -25, 0, 101, 76, 51,
0, 0, -75, -25, 51, 25, 76, -75, 51, -50, -25, 51, 0,
],
},
]
}
#[test]
fn three_levels_keep_internal_tile_halos_and_local_phase() {
for (tile, bands, edges) in [
(0, left_halo_fixture(), TileEdges { right: true, ..Default::default() }),
(1, right_halo_fixture(), TileEdges { left: true, ..Default::default() }),
] {
let expected: Vec<i32> =
(0..9).flat_map(|y| (tile * 20..tile * 20 + 20).map(move |x| ((x * 23 + y * 37 + x * y * 7) % 101) - 50)).collect();
assert_eq!(reconstruct(&bands, 20, 9, 3, edges).unwrap(), expected);
assert!(reconstruct(&bands, 20, 9, 3, TileEdges::default()).is_err());
}
}
}
+4
View File
@@ -2,11 +2,15 @@
pub mod arw;
pub mod cr2;
pub mod cr3;
pub mod crx;
pub(crate) mod crx_wavelet;
pub mod nef;
pub mod nefc;
pub mod orf;
pub mod pef;
pub mod raf;
mod rafc;
pub mod rw2;
use crate::{BlackLevel, Rect};
+65 -3
View File
@@ -30,7 +30,13 @@
//! first two pixels of the previous row of the same parity, starting at the seeds of maker note `0x0096`.
//! - Lossless (`0x0096` version `0x46`): the decoded values are the samples. Lossy (version `0x44`): they index a
//! curve. Type 1 (`0x44 0x10`) stores the full curve; type 2 (`0x44 0x20`, `0x44 0x40`) stores 257 points,
//! one every `2^bits / 256` codes, linearly interpolated.
//! one every `2^bits / 256` codes (`0x20`) or `2^bits / 1024` codes (`0x40`), linearly interpolated. The span
//! was established from the files: in every `0x20` file the first interval of the 257 points rises by
//! `2^bits / 256` (16 at 12 bit, 64 at 14 bit) and in every `0x40` file by `2^bits / 1024` (4 and 16), i.e.
//! the curve is the identity in the dark part, one code per sample. Read with a point every `2^bits / 256`
//! codes instead, the 12 `0x40` files that are not split decoded to values that never exceeded 255 (12 bit) or
//! 1023 (14 bit) and whose darkest 0.01 % sat at a quarter of the black level of maker note `0x003d` (63 against
//! 252 in 12-bit units); with the first-interval step they sit at that black level.
//! - Values in the maker note are in the maker note's byte order (newer bodies write little-endian notes).
//!
//! Not supported (returned as [`RawError::Unsupported`], the embedded preview is used instead):
@@ -127,8 +133,10 @@ pub(crate) fn parse_table(t: &[u8], order: ByteOrder, bits: u32) -> Result<Decod
"Nikon \"lossy after split\" compressed NEF (split at row {split}) is not decoded yet"
)));
}
// type 2: `n` points, one every 2^bits / (n - 1) codes
let range = 1usize << bits;
// type 2: `n` points covering `range` codes, one every `range / (n - 1)`; the curve's first interval
// rises by exactly that step (identity in the dark part) and the table version sets the span:
// `0x20` covers all 2^bits codes, `0x40` the first quarter (see the module docs)
let range = if v1 == 0x40 { 1usize << (bits - 2) } else { 1usize << bits };
if n - 1 > range || !range.is_multiple_of(n - 1) {
return Err(RawError::Unsupported(format!("NEF: {n}-point lossy curve for {bits}-bit data")));
}
@@ -355,6 +363,60 @@ mod tests {
}
}
/// Maker note `0x0096` as bytes, written out by hand: version `0x44 <v1>`, four seeds, 257 points (little-endian
/// as newer bodies write it) of a curve whose first 256 points rise by `step` and whose last is the white
/// level, and a zero split value at offset 562.
fn hand_table(v1: u8, seed: u16, step: u16, white: u16) -> Vec<u8> {
let mut t = vec![0x44, v1];
for _ in 0..4 {
t.extend_from_slice(&seed.to_le_bytes());
}
t.extend_from_slice(&257u16.to_le_bytes());
for k in 0..256u16 {
t.extend_from_slice(&(k * step).to_le_bytes());
}
t.extend_from_slice(&white.to_le_bytes());
t.resize(624, 0);
t
}
/// Known answers for the `0x44 0x40` curve, from bytes and bit strings written by hand (nothing here uses the
/// decoder's span rule or the test encoder): the first interval of the points rises by `2^bits / 1024`, so
/// the curve is the identity for one code per sample and spans `2^bits / 4` codes. The `0x20` curve of the
/// same sizes keeps its span of all `2^bits` codes.
#[test]
fn lossy_type2_version_0x40_spans_a_quarter_of_the_range() {
// 12 bit: points 0, 4, 8 … 1020, 4095
let t = parse_table(&hand_table(0x40, 252, 4, 4095), ByteOrder::Little, 12).unwrap();
let Encoding::Lossy(curve) = &t.encoding else { panic!() };
assert_eq!(curve.len(), 1025);
assert_eq!(curve[0..6], [0, 1, 2, 3, 4, 5]);
assert_eq!((curve[252], curve[255], curve[1020]), (252, 255, 1020));
assert_eq!((curve[1021], curve[1022], curve[1023], curve[1024]), (1788, 2557, 3326, 4095));
// the pixels p0 = 252 + 4, p1 = 252, p2 = p0 - 1, p3 = p1, coded as 011 100 | 11110 | 1110 0 | 11110
// (LOSSY_12: category 3 = 011, 0 = 11110, 1 = 1110; extra bits 100 = +4, 0 = -1)
let out = decode(&[0x73, 0xdc, 0xf0], 4, 1, 12, &t).unwrap();
assert_eq!(out, [256, 252, 255, 252]);
// 14 bit: points 0, 16, 32 … 4080, 16383
let t = parse_table(&hand_table(0x40, 1008, 16, 16383), ByteOrder::Little, 14).unwrap();
let Encoding::Lossy(curve) = &t.encoding else { panic!() };
assert_eq!(curve.len(), 4097);
assert_eq!(curve[0..6], [0, 1, 2, 3, 4, 5]);
assert_eq!((curve[1008], curve[4080], curve[4096]), (1008, 4080, 16383));
// the same pixels coded as 1100 100 | 111110 | 11110 0 | 111110
// (LOSSY_14: category 3 = 1100, 0 = 111110, 1 = 11110)
let out = decode(&[0xc9, 0xf7, 0x9f, 0x00], 4, 1, 14, &t).unwrap();
assert_eq!(out, [1012, 1008, 1011, 1008]);
// version 0x20 with the same points is a curve over all 2^bits codes: the points are 16 codes apart
let t = parse_table(&hand_table(0x20, 252, 16, 4095), ByteOrder::Little, 12).unwrap();
let Encoding::Lossy(curve) = &t.encoding else { panic!() };
assert_eq!((curve.len(), curve[16], curve[17], curve[4095]), (4097, 16, 17, 4094));
// 200 points can't be spaced evenly over the 1024 codes of a 12-bit 0x40 table
let mut odd = hand_table(0x40, 252, 4, 4095);
odd[10..12].copy_from_slice(&200u16.to_le_bytes());
assert!(parse_table(&odd, ByteOrder::Little, 12).is_err());
}
#[test]
fn lossy_type1_full_curve() {
let mut t = vec![0x44, 0x10, 0, 10, 0, 10, 0, 10, 0, 10, 0, 5];
+65 -24
View File
@@ -1,4 +1,4 @@
//! Fujifilm RAF — uncompressed Bayer and X-Trans.
//! Fujifilm RAF — uncompressed, lossless compressed and lossy compressed Bayer and X-Trans.
//!
//! Sources: the ExifTool FujiFilm tag-name documentation (RAF header record tags `0x0100` RawImageFullSize,
//! `0x0110` RawImageCropTopLeft, `0x0111` RawImageCroppedSize, `0x0131` XTransLayout; raw-IFD tags `0xf001`
@@ -17,7 +17,7 @@
//! is anchored at raw pixel (0, 0) — verified on X-Trans I and III samples by minimising the difference
//! between horizontally / vertically adjacent "green" samples over all 72 orientations/phases, and by colour
//! renders. Bayer bodies (X-A series) use RGGB at (0, 0), verified by colour renders.
//! - Fujifilm's compressed RAF variants are reported as unsupported (their embedded preview still works).
//! - Striped predictive compression is decoded by `rafc`; see `docs/raf-compression.md`.
use super::white_from_data;
use crate::unpack::{unpack_lsb, unpack_msb};
@@ -118,35 +118,56 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
let h = header(bytes)?;
let raw = h.raw.ok_or_else(|| RawError::Corrupt("RAF without raw data".into()))?;
let ifd = raw_ifd(raw)?;
let get = |t: u16| ifd.u64(t).map(|v| v as usize);
let (w, hgt) = (get(0xf001).unwrap_or(0), get(0xf002).unwrap_or(0));
let bits = get(0xf003).unwrap_or(0) as u32;
let (off, len) = (get(0xf007).unwrap_or(0), get(0xf008).unwrap_or(0));
let get = |t: u16| ifd.u64(t).map(|v| usize::try_from(v).map_err(|_| RawError::Limit("RAF IFD value too large"))).transpose();
let (w, hgt) = (get(0xf001)?.unwrap_or(0), get(0xf002)?.unwrap_or(0));
let bits = ifd.u64(0xf003).and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
let (off, len) = (get(0xf007)?.unwrap_or(0), get(0xf008)?.unwrap_or(0));
if w == 0 || hgt == 0 || !(8..=16).contains(&bits) {
return Err(RawError::Corrupt(format!("RAF raw IFD: {w}x{hgt}, {bits} bits")));
}
let n = w.checked_mul(hgt).filter(|n| *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?;
// samples of any depth may be stored in 16-bit words (e.g. 14-bit X-T20); otherwise packed at `bits`
let store = if len as u64 >= (n as u64) * 2 { 16 } else { bits };
let packed_row = if store == 16 { w * 2 } else { (w * store as usize).div_ceil(8) };
let stride = match get(0xf005).unwrap_or(0) {
0 => packed_row,
words => (words * 4).max(packed_row),
let src = raw
.get(off..off.checked_add(len).ok_or_else(|| RawError::Corrupt("RAF strip size overflow".into()))?)
.ok_or_else(|| RawError::Corrupt("RAF strip outside file".into()))?;
let cfa = cfa(&h);
let compression = be32(bytes, 108).unwrap_or(0);
let compressed = match compression {
0 => false,
2 | 3 => true,
_ => return Err(RawError::Unsupported(format!("Fujifilm RAF compression mode {compression}"))),
};
if (len as u64) < (stride as u64) * (hgt as u64 - 1) + packed_row as u64 {
return Err(RawError::Unsupported(format!("Fujifilm compressed RAF ({len} bytes for {w}x{hgt} {bits}-bit)")));
}
let src = raw.get(off..off.saturating_add(len).min(raw.len())).ok_or_else(|| RawError::Corrupt("RAF strip outside file".into()))?;
let mut data = Vec::new();
if mode == Mode::Full {
data = vec![0u16; n];
data.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
let s = src.get(y * stride..(y * stride + packed_row).min(src.len())).unwrap_or(&[]);
unpack_row(s, store, row);
});
if compressed {
if cfa.width == 6 && cfa != Cfa::xtrans() {
return Err(RawError::Unsupported("Fujifilm compressed RAF with an unknown X-Trans layout".into()));
}
let flag = if compression == 2 { 1 } else { 0 };
if src.get(2) != Some(&flag) {
return Err(RawError::Corrupt("RAF container and stream compression modes disagree".into()));
}
data = super::rafc::decode(src, w, hgt, bits, cfa.width == 6, mode)?;
} else {
// Samples may use 16-bit words (e.g. 14-bit X-T20); otherwise they are packed at `bits`.
let store = if len as u64 >= (n as u64) * 2 { 16 } else { bits };
let packed_row = w.checked_mul(store as usize).map(|n| n.div_ceil(8)).ok_or(RawError::Limit("RAF row too large"))?;
let stride = match get(0xf005)?.unwrap_or(0) {
0 => packed_row,
words => words.checked_mul(4).ok_or(RawError::Limit("RAF stride too large"))?.max(packed_row),
};
let required = stride.checked_mul(hgt - 1).and_then(|n| n.checked_add(packed_row)).ok_or(RawError::Limit("RAF strip too large"))?;
if len < required {
return Err(RawError::Unsupported(format!("Fujifilm RAF packing ({len} bytes for {w}x{hgt} {bits}-bit)")));
}
if mode == Mode::Full {
data = vec![0u16; n];
data.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
// `required <= src.len()` proves these row offsets fit, including the final row.
let s = src.get(y * stride..y * stride + packed_row).unwrap_or(&[]);
unpack_row(s, store, row);
});
}
}
let cfa = cfa(&h);
let active = match (pair(&h, CROP_TOP_LEFT), pair(&h, CROPPED_SIZE)) {
(Some((top, left)), Some((ch, cw))) if ch > 0 && cw > 0 => Rect::new(left, top, cw, ch).clipped(w, hgt),
_ => Rect::new(0, 0, w, hgt),
@@ -194,7 +215,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
}
#[cfg(test)]
mod tests {
pub(super) mod tests {
use super::*;
/// Build a minimal RAF: header, records, raw block (LE TIFF whose IFD0 points to a 0xf000 raw IFD).
@@ -247,6 +268,10 @@ mod tests {
dir.extend_from_slice(&recs);
let mut out = b"FUJIFILMCCD-RAW 0201FF000000TEST".to_vec();
out.resize(108, 0);
if strip.starts_with(b"IS") {
let compression = if strip.get(2) == Some(&0) { 3u32 } else { 2u32 };
out.extend_from_slice(&compression.to_be_bytes());
}
let jpeg_off = 160u32;
let dir_off = jpeg_off + jpeg.len() as u32;
let raw_off = dir_off + dir.len() as u32;
@@ -310,8 +335,24 @@ mod tests {
}
let bytes = raf(w, h, 16, pack(&[0; 96], 16), None, b"");
assert_eq!(crate::decode(&bytes).unwrap().cfa.unwrap().name(), "RGGB");
let mut px = vec![0; 96];
px[0] = 0x5349;
let mut bytes = raf(w, h, 16, pack(&px, 16), None, b"");
bytes[108..112].copy_from_slice(&0u32.to_be_bytes());
assert_eq!(crate::decode(&bytes).unwrap().data, RawData::U16(px));
// too little data for the size: compressed
let bytes = raf(w, h, 14, vec![0; 20], None, b"");
assert!(matches!(crate::decode(&bytes), Err(RawError::Unsupported(_))));
}
#[test]
fn overflowing_row_stride_is_an_error() {
let mut bytes = raf(16, 6, 16, vec![0; 192], None, &[]);
let off = be32(&bytes, 100).unwrap();
// Replace WB with a LONG8 stride, pointing to the existing out-of-line WB storage.
bytes[off + 100..off + 104].copy_from_slice(&[0x05, 0xf0, 16, 0]);
bytes[off + 104..off + 108].copy_from_slice(&1u32.to_le_bytes());
bytes[off + 116..off + 124].copy_from_slice(&u64::MAX.to_le_bytes());
assert!(matches!(crate::decode(&bytes), Err(RawError::Limit(_))));
}
}
+505
View File
@@ -0,0 +1,505 @@
//! Fujifilm's striped predictive RAW compression.
//!
//! Clean-room implementation: Fabian's prose description of lossless RAF compression
//! (https://capnfabs.net/posts/fuji-raf-compression-algorithm/) supplied the broad structure;
//! headers, colour-vector placement, predictors, contexts and adaptation were recovered
//! from sample bytes and black-box comparisons of decoded sensor arrays. No other RAW
//! decoder's source was consulted. See docs/raf-compression.md for verification details.
use crate::{Mode, RawError, Result};
use rayon::prelude::*;
const BLOCK: usize = 768;
const XTRANS: [[usize; 6]; 6] =
[[1, 1, 0, 1, 1, 2], [1, 1, 2, 1, 1, 0], [2, 0, 1, 0, 2, 1], [1, 1, 2, 1, 1, 0], [1, 1, 0, 1, 1, 2], [0, 2, 1, 2, 0, 1]];
const MASKS: [[u8; 2]; 6] = [[10, 15], [15, 10], [14, 10], [15, 11], [11, 15], [10, 14]];
fn corrupt(why: &str) -> RawError {
RawError::Corrupt(format!("Fujifilm compressed RAF: {why}"))
}
fn be16(src: &[u8], at: usize) -> Result<usize> {
let s = src.get(at..at.saturating_add(2)).ok_or_else(|| corrupt("truncated header"))?;
let [a, b] = s else { return Err(corrupt("truncated header")) };
Ok(u16::from_be_bytes([*a, *b]) as usize)
}
struct Stripe<'a> {
bytes: &'a [u8],
quantizers: Option<&'a [u8]>,
width: usize,
}
pub(super) fn decode(src: &[u8], width: usize, height: usize, bits: u32, xtrans: bool, mode: Mode) -> Result<Vec<u16>> {
let h = src.get(..16).ok_or_else(|| corrupt("truncated header"))?;
if !h.starts_with(b"IS") {
return Err(corrupt("missing compression signature"));
}
let flag = h.get(2).copied().ok_or_else(|| corrupt("truncated header"))?;
if flag > 1 {
return Err(corrupt("invalid compression flag"));
}
let sensor = h.get(3).copied().ok_or_else(|| corrupt("truncated header"))?;
if sensor != if xtrans { 16 } else { 0 } {
return Err(corrupt("compression sensor type disagrees with CFA"));
}
if !matches!(bits, 12 | 14 | 16) {
return Err(RawError::Unsupported(format!("Fujifilm compressed RAF at {bits} bits")));
}
let count = h.get(13).copied().ok_or_else(|| corrupt("truncated header"))? as usize;
if h.get(4).copied() != Some(bits as u8)
|| be16(h, 5)? != height
|| be16(h, 9)? != width
|| count == 0
|| count != width.div_ceil(BLOCK)
|| be16(h, 7)? != count * BLOCK
|| be16(h, 11)? != BLOCK
|| height == 0
|| !height.is_multiple_of(6)
|| be16(h, 14)? != height / 6
{
return Err(corrupt("inconsistent compression dimensions"));
}
let n = width.checked_mul(height).filter(|&n| n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?;
let table_end = 16 + count * 4;
let mut offset = table_end.div_ceil(16) * 16;
let quantizer_start = offset;
let quantizer_stride = (height / 6).div_ceil(16) * 16;
if flag == 0 {
offset += quantizer_stride * count;
if src.get(quantizer_start..offset).is_none() {
return Err(corrupt("truncated quantizer tables"));
}
}
let mut stripes = Vec::with_capacity(count);
for i in 0..count {
let entry = src.get(16 + i * 4..20 + i * 4).ok_or_else(|| corrupt("truncated stripe size table"))?;
let [a, b, c, d] = entry else { return Err(corrupt("truncated stripe size table")) };
let size = u32::from_be_bytes([*a, *b, *c, *d]) as usize;
let end = offset.checked_add(size).ok_or_else(|| corrupt("stripe size overflow"))?;
let bytes = src.get(offset..end).filter(|s| !s.is_empty()).ok_or_else(|| corrupt("stripe outside file"))?;
let quantizers = if flag == 0 {
Some(
src.get(quantizer_start + i * quantizer_stride..quantizer_start + i * quantizer_stride + height / 6)
.ok_or_else(|| corrupt("truncated quantizer table"))?,
)
} else {
None
};
stripes.push(Stripe { bytes, quantizers, width: (width - i * BLOCK).min(BLOCK) });
offset = end;
}
if mode == Mode::Header {
return Ok(Vec::new());
}
let decoded: Vec<Vec<u16>> = stripes.par_iter().map(|s| decode_stripe(s, height, bits, xtrans)).collect::<Result<_>>()?;
let mut output = vec![0; n];
for (i, (stripe, data)) in stripes.iter().zip(&decoded).enumerate() {
for (row, samples) in output.chunks_exact_mut(width).zip(data.chunks_exact(stripe.width)) {
row.get_mut(i * BLOCK..i * BLOCK + stripe.width).ok_or_else(|| corrupt("invalid stripe placement"))?.copy_from_slice(samples);
}
}
Ok(output)
}
struct Bits<'a> {
src: &'a [u8],
pos: usize,
acc: u64,
available: u32,
}
impl<'a> Bits<'a> {
fn new(src: &'a [u8]) -> Self {
Self { src, pos: 0, acc: 0, available: 0 }
}
#[inline]
fn get(&mut self, n: u32) -> Result<u32> {
while self.available < n {
let b = self.src.get(self.pos).copied().ok_or_else(|| corrupt("truncated entropy stream"))?;
self.pos += 1;
self.acc = (self.acc << 8) | u64::from(b);
self.available += 8;
}
self.available -= n;
Ok(((self.acc >> self.available) & ((1u64 << n) - 1)) as u32)
}
fn residual(&mut self, k: u32, bits: u32, range: u32, limit: u32) -> Result<i32> {
let mut zeros = 0;
while self.get(1)? == 0 {
zeros += 1;
if zeros > limit {
return Err(corrupt("invalid residual escape"));
}
}
let code = if zeros == limit { self.get(bits)? + 1 } else { (zeros << k) | self.get(k)? };
if code > range {
return Err(corrupt(&format!(
"residual {code} outside sample range {range} (k={k}, prefix={zeros}, bits={bits}, position={})",
self.pos * 8 - self.available as usize
)));
}
Ok(if code & 1 == 0 { (code / 2) as i32 } else { -((code / 2) as i32) - 1 })
}
}
#[derive(Clone, Copy)]
struct State {
sum: u32,
count: u32,
}
impl State {
fn k(self) -> u32 {
let mut k = 0;
while self.count << k < self.sum {
k += 1;
}
k
}
fn update(&mut self, delta: i32) {
self.sum += delta.unsigned_abs();
if self.count == 64 {
self.sum >>= 1;
self.count >>= 1;
}
self.count += 1;
}
}
#[inline]
fn quant(v: i32, near: u32) -> i32 {
let t = [18 + 3 * near, 67 + 5 * near, 276 + 7 * near];
let a = v.unsigned_abs();
let bucket = if a <= near {
0
} else if a < t[0] {
1
} else if a < t[1] {
2
} else if a < t[2] {
3
} else {
4
};
if v < 0 { -bucket } else { bucket }
}
#[inline]
fn even_prediction(nw: i32, n: i32, ne: i32, nn: i32) -> i32 {
let (a, b, c) = ((nw - n).abs(), (ne - n).abs(), (nn - n).abs());
let pair = if a > b && a > c {
ne + nn
} else if b > a && b > c {
nw + nn
} else {
nw + ne
};
(pair + 2 * n) >> 2
}
#[inline]
fn sample(rows: &[Vec<i32>], y: usize, x: usize) -> Result<i32> {
rows.get(y).and_then(|r| r.get(x)).copied().ok_or_else(|| corrupt("invalid predictor position"))
}
fn decode_stripe(stripe: &Stripe<'_>, height: usize, bits: u32, xtrans: bool) -> Result<Vec<u16>> {
let size = if xtrans { BLOCK * 2 / 3 } else { BLOCK / 2 };
let mut previous = vec![vec![vec![0; size + 2]; 2]; 3];
let max = (1u32 << bits) - 1;
let range = |near: u32| (max + 2 * near) / (2 * near + 1) + 1;
let seed = |near: u32| State { sum: ((range(near) + 32) / 64).max(2), count: 1 };
let mut states = [[[seed(0); 41]; 2]; 3];
// Smooth regions retain finer precision and independent state across block quantizer changes.
let mut fine = [[[[seed(0); 5]; 2]; 3], [[[seed(1); 5]; 2]; 3], [[[seed(2); 5]; 2]; 3]];
let mut last_near = None;
let mut reader = Bits::new(stripe.bytes);
let n = height.checked_mul(stripe.width).filter(|&n| n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?;
let mut output = Vec::with_capacity(n);
for group in 0..height / 6 {
let near = match stripe.quantizers {
Some(q) => u32::from(*q.get(group).ok_or_else(|| corrupt("truncated quantizer table"))?),
None => 0,
};
if last_near != Some(near) {
states = [[[seed(near); 41]; 2]; 3];
last_near = Some(near);
}
let mut rows = previous;
for pair in 0..6 {
let colours = if pair % 2 == 0 { [0, 1] } else { [1, 2] };
for &colour in &colours {
let lines = rows.get_mut(colour).ok_or_else(|| corrupt("invalid colour vector"))?;
let mut row = vec![0; size + 2];
let prev = lines.len() - 1;
let left = sample(lines, prev, 1)?;
let right = sample(lines, prev, size)?;
if let Some(v) = row.first_mut() {
*v = left;
}
if let Some(v) = row.last_mut() {
*v = right;
}
lines.push(row);
}
for even in (0..size + 8).step_by(2) {
for (odd, position) in [(0, Some(even)), (1, even.checked_sub(7))] {
let Some(pos) = position.filter(|&x| x < size) else { continue };
let x = pos + 1;
for (which, &colour) in colours.iter().enumerate() {
let lines = rows.get_mut(colour).ok_or_else(|| corrupt("invalid colour vector"))?;
let y = lines.len() - 1;
let (nw, north, ne, nn) =
(sample(lines, y - 1, x - 1)?, sample(lines, y - 1, x)?, sample(lines, y - 1, x + 1)?, sample(lines, y - 2, x)?);
let west = sample(lines, y, x - 1)?;
let east = sample(lines, y, x + 1)?;
let (predict, d1, d2) = if odd == 0 {
(even_prediction(nw, north, ne, nn), north - nn, nw - north)
} else {
let p = if (nw.min(ne)..=nw.max(ne)).contains(&north) { (west + east) >> 1 } else { (west + east + 2 * north) >> 2 };
(p, north - nw, nw - west)
};
let real = !xtrans || MASKS.get(pair).and_then(|m| m.get(which)).is_some_and(|m| (m >> (pos % 4)) & 1 != 0);
let value = if real {
let contrast = d1.unsigned_abs() + d2.unsigned_abs();
let precision = match contrast {
0..=5 => 0,
6 => near.min(1),
7 => near.min(2),
_ => near,
};
let (context, state) = if precision < near {
let sign = |v: i32| if v.unsigned_abs() <= precision { 0 } else { v.signum() };
let context = 3 * sign(d1) + sign(d2);
let state = fine
.get_mut(precision as usize)
.and_then(|f| f.get_mut(pair % 3))
.and_then(|s| s.get_mut(odd))
.and_then(|s| s.get_mut(context.unsigned_abs() as usize))
.ok_or_else(|| corrupt("invalid fine coding context"))?;
(context, state)
} else {
let context = 9 * quant(d1, near) + quant(d2, near);
let state = states
.get_mut(pair % 3)
.and_then(|s| s.get_mut(odd))
.and_then(|s| s.get_mut(context.unsigned_abs() as usize))
.ok_or_else(|| corrupt("invalid coding context"))?;
(context, state)
};
let coded_range = range(precision);
let coded_bits = 32 - (coded_range - 1).leading_zeros();
let mut delta = reader.residual(state.k(), coded_bits, coded_range, 4 * bits - coded_bits - 1)?;
state.update(delta);
if context < 0 {
delta = -delta;
}
let step = (2 * precision + 1) as i32;
let mut value = predict + delta * step;
if value < -(precision as i32) {
value += coded_range as i32 * step;
} else if value > (max + precision) as i32 {
value -= coded_range as i32 * step;
}
value.clamp(0, max as i32)
} else {
predict
};
*lines.get_mut(y).and_then(|r| r.get_mut(x)).ok_or_else(|| corrupt("invalid predictor position"))? = value;
}
}
}
}
for y in 0..6 {
for x in 0..stripe.width {
let colour = if xtrans {
XTRANS.get(y).and_then(|r| r.get(x % 6)).copied().ok_or_else(|| corrupt("invalid CFA position"))?
} else {
match (y % 2, x % 2) {
(0, 0) => 0,
(1, 1) => 2,
_ => 1,
}
};
let row = 2 + if colour == 1 { y } else { y / 2 };
let col = 1 + if xtrans { 2 * (x / 3) + (x % 3).min(1) } else { x / 2 };
output.push(sample(rows.get(colour).ok_or_else(|| corrupt("invalid colour vector"))?, row, col)? as u16);
}
}
previous = rows.into_iter().map(|mut r| r.split_off(r.len() - 2)).collect();
}
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Cfa;
use proptest::prelude::*;
/// Procedural zero residuals: no camera media or encoder dependency.
fn zero_stream(bits: u32, xtrans: bool, quantizers: Option<&[u8]>) -> Vec<u8> {
let groups = quantizers.map_or(3, <[u8]>::len);
let mut main = [[(1u32 << (bits - 6), 1u32); 2]; 3];
let mut fine = main;
let mut previous = None;
let mut stream = Vec::new();
for group in 0..groups {
let q = quantizers.map_or(0, |q| q[group]);
if previous != Some(q) {
main = [[(1u32 << (bits - 6), 1u32); 2]; 3];
previous = Some(q);
}
for pair in 0..6 {
let width = if xtrans { 512 } else { 384 };
for even in (0usize..width + 8).step_by(2) {
for (parity, column) in [(0, Some(even)), (1, even.checked_sub(7))] {
let Some(column) = column.filter(|&x| x < width) else { continue };
for colour in 0..2 {
if xtrans && (MASKS[pair][colour] >> (column % 4)) & 1 == 0 {
continue;
}
let (sum, count) = &mut if q == 0 { &mut main } else { &mut fine }[pair % 3][parity];
let k = 32 - (sum.saturating_sub(1) / *count).leading_zeros();
stream.push(true);
stream.extend(std::iter::repeat_n(false, k as usize));
if *count == 64 {
*sum /= 2;
*count /= 2;
}
*count += 1;
}
}
}
}
}
stream.chunks(8).map(|chunk| chunk.iter().enumerate().fold(0, |b, (i, bit)| b | (u8::from(*bit) << (7 - i)))).collect()
}
fn compressed(bits: u32, xtrans: bool, quantizers: Option<&[u8]>) -> Vec<u8> {
let groups = quantizers.map_or(3, <[u8]>::len);
let stream = zero_stream(bits, xtrans, quantizers);
// Two stripes; the second is mostly padding, which must never enter the output.
let mut out = b"IS".to_vec();
out.extend_from_slice(&[u8::from(quantizers.is_none()), if xtrans { 16 } else { 0 }, bits as u8]);
for v in [groups as u16 * 6, 1536, 780, 768] {
out.extend_from_slice(&v.to_be_bytes());
}
out.push(2);
out.extend_from_slice(&(groups as u16).to_be_bytes());
for _ in 0..2 {
out.extend_from_slice(&(stream.len() as u32).to_be_bytes());
}
out.resize(32, 0);
if let Some(q) = quantizers {
for _ in 0..2 {
out.extend_from_slice(q);
out.resize(out.len() + q.len().div_ceil(16) * 16 - q.len(), 0);
}
}
out.extend_from_slice(&stream);
out.extend_from_slice(&stream);
out
}
#[test]
fn lossless_and_lossy_bayer_and_xtrans_stripes() {
for bits in [12, 14, 16] {
for xtrans in [false, true] {
for q in [None, Some([2, 0, 5].as_slice())] {
let src = compressed(bits, xtrans, q);
let out = decode(&src, 780, 18, bits, xtrans, Mode::Full).unwrap();
assert_eq!(out, vec![0; 780 * 18]);
assert!(decode(&src, 780, 18, bits, xtrans, Mode::Header).unwrap().is_empty());
let layout = xtrans.then(|| {
let mut l = [0; 36];
for (to, from) in l.iter_mut().zip(XTRANS.iter().flatten().rev()) {
*to = *from as u8;
}
l
});
let raf = super::super::raf::tests::raf(780, 18, bits, src, layout, &[]);
let image = crate::decode(&raf).unwrap();
assert_eq!(crate::probe_info(&raf).unwrap(), image.info());
assert_eq!(image.data, crate::RawData::U16(out));
}
}
}
}
#[test]
fn malformed_headers_and_truncated_entropy_are_errors() {
let valid = compressed(14, true, Some(&[1, 2, 0]));
for len in (0..96).chain([valid.len() / 2, valid.len() - 1]) {
assert!(decode(&valid[..len], 780, 18, 14, true, Mode::Full).is_err(), "length {len}");
}
for at in [2, 3, 4, 5, 7, 9, 11, 13, 14, 16] {
let mut src = valid.clone();
src[at] ^= 0xff;
assert!(decode(&src, 780, 18, 14, true, Mode::Full).is_err(), "header byte {at}");
}
assert!(decode(&valid, usize::MAX, usize::MAX, 14, true, Mode::Full).is_err());
let mut reader = Bits::new(&[0; 64]);
assert!(reader.residual(0, 14, 16384, 41).is_err());
}
#[test]
fn unsupported_container_modes_and_cfa_are_not_silently_decoded() {
let layout = std::array::from_fn(|i| Cfa::xtrans().pattern[35 - i]);
let src = compressed(14, true, None);
let raf = super::super::raf::tests::raf(780, 18, 14, src, Some(layout), &[]);
let mut mismatched = raf.clone();
mismatched[108..112].copy_from_slice(&3u32.to_be_bytes());
assert!(crate::decode(&mismatched).is_err(), "declared lossy mode disagrees with lossless stream");
let bad_layout = layout.map(|c| {
if c == 0 {
2
} else if c == 2 {
0
} else {
c
}
});
let raf = super::super::raf::tests::raf(780, 18, 14, compressed(14, true, None), Some(bad_layout), &[]);
assert!(matches!(crate::decode(&raf), Err(RawError::Unsupported(_))), "compression uses a fixed X-Trans arrangement");
let mut raf = super::super::raf::tests::raf(780, 18, 16, vec![0; 780 * 18 * 2], None, &[]);
for mode in [1u32, 4, u32::MAX] {
raf[108..112].copy_from_slice(&mode.to_be_bytes());
assert!(matches!(crate::decode(&raf), Err(RawError::Unsupported(_))), "unknown mode {mode}");
assert!(matches!(crate::probe_info(&raf), Err(RawError::Unsupported(_))), "header-only mode {mode}");
}
}
#[test]
fn residual_sign_wrap_and_escape() {
// Ordinary codes 0, 1, 2, 3 with k=2; then the escape for -8192.
let mut stream = vec![true, false, false, true, false, true, true, true, false, true, true, true];
stream.extend(std::iter::repeat_n(false, 41));
stream.push(true);
let escaped = 16382u32;
stream.extend((0..14).rev().map(|i| (escaped >> i) & 1 != 0));
let bytes: Vec<u8> = stream.chunks(8).map(|chunk| chunk.iter().enumerate().fold(0, |b, (i, bit)| b | (u8::from(*bit) << (7 - i)))).collect();
let mut reader = Bits::new(&bytes);
for delta in [0, -1, 1, -2, -8192] {
assert_eq!(reader.residual(2, 14, 16384, 41).unwrap(), delta);
}
assert_eq!(even_prediction(1112, 858, 1104, 1152), 983);
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn malformed_streams_never_panic(data in prop::collection::vec(any::<u8>(), 0..2048), quantizer in any::<u8>(), bits in prop::sample::select(vec![12,14,16]), xtrans in any::<bool>()) {
let mut src = compressed(bits, xtrans, Some(&[quantizer]));
let start = 64;
src.truncate(start);
for i in [16, 20] { src[i..i+4].copy_from_slice(&(data.len() as u32).to_be_bytes()); }
src.extend_from_slice(&data);
src.extend_from_slice(&data);
let _ = decode(&src, 780, 6, bits, xtrans, Mode::Full);
}
}
}
+121 -8
View File
@@ -1,7 +1,7 @@
//! Corpus test over `corpus/raw/**` (git-ignored CC0 samples from raw.pixls.us, fetched with
//! `cargo xtask corpus --download`; `LIGHTCRAFT_CORPUS` overrides the corpus root). Skips cleanly when absent.
//!
//! Every file must be recognised, carry an embedded JPEG preview (DNG: optional), and either decode to a valid image or report
//! Every file must be recognised, carry an embedded JPEG preview (optional for DNG, older Panasonic RAW and HEVC-preview CR3), and either decode to a valid image or report
//! `Unsupported` for one of the variants we know we don't decode yet. Prints decode times
//! (`cargo test -p lightcraft-raw --release --test corpus -- --nocapture`).
@@ -15,10 +15,9 @@ fn corpus_root() -> PathBuf {
/// Variants known not to decode yet (see the crate docs): matched against the lower-case file name.
const KNOWN_UNSUPPORTED: &[&str] = &[
"cr3-", // CR3 / CRX (M11.1)
"raf-fuji-xt20-compressed", // Fujifilm compressed RAF
"orf-olympus-em", // Olympus compressed ORF
"sraw", // Canon sRAW / mRAW
"cr3-", // Unverified CRX coding variants; exact supported cases live in cr3_corpus.rs.
"orf-olympus-em", // Olympus compressed ORF
"sraw", // Canon sRAW / mRAW
];
#[test]
@@ -42,11 +41,14 @@ fn corpus_raw_decodes() {
let preview = embedded_preview(&bytes);
let tp = t0.elapsed().as_secs_f64() * 1e3;
// DNG previews are optional (and some carry only an uncompressed RGB thumbnail); vendor raws embed a JPEG,
// except the Panasonic `.RAW` files of 2005–2007
// except the Panasonic `.RAW` files of 2005–2007 and Canon's explicit HEVC preview tracks.
if let Some(p) = &preview {
assert!(p.starts_with(&[0xff, 0xd8]) && p.ends_with(&[0xff, 0xd9]), "{name}: preview is not a JPEG");
} else {
assert!(fmt == RawFormat::Dng || name.starts_with("raw-panasonic-"), "{name}: no embedded preview");
let hevc_preview = fmt == RawFormat::Cr3
&& lightcraft_meta::cr3::parse_cr3(&bytes)
.is_some_and(|c| c.tracks.iter().any(|t| t.kind == lightcraft_meta::cr3::Cr3TrackKind::Other(*b"HEVC") && t.data.is_some()));
assert!(fmt == RawFormat::Dng || name.starts_with("raw-panasonic-") || hevc_preview, "{name}: no embedded preview");
}
let preview_kb = preview.as_ref().map_or(0, |p| p.len() / 1024);
let t1 = Instant::now();
@@ -86,7 +88,7 @@ fn corpus_raw_decodes() {
Err(e) => panic!("{name}: {e}"),
}
}
eprintln!("corpus/raw: {ok} decoded, {unsupported} known-unsupported (preview only)");
eprintln!("corpus/raw: {ok} decoded, {unsupported} known-unsupported (embedded JPEG when available)");
}
/// Green-channel means of 32×32 blocks.
@@ -166,6 +168,34 @@ fn corpus_cr2_cfa_patterns() {
eprintln!("CR2 CFA layouts checked on {seen} files");
}
/// The as-shot white balance of Canon CR2s whose ColorData is stored as SHORT words stays readable (the PowerShot /
/// EOS M files that store it as UNDEFINED bytes are covered by the unit test in `vendor/cr2.rs`; none is in this corpus).
/// Daylight-ish CR2s have R/G above 1 and B/G between 1 and 2.5 (observed 1.4-2.7 on these nine bodies).
#[test]
fn corpus_cr2_as_shot_white_balance() {
let dir = corpus_root().join("raw");
let mut seen = 0;
for name in [
"cr2-canon-40d.cr2",
"cr2-canon-550d.cr2",
"cr2-canon-5d2.cr2",
"cr2-canon-5d3.cr2",
"cr2-canon-5dsr.cr2",
"cr2-canon-6d.cr2",
"cr2-canon-7d.cr2",
"cr2-canon-80d.cr2",
] {
let Ok(bytes) = std::fs::read(dir.join(name)) else {
eprintln!("skip: {name} absent");
continue;
};
let wb = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")).wb_multipliers.unwrap_or_else(|| panic!("{name}: no as-shot WB"));
assert!(wb[1] == 1.0 && wb[0] > 1.0 && wb[2] > 0.5 && wb[0] < 4.0 && wb[2] < 4.0, "{name}: WB {wb:?}");
seen += 1;
}
eprintln!("CR2 as-shot WB checked on {seen} files");
}
/// raw.pixls.us has the same D5100 scene as 14-bit lossless compressed and uncompressed NEF: the Huffman decode must
/// match the uncompressed image (up to the small differences between two exposures).
#[test]
@@ -456,3 +486,86 @@ fn corpus_orf_cfa_patterns() {
}
eprintln!("ORF CFA layouts checked on {seen} files");
}
/// Reference sensor sums and position-weighted sums from black-box decoding, independent
/// of this implementation. These cover complete images, padding, stripe boundaries and
/// lossy block quantizer changes; the source files are CC0, fetched by `xtask corpus`.
#[test]
fn corpus_fujifilm_compressed_samples() {
use sha2::{Digest, Sha256};
let checksums = include_str!("../../../docs/raf-corpus.sha256");
let cases: &[(&str, usize, usize, u64, u64)] = &[
("raf-fuji-gfx100-3773.raf", 11808, 8754, 483668733090, 6453395759968151080),
("raf-fuji-gfx100-3775.raf", 11808, 8754, 120434414850, 6144091676930008551),
("raf-fuji-gfx100rf-8091.raf", 11808, 8754, 509005112862, 6771145666297463071),
("raf-fuji-gfx100s-4495.raf", 11808, 8754, 941987648006, 4811558780180901389),
("raf-fuji-gfx100s-4503.raf", 11808, 8754, 939580201129, 4821879272388485929),
("raf-fuji-gfx50s-1435.raf", 9216, 6210, 79545293042, 2565169091480702981),
("raf-fuji-xe5-8509.raf", 7872, 5196, 76435253154, 1299804440011842144),
("raf-fuji-xh2-6001.raf", 7872, 5196, 83480264024, 1799924693528487099),
("raf-fuji-xh2-6002.raf", 7872, 5196, 72476569659, 1547275125734054244),
("raf-fuji-xm5-7748.raf", 6336, 4182, 93243605328, 986712869515471030),
("raf-fuji-xt2-865.raf", 6048, 4038, 44212684244, 541983062567959797),
("raf-fuji-xt20-compressed.raf", 6048, 4038, 72690241711, 882852288583469317),
("raf-fuji-xt4-3914.raf", 6384, 4182, 45943146703, 599112804573868025),
("raf-fuji-xt4-3918.raf", 6384, 4182, 46401490361, 605690093061448074),
("raf-fuji-xt5-6122.raf", 7872, 5196, 109559716516, 1653376659872066392),
("raf-fuji-xt5-6123.raf", 7872, 5196, 109489917369, 1673725237502600190),
("raf-fuji-xt50-7807.raf", 7872, 5196, 76453904143, 1262190863834170778),
];
let dir = corpus_root().join("raw");
let mut seen = 0;
for &(name, width, height, sum, weighted) in cases {
let path = format!("corpus/raw/{name}");
let checksum = checksums.lines().filter_map(|s| s.split_once(" ")).find(|(_, p)| *p == path).unwrap().0;
assert_eq!(checksum.len(), 64, "{name}: missing published SHA-256");
let Ok(bytes) = std::fs::read(dir.join(name)) else { continue };
let actual_sha: String = Sha256::digest(&bytes).iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(actual_sha, checksum, "{name}: corpus file differs from its pinned identity");
let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!((img.width, img.height), (width, height), "{name}");
let lightcraft_raw::RawData::U16(data) = img.data else { panic!("{name}: float data") };
let actual = data
.iter()
.enumerate()
.fold((0u64, 0u64), |(s, w), (i, &v)| (s.wrapping_add(u64::from(v)), w.wrapping_add((i as u64 + 1).wrapping_mul(u64::from(v)))));
assert_eq!(actual, (sum, weighted), "{name}: sensor samples differ from reference");
seen += 1;
}
eprintln!("verified {seen} Fujifilm compressed sensor arrays");
}
/// The container rule for TIFF "shells" (a small IFD0 next to a private raw block) must not touch real
/// raws: every corpus file keeps the format its maker implies, and none is described as a private block.
#[test]
fn corpus_raws_keep_their_container_and_are_not_thumbnail_shells() {
let dir = corpus_root().join("raw");
let Ok(rd) = std::fs::read_dir(&dir) else {
eprintln!("skip: {} absent", dir.display());
return;
};
let mut checked = 0;
for p in rd.flatten().map(|e| e.path()).filter(|p| p.is_file()) {
let name = p.file_name().unwrap().to_string_lossy().to_lowercase();
let expected = match name.split(['-', '.']).next().unwrap_or_default() {
"arw" => RawFormat::Arw,
"cr2" => RawFormat::Cr2,
"cr3" => RawFormat::Cr3,
"dng" => RawFormat::Dng,
"nef" => RawFormat::Nef,
"nrw" => RawFormat::Nef, // probed as Nef when the file has several IFDs
"orf" => RawFormat::Orf,
"pef" => RawFormat::Pef,
"raf" => RawFormat::Raf,
"rw2" | "rwl" | "raw" => RawFormat::Rw2,
_ => continue,
};
let bytes = std::fs::read(&p).unwrap();
assert_eq!(probe(&bytes), Some(expected), "{name}");
if let Err(RawError::Unsupported(why)) = probe_info(&bytes) {
assert!(!why.contains("private block"), "{name}: {why}");
}
checked += 1;
}
eprintln!("{checked} corpus raws keep their container");
}
+78
View File
@@ -0,0 +1,78 @@
//! Exact sensor-pixel regressions, not embedded-JPEG comparisons.
//!
//! Public-domain inputs from raw.pixls.us, kept in the gitignored corpus/raw.
//! Reference pixels were independently obtained by running the DNGLab v0.8.0
//! release executable as a black-box oracle (analyze --raw-pixel). Its source
//! and camera matrices were not used. Canonical FNV-1a hashes below are over
//! decoded u16 samples in row-major, little-endian order, including masked borders.
//! macOS oracle archive SHA256:
//! ee70805cb60f18d5ed62548c4e595f6cee0a33407e15358eb1766a46afc1c16e.
use lightcraft_raw::{RawData, RawFormat, decode, probe_info};
use std::path::{Path, PathBuf};
fn corpus() -> PathBuf {
std::env::var_os("LIGHTCRAFT_CORPUS").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
}
fn check(name: &str, hash: u64, sensor: (usize, usize)) {
let path = corpus().join("raw").join(name);
let Ok(bytes) = std::fs::read(&path) else {
eprintln!("skip: {} absent", path.display());
return;
};
let raw = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(raw.format, RawFormat::Cr3);
raw.validate().unwrap();
assert_eq!((raw.width, raw.height, raw.cpp), (sensor.0, sensor.1, 1));
assert_eq!(raw.info().developed_size(), (6000, 4000));
assert_eq!(raw.info(), probe_info(&bytes).unwrap());
let RawData::U16(samples) = &raw.data else { panic!("{name}: integer sensor data expected") };
let actual =
samples.iter().flat_map(|v| v.to_le_bytes()).fold(0xcbf2_9ce4_8422_2325u64, |h, byte| (h ^ u64::from(byte)).wrapping_mul(0x100_0000_01b3));
assert_eq!(actual, hash, "{name}: sensor pixels differ from the independent reference");
assert!(raw.wb_multipliers.is_some(), "{name}: as-shot white balance missing");
assert!(raw.white_at(0) > raw.black.mean(), "{name}: invalid black/white levels");
}
#[test]
fn canon_m50_lossless_sensor_is_exact() {
// raw.pixls.us/getfile.php/4657; SHA256
// 1ba9ad6b51b315b88820eb0fe5fd7f52bc136ba37b1a5ca76825ebfe25b18058
check("cr3-canon-m50-raw.cr3", 0x6226_1f0b_a81c_fcd2, (6288, 4056));
}
#[test]
fn canon_r100_lossless_sensor_is_exact() {
// raw.pixls.us/getfile.php/7896; SHA256
// 6d83217d58a5e6d2dabcf470430e91a16676b60531c025b81fa7453fc74b0521
check("cr3-canon-r100-raw.cr3", 0xcbe5_299a_b9c5_2630, (6288, 4056));
}
#[test]
fn canon_m50_craw_sensor_is_exact() {
// raw.pixls.us/getfile.php/2663; SHA256
// 15384b775867ec4c42b11882837f1e368cedc0561832ffab271221e6bb80be4c
check("cr3-canon-m50-craw.cr3", 0x9aac_bfe6_6f50_5e1b, (6288, 4056));
}
#[test]
fn canon_r100_craw_sensor_is_exact() {
// raw.pixls.us/getfile.php/7897; SHA256
// 0b66842b2fe00329ebc05fe8ab9357ddbbe1a0a2d2e69a2893102de7fdf5c942
check("cr3-canon-r100-craw.cr3", 0x341c_706b_37c3_8bbf, (6288, 4056));
}
#[test]
fn canon_r8_lossless_sensor_is_exact() {
// raw.pixls.us/getfile.php/6585; SHA256
// 7d5c6dbb11ff7e6ee58715d90a20f5d801e8b103f4711ff6ea329ceafcef254c
check("cr3-canon-r8-raw.cr3", 0x63c1_c6d8_d1eb_2312, (6188, 4120));
}
#[test]
fn canon_r8_craw_sensor_is_exact() {
// raw.pixls.us/getfile.php/6587; SHA256
// df33cf394573645ce03dea1b2e9f0b5cc2b7734e9e3391ee7114151414cf2812
check("cr3-canon-r8-craw.cr3", 0xf836_a663_a795_a375, (6188, 4120));
}
+191
View File
@@ -0,0 +1,191 @@
{
" ({unsure} with several look-alike files: use Locate)": " ({unsure} mit mehreren ähnlichen Dateien: „Fehlende Datei suchen“ verwenden)",
" ({} in Recently Deleted)": " ({} in „Zuletzt gelöscht“)",
" · {:.0}% clipped": " · {:.0}% beschnitten",
" · {failed} couldn't be rendered": " · nicht gerenderte Fotos: {failed}",
" · {skipped} skipped (file exists)": " · {skipped} übersprungen (Datei vorhanden)",
" · {} duplicate{} skipped": " · {} übersprungen (Duplikate){:.0}",
" · {} failed: {}": " · {} fehlgeschlagen: {}",
" · {} moved": " · {} verschoben",
" · {} not readable": " · {} nicht lesbar",
" · {} original{} left at the source": " · Originale an der Quelle verblieben: {}{:.0}",
" · {} restored from Recently Deleted": " · {} aus „Zuletzt gelöscht“ wiederhergestellt",
" — not carried over: {}{}": " — nicht übernommen: {}{}",
"+{} more": "+{} weitere",
", {failed} file{} not readable": ", nicht lesbare Dateien: {failed}{:.0}",
"; {kept} already had a location": "; bereits verortet: {kept}",
"; {outside} outside its time range": "; {outside} außerhalb des Zeitbereichs",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PRs, {} Commits, +{} / −{} Zeilen (Δ {}), +{} / −{} Binärdateien, {} – {}",
"Add {local_n} to My Photos": "{local_n} zu „Meine Fotos“ hinzufügen",
"Add {} Photo{}": "{} Foto{} hinzufügen",
"Add {} Photo{}, restore {}": "{} Foto{} hinzufügen, {} wiederherstellen",
"Add “{}” to Favorites": "„{}“ zu Favoriten hinzufügen",
"Added {n} photo{} to My Photos": "{n} Foto{} zu „Meine Fotos“ hinzugefügt",
"Added {n} photo{} to “{}”": "{n} Foto{0} zu „{1}“ hinzugefügt",
"Added {what} to {name}": "{what} zu {name} hinzugefügt",
"Adding photos… {} of {}": "Fotos werden hinzugefügt… {} von {}",
"All {n} selected photos move by the same amount.": "Alle {n} ausgewählten Fotos werden um denselben Betrag verschoben.",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Bereits in der Bibliothek unter „Zuletzt gelöscht“: {} Foto{} ausgewählt · Rechtsklick → Wiederherstellen, oder endgültig löschen, um neu zu importieren",
"Already in the library: {} photo{} selected in All Photos": "Bereits in der Bibliothek: {} Foto{} in „Alle Fotos“ ausgewählt",
"Auto ({})": "Automatisch ({})",
"Auto Import: added {} photo{}": "Automatischer Import: {} Foto{} hinzugefügt",
"Auto Sync: edits apply to {n} photos": "Automatische Synchronisierung: Bearbeitungen gelten für {n} Fotos",
"Automatic ({} MB)": "Automatisch ({} MB)",
"Build 1:1 Previews ({scope})": "1:1-Vorschauen erstellen ({scope})",
"Build Smart Previews ({scope})": "Smart-Vorschauen erstellen ({scope})",
"Build Standard-Sized Previews ({scope})": "Standardvorschauen erstellen ({scope})",
"Building previews for {} photos…": "Vorschauen für {} Fotos werden erstellt…",
"Click {}:1": "Klick {}:1",
"Couldn't open the editor: {e}": "Editor konnte nicht geöffnet werden: {e}",
"Create {n} Virtual Copies": "{n} virtuelle Kopien erstellen",
"Creates {} stacks from {} of {scope}": "Erstellt {} Stapel aus {} von {scope}",
"Decoder: {reason}": "Decoder: {reason}",
"Delete \"{label}\"": "„{label}“ löschen",
"Delete \"{label}\" (and the mask)": "„{label}“ löschen (und die Maske)",
"Delete {n} Photos": "{n} Fotos löschen",
"Delete “{current}”": "„{current}“ löschen",
"Discard Smart Previews ({scope})": "Smart-Vorschauen verwerfen ({scope})",
"Done ({what}): {done} photo{plural}": "Fertig ({what}): {done} Foto{plural}",
"Duplicate \"{label}\"": "„{label}“ duplizieren",
"Example: {example}": "Beispiel: {example}",
"Exported {n} photo{}": "{n} Foto{} exportiert",
"Exported {ok} of {total} photo{}": "{ok} von {total} Foto{} exportiert",
"Exported {} point curve preset{}": "Exportierte Punktkurvenvorgaben: {}{:.0}",
"Exported {} preset{}": "Exportierte Vorgaben: {}{:.0}",
"Exporting {} of {total}": "{} von {total} wird exportiert",
"Filter bar — {active} active filter{}": "Filterleiste — aktive Filter: {active}{:.0}",
"Focus {:.0}": "Schärfe {:.0}",
"Folder “{}” (and its subfolders)": "Ordner „{}“ (und seine Unterordner)",
"Found {n} missing photo{}; {left} still missing{unsure}": "Gefundene fehlende Fotos: {n}{:.0}; weiterhin fehlend: {left}{unsure}",
"Healed {n} dust spot{}": "Reparierte Staubflecken: {n}{:.0}",
"Import cancelled · {} photo{} added": "Import abgebrochen · {} Foto{} hinzugefügt",
"Import failed: {e}": "Import fehlgeschlagen: {e}",
"Imported {n} point curve preset{}": "Importierte Punktkurvenvorgaben: {n}{:.0}",
"Imported {n} preset{}": "Importierte Vorgaben: {n}{:.0}",
"Imported {n} preset{}, {f} file{} not readable": "Importierte Vorgaben: {n}{0:.0}, nicht lesbare Dateien: {f}{1:.0}",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "Importierte Profile: {profiles}{:.0} (Profilbrowser → jeweilige Gruppen)",
"Imported {} photo{}": "{} Foto{} importiert",
"Insert at the cursor (also written {})": "An Cursorposition einfügen (auch als {} geschrieben)",
"Label Set: {name}": "Farbmarkierungssatz: {name}",
"LightCraft could not open its window: {e}": "LightCraft konnte sein Fenster nicht öffnen: {e}",
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft konnte sein Fenster nicht öffnen: {e}\n\nEinzelheiten stehen in der Protokolldatei: {path}",
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Lightroom-Katalog geprüft: {photos} Fotos, {collections} Sammlungen, {missing} fehlen",
"Lightroom import complete: {imported} photos added": "Lightroom-Import abgeschlossen: {imported} Fotos hinzugefügt",
"Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom-Import abgeschlossen: {imported} Fotos hinzugefügt, {failed} fehlgeschlagen",
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom-Import abgeschlossen: {imported} Fotos hinzugefügt; Index-Warnung: {warning}",
"Lightroom import failed: {e}{warning}": "Lightroom-Import fehlgeschlagen: {e}{warning}",
"Lightroom inspection failed: {e}{warning}": "Lightroom-Prüfung fehlgeschlagen: {e}{warning}",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT ODER Apache-2.0. Schriften: {} (OFL). Symbole: eigene Gestaltung.",
"Matches: {}": "Treffer: {}",
"Merging… {stage} {:.0}%": "Wird zusammengefügt… {stage} {:.0}%",
"Move {what} to Recently Deleted?": "{what} nach „Zuletzt gelöscht“ verschieben?",
"Named ({named_n})": "Benannt ({named_n})",
"Painting “{k}”: click photos to add or remove it · Esc stops": "„{k}“ auftragen: Fotos anklicken, um es hinzuzufügen oder zu entfernen · Esc beendet",
"Paste into {n} photo{} — only settings that were copied are pasted": "In {n} Foto{} einfügen — nur kopierte Einstellungen werden eingefügt",
"Preset: {name}": "Vorgabe: {name}",
"Preview only — editing the camera's embedded JPEG ({})": "Nur Vorschau — das eingebettete Kamera-JPEG wird bearbeitet ({})",
"Preview only — {}": "Nur Vorschau — {}",
"Previews ready for {done} photo{plural}": "Vorschauen für {done} Foto{plural} fertig",
"Profile: {}": "Profil: {}",
"Proof Copy ({})": "Softproof-Kopie ({})",
"Proof Preview · {}": "Softproof-Vorschau · {}",
"Quick Develop · {n} photos": "Schnellentwicklung · {n} Fotos",
"Quick Develop: {name}": "Schnellentwicklung: {name}",
"Rated {}": "Bewertet mit {}",
"Reading Lightroom catalog… {done} of {total}": "Lightroom-Katalog wird gelesen … {done} von {total}",
"Reading photos… {done} of {total}": "Fotos werden gelesen… {done} von {total}",
"Reading photos… {} of {}": "Fotos werden gelesen… {} von {}",
"Redo {}": "Wiederholen: {}",
"Remove “{name}” and its {count} photo{} from the library?": "„{name}“ mit {count} Foto{} aus der Bibliothek entfernen?",
"Remove “{}” from Favorites": "„{}“ aus Favoriten entfernen",
"Removed from {}": "Entfernt von {}",
"Removed {what} from {name}": "{what} aus {name} entfernt",
"Rename {n} Photos…": "{n} Fotos umbenennen…",
"Rename “{name}”": "„{name}“ umbenennen",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Benennt „{from}“ bei {n} Foto{} um (auch untergeordnete Stichwörter). Verwende | für Ebenen, z. B. Reise|Italien. Bei vorhandenem Namen werden beide zusammengeführt.",
"Rendering on the CPU: {why}": "Rendering auf der CPU: {why}",
"Replace {} with:": "{} ersetzen durch:",
"Reset {name}": "{name} zurücksetzen",
"Reset {} Channel": "Kanal {} zurücksetzen",
"Restore {}": "{} wiederherstellen",
"Restore {} Photo{}": "{} Foto{} wiederherstellen",
"Saved export preset “{}”": "Exportvorgabe „{}“ gespeichert",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Bewertet {scope} nach Schärfe und Belichtung und findet ähnliche Aufnahmen im Abstand weniger Sekunden (Serien).",
"Show {hidden} hidden location{}": "Ausgeblendete Orte anzeigen: {hidden}{:.0}",
"Showing {n} of {}": "{n} von {} werden angezeigt",
"Smart album: {}": "Smart-Album: {}",
"Tagged {n} photo{} from the tracklog": "{n} Foto{} anhand des GPS-Tracks verortet",
"The SAM 3 download failed: {e}": "SAM-3-Download fehlgeschlagen: {e}",
"Undo {}": "Rückgängig: {}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "Unbekannte Platzhalter{s:.0} {} bleiben unverändert{:.0} (siehe „Platzhalter“)",
"Updated “{name}”": "„{name}“ aktualisiert",
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "Version {} — eine unabhängig entwickelte Fotobibliothek und RAW-Entwicklung in reinem Rust.",
"Version: {}": "Version: {}",
"Virtual copy “{name}” of {of}": "Virtuelle Kopie „{name}“ von {of}",
"Working on {what} for {} photos…": "{what} für {} Fotos wird verarbeitet…",
"none (CPU rendering): {why}": "keine (CPU-Rendering): {why}",
"the {n} selected photos": "die {n} ausgewählten Fotos",
"the {} photos in view": "die {} Fotos in der Ansicht",
"these {count} photos": "diese {count} Fotos",
"{count} smart preview{} · {:.1} MB": "Smart-Vorschauen: {count}{0:.0} · {1:.1} MB",
"{f} folders (and their subfolders)": "{f} Ordner (und ihre Unterordner)",
"{label} Grading": "Farbtonung: {label}",
"{label} {d:+} on every selected photo": "{label} {d:+} für jedes ausgewählte Foto",
"{label} {index} of {n}": "{label} {index} von {n}",
"{label}, {n} photos": "{label}, Fotos: {n}",
"{matching} of {t} photos": "Fotos: {matching} von {t}",
"{matching} photos": "Fotos: {matching}",
"{mode} spot {} of {}": "{mode}: Stelle {} von {}",
"{month}": "{month:02}",
"{month} {year}": "{month:02}/{year}",
"{msg} ({failed} failed)": "{msg} ({failed} fehlgeschlagen)",
"{name} Label": "Farbmarkierung {name}",
"{name} Off": "{name} aus",
"{name} On": "{name} ein",
"{name} opened for editing; it is stacked with the original": "{name} zum Bearbeiten geöffnet; mit dem Original gestapelt",
"{name} — double-click to reset it": "{name} — zum Zurücksetzen doppelklicken",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Nicht auf den Datenträger geschriebene Änderungen: {n}{0:.0}: {e}\nWenn du jetzt beendest, gehen diese Änderungen verloren.{1:.0}",
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Ungespeicherte Änderungen: {n}{0:.0} im Arbeitsspeicher, aber nicht auf dem Datenträger: {e}\nLightCraft versucht es automatisch erneut; beim Beenden gingen diese Änderungen verloren.{1:.0}",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Änderungen nur im Arbeitsspeicher gespeichert: {n}{:.0}: {e} — LightCraft versucht es erneut",
"{n} found · {sel} selected": "{n} gefunden · {sel} ausgewählt",
"{n} photos": "Fotos: {n}",
"{n} photos in {} are hidden by the filters above": "Ausgeblendete Fotos in {}: {n} (durch die obigen Filter)",
"{n} photo{}": "{n} Foto{}",
"{n} photo{} match · updates automatically as photos change": "Passende Fotos: {n}{:.0} · wird bei Änderungen an Fotos automatisch aktualisiert",
"{n} photo{} now · updates automatically as photos change": "Aktuelle Fotos: {n}{:.0} · wird bei Änderungen an Fotos automatisch aktualisiert",
"{pid} (missing)": "{pid} (fehlt)",
"{sel_n} selected · {counted}": "{sel_n} ausgewählt · {counted}",
"{stage}… {:.0}%": "{stage}… {:.0}%",
"{verb} {n} Photo{}": "{verb}: {n} Foto{}",
"{weekday}, {day}": "{weekday}, {day}.",
"{weekday}, {day} {month} {year}": "{weekday}, {day}.{month:02}.{year}",
"{what} merge added": "{what}-Zusammenfügung hinzugefügt",
"{what} merge failed: {e}": "{what}-Zusammenfügung fehlgeschlagen: {e}",
"{year}": "{year}",
"{{date:…}} directives: {}": "{{date:…}}-Formatangaben: {}",
"{} and higher": "{} und höher",
"{} contributors · {} commits": "{} Mitwirkende · {} Commits",
"{} failed": "{} fehlgeschlagen",
"{} files": "{} Dateien",
"{} files and {f} folder{}": "{} Dateien und {f} Ordner{:.0}",
"{} item{}": "Elemente: {}{:.0}",
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} von 4 Hilfslinien — entlang von Linien ziehen, die senkrecht oder waagerecht sein sollen.",
"{} photos": "Fotos: {}",
"{} photos · exposures {} EV": "{} Fotos · Belichtungen {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "{} Fotos · {} · {:.0}° × {:.0}°",
"{} photo{} not readable": "{} Foto{} nicht lesbar",
"{} sample{}": "Farbproben: {}{:.0}",
"{} sec": "{} s",
"{} spot(s) on this photo": "{} Retuschierstelle(n) in diesem Foto",
"{} — show their photos": "{} — Fotos dieser Person anzeigen",
"· {dups} already in the library": "· {dups} bereits in der Bibliothek",
"· {n} photo{}": "· {n} Foto{}",
"… 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",
"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}",
", {} label": ", Markierung: {}"
}
File diff suppressed because it is too large. Load diff
+191
View File
@@ -0,0 +1,191 @@
{
" ({unsure} with several look-alike files: use Locate)": " ({unsure} con varios archivos parecidos: usar Localizar)",
" ({} in Recently Deleted)": " ({} en Eliminadas recientemente)",
" · {:.0}% clipped": " · {:.0}% recortado",
" · {failed} couldn't be rendered": " · no se pudieron renderizar: {failed}",
" · {skipped} skipped (file exists)": " · omitidas: {skipped} (el archivo existe)",
" · {} duplicate{} skipped": " · {0} duplicado{1} omitido{1}",
" · {} failed: {}": " · {} con errores: {}",
" · {} moved": " · movidas: {}",
" · {} not readable": " · ilegibles: {}",
" · {} original{} left at the source": " · originales conservados en el origen: {}{:.0}",
" · {} restored from Recently Deleted": " · restauradas desde Eliminadas recientemente: {}",
" — not carried over: {}{}": " — no transferido: {}{}",
"+{} more": "+{} más",
", {failed} file{} not readable": ", {failed} archivo{0} ilegible{0}",
"; {kept} already had a location": "; ya tenían ubicación: {kept}",
"; {outside} outside its time range": "; fuera de su intervalo de tiempo: {outside}",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PR, {} commits, +{} / −{} líneas (Δ {}), +{} / −{} recursos binarios, {} – {}",
"Add {local_n} to My Photos": "Añadir {local_n} a Mis fotos",
"Add {} Photo{}": "Añadir {} foto{}",
"Add {} Photo{}, restore {}": "Añadir {} foto{}, restaurar {}",
"Add “{}” to Favorites": "Añadir “{}” a Favoritos",
"Added {n} photo{} to My Photos": "{n} foto{0} añadida{0} a Mis fotos",
"Added {n} photo{} to “{}”": "{n} foto{0} añadida{0} a “{1}”",
"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: {} 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}",
"Auto Sync: edits apply to {n} photos": "Sincronización automática: las ediciones se aplican a {n} fotos",
"Automatic ({} MB)": "Automático ({} MB)",
"Build 1:1 Previews ({scope})": "Generar vistas previas 1:1 ({scope})",
"Build Smart Previews ({scope})": "Generar vistas previas inteligentes ({scope})",
"Build Standard-Sized Previews ({scope})": "Generar vistas previas de tamaño estándar ({scope})",
"Building previews for {} photos…": "Generando vistas previas de {} fotos…",
"Click {}:1": "Clic {}:1",
"Couldn't open the editor: {e}": "No se pudo abrir el editor: {e}",
"Create {n} Virtual Copies": "Crear {n} copias virtuales",
"Creates {} stacks from {} of {scope}": "Crea {} pilas a partir de {} de {scope}",
"Decoder: {reason}": "Decodificador: {reason}",
"Delete \"{label}\"": "Eliminar \"{label}\"",
"Delete \"{label}\" (and the mask)": "Eliminar \"{label}\" (y la máscara)",
"Delete {n} Photos": "Eliminar {n} fotos",
"Delete “{current}”": "Eliminar “{current}”",
"Discard Smart Previews ({scope})": "Descartar vistas previas inteligentes ({scope})",
"Done ({what}): {done} photo{plural}": "Listo ({what}): {done} foto{plural}",
"Duplicate \"{label}\"": "Duplicar \"{label}\"",
"Example: {example}": "Ejemplo: {example}",
"Exported {n} photo{}": "{n} foto{0} exportada{0}",
"Exported {ok} of {total} photo{}": "Exportadas {ok} de {total} foto{}",
"Exported {} point curve preset{}": "{0} ajuste{1} preestablecido{1} de curva de puntos exportado{1}",
"Exported {} preset{}": "{0} ajuste{1} preestablecido{1} exportado{1}",
"Exporting {} of {total}": "Exportando {} de {total}",
"Filter bar — {active} active filter{}": "Barra de filtros: {active} filtro{0} activo{0}",
"Focus {:.0}": "Enfoque {:.0}",
"Folder “{}” (and its subfolders)": "Carpeta “{}” (y sus subcarpetas)",
"Found {n} missing photo{}; {left} still missing{unsure}": "Fotos que faltaban encontradas: {n}{:.0}; siguen faltando: {left}{unsure}",
"Healed {n} dust spot{}": "{n} mancha{0} de polvo corregida{0}",
"Import cancelled · {} photo{} added": "Importación cancelada · {0} foto{1} añadida{1}",
"Import failed: {e}": "Error de importación: {e}",
"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 {} 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}",
"LightCraft could not open its window: {e}": "LightCraft no pudo abrir su ventana: {e}",
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft no pudo abrir su ventana: {e}\n\nLos detalles están en el archivo de registro: {path}",
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Catálogo de Lightroom inspeccionado: {photos} fotos, {collections} colecciones, {missing} no encontradas",
"Lightroom import complete: {imported} photos added": "Importación de Lightroom completada: {imported} fotos añadidas",
"Lightroom import complete: {imported} photos added, {failed} failed": "Importación de Lightroom completada: {imported} fotos añadidas, {failed} con errores",
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Importación de Lightroom completada: {imported} fotos añadidas; aviso del índice: {warning}",
"Lightroom import failed: {e}{warning}": "Error al importar de Lightroom: {e}{warning}",
"Lightroom inspection failed: {e}{warning}": "Error al inspeccionar Lightroom: {e}{warning}",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Fuentes: {} (OFL). Iconos: originales.",
"Matches: {}": "Coincidencias: {}",
"Merging… {stage} {:.0}%": "Combinando… {stage} {:.0}%",
"Move {what} to Recently Deleted?": "¿Mover {what} a Eliminadas recientemente?",
"Named ({named_n})": "Con nombre ({named_n})",
"Painting “{k}”: click photos to add or remove it · Esc stops": "Pintando “{k}”: hacer clic en las fotos para añadirla o quitarla · Esc para terminar",
"Paste into {n} photo{} — only settings that were copied are pasted": "Pegar en {n} foto{}: solo se pegan los ajustes copiados",
"Preset: {name}": "Ajuste preestablecido: {name}",
"Preview only — editing the camera's embedded JPEG ({})": "Solo vista previa: editando el JPEG incrustado de la cámara ({})",
"Preview only — {}": "Solo vista previa: {}",
"Previews ready for {done} photo{plural}": "Vistas previas listas para {done} foto{plural}",
"Profile: {}": "Perfil: {}",
"Proof Copy ({})": "Copia de prueba ({})",
"Proof Preview · {}": "Vista previa de prueba · {}",
"Quick Develop · {n} photos": "Revelado rápido · {n} fotos",
"Quick Develop: {name}": "Revelado rápido: {name}",
"Rated {}": "Valorada con {}",
"Reading Lightroom catalog… {done} of {total}": "Leyendo catálogo de Lightroom… {done} de {total}",
"Reading photos… {done} of {total}": "Leyendo fotos… {done} de {total}",
"Reading photos… {} of {}": "Leyendo fotos… {} de {}",
"Redo {}": "Rehacer {}",
"Remove “{name}” and its {count} photo{} from the library?": "¿Quitar “{name}” y sus {count} foto{} de la biblioteca?",
"Remove “{}” from Favorites": "Quitar “{}” de Favoritos",
"Removed from {}": "Quitado de {}",
"Removed {what} from {name}": "{what} quitado de {name}",
"Rename {n} Photos…": "Cambiar nombre de {n} fotos…",
"Rename “{name}”": "Cambiar nombre de “{name}”",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Cambia el nombre de “{from}” en {n} foto{} (también las palabras clave que dependen de ella). | separa niveles, p. ej. Viajes|Italia. Un nombre existente combina las dos.",
"Rendering on the CPU: {why}": "Renderizado en la CPU: {why}",
"Replace {} with:": "Reemplazar {} por:",
"Reset {name}": "Restablecer {name}",
"Reset {} Channel": "Restablecer canal {}",
"Restore {}": "Restaurar {}",
"Restore {} Photo{}": "Restaurar {} foto{}",
"Saved export preset “{}”": "Ajuste preestablecido de exportación “{}” guardado",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Puntúa el enfoque y la exposición de {scope} y busca tomas similares hechas con pocos segundos de diferencia (ráfagas).",
"Show {hidden} hidden location{}": "Mostrar ubicaciones ocultas: {hidden}{:.0}",
"Showing {n} of {}": "Mostrando {n} de {}",
"Smart album: {}": "Álbum inteligente: {}",
"Tagged {n} photo{} from the tracklog": "{n} foto{0} ubicada{0} desde el registro de ruta",
"The SAM 3 download failed: {e}": "Error en la descarga de SAM 3: {e}",
"Undo {}": "Deshacer {}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "Etiqueta{s} desconocida{s} {} sin cambios{:.0} (ver Etiquetas)",
"Updated “{name}”": "“{name}” actualizado",
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "Versión {}: biblioteca de fotos y revelador RAW de desarrollo independiente (clean-room) en Rust puro.",
"Version: {}": "Versión: {}",
"Virtual copy “{name}” of {of}": "Copia virtual “{name}” de {of}",
"Working on {what} for {} photos…": "Procesando {what} para {} fotos…",
"none (CPU rendering): {why}": "ninguna (renderizado por CPU): {why}",
"the {n} selected photos": "las {n} fotos seleccionadas",
"the {} photos in view": "las {} fotos de la vista",
"these {count} photos": "estas {count} fotos",
"{count} smart preview{} · {:.1} MB": "{count} vista{0} previa{0} inteligente{0} · {1:.1} MB",
"{f} folders (and their subfolders)": "{f} carpetas (y sus subcarpetas)",
"{label} Grading": "Gradación de {label}",
"{label} {d:+} on every selected photo": "{label} {d:+} en cada foto seleccionada",
"{label} {index} of {n}": "{label} {index} de {n}",
"{label}, {n} photos": "{label}, {n} fotos",
"{matching} of {t} photos": "{matching} de {t} fotos",
"{matching} photos": "{matching} fotos",
"{mode} spot {} of {}": "{mode}: mancha {} de {}",
"{month}": "{month:02}",
"{month} {year}": "{month:02}/{year}",
"{msg} ({failed} failed)": "{msg} ({failed} con errores)",
"{name} Label": "Etiqueta {name}",
"{name} Off": "{name}: desactivado",
"{name} On": "{name}: activado",
"{name} opened for editing; it is stacked with the original": "{name} abierta para editar; está apilada con el original",
"{name} — double-click to reset it": "{name}: doble clic para restablecer",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Cambios que no se pudieron escribir en el disco: {n}{0:.0}: {e}\nSi se sale ahora, se perderán.{1:.0}",
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Cambios guardados en memoria, pero no escritos en el disco: {n}{0:.0}: {e}\nLightCraft vuelve a intentarlo automáticamente; si se sale ahora, se perderán.{1:.0}",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Cambios guardados solo en memoria: {n}{:.0}: {e}; LightCraft volverá a intentarlo",
"{n} found · {sel} selected": "{n} encontradas · {sel} seleccionadas",
"{n} photos": "{n} fotos",
"{n} photos in {} are hidden by the filters above": "Los filtros de arriba ocultan {n} fotos en {}",
"{n} photo{}": "{n} foto{}",
"{n} photo{} match · updates automatically as photos change": "{n} foto{0} coincidente{0} · se actualiza automáticamente cuando cambian las fotos",
"{n} photo{} now · updates automatically as photos change": "{n} foto{} ahora · se actualiza automáticamente cuando cambian las fotos",
"{pid} (missing)": "{pid} (falta)",
"{sel_n} selected · {counted}": "{sel_n} seleccionadas · {counted}",
"{stage}… {:.0}%": "{stage}… {:.0}%",
"{verb} {n} Photo{}": "{verb} {n} foto{}",
"{weekday}, {day}": "{weekday}, {day}",
"{weekday}, {day} {month} {year}": "{weekday}, {day}/{month:02}/{year}",
"{what} merge added": "Combinación {what} añadida",
"{what} merge failed: {e}": "Error en la combinación {what}: {e}",
"{year}": "{year}",
"{{date:…}} directives: {}": "Directivas de {{date:…}}: {}",
"{} and higher": "{} o más",
"{} contributors · {} commits": "{} colaboradores · {} commits",
"{} failed": "Con errores: {}",
"{} files": "{} archivos",
"{} files and {f} folder{}": "{} archivos y {f} carpeta{}",
"{} item{}": "{0} elemento{1}",
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} de 4 guías: arrastrar a lo largo de líneas que deban ser verticales u horizontales.",
"{} photos": "{} fotos",
"{} photos · exposures {} EV": "{} fotos · exposiciones {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "{} fotos · {} · {:.0}° × {:.0}°",
"{} photo{} not readable": "{0} foto{1} ilegible{1}",
"{} sample{}": "{} muestra{}",
"{} sec": "{} s",
"{} spot(s) on this photo": "{} mancha(s) en esta foto",
"{} — show their photos": "{}: mostrar sus fotos",
"· {dups} already in the library": "· ya en la biblioteca: {dups}",
"· {n} photo{}": "· {n} foto{}",
"… 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",
"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{}",
", {} label": ", etiqueta {}"
}
File diff suppressed because it is too large. Load diff
+66 -1
View File
@@ -35,6 +35,8 @@
"Imported {n} preset{}": "{n}件のプリセットを読み込みました{:.0}",
"Imported {n} preset{}, {f} file{} not readable": "{n}件のプリセットを読み込みました{:.0}。{f}件は読み込めません{:.0}",
"Imported {} photo{}": "{}枚を読み込みました{:.0}",
"LightCraft could not open its window: {e}": "LightCraftのウィンドウを開けませんでした: {e}",
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraftのウィンドウを開けませんでした: {e}\n\n詳しくはログファイルをご覧ください: {path}",
"Matches: {}": "一致条件:{}",
"Merging… {stage} {:.0}%": "合成中… {stage} {:.0}%",
"Move {what} to Recently Deleted?": "{what}を「最近削除した写真」に移動しますか?",
@@ -48,13 +50,16 @@
"Proof Preview · {}": "校正プレビュー・{}",
"Quick Develop · {n} photos": "クイック現像・{n}枚",
"Rated {}": "評価:{}",
"Reading Lightroom catalog… {done} of {total}": "Lightroom カタログを読み取り中… {done} / {total}",
"Reading photos… {done} of {total}": "写真を読み込み中:{total}枚中{done}枚",
"Redo {}": "やり直す:{}",
"Remove “{}” from Favorites": "「{}」をお気に入りから削除",
"Removed from {}": "{} から削除しました",
"Rename {n} Photos…": "{n}枚の写真の名前を変更…",
"Rename “{name}”": "「{name}」の名前を変更",
"Rendering on the CPU: {why}": "CPUで描画:{why}",
"Reset {} Channel": "{}チャンネルをリセット",
"Restore {}": "{} に戻す",
"Restore {} Photo{}": "{}枚の写真を復元{:.0}",
"Saved export preset “{}”": "書き出しプリセット「{}」を保存しました",
"Show {hidden} hidden location{}": "非表示の{hidden}か所を表示{:.0}",
@@ -112,6 +117,12 @@
"Build Smart Previews ({scope})": "スマートプレビューを作成({scope})",
"Build Standard-Sized Previews ({scope})": "標準プレビューを作成({scope})",
"Discard Smart Previews ({scope})": "スマートプレビューを破棄({scope})",
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Lightroom カタログを確認しました:写真 {photos} 枚、コレクション {collections} 件、見つからないファイル {missing} 件",
"Lightroom import complete: {imported} photos added": "Lightroom の読み込みが完了しました:{imported} 枚の写真を追加",
"Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom の読み込みが完了しました:{imported} 枚を追加、{failed} 枚が失敗",
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom の読み込みが完了しました:{imported} 枚を追加。インデックスの警告:{warning}",
"Lightroom import failed: {e}{warning}": "Lightroom の読み込みに失敗しました:{e}{warning}",
"Lightroom inspection failed: {e}{warning}": "Lightroom の確認に失敗しました:{e}{warning}",
"Label Set: {name}": "ラベルセット:{name}",
"Quick Develop: {name}": "クイック現像: {name}",
"Reset {name}": "{name}をリセット",
@@ -122,5 +133,59 @@
"{weekday}, {day}": "{day}日({weekday})",
"{weekday}, {day} {month} {year}": "{year}年{month}月{day}日({weekday})",
"{year}": "{year}年",
"Remove “{name}” and its {count} photo{} from the library?": "「{name}」と写真{count}枚をライブラリから除去しますか?{:.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}%が白とび/黒つぶれ",
" · {failed} couldn't be rendered": "・{failed}枚は描画できませんでした",
" · {} original{} left at the source": "・元のファイル{}枚を読み込み元に残しました{:.0}",
" — not carried over: {}{}": " — 引き継がなかった設定:{}{}",
"+{} more": "ほか{}件",
"; {kept} already had a location": "。{kept}枚はすでに位置情報あり",
"; {outside} outside its time range": "。{outside}枚は記録時間の範囲外",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}:PR {}件、コミット {}件、+{} / −{} 行(Δ {})、バイナリアセット +{} / −{}、{} – {}",
"Added {what} to {name}": "{what}を{name}に追加しました",
"Auto Import: added {} photo{}": "自動読み込み:{}枚の写真を追加しました{:.0}",
"Building previews for {} photos…": "{}枚のプレビューを作成中…",
"Couldn't open the editor: {e}": "エディターを開けませんでした:{e}",
"Creates {} stacks from {} of {scope}": "{scope}の{1}枚から{0}個のスタックを作成します",
"Done ({what}): {done} photo{plural}": "完了({what}):{done}枚{plural:.0}",
"Focus {:.0}": "ピント {:.0}",
"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}(プロファイルブラウザー ▸ 各グループ)",
"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で終了",
"Previews ready for {done} photo{plural}": "{done}枚のプレビューが完成しました{plural:.0}",
"Removed {what} from {name}": "{what}を{name}から除去しました",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "{n}枚の写真の「{from}」の名前を変更します{:.0}(下位のキーワードも含む)。階層は | で区切ります(例:旅行|イタリア)。既存の名前にすると2つを統合します",
"Replace {} with:": "{}を次に置換:",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "{scope}のピントと露出を評価し、数秒以内に撮影した似た写真(連写)を検出します",
"Tagged {n} photo{} from the tracklog": "GPS軌跡から{n}枚の写真に位置情報を付けました{:.0}",
"The SAM 3 download failed: {e}": "SAM 3のダウンロードに失敗しました:{e}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "不明なタグ{s:.0} {} は入力したまま残ります{:.0}(「タグ」を参照)",
"Virtual copy “{name}” of {of}": "{of}の仮想コピー「{name}」",
"Working on {what} for {} photos…": "{}枚の写真の{what}を処理中…",
"{label} {index} of {n}": "{label} {index}/{n}",
"{name} opened for editing; it is stacked with the original": "{name}を編集用に開きました。元の写真とスタックにしてあります",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "{n}件の変更{0:.0}をディスクに書き込めませんでした:{e}\n今終了すると、これらの変更は失われます{1:.0}",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "{n}件の変更{:.0}はメモリに保存しましたが、ディスクには書き込めていません:{e} — LightCraftが再試行します",
"{pid} (missing)": "{pid}(見つかりません)",
"{{date:…}} directives: {}": "{{date:…}} の指定子:{}",
"{} contributors · {} commits": "コントリビューター {}人・コミット {}件",
"{} failed": "{}に失敗しました",
"{} item{}": "{}項目{:.0}",
"{} photos · exposures {} EV": "{}枚・露出 {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "{}枚・{}・{:.0}° × {:.0}°",
"{} sample{}": "サンプル{}個{:.0}",
"{} sec": "{}秒",
"{} — 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}・すべて表示するには「表示 ▸ フィルターを解除」",
"Moved; {} photo(s) relinked": "移動しました。{}枚の写真の参照先を更新しました",
"{} label (click more labels to show any of them)": "{}ラベル(ほかのラベルもクリックすると、いずれかに一致する写真を表示)",
", {} star{}": "、星{}つ{:.0}",
", {} label": "、{}ラベル"
}
File diff suppressed because it is too large. Load diff
+66 -1
View File
@@ -45,7 +45,15 @@
"Imported {n} preset{}": "Importadas {n} predefinição{:.0}",
"Imported {n} preset{}, {f} file{} not readable": "Importadas {n} predefinição{:.0}, {f} arquivo{:.0} não legível",
"Imported {} photo{}": "Importadas {} foto{:.0}",
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Catálogo do Lightroom inspecionado: {photos} fotos, {collections} coleções, {missing} ausentes",
"Lightroom import complete: {imported} photos added": "Importação do Lightroom concluída: {imported} fotos adicionadas",
"Lightroom import complete: {imported} photos added, {failed} failed": "Importação do Lightroom concluída: {imported} fotos adicionadas, {failed} com falha",
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Importação do Lightroom concluída: {imported} fotos adicionadas; aviso do índice: {warning}",
"Lightroom import failed: {e}{warning}": "Falha na importação do Lightroom: {e}{warning}",
"Lightroom inspection failed: {e}{warning}": "Falha na inspeção do Lightroom: {e}{warning}",
"Label Set: {name}": "Conjunto de rótulos: {name}",
"LightCraft could not open its window: {e}": "O LightCraft não conseguiu abrir sua janela: {e}",
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "O LightCraft não conseguiu abrir sua janela: {e}\n\nO arquivo de log tem os detalhes: {path}",
"Matches: {}": "Correspondências: {}",
"Merging… {stage} {:.0}%": "Mesclando… {stage} {:.0}%",
"Move {what} to Recently Deleted?": "Mover {what} para Excluídos recentemente?",
@@ -60,16 +68,19 @@
"Quick Develop · {n} photos": "Revelação rápida · {n} fotos",
"Quick Develop: {name}": "Revelação rápida: {name}",
"Rated {}": "Avaliada {}",
"Reading Lightroom catalog… {done} of {total}": "Lendo catálogo do Lightroom… {done} de {total}",
"Reading photos… {done} of {total}": "Lendo fotos… {done} de {total}",
"Reading photos… {} of {}": "Lendo fotos… {} de {}",
"Redo {}": "Refazer {}",
"Remove “{name}” and its {count} photo{} from the library?": "Remover “{name}” e {count} foto{} da biblioteca?",
"Remove “{}” from Favorites": "Remover “{}” dos favoritos",
"Removed from {}": "Removido de {}",
"Rename {n} Photos…": "Renomear {n} fotos…",
"Rename “{name}”": "Renomear “{name}”",
"Rendering on the CPU: {why}": "Renderizando na CPU: {why}",
"Reset {name}": "Redefinir {name}",
"Reset {} Channel": "Redefinir canal {}",
"Restore {}": "Restaurar {}",
"Restore {} Photo{}": "Restaurar {} foto{:.0}",
"Saved export preset “{}”": "Predefinição de exportação “{}” salva",
"Show {hidden} hidden location{}": "Mostrar {hidden} local{:.0} oculto",
@@ -122,5 +133,59 @@
"{} spot(s) on this photo": "{} ponto(s) nesta foto",
"· {dups} already in the library": "· {dups} já na biblioteca",
"· {n} photo{}": "· {n} foto{:.0}",
"… and {more} more": "… e mais {more}"
"… and {more} more": "… e mais {more}",
" ({unsure} with several look-alike files: use Locate)": " ({unsure} com vários arquivos parecidos: use Localizar)",
" · {:.0}% clipped": " · {:.0}% recortado",
" · {failed} couldn't be rendered": " · {failed} não puderam ser renderizadas",
" · {} original{} left at the source": " · originais mantidos na origem: {}{:.0}",
" — not carried over: {}{}": " — não transferido: {}{}",
"+{} more": "+{} a mais",
"; {kept} already had a location": "; {kept} já tinham localização",
"; {outside} outside its time range": "; {outside} fora do intervalo de tempo",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PRs, {} commits, +{} / −{} linhas (Δ {}), +{} / −{} arquivos binários, {} – {}",
"Added {what} to {name}": "{what} adicionado a {name}",
"Auto Import: added {} photo{}": "Importação automática: {} foto{1} adicionada{1}",
"Building previews for {} photos…": "Gerando prévias de {} fotos…",
"Couldn't open the editor: {e}": "Não foi possível abrir o editor: {e}",
"Creates {} stacks from {} of {scope}": "Cria {} pilhas a partir de {} de {scope}",
"Done ({what}): {done} photo{plural}": "Concluído ({what}): {done} foto{plural}",
"Focus {:.0}": "Foco {:.0}",
"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)",
"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",
"Previews ready for {done} photo{plural}": "Prévias prontas para {done} foto{plural}",
"Removed {what} from {name}": "{what} removido de {name}",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Renomeia “{from}” em {n} foto{} (incluindo as palavras-chave abaixo dela). Use | para níveis, p. ex. Viagem|Itália. Um nome já existente mescla os dois.",
"Replace {} with:": "Substituir {} por:",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Avalia o foco e a exposição de {scope} e encontra fotos semelhantes tiradas com segundos de diferença (sequências).",
"Tagged {n} photo{} from the tracklog": "{n} foto{0} marcada{0} pelo tracklog",
"The SAM 3 download failed: {e}": "Falha no download do SAM 3: {e}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "Marcador(es) desconhecido(s){s:.0} {} permanece(m) como digitado(s){:.0} (veja Marcadores)",
"Virtual copy “{name}” of {of}": "Cópia virtual “{name}” de {of}",
"Working on {what} for {} photos…": "Processando {what} em {} fotos…",
"{label} {index} of {n}": "{label} {index} de {n}",
"{name} opened for editing; it is stacked with the original": "{name} aberta para edição; está empilhada com o original",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "{n} alteração(ões){0:.0} não puderam ser gravadas no disco: {e}\nSe você sair agora, elas serão perdidas.{1:.0}",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "{n} alteração(ões){:.0} salva(s) na memória, mas não gravada(s) no disco: {e} — o LightCraft tentará de novo",
"{pid} (missing)": "{pid} (ausente)",
"{{date:…}} directives: {}": "Diretivas de {{date:…}}: {}",
"{} contributors · {} commits": "{} colaboradores · {} commits",
"{} failed": "Falha: {}",
"{} item{}": "{} item(ns){:.0}",
"{} photos · exposures {} EV": "{} fotos · exposições {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "{} fotos · {} · {:.0}° × {:.0}°",
"{} sample{}": "{} amostra{}",
"{} sec": "{} s",
"{} — 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",
"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{}",
", {} label": ", rótulo {}"
}
File diff suppressed because it is too large. Load diff
+191
View File
@@ -0,0 +1,191 @@
{
" ({unsure} with several look-alike files: use Locate)": " (из них с несколькими похожими файлами: {unsure} — используйте «Найти фото»)",
" ({} in Recently Deleted)": " ({} в «Недавно удалённых»)",
" · {:.0}% clipped": " · обрезано: {:.0}%",
" · {failed} couldn't be rendered": " · не удалось отрисовать: {failed}",
" · {skipped} skipped (file exists)": " · пропущено: {skipped} (файл существует)",
" · {} duplicate{} skipped": " · пропущено дубликатов: {}{:.0}",
" · {} failed: {}": " · не удалось: {}; ошибка: {}",
" · {} moved": " · перемещено: {}",
" · {} not readable": " · не читается: {}",
" · {} original{} left at the source": " · оригиналов оставлено в источнике: {}{:.0}",
" · {} restored from Recently Deleted": " · восстановлено из «Недавно удалённых»: {}",
" — not carried over: {}{}": " — не перенесено: {}{}",
"+{} more": "+{} ещё",
", {failed} file{} not readable": ", не читается файлов: {failed}{:.0}",
", {} label": ", метка: {}",
", {} star{}": ", звёзд: {}{:.0}",
"; {kept} already had a location": "; уже имели местоположение: {kept}",
"; {outside} outside its time range": "; вне временного диапазона трека: {outside}",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: PR: {}, коммитов: {}, строк: +{} / −{} (Δ {}), бинарных файлов: +{} / −{}, {} – {}",
"Add {local_n} to My Photos": "Добавить в «Мои фото»: {local_n}",
"Add {} Photo{}": "Добавить фото: {}{:.0}",
"Add {} Photo{}, restore {}": "Добавить фото: {}{:.0}, восстановить: {}",
"Add “{}” to Favorites": "Добавить «{}» в избранное",
"Added {n} photo{} to My Photos": "В «Мои фото» добавлено фото: {n}{:.0}",
"Added {n} photo{} to “{}”": "В «{1}» добавлено фото: {n}{0:.0}",
"Added {what} to {name}": "{what}: добавлено в {name}",
"Adding photos… {} of {}": "Добавление фото… {} из {}",
"All {n} selected photos move by the same amount.": "Все выбранные фото ({n}) сдвигаются на одно и то же значение.",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Уже в библиотеке, в «Недавно удалённых»: выбрано фото: {}{:.0} · щёлкните правой кнопкой → Восстановить или Удалить навсегда, чтобы импортировать заново",
"Already in the library: {} photo{} selected in All Photos": "Уже в библиотеке: выбрано фото в разделе «Все фото»: {}{:.0}",
"Auto ({})": "Авто ({})",
"Auto Import: added {} photo{}": "Автоимпорт: добавлено фото: {}{:.0}",
"Auto Sync: edits apply to {n} photos": "Автосинхронизация: правки применяются к фото: {n}",
"Automatic ({} MB)": "Автоматически ({} МБ)",
"Build 1:1 Previews ({scope})": "Создать просмотры 1:1 ({scope})",
"Build Smart Previews ({scope})": "Создать смарт-просмотры ({scope})",
"Build Standard-Sized Previews ({scope})": "Создать просмотры стандартного размера ({scope})",
"Building previews for {} photos…": "Создание просмотров для фото: {}…",
"Click {}:1": "Щелчок {}:1",
"Couldn't open the editor: {e}": "Не удалось открыть редактор: {e}",
"Create {n} Virtual Copies": "Создать виртуальные копии: {n}",
"Creates {} stacks from {} of {scope}": "Будет создано стопок: {} из {} фото ({scope})",
"Decoder: {reason}": "Декодер: {reason}",
"Delete \"{label}\"": "Удалить «{label}»",
"Delete \"{label}\" (and the mask)": "Удалить «{label}» (и маску)",
"Delete {n} Photos": "Удалить фото: {n}",
"Delete “{current}”": "Удалить «{current}»",
"Discard Smart Previews ({scope})": "Удалить смарт-просмотры ({scope})",
"Done ({what}): {done} photo{plural}": "Готово ({what}): фото: {done}{plural:.0}",
"Duplicate \"{label}\"": "Дублировать «{label}»",
"Example: {example}": "Пример: {example}",
"Exported {n} photo{}": "Экспортировано фото: {n}{:.0}",
"Exported {ok} of {total} photo{}": "Экспортировано фото: {ok} из {total}{:.0}",
"Exported {} point curve preset{}": "Экспортировано пресетов точечной кривой: {}{:.0}",
"Exported {} preset{}": "Экспортировано пресетов: {}{:.0}",
"Exporting {} of {total}": "Экспорт: {} из {total}",
"Filter bar — {active} active filter{}": "Панель фильтров — активных фильтров: {active}{:.0}",
"Filtered: {label}": "Отфильтровано: {label}",
"Focus {:.0}": "Резкость {:.0}",
"Folder “{}” (and its subfolders)": "Папка «{}» (и вложенные папки)",
"Found {n} missing photo{}; {left} still missing{unsure}": "Найдено отсутствующих фото: {n}{:.0}; всё ещё отсутствует: {left}{unsure}",
"Healed {n} dust spot{}": "Убрано пылинок: {n}{:.0}",
"Import cancelled · {} photo{} added": "Импорт отменён · добавлено фото: {}{:.0}",
"Import failed: {e}": "Ошибка импорта: {e}",
"Imported {n} point curve preset{}": "Импортировано пресетов точечной кривой: {n}{:.0}",
"Imported {n} preset{}": "Импортировано пресетов: {n}{:.0}",
"Imported {n} preset{}, {f} file{} not readable": "Импортировано пресетов: {n}{:.0}, не читается файлов: {f}{:.0}",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "Импортировано профилей: {profiles}{:.0} (Обозреватель профилей → их группы)",
"Imported {} photo{}": "Импортировано фото: {}{:.0}",
"Insert at the cursor (also written {})": "Вставить в позицию курсора (также пишется как {})",
"Label Set: {name}": "Набор меток: {name}",
"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-просмотр камеры, в котором уже применены стиль изображения камеры (например, «Монохром») и баланс белого.",
"LightCraft could not open its window: {e}": "LightCraft не удалось открыть окно: {e}",
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft не удалось открыть окно: {e}\n\nПодробности в файле журнала: {path}",
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Каталог Lightroom проверен: фото — {photos}, коллекций — {collections}, отсутствует — {missing}",
"Lightroom import complete: {imported} photos added": "Импорт из Lightroom завершён: добавлено фото — {imported}",
"Lightroom import complete: {imported} photos added, {failed} failed": "Импорт из Lightroom завершён: добавлено фото — {imported}, ошибок — {failed}",
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Импорт из Lightroom завершён: добавлено фото — {imported}; предупреждение индекса: {warning}",
"Lightroom import failed: {e}{warning}": "Ошибка импорта из Lightroom: {e}{warning}",
"Lightroom inspection failed: {e}{warning}": "Ошибка проверки Lightroom: {e}{warning}",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Шрифты: {} (OFL). Значки: оригинальные.",
"Matches: {}": "Совпадения: {}",
"Merging… {stage} {:.0}%": "Слияние… {stage} {:.0}%",
"Move {what} to Recently Deleted?": "Переместить {what} в «Недавно удалённые»?",
"Moved; {} photo(s) relinked": "Перемещено; перепривязано фото: {}",
"Named ({named_n})": "Именованные ({named_n})",
"Painting “{k}”: click photos to add or remove it · Esc stops": "Назначение «{k}»: щёлкайте по фото, чтобы добавить или снять его · Esc — завершить",
"Paste into {n} photo{} — only settings that were copied are pasted": "Вставка в фото: {n}{:.0} — вставляются только скопированные настройки",
"Preset: {name}": "Пресет: {name}",
"Preview only — editing the camera's embedded JPEG ({})": "Только просмотр — правка встроенного в камеру JPEG ({})",
"Preview only — {}": "Только просмотр — {}",
"Previews ready for {done} photo{plural}": "Просмотры готовы, фото: {done}{plural:.0}",
"Profile: {}": "Профиль: {}",
"Proof Copy ({})": "Пробная копия ({})",
"Proof Preview · {}": "Пробный просмотр · {}",
"Quick Develop · {n} photos": "Быстрая коррекция · фото: {n}",
"Quick Develop: {name}": "Быстрая коррекция: {name}",
"Rated {}": "Оценка: {}",
"Reading Lightroom catalog… {done} of {total}": "Чтение каталога Lightroom… {done} из {total}",
"Reading photos… {done} of {total}": "Чтение фото… {done} из {total}",
"Reading photos… {} of {}": "Чтение фото… {} из {}",
"Redo {}": "Повторить {}",
"Remove “{name}” and its {count} photo{} from the library?": "Удалить «{name}» и фото ({count}){:.0} из библиотеки?",
"Remove “{}” from Favorites": "Убрать «{}» из избранного",
"Removed from {}": "Удалено из: {}",
"Removed {what} from {name}": "{what}: удалено из {name}",
"Rename {n} Photos…": "Переименовать фото: {n}…",
"Rename “{name}”": "Переименовать «{name}»",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Переименовывает «{from}» на фото ({n}){:.0} (включая вложенные ключевые слова). Уровни разделяются символом |, например Путешествия|Италия. Если такое имя уже есть, слова объединяются.",
"Rendering on the CPU: {why}": "Рендеринг на CPU: {why}",
"Replace {} with:": "Заменить {} на:",
"Reset {name}": "Сбросить: {name}",
"Reset {} Channel": "Сбросить канал: {}",
"Restore {}": "Восстановить {}",
"Restore {} Photo{}": "Восстановить фото: {}{:.0}",
"Saved export preset “{}”": "Пресет экспорта «{}» сохранён",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Оценивает резкость и экспозицию ({scope}) и находит похожие снимки, сделанные с разницей в несколько секунд (серии).",
"Show {hidden} hidden location{}": "Показать скрытые места: {hidden}{:.0}",
"Showing {n} of {}": "Показано {n} из {}",
"Smart album: {}": "Смарт-альбом: {}",
"Tagged {n} photo{} from the tracklog": "Привязано по треку фото: {n}{:.0}",
"The SAM 3 download failed: {e}": "Не удалось загрузить SAM 3: {e}",
"Undo {}": "Отменить {}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "Неизвестные теги{s:.0} {} остаются как введены{:.0} (см. «Теги»)",
"Updated “{name}”": "«{name}» обновлено",
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "Версия {} — независимо разработанная библиотека фото и RAW-конвертер на чистом Rust.",
"Version: {}": "Версия: {}",
"Virtual copy “{name}” of {of}": "Виртуальная копия «{name}» от {of}",
"Working on {what} for {} photos…": "{what}: обработка фото ({})…",
"none (CPU rendering): {why}": "нет (рендеринг на CPU): {why}",
"the {n} selected photos": "выбранные {n} фото",
"the {} photos in view": "все {} фото в представлении",
"these {count} photos": "эти {count} фото",
"{count} smart preview{} · {:.1} MB": "Смарт-просмотров: {count}{:.0} · {:.1} МБ",
"{f} folders (and their subfolders)": "Папок: {f} (и их подпапок)",
"{label} Grading": "Цветокоррекция: {label}",
"{label} {d:+} on every selected photo": "{label} {d:+} для каждого выбранного фото",
"{label} {index} of {n}": "{label} {index} из {n}",
"{label}, {n} photos": "{label}, фото: {n}",
"{matching} of {t} photos": "Фото: {matching} из {t}",
"{matching} photos": "Фото: {matching}",
"{mode} spot {} of {}": "{mode}: пятно {} из {}",
"{month}": "{month}",
"{month} {year}": "{month:02}.{year}",
"{msg} ({failed} failed)": "{msg} (ошибок: {failed})",
"{name} Label": "Метка: {name}",
"{name} Off": "{name}: выкл.",
"{name} On": "{name}: вкл.",
"{name} opened for editing; it is stacked with the original": "{name} открыт для редактирования и объединён в стопку с оригиналом",
"{name} — double-click to reset it": "{name} — двойной щелчок для сброса",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Изменений не удалось записать на диск: {n}{0:.0}: {e}\nЕсли выйти сейчас, они будут потеряны.{1:.0}",
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Не записано на диск, хранится только в памяти (изменений: {n}): {e}\nLightCraft повторяет попытку автоматически; при выходе сейчас они будут потеряны.{:.0}{:.0}",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Изменений сохранено в памяти, но не записано на диск: {n}{:.0}: {e} — LightCraft повторит попытку",
"{n} found · {sel} selected": "Найдено: {n} · выбрано: {sel}",
"{n} photos": "Фото: {n}",
"{n} photos in {} are hidden by the filters above": "Фильтры выше скрывают фото в разделе «{}»: {n}",
"{n} photo{}": "Фото: {n}{:.0}",
"{n} photo{} match · updates automatically as photos change": "Подходящих фото: {n}{:.0} · обновляется автоматически при изменении фото",
"{n} photo{} now · updates automatically as photos change": "Сейчас фото: {n}{:.0} · обновляется автоматически при изменении фото",
"{n} similar photo{} · View ▸ Clear Filters to see all": "Похожих фото: {n}{:.0} · Вид → Сбросить фильтры, чтобы увидеть все",
"{pid} (missing)": "{pid} (отсутствует)",
"{sel_n} selected · {counted}": "Выбрано: {sel_n} · {counted}",
"{stage}… {:.0}%": "{stage}… {:.0}%",
"{verb} {n} Photo{}": "{verb} (фото: {n}){:.0}",
"{weekday}, {day}": "{weekday}, {day}",
"{weekday}, {day} {month} {year}": "{weekday}, {day:02}.{month:02}.{year}",
"{what} merge added": "{what}: слияние добавлено",
"{what} merge failed: {e}": "{what}: ошибка слияния: {e}",
"{year}": "{year}",
"{{date:…}} directives: {}": "Директивы {{date:…}}: {}",
"{} and higher": "{} и выше",
"{} contributors · {} commits": "Участников: {} · коммитов: {}",
"{} failed": "Ошибка: {}",
"{} files": "Файлов: {}",
"{} files and {f} folder{}": "Файлов: {}, папок: {f}{:.0}",
"{} item{}": "Элементов: {}{:.0}",
"{} label (click more labels to show any of them)": "Метка «{}» (щёлкните другие метки, чтобы показать фото с любой из них)",
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "Направляющих: {} из 4 — проведите вдоль линий, которые должны быть вертикальными или горизонтальными.",
"{} photos": "Фото: {}",
"{} photos · exposures {} EV": "Фото: {} · экспозиции {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "Фото: {} · {} · {:.0}° × {:.0}°",
"{} photo{} not readable": "Не читается фото: {}{:.0}",
"{} sample{}": "Образцов: {}{:.0}",
"{} sec": "{} с",
"{} spot(s) on this photo": "Пятен на этом фото: {}",
"{} — show their photos": "{} — показать фото этого человека",
"· {dups} already in the library": "· уже в библиотеке: {dups}",
"· {n} photo{}": "· фото: {n}{:.0}",
"… and {more} more": "… и ещё {more}"
}
File diff suppressed because it is too large. Load diff
+66 -1
View File
@@ -35,6 +35,8 @@
"Imported {n} preset{}": "已导入 {n} 个预设{0:.0}",
"Imported {n} preset{}, {f} file{} not readable": "已导入 {n} 个预设,{f} 个文件无法读取{0:.0}{1:.0}",
"Imported {} photo{}": "已导入 {0} 张照片{1:.0}",
"LightCraft could not open its window: {e}": "LightCraft 无法打开窗口:{e}",
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft 无法打开窗口:{e}\n\n详细信息见日志文件:{path}",
"Matches: {}": "匹配项:{0}",
"Merging… {stage} {:.0}%": "合成中… {stage} {0:.0}%",
"Move {what} to Recently Deleted?": "将 {what} 移到最近删除?",
@@ -48,13 +50,16 @@
"Proof Preview · {}": "校样预览 · {0}",
"Quick Develop · {n} photos": "快速编辑 · {n} 张照片",
"Rated {}": "已评级 {0}",
"Reading Lightroom catalog… {done} of {total}": "正在读取 Lightroom 目录… {done} / {total}",
"Reading photos… {done} of {total}": "正在读取照片… {total} 张中的 {done} 张",
"Redo {}": "重做:{0}",
"Remove “{}” from Favorites": "从收藏夹中移除“{0}”",
"Removed from {}": "已从 {} 移除",
"Rename {n} Photos…": "重命名 {n} 张照片…",
"Rename “{name}”": "重命名“{name}”",
"Rendering on the CPU: {why}": "正在使用 CPU 渲染:{why}",
"Reset {} Channel": "重置 {0} 通道",
"Restore {}": "恢复为 {}",
"Restore {} Photo{}": "恢复 {0} 张照片{1:.0}",
"Saved export preset “{}”": "已保存导出预设“{0}”",
"Show {hidden} hidden location{}": "显示 {hidden} 个隐藏位置{0:.0}",
@@ -112,6 +117,12 @@
"Build Smart Previews ({scope})": "生成智能预览({scope})",
"Build Standard-Sized Previews ({scope})": "生成标准尺寸预览({scope})",
"Discard Smart Previews ({scope})": "丢弃智能预览({scope})",
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "已检查 Lightroom 目录:{photos} 张照片,{collections} 个收藏夹,{missing} 个缺失",
"Lightroom import complete: {imported} photos added": "Lightroom 导入完成:已添加 {imported} 张照片",
"Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom 导入完成:已添加 {imported} 张照片,{failed} 张失败",
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom 导入完成:已添加 {imported} 张照片;索引警告:{warning}",
"Lightroom import failed: {e}{warning}": "Lightroom 导入失败:{e}{warning}",
"Lightroom inspection failed: {e}{warning}": "Lightroom 检查失败:{e}{warning}",
"Label Set: {name}": "标签集:{name}",
"Quick Develop: {name}": "快速编辑:{name}",
"Reset {name}": "重置{name}",
@@ -122,5 +133,59 @@
"{month} {year}": "{year}年{month}月",
"{weekday}, {day}": "{day}日 {weekday}",
"{weekday}, {day} {month} {year}": "{year}年{month}月{day}日 {weekday}",
"Remove “{name}” and its {count} photo{} from the library?": "要将“{name}”及其 {count} 张照片从图库中移除吗?{:.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:.0}% 剪切",
" · {failed} couldn't be rendered": " · {failed} 张无法渲染",
" · {} original{} left at the source": " · {0} 个原始文件留在了来源位置{1:.0}",
" — not carried over: {}{}": " — 未沿用:{0}{1}",
"+{} more": "+{0} 个",
"; {kept} already had a location": ";{kept} 张已有位置信息",
"; {outside} outside its time range": ";{outside} 张不在其时间范围内",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}:{} 个 PR,{} 次提交,+{} / −{} 行(Δ {}),+{} / −{} 个二进制资源,{} – {}",
"Added {what} to {name}": "已将{what}添加到 {name}",
"Auto Import: added {} photo{}": "自动导入:已添加 {0} 张照片{1:.0}",
"Building previews for {} photos…": "正在为 {0} 张照片生成预览…",
"Couldn't open the editor: {e}": "无法打开编辑器:{e}",
"Creates {} stacks from {} of {scope}": "将从{scope}中的 {1} 张照片创建 {0} 个堆叠",
"Done ({what}): {done} photo{plural}": "完成({what}):{done} 张照片{plural:.0}",
"Focus {:.0}": "对焦 {0:.0}",
"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}(配置文件浏览器 ▸ 各自的分组)",
"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 停止",
"Previews ready for {done} photo{plural}": "已为 {done} 张照片生成预览{plural:.0}",
"Removed {what} from {name}": "已从 {name} 移除{what}",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "在 {n} 张照片上重命名“{from}”{0:.0}(其下级关键字也一并处理)。用 | 分隔层级,例如 Travel|Italy。改为已有名称时,两者将合并。",
"Replace {} with:": "将 {0} 替换为:",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "为{scope}评估对焦和曝光,并找出相隔几秒内拍摄的相似照片(连拍)。",
"Tagged {n} photo{} from the tracklog": "已根据轨迹日志标记 {n} 张照片{0:.0}",
"The SAM 3 download failed: {e}": "SAM 3 下载失败:{e}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "未知标签{s:.0} {0} 将按原样保留{1:.0}(参见“标签”)",
"Virtual copy “{name}” of {of}": "{of} 的虚拟副本“{name}”",
"Working on {what} for {} photos…": "正在为 {0} 张照片处理{what}…",
"{label} {index} of {n}": "{label} {index}/{n}",
"{name} opened for editing; it is stacked with the original": "已打开 {name} 进行编辑;它已与原始照片堆叠在一起",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "{n} 项更改{0:.0}无法写入磁盘:{e}\n如果现在退出,这些更改将会丢失。{1:.0}",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "{n} 项更改{0:.0}已保存在内存中,但未写入磁盘:{e} — LightCraft 会重试",
"{pid} (missing)": "{pid}(缺失)",
"{{date:…}} directives: {}": "{{date:…}} 格式说明:{0}",
"{} contributors · {} commits": "{0} 位贡献者 · {1} 次提交",
"{} failed": "{0}失败",
"{} item{}": "{0} 项{1:.0}",
"{} photos · exposures {} EV": "{0} 张照片 · 曝光 {1} EV",
"{} photos · {} · {:.0}° × {:.0}°": "{0} 张照片 · {1} · {2:.0}° × {3:.0}°",
"{} sample{}": "{0} 个样本{1:.0}",
"{} sec": "{0} 秒",
"{} — 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} · 选择“视图 ▸ 清除过滤器”可查看全部",
"Moved; {} photo(s) relinked": "已移动;已重新链接 {0} 张照片",
"{} label (click more labels to show any of them)": "{0}标签(单击更多标签可显示符合其中任一标签的照片)",
", {} star{}": ",{0} 星{1:.0}",
", {} label": ",{0}标签"
}
+371 -2
View File
@@ -1,14 +1,31 @@
{
" (library default)": " (图库默认值)",
" (inverted)": " (已反相)",
" · best of its burst": " · 连拍中的最佳照片",
" · in a burst": " · 属于一组连拍",
"% of all commits": "占全部提交的百分比",
"+ Group": "+ 组",
"+ Rule": "+ 条件",
", picked": ",留用",
", rejected": ",排除",
"0: nothing is rejected.": "0:不排除任何照片。",
"1 day": "1 天",
"2026-04 or 2026-04-12": "2026-04 或 2026-04-12",
"= exactly": "= 等于",
"A blank template keeps the original names.": "模板留空则保留原始名称。",
"A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "筛选器正在隐藏此视图中的照片:请修改筛选条件,或清除筛选(视图 → 清除筛选)",
"A one-time download of about": "一次性下载,大小约",
"AI Mask Model Status": "AI 蒙版模型状态",
"About": "关于",
"About LightCraft": "关于 LightCraft",
"Active": "当前照片",
"Activity": "活动",
"Actual size": "实际大小",
"Add": "添加",
"Add Catchlight": "添加眼神光",
"Add Folder to Local": "将文件夹添加到本地",
"Add Mask": "添加蒙版",
"Add Object Click": "添加对象点击",
"Add Point Color Sample": "添加点颜色样本",
"Add Red Eye Correction": "添加红眼校正",
"Add Remove Spot": "添加污点去除",
@@ -21,12 +38,15 @@
"Add to": "添加到",
"Add to Album": "添加到相册",
"Add to Favorites": "添加到收藏夹",
"Add to Local": "添加到本地",
"Add to My Photos": "添加到我的照片",
"Add to Selected Photos": "添加到所选照片",
"Add to Target Album": "添加到目标相册",
"Add to this mask": "添加到此蒙版",
"Add/Subtract/Intersect Mask": "添加/减去/相交蒙版",
"Added to photos you import that don't already have it.": "导入照片时,仅为尚未包含该信息的照片添加。",
"Adding": "正在添加",
"Adding Lightroom photos…": "正在添加 Lightroom 照片…",
"Advanced search…": "高级搜索…",
"After": "修改后",
"After 30 days": "30 天后",
@@ -44,6 +64,11 @@
"Alt Text": "替代文本",
"Always": "始终",
"Amount": "数量",
"Analyzing the photo for AI masks…": "正在分析照片以生成 AI 蒙版…",
"Any Searchable Text": "任意可搜索文本",
"Any camera": "任意相机",
"Any keyword": "任意关键字",
"Any lens": "任意镜头",
"Aperture": "光圈",
"Application": "应用程序",
"Apply": "应用",
@@ -80,9 +105,11 @@
"Auto Import Now": "立即自动导入",
"Auto Import Settings": "自动导入设置",
"Auto Mask": "自动蒙版",
"Auto Select": "自动选择",
"Auto Settings": "自动设置",
"Auto Straighten": "自动拉直",
"Auto Sync": "自动同步",
"Auto settings applied": "已应用自动设置",
"Auto-Stack by Capture Time": "按拍摄时间自动堆叠",
"Auto-Stack by Capture Time…": "按拍摄时间自动堆叠…",
"Auto-Tag Photos from Tracklog": "从轨迹日志自动标记照片",
@@ -110,6 +137,9 @@
"Before/After Split Top/Bottom": "修改前/修改后上下分屏",
"Before/After Top/Bottom": "修改前/修改后上下排列",
"Begin Interaction": "开始交互",
"Best of Similar Shots": "相似照片中的最佳",
"Binary assets added": "新增的二进制资源",
"Binary assets removed": "删除的二进制资源",
"Bit depth": "位深度",
"Black & White": "黑白",
"Blacks": "黑色色阶",
@@ -123,6 +153,7 @@
"Blue Primary": "蓝色原色",
"Blue Saturation": "蓝色饱和度",
"Blue channel": "蓝色通道",
"Blue label": "蓝色标签",
"Blue/Yellow Fringe": "蓝/黄边缘",
"Blurry photos below the score are flagged as rejects.": "低于该分值的模糊照片将被标记为排除。",
"Boundary Warp": "边界变形",
@@ -140,19 +171,33 @@
"By Date": "按日期",
"By day (YYYY/YYYY-MM-DD)": "按日 (YYYY/YYYY-MM-DD)",
"By month (YYYY/YYYY-MM)": "按月 (YYYY/YYYY-MM)",
"CPU threads": "CPU 线程数",
"Calibration": "校准",
"Camera": "相机",
"Camera make and model": "相机品牌和型号",
"Cameras and Cards": "相机和存储卡",
"Cancel": "取消",
"Cancel AI Mask Model Download": "取消下载 AI 蒙版模型",
"Cancel Download": "取消下载",
"Cancel Interaction": "取消交互",
"Cancel Preview Build": "取消生成预览",
"Candidate": "候选",
"Caption": "题注",
"Capture Date": "拍摄日期",
"Capture date and time in your own format (directives below)": "自定义格式的拍摄日期和时间(格式说明见下方)",
"Capture date as YYYYMMDD": "拍摄日期(YYYYMMDD)",
"Captured": "拍摄时间",
"Captured from": "拍摄时间起于",
"Captured until": "拍摄时间止于",
"Catalog Statistics": "目录统计",
"Catchlight": "眼神光",
"Change": "修改方式",
"Change Component Mode": "更改组件模式",
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "文件名中不允许的字符(/ \\ : * ? \" < > |)会替换为 -;与现有名称重复时添加 -1、-2…。",
"Check All": "全部选中",
"Check Output Path": "检查输出路径",
"Check the files of this type": "勾选此类型的所有文件",
"Checking names…": "正在检查名称…",
"Choose Another Library…": "选择其他图库…",
"Choose Edit Settings to Copy": "选择要复制的编辑设置",
"Choose Edit Settings to Copy…": "选择要复制的编辑设置…",
@@ -173,12 +218,18 @@
"Clear Reference": "清除参考照片",
"Clear all": "全部清除",
"Clear form": "清除表单",
"Clear rating": "清除星级",
"Clear rating and advance": "清除星级并前进",
"Cleared History": "已清除历史记录",
"Click Zoom Ratio": "单击缩放比例",
"Click a shortcut, then press the new keys (Esc cancels).": "点击快捷键,然后按下新按键(Esc 取消)。",
"Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "单击标签即可插入。示例基于 2026 年 1 月 14 日 05:58:48 拍摄的 IMG_0042.CR3。",
"Click photos in the grid to toggle the keyword; Esc stops": "在网格中单击照片可切换关键字;按 Esc 结束",
"Click the object to select it · ⌥-click leaves a part out": "单击对象将其选中 · 按住 ⌥ 单击可排除某一部分",
"Click the photo to pick a colour · ⇧-click adds more": "单击照片选择颜色 · 按 ⇧ 单击可添加更多",
"Click the photo to pick a colour; ⇧-click adds more (up to 5)": "单击照片选择颜色;按 ⇧ 单击可添加更多(最多 5 个)",
"Click to clear these filters": "单击以清除这些筛选条件",
"Click to restore · double-click to rename": "单击以恢复 · 双击以重命名",
"Clone": "仿制",
"Close": "关闭",
"Close Panel": "关闭面板",
@@ -187,7 +238,9 @@
"Close file": "关闭文件",
"Cloudy": "阴天",
"Collapse All Stacks": "折叠所有堆叠",
"Collapse Stack": "折叠堆叠",
"Color": "颜色",
"Color Label": "颜色标签",
"Color label cleared": "已清除颜色标签",
"Color Grading": "颜色分级",
"Color Label Names": "颜色标签名称",
@@ -198,9 +251,12 @@
"Color Range": "颜色范围",
"Color space": "色彩空间",
"Color space: sRGB (AVIF)": "色彩空间:sRGB(AVIF)",
"Colors": "颜色",
"Combine files…": "合并文件…",
"Command Journal": "命令日志",
"Comments panel": "注释面板",
"Commits": "提交",
"Company": "公司",
"Compare": "比较",
"Compare (Shift+C)": "比较(Shift+C)",
"Compare Before and After": "比较修改前和修改后",
@@ -211,13 +267,16 @@
"Confirm before deleting": "删除前确认",
"Confirm before moving photos to Recently Deleted": "将照片移到“最近删除”前确认",
"Constrain Crop": "限制裁剪",
"Continue": "继续",
"Continue Without Saving": "不保存继续",
"Contrast": "对比度",
"Contributors": "贡献者",
"Convert to DNG": "转换为 DNG",
"Cool Fade": "冷调褪色",
"Cool Morning": "清冷晨光",
"Copy": "复制",
"Copy After's Settings to Before": "将修改后的设置复制到修改前",
"Copy Before to After": "将修改前复制到修改后",
"Copy Before's Settings to After": "将修改前的设置复制到修改后",
"Copy Edit Settings": "复制编辑设置",
"Copy History Step Settings to Before": "将历史记录步骤的设置复制到修改前",
@@ -252,16 +311,19 @@
"Create Version from Step": "从该步骤创建版本",
"Create Virtual Copy": "创建虚拟副本",
"Creator": "创建者",
"Creator metadata": "元数据中的创建者",
"Crisp Landscape": "清晰风光",
"Crisp Vista": "通透远景",
"Crop": "裁剪",
"Crop & Rotate": "裁剪和旋转",
"Crop & Rotate (C)": "裁剪和旋转(C)",
"Crop Aspect": "裁切长宽比",
"Crop, Rotate, Geometry": "裁剪、旋转、几何",
"Cross Process": "交叉冲洗",
"Culling": "挑片",
"Curve": "曲线",
"Custom": "自定义",
"Custom template…": "自定义模板…",
"Custom…": "自定义…",
"Cut": "剪切",
"Cut pages": "剪切页面",
@@ -274,6 +336,7 @@
"Dark & Moody": "暗调氛围",
"Darken": "压暗",
"Darks": "暗部",
"Date": "日期",
"Date Groups": "日期分组",
"Day": "日",
"Daylight": "日光",
@@ -288,6 +351,7 @@
"Delete Album": "删除相册",
"Delete All Masks": "删除所有蒙版",
"Delete Color Label Set": "删除颜色标签集",
"Delete Component": "删除组件",
"Delete Export Preset": "删除导出预设",
"Delete Filter Preset": "删除筛选器预设",
"Delete Imported Profile": "删除导入的配置文件",
@@ -298,9 +362,11 @@
"Delete Permanently": "永久删除",
"Delete Photo": "删除照片",
"Delete Photos": "删除照片",
"Delete Point Color": "删除点颜色",
"Delete Point Color Sample": "删除点颜色样本",
"Delete Point Curve Preset": "删除点曲线预设",
"Delete Preset": "删除预设",
"Delete Red Eye": "删除红眼校正",
"Delete Red Eye Correction": "删除红眼校正",
"Delete Spot": "删除污点",
"Delete Version": "删除版本",
@@ -311,27 +377,41 @@
"Density": "密度",
"Depth Range": "深度范围",
"Descending": "降序",
"Describe": "描述",
"Describe what to keep": "描述要保留的内容",
"Describe what to leave out": "描述要排除的内容",
"Describe what to select": "描述要选择的内容",
"Describe…": "描述…",
"Deselect": "取消选择",
"Deselect All": "取消全选",
"Desert Heat": "沙漠热浪",
"Desert Warmth": "沙漠暖意",
"Desktop": "桌面",
"Detail": "细节",
"Detail (D)": "细节 (D)",
"Details": "详细信息",
"Diagonal": "对角线",
"Discard Preview Cache": "丢弃预览缓存",
"Display P3": "Display P3",
"Display name": "显示名称",
"Display theme": "显示主题",
"Distortion": "扭曲",
"Document properties…": "文档属性…",
"Don't enlarge": "不放大",
"Done": "完成",
"Download": "下载",
"Download AI Mask Model": "下载 AI 蒙版模型",
"Download the SAM 3 Model?": "要下载 SAM 3 模型吗?",
"Downloads": "下载",
"Drag along the horizon; double-click for Auto": "沿地平线拖动;双击以自动校正",
"Drag over an eye on the photo; the pupil inside is found automatically.": "在照片中的眼睛上拖动;会自动找到其中的瞳孔。",
"Draw Guides": "绘制参考线",
"Duplicate": "创建副本",
"Duplicate Component": "复制组件",
"Duplicate Mask": "复制蒙版",
"Duplicate and Invert Mask": "复制并反转蒙版",
"Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "每个 / 开始一层文件夹;标签按照片填充(缺少元数据的层级为“unknown”)。",
"Edge": "边缘",
"Edit": "编辑",
"Edit (E)": "编辑 (E)",
"Edit Capture Time": "编辑拍摄时间",
@@ -339,27 +419,36 @@
"Edit Color Label Names": "编辑颜色标签名称",
"Edit Color Label Names…": "编辑颜色标签名称…",
"Edit Copy for External Editor": "编辑外部编辑器副本",
"Edit Date": "编辑日期",
"Edit Info": "编辑信息",
"Edit Label Names": "编辑标签名称",
"Edit Label Names…": "编辑标签名称…",
"Edit Mask": "编辑蒙版",
"Edit Mask Component": "编辑蒙版组件",
"Edit Sections": "编辑板块",
"Edit Smart Album": "编辑智能相册",
"Edit Smart Album…": "编辑智能相册…",
"Edit Spot": "编辑污点",
"Edit in External Editor": "在外部编辑器中编辑",
"Edit settings copied": "已复制编辑设置",
"Edited": "已编辑",
"Edited or not": "不限是否编辑",
"Editing": "编辑",
"Effect": "效果",
"Effects": "效果",
"Empty Recently Deleted": "清空“最近删除”",
"Enable Lens Corrections": "启用镜头校正",
"Enable Profile Corrections": "启用配置文件校正",
"End Interaction": "结束交互",
"English": "English",
"Enter Full Screen": "进入全屏",
"Enter a date as YYYY-MM-DD HH:MM:SS": "请按 YYYY-MM-DD HH:MM:SS 格式输入日期",
"Erase": "擦除",
"Every EXIF, GPS and XMP field in the file": "文件中的每个 EXIF、GPS 和 XMP 字段",
"Existing previews": "现有预览",
"Exit": "退出",
"Expand All Stacks": "展开所有堆叠",
"Expand Stack": "展开堆叠",
"Expand/Collapse Stack": "展开/折叠堆叠",
"Export": "导出",
"Export Album with Preset": "使用预设导出相册",
@@ -372,6 +461,7 @@
"Export Presets": "导出预设",
"Export Presets…": "导出预设…",
"Export User Presets…": "导出用户预设…",
"Export stopped unexpectedly": "导出意外停止",
"Export with Preset": "使用预设导出",
"Export with Previous": "使用上次设置导出",
"Exporting": "正在导出",
@@ -380,6 +470,9 @@
"Exposure (shutter · aperture · ISO)": "曝光 (快门 · 光圈 · ISO)",
"Extended Description": "扩展描述",
"External Editor": "外部编辑器",
"Face Boxes": "人脸框",
"Face boxes": "人脸框",
"Face names written to XMP by Lightroom and other apps show up here.": "Lightroom 及其他应用写入 XMP 的人脸名称会显示在这里。",
"Faded Matte": "褪色哑光",
"Faded Slide": "褪色正片",
"Faded Urban": "褪色都市",
@@ -389,13 +482,159 @@
"Feather": "羽化",
"File": "文件",
"File & date": "文件 & 日期",
"File Format": "文件格式",
"File Name": "文件名",
"File Path": "文件路径",
"File Size": "文件大小",
"File Type": "文件类型",
"File types:": "文件类型:",
"Filename": "文件名",
"Fill": "填满",
"First": "最早",
"First commit": "首次提交",
"Five stars": "五星",
"Fixed keys": "固定按键",
"Flagged as Pick": "已标记为留用",
"Flagged as Reject": "已标记为排除",
"Focal Length": "焦距",
"Focus (assisted culling)": "对焦(辅助筛选)",
"Frame time": "帧耗时",
"Frame update": "帧更新",
"GPU rendering": "GPU 渲染",
"Generated demo photo": "生成的演示照片",
"Grab bag": "名单",
"Green label": "绿色标签",
"Group name": "分组名称",
"HDR Merge Preview": "HDR 合并预览",
"HDR Panorama Merge Preview": "HDR 全景合并预览",
"HDR Panoramas": "HDR 全景",
"HDR panorama": "HDR 全景",
"HDR panoramas": "HDR 全景",
"Has Edits": "有编辑",
"Has GPS": "有 GPS",
"Home": "主文件夹",
"Hours": "小时",
"ISO Speed": "ISO 感光度",
"ISO speed": "ISO 感光度",
"Import Lightroom Catalog": "导入 Lightroom 目录",
"Import Lightroom Catalog…": "导入 Lightroom 目录…",
"Import as new": "作为新照片导入",
"Imported": "已导入",
"Importing Lightroom Catalog": "正在导入 Lightroom 目录",
"In Photo Grid and Square Grid: Flag as Pick and advance": "在照片网格和正方形网格中:标记为留用并前进",
"In Photo Grid and Square Grid: expand or collapse the stack": "在照片网格和正方形网格中:展开或折叠堆叠",
"Info Overlay Off": "关闭信息叠加",
"Info Overlay: Exposure": "信息叠加:曝光",
"Info Overlay: File": "信息叠加:文件",
"Insert at the cursor": "在光标处插入",
"Inspect Lightroom Catalog": "检查 Lightroom 目录",
"Inspecting Lightroom Catalog": "正在检查 Lightroom 目录",
"Invert Component": "反相组件",
"Join the ArtCraft community on Discord": "加入 Discord 上的 ArtCraft 社区",
"Label": "标签",
"Label Set": "标签集",
"Last GPU fallback": "最近一次 GPU 回退",
"Last commit": "最近一次提交",
"Last loupe render": "最近一次放大视图渲染",
"Lens Blur": "镜头模糊",
"Library saved": "图库已保存",
"Licence:": "许可证:",
"LightCraft could not start": "LightCraft 无法启动",
"Lightroom Classic Catalog": "Lightroom Classic 目录",
"Lightroom import cancelled": "已取消 Lightroom 导入",
"Lightroom inspection cancelled": "已取消 Lightroom 检查",
"Line delta": "行数差",
"Linear": "线性",
"Lines +/−": "行数 +/−",
"Lines added": "新增行数",
"Lines deleted": "删除行数",
"Lock": "锁定",
"Lock Crop Aspect": "锁定裁剪长宽比",
"Locked": "已锁定",
"Mask Adjustment": "蒙版调整",
"Memory budget": "内存预算",
"Merged": "已合并",
"Merged PRs": "已合并的 PR",
"Meta's terms, not LightCraft's. Downloading it means accepting them.": "适用的是 Meta 的条款,而非 LightCraft 的条款。下载即表示接受这些条款。",
"Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "缺少的元数据({camera}、{title}…)会留空;如果生成的名称为空,则保留原始名称。",
"Model": "型号",
"Models": "模型",
"Move Down": "下移",
"Move Preset to New Group": "将预设移动到新分组",
"Move Up": "上移",
"Move in Stack": "在堆叠中移动",
"Name (A–Z)": "名称(A–Z)",
"Name of the folder the original is in": "原始文件所在文件夹的名称",
"Named People": "已命名的人物",
"Nested group": "嵌套组",
"New time": "新时间",
"No contributor data was built into this copy.": "此版本未内置贡献者数据。",
"No matching commands": "没有匹配的命令",
"No model credits were built into this copy.": "此版本未内置模型致谢信息。",
"No named people in these photos": "这些照片中没有已命名的人物",
"No named people yet": "还没有已命名的人物",
"Not Now": "以后再说",
"Object Mask": "对象蒙版",
"Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.": "对象蒙版和描述蒙版使用 Meta 的分割模型 SAM 3。它不属于 LightCraft,其他所有功能无需它也能使用。",
"October 2026": "2026年10月",
"Only": "仅限",
"Original extension without the dot": "不含点的原始扩展名",
"Original file name without its extension": "不含扩展名的原始文件名",
"Panorama": "全景",
"Panorama Merge Preview": "全景合并预览",
"Panoramas": "全景",
"Paste Settings": "粘贴设置",
"Paste from Previous": "粘贴上一张照片的设置",
"People": "人物",
"Person": "人物",
"Photos per bracket (0 = auto)": "每组包围曝光的照片数(0 = 自动)",
"Pick Flag": "留用旗标",
"Pictures": "图片",
"Prepare AI Masks": "准备 AI 蒙版",
"Presets and profiles": "预设和配置文件",
"Press keys…": "请按键…",
"Preview only: editing the camera's embedded JPEG": "仅预览:正在编辑相机内嵌的 JPEG",
"Proof copy created": "已创建校样副本",
"Quit Anyway": "仍然退出",
"Quit with unsaved changes?": "有未保存的更改,确定退出吗?",
"RAW decoding": "RAW 解码",
"Radial": "径向",
"Random": "随机",
"Rate ★": "评为 ★",
"Rate ★ and advance": "评为 ★ 并前进",
"Rate ★★": "评为 ★★",
"Rate ★★ and advance": "评为 ★★ 并前进",
"Rate ★★★": "评为 ★★★",
"Rate ★★★ and advance": "评为 ★★★ 并前进",
"Rate ★★★★": "评为 ★★★★",
"Rate ★★★★ and advance": "评为 ★★★★ 并前进",
"Rate ★★★★★": "评为 ★★★★★",
"Rate ★★★★★ and advance": "评为 ★★★★★ 并前进",
"Rating cleared": "已清除星级",
"Raw": "RAW",
"Read the SAM License": "阅读 SAM 许可证",
"Reading Lightroom catalog…": "正在读取 Lightroom 目录…",
"Real name": "真实姓名",
"Recent": "最近使用",
"Recent Keywords": "最近使用的关键字",
"Red label": "红色标签",
"Refine": "细化",
"Refine AI Mask Detail": "细化 AI 蒙版细节",
"Refine Edges": "细化边缘",
"Refine Saturation": "细化饱和度",
"Refining the mask's detail…": "正在细化蒙版细节…",
"Reliability": "可靠性",
"Relink Photo": "重新链接照片",
"Reload Changed Files": "重新载入已更改的文件",
"Remove Face Box": "移除人脸框",
"Remove a filter above, or choose Clear all": "请移除上方的某个筛选条件,或选择“全部清除”",
"Remove from Target Album": "从目标相册中移除",
"Remove shortcut": "移除快捷键",
"Remove this face box (undo with Edit ▸ Undo)": "移除此人脸框(可通过“编辑 ▸ 撤销”恢复)",
"Rename Component": "重命名组件",
"Reorder Masks": "重新排序蒙版",
"Reset All": "全部重置",
"Reset All Keyboard Shortcuts": "重置所有键盘快捷键",
"Reshuffle": "重新随机排序",
"File name": "文件名",
"Fill Edges": "填充边缘",
@@ -570,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": "横向色差",
@@ -937,25 +1176,32 @@
"Reset Tone Curve": "重置色调曲线",
"Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "重置所有曲线:点曲线(所有通道)和参数曲线(双击某个通道可单独重置该通道)",
"Reset all edits (Cmd+Shift+R)": "重置所有编辑 (Cmd+Shift+R)",
"Resize Face Box": "调整人脸框大小",
"Resize to": "调整大小为",
"Resolution (ppi)": "分辨率 (ppi)",
"Restore": "恢复",
"Restore (no shortcut)": "恢复(无快捷键)",
"Restore Library from Backup…": "从备份恢复图库…",
"Restore Version": "恢复版本",
"Restore keeps their edits; Import as new deletes the old record first.": "恢复会保留编辑;作为新照片导入会先删除旧记录。",
"Restore them": "恢复",
"Retouch": "润饰",
"Reverse the order": "倒序排列",
"Revert": "恢复",
"Review": "审阅",
"Review Import": "查看导入内容",
"Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "右键单击照片 » 设为参考照片,然后选择“视图 → 参考视图”(Shift+R)",
"Right-click a preset to delete it": "右键单击预设即可将其删除",
"Rights Usage Terms": "使用条款",
"Rotate": "旋转",
"Rotate Crop": "旋转裁剪",
"Rotate Crop Aspect": "旋转裁切长宽比",
"Rotate Left": "向左旋转",
"Rotate Right": "向右旋转",
"Roughness": "粗糙度",
"Roundness": "圆度",
"Rules": "规则",
"SAM 3 download stopped: it resumes where it left off next time.": "SAM 3 下载已停止:下次将从中断处继续。",
"Same as raw default": "与 RAW 默认设置相同",
"Sample Mask Color": "取样蒙版颜色",
"Sample a colour on the photo": "在照片上取样颜色",
@@ -976,15 +1222,22 @@
"Save Point Curve Preset": "保存点曲线预设",
"Save Point Curve…": "保存点曲线…",
"Save as Preset": "保存为预设",
"Save as Set…": "存储为标签集…",
"Save as Smart Album…": "保存为智能相册…",
"Save as set": "保存为集",
"Save as…": "另存为…",
"Save preset": "保存预设",
"Saving Lightroom import index…": "正在保存 Lightroom 导入索引…",
"Scale": "缩放",
"Scan failed": "扫描失败",
"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": "搜索命令或按键",
"Search…": "搜索…",
"Seasons": "四季",
"Second Window": "第二个窗口",
@@ -1000,29 +1253,40 @@
"Select photos, then choose View → Survey (N)": "选择照片,然后选择 视图 → 筛选 (N)",
"Select two photos, then choose View → Compare (Shift+C)": "选择两张照片,然后选择 视图 → 比较 (Shift+C)",
"Selected": "已选择",
"Selecting…": "正在选择…",
"Selenium Tone": "硒调色",
"Send Feedback…": "发送反馈…",
"Sepia Tone": "棕褐色调",
"Sequence number zero-padded to N digits (1–9)": "补零至 N 位的序号(1–9)",
"Sequence number, counting from the start number": "从起始编号开始计数的序号",
"Services": "服务",
"Set": "标签集",
"Set Album Cover": "设置相册封面",
"Set Before": "设为修改前",
"Set Color Label": "设置颜色标签",
"Set Crop": "设置裁切",
"Set Develop Values": "设置编辑参数",
"Set Flag": "设置旗标",
"Set Image Zoom and Pan": "设置图像缩放和平移",
"Set Keyboard Shortcut": "设置键盘快捷键",
"Set Mask Adjustments": "设置蒙版调整",
"Set Point Curve": "设置点曲线",
"Set Profile": "设置配置文件",
"Set Rating": "设置星级",
"Set Top of Stack": "设为堆叠顶部",
"Set as Album Cover": "设为相册封面",
"Set as Before": "设为修改前",
"Set as Reference Photo": "设为参考照片",
"Set as Target Album": "设为目标相册",
"Set as Target Album (B adds to it)": "设为目标相册 (按 B 添加)",
"Set as Top of Stack": "设为堆叠顶部",
"Set date & time": "设置日期和时间",
"Set name": "标签集名称",
"Set the mix from the photo's colours": "根据照片颜色设置混合",
"Settings": "设置",
"Settings file problem": "设置文件有问题",
"Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "新 RAW 照片的初始设置。更改这些设置不会影响图库中已有的照片。",
"Settings pasted": "已粘贴设置",
"Settings to include": "要包含的设置",
"Settings…": "设置…",
"Shade": "阴影",
@@ -1036,7 +1300,10 @@
"Sharpen": "锐化",
"Sharpening": "锐化",
"Sharpness": "锐度",
"Shift": "偏移量",
"Shift by": "偏移",
"Short Edge": "短边",
"Show": "显示",
"Show All": "显示全部",
"Show Clipping": "显示溢出警告",
"Show Deghost Overlay": "显示去重影叠加",
@@ -1051,6 +1318,7 @@
"Show Pins": "显示图钉",
"Show Source": "显示来源",
"Show Thumbnails": "显示缩略图",
"Show commands without a shortcut": "显示没有快捷键的命令",
"Show destination gamut warning": "显示目标色域警告",
"Show display gamut warning": "显示显示器色域警告",
"Show file names": "显示文件名",
@@ -1073,11 +1341,16 @@
"Skip": "跳过",
"Sky": "天空",
"Slideshow": "幻灯片播放",
"Slideshow paused": "幻灯片播放已暂停",
"Slideshow resumed": "幻灯片播放已继续",
"Slideshow · Space pauses · Esc ends": "幻灯片播放 · 空格键暂停 · Esc 结束",
"Smart Album": "智能相册",
"Smart Album Rule Fields": "智能相册规则字段",
"Smart Preview Status": "智能预览状态",
"Smart Previews Location": "智能预览位置",
"Smart preview available": "有智能预览",
"Smart previews": "智能预览",
"Smart previews folder changed": "智能预览文件夹已更改",
"Smoothness": "平滑度",
"Soft B&W": "柔和黑白",
"Soft Pastel": "柔和粉彩",
@@ -1098,8 +1371,10 @@
"Stack photos taken within this time of each other:": "将拍摄时间在此间隔内的照片堆叠在一起:",
"Stacks": "堆叠",
"Standard": "标准",
"Star rating (0–5)": "星级(0–5)",
"Start at": "起始编号",
"Start number": "起始编号",
"Starting": "正在开始",
"Startup": "启动",
"State / Province": "省 / 州",
"Stop": "停止",
@@ -1114,6 +1389,7 @@
"Subfolder name": "子文件夹名称",
"Subject": "主题",
"Subtract": "减去",
"Subtract from this mask": "从此蒙版中减去",
"Suggestions": "建议",
"Summer Haze": "夏日薄雾",
"Sunday": "星期日",
@@ -1121,13 +1397,16 @@
"Survey (N)": "筛选视图(N)",
"Swap": "交换",
"Swap Aspect (X)": "交换长宽比(X)",
"Swap Before and After": "交换修改前和修改后",
"Swap Before and After Settings": "交换修改前和修改后的设置",
"Swap Compare Photos": "交换对比照片",
"Switch light / dark theme": "切换浅色/深色主题",
"Sync Settings": "同步设置",
"System": "系统",
"System Info": "系统信息",
"System Info…": "系统信息…",
"System default": "系统默认",
"Table": "表格",
"Tags": "标签",
"Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "标签:请参阅模板旁的“标签”。文件将在磁盘上重命名(连同其 XMP 附属文件);已有同名文件会得到 -1、-2…",
"Take a snapshot": "创建快照",
@@ -1143,6 +1422,11 @@
"Text": "文字",
"Text direction": "文字方向",
"Texture": "纹理",
"That key can't be used as a shortcut": "该按键不能用作快捷键",
"The SAM 3 model is installed: Object and Describe masks are ready.": "SAM 3 模型已安装:对象蒙版和描述蒙版可以使用了。",
"The download didn't work:": "下载失败:",
"The number at the end of the original name (IMG_0042 → 0042)": "原始名称末尾的数字(IMG_0042 → 0042)",
"The original extension is always added; {ext} only puts it inside the name as well.": "始终会添加原始扩展名;{ext} 只是额外把它放进名称中。",
"The original files, unchanged, each with an XMP sidecar holding its edits.": "保留原始文件不变,每个文件附带一个保存其编辑内容的 XMP 附属文件。",
"The photo has these settings": "此照片具有这些设置",
"Theme": "主题",
@@ -1153,9 +1437,12 @@
"Threshold": "阈值",
"Thumbnail cache": "缩略图缓存",
"Thursday": "星期四",
"Time zone": "时区",
"Tint": "色调",
"Title": "标题",
"Title metadata": "元数据中的标题",
"Toggle Keyword from Set": "切换关键字集中的关键字",
"Toggle Mask": "切换蒙版",
"Toggle Section": "切换板块",
"Toggle Zoom": "切换缩放",
"Tone Curve": "色调曲线",
@@ -1165,18 +1452,22 @@
"Treatment": "处理方式",
"Triangle": "三角形",
"Try Again": "重试",
"Try Saving Again": "再次尝试保存",
"Tuesday": "星期二",
"Tungsten": "钨丝灯",
"Turn Off": "关闭",
"Turn on Include subfolders, or pick another folder under Local": "开启“包括子文件夹”,或在“本地”下选择其他文件夹",
"Type": "类型",
"Uncheck All": "全部取消选中",
"Uncheck the files of this type": "取消勾选此类型的所有文件",
"Undo": "撤销",
"Unedited": "未编辑",
"Unedited raws": "未编辑的 RAW",
"Unflag": "取消旗标",
"Unflagged": "无旗标",
"Ungroup Stack": "取消堆叠",
"Unknown Date": "未知日期",
"Unknown tags stay as typed — check the preview for a {typo}.": "未知标签会按原样保留 — 请在预览中检查是否有{typo}。",
"Unrated": "未评级",
"Unreadable": "无法读取",
"Update": "更新",
@@ -1186,6 +1477,7 @@
"Update Version": "更新版本",
"Update Version with Current Settings": "使用当前设置更新版本",
"Update with Current Settings": "使用当前设置更新",
"Updated the edits saved in the external editor": "已更新在外部编辑器中保存的编辑",
"Upright": "垂直校正",
"Urban": "都市",
"Use Keyword Set": "使用关键字集",
@@ -1193,6 +1485,7 @@
"Use camera-specific defaults": "使用相机专属默认值",
"Use the GPU for rendering": "使用 GPU 渲染",
"User Presets": "用户预设",
"Username": "用户名",
"Value": "值",
"Variance": "变化幅度",
"Version": "版本",
@@ -1204,6 +1497,7 @@
"Vibrance": "鲜艳度",
"Videos": "视频",
"View": "视图",
"Viewing and sharing": "查看与分享",
"Vignette": "暗角",
"Vignetting": "暗角",
"Vintage": "复古",
@@ -1242,7 +1536,10 @@
"Yellow Hue": "黄色色相",
"Yellow Luminance": "黄色明度",
"Yellow Saturation": "黄色饱和度",
"Yellow label": "黄色标签",
"Yes": "是",
"You can close this: the download continues, and resumes if interrupted.": "可以关闭此窗口:下载会继续进行,中断后也会继续。",
"Your Name": "你的名字",
"Your library couldn't be opened": "无法打开图库",
"Zoom": "缩放",
"Zoom 100%": "缩放 100%",
@@ -1250,13 +1547,82 @@
"Zoom Out": "缩小",
"Zoom to Fit": "适合窗口",
"Zoom to page level": "缩放至页面级别",
"a literal %": "字面上的 %",
"a removed photo": "一张已移除的照片",
"all": "全部",
"and": "和",
"any": "任一",
"are empty": "为空",
"aren't empty": "不为空",
"blue": "蓝色",
"comma, separated": "逗号分隔",
"contains": "包含",
"copyrighted": "受版权保护",
"day 01–31": "日 01–31",
"days": "天",
"discarding smart previews": "正在丢弃智能预览",
"doesn't contain": "不包含",
"e.g. sky · the red car · car, road": "例如(英文):sky · the red car · car, road",
"ends with": "结尾为",
"false": "否",
"green": "绿色",
"hour 00–23": "时 00–23",
"hours": "小时",
"image": "图像",
"is": "是",
"is <": "小于",
"is >": "大于",
"is after": "晚于",
"is before": "早于",
"is between": "介于",
"is empty": "为空",
"is in the last": "在最近",
"is unknown": "未知",
"is ≤": "小于等于",
"is ≥": "大于等于",
"isn't": "不是",
"isn't empty": "不为空",
"isn't in the last": "不在最近",
"keep names — or e.g. {date}_{seq:3}": "保留名称 — 或如 {date}_{seq:3}",
"latitude, longitude": "纬度, 经度",
"memory only": "仅内存",
"minute": "分",
"month 01–12": "月 01–12",
"months": "个月",
"none": "无",
"none (CPU rendering)": "无(CPU 渲染)",
"none of": "均不符合",
"not available here": "此处不可用",
"of the following rules:": "以下规则:",
"or": "或",
"pick": "留用",
"previews": "预览",
"publicDomain": "公共领域",
"purple": "紫色",
"raw": "RAW",
"red": "红色",
"reject": "排除",
"saved in": "保存在",
"second": "秒",
"smart previews": "智能预览",
"starts with": "开头为",
"the photos in view": "视图中的照片",
"the selected photos": "所选照片",
"this photo": "这张照片",
"true": "是",
"unknown": "未知",
"video": "视频",
"weeks": "周",
"year, 2 digits": "年,2 位",
"year, 4 digits": "年,4 位",
"years": "年",
"yellow": "黄色",
"{date} is the capture time; a photo without one uses the time it was imported.": "{date} 是拍摄时间;没有拍摄时间的照片使用其导入时间。",
"© 2026 Your Name": "© 2026 你的名字",
"© Your Name": "© 你的名字",
"− harder border · + softer border": "− 边缘更硬 · + 边缘更柔和",
"≤ at most": "≤ 最多",
"≥ at least": "≥ 至少",
"日本語": "日本語",
"繁體中文(台灣)": "繁體中文(台灣)",
"In Recently Deleted": "在“最近删除”中",
@@ -1272,5 +1638,8 @@
"Expand": "展开",
"Remove Disk from Library": "从图库中移除磁盘",
"All the photos imported from this disk are included.": "包括从此磁盘导入的所有照片。",
"That includes the photos in the folders inside it.": "其中文件夹里的照片也包括在内。"
"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 放入上方的文件夹:放入后即可使用“对象”和“描述”。"
}
+66 -1
View File
@@ -42,7 +42,15 @@
"Imported {n} preset{}": "已匯入 {n} 個預設集{:.0}",
"Imported {n} preset{}, {f} file{} not readable": "已匯入 {n} 個預設集{:.0},{f} 個檔案無法讀取{:.0}",
"Imported {} photo{}": "已匯入 {} 張照片{:.0}",
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "已檢查 Lightroom 目錄:{photos} 張相片,{collections} 個收藏集,{missing} 個遺失",
"Lightroom import complete: {imported} photos added": "Lightroom 匯入完成:已加入 {imported} 張相片",
"Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom 匯入完成:已加入 {imported} 張相片,{failed} 張失敗",
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom 匯入完成:已加入 {imported} 張相片;索引警告:{warning}",
"Lightroom import failed: {e}{warning}": "Lightroom 匯入失敗:{e}{warning}",
"Lightroom inspection failed: {e}{warning}": "Lightroom 檢查失敗:{e}{warning}",
"Label Set: {name}": "標籤集:{name}",
"LightCraft could not open its window: {e}": "LightCraft 無法開啟視窗:{e}",
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft 無法開啟視窗:{e}\n\n詳細資訊請見記錄檔:{path}",
"Matches: {}": "符合條件:{}",
"Merging… {stage} {:.0}%": "正在合併… {stage} {:.0}%",
"Move {what} to Recently Deleted?": "要將{what}移至「最近刪除」嗎?",
@@ -57,15 +65,18 @@
"Quick Develop · {n} photos": "快速顯像 · {n} 張照片",
"Quick Develop: {name}": "快速顯像:{name}",
"Rated {}": "評分:{}",
"Reading Lightroom catalog… {done} of {total}": "正在讀取 Lightroom 目錄… {done} / {total}",
"Reading photos… {done} of {total}": "正在讀取照片… {done}/{total}",
"Reading photos… {} of {}": "正在讀取照片… {}/{}",
"Redo {}": "重做 {}",
"Remove “{}” from Favorites": "將「{}」從喜好項目移除",
"Removed from {}": "已從 {} 移除",
"Rename {n} Photos…": "重新命名 {n} 張照片…",
"Rename “{name}”": "重新命名「{name}」",
"Rendering on the CPU: {why}": "使用 CPU 算繪:{why}",
"Reset {name}": "重設{name}",
"Reset {} Channel": "重設 {} 通道",
"Restore {}": "還原為 {}",
"Restore {} Photo{}": "還原 {} 張照片{:.0}",
"Saved export preset “{}”": "已儲存匯出預設集「{}」",
"Show {hidden} hidden location{}": "顯示 {hidden} 個隱藏的位置{:.0}",
@@ -122,5 +133,59 @@
" ({} 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} · 按右鍵 ▸ 還原,或永久刪除後重新匯入",
"Remove “{name}” and its {count} photo{} from the library?": "要將「{name}」及其 {count} 張照片從照片庫移除嗎?{:.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}% 剪裁",
" · {failed} couldn't be rendered": " · {failed} 張無法算繪",
" · {} original{} left at the source": " · {} 個原始檔留在來源位置{:.0}",
" — not carried over: {}{}": " — 未沿用:{}{}",
"+{} more": "+{} 個",
"; {kept} already had a location": ";{kept} 張已有位置資訊",
"; {outside} outside its time range": ";{outside} 張不在其時間範圍內",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}:{} 個 PR,{} 次提交,+{} / −{} 行(Δ {}),+{} / −{} 個二進位資產,{} – {}",
"Added {what} to {name}": "已將{what}加入 {name}",
"Auto Import: added {} photo{}": "自動匯入:已新增 {} 張照片{:.0}",
"Building previews for {} photos…": "正在為 {} 張照片建立預覽…",
"Couldn't open the editor: {e}": "無法開啟編輯器:{e}",
"Creates {} stacks from {} of {scope}": "將從{scope}中的 {1} 張照片建立 {0} 個堆疊",
"Done ({what}): {done} photo{plural}": "完成({what}):{done} 張照片{plural:.0}",
"Focus {:.0}": "焦點 {:.0}",
"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}(描述檔瀏覽器 ▸ 各自的群組)",
"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 停止",
"Previews ready for {done} photo{plural}": "已為 {done} 張照片建立預覽{plural:.0}",
"Removed {what} from {name}": "已從 {name} 移除{what}",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "在 {n} 張照片上重新命名「{from}」{:.0}(其下層關鍵字一併處理)。以 | 區隔層級,例如 Travel|Italy。改為現有名稱時,兩者會合併。",
"Replace {} with:": "將 {} 取代為:",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "為{scope}評估焦點與曝光,並找出相隔數秒內拍攝的相似照片(連拍)。",
"Tagged {n} photo{} from the tracklog": "已依軌跡記錄標記 {n} 張照片的位置{:.0}",
"The SAM 3 download failed: {e}": "SAM 3 下載失敗:{e}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "未知的標籤{s:.0} {} 會保持原樣{:.0}(請參閱「標籤」)",
"Virtual copy “{name}” of {of}": "{of} 的虛擬副本「{name}」",
"Working on {what} for {} photos…": "正在為 {} 張照片處理{what}…",
"{label} {index} of {n}": "{label} {index}/{n}",
"{name} opened for editing; it is stacked with the original": "已開啟 {name} 進行編輯;它已與原始照片堆疊在一起",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "{n} 項變更{0:.0}無法寫入磁碟:{e}\n如果現在結束,這些變更將會遺失。{1:.0}",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "{n} 項變更{:.0}已儲存在記憶體中,但未寫入磁碟:{e} — LightCraft 會重試",
"{pid} (missing)": "{pid}(遺失)",
"{{date:…}} directives: {}": "{{date:…}} 格式說明:{}",
"{} contributors · {} commits": "{} 位貢獻者 · {} 次提交",
"{} failed": "{}失敗",
"{} item{}": "{} 個項目{:.0}",
"{} photos · exposures {} EV": "{} 張照片 · 曝光 {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "{} 張照片 · {} · {:.0}° × {:.0}°",
"{} sample{}": "{} 個樣本{:.0}",
"{} sec": "{} 秒",
"{} — 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} · 選擇「檢視 ▸ 清除篩選條件」可查看全部",
"Moved; {} photo(s) relinked": "已移動;已重新連結 {} 張照片",
"{} label (click more labels to show any of them)": "{}標籤(點擊更多標籤可顯示符合其中任一標籤的照片)",
", {} star{}": ",{} 顆星{:.0}",
", {} label": ",{}標籤"
}
+371 -2
View File
@@ -1,14 +1,31 @@
{
" (library default)": " (照片庫預設值)",
" (inverted)": " (已反轉)",
" · best of its burst": " · 連拍中的最佳照片",
" · in a burst": " · 屬於一組連拍",
"% of all commits": "佔所有提交的百分比",
"+ Group": "+ 群組",
"+ Rule": "+ 規則",
", picked": ",精選",
", rejected": ",排除",
"0: nothing is rejected.": "0:不排除任何照片。",
"1 day": "1 天",
"2026-04 or 2026-04-12": "2026-04 或 2026-04-12",
"= exactly": "= 等於",
"A blank template keeps the original names.": "範本留空時保留原始名稱。",
"A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "篩選條件隱藏了此檢視的照片:請變更條件,或選擇「檢視 → 清除篩選條件」",
"A one-time download of about": "一次性下載,大小約",
"AI Mask Model Status": "AI 遮色片模型狀態",
"About": "關於",
"About LightCraft": "關於 LightCraft",
"Active": "目前照片",
"Activity": "處理狀態",
"Actual size": "實際大小",
"Add": "新增",
"Add Catchlight": "新增眼神光",
"Add Folder to Local": "將資料夾加入本機",
"Add Mask": "新增遮色片",
"Add Object Click": "新增物件點擊",
"Add Point Color Sample": "新增點色彩樣本",
"Add Red Eye Correction": "新增紅眼校正",
"Add Remove Spot": "新增移除斑點",
@@ -21,12 +38,15 @@
"Add to": "加入至",
"Add to Album": "加入相簿",
"Add to Favorites": "加入喜好項目",
"Add to Local": "加入本機",
"Add to My Photos": "加入我的照片",
"Add to Selected Photos": "加入選取的照片",
"Add to Target Album": "加入目標相簿",
"Add to this mask": "加入此遮色片",
"Add/Subtract/Intersect Mask": "新增/減去/交集遮色片",
"Added to photos you import that don't already have it.": "只會加入至匯入時尚未具有這些資訊的照片。",
"Adding": "正在新增",
"Adding Lightroom photos…": "正在加入 Lightroom 相片…",
"Advanced search…": "進階搜尋…",
"After": "編輯後",
"After 30 days": "30 天後",
@@ -44,6 +64,11 @@
"Alt Text": "替代文字",
"Always": "一律",
"Amount": "強度",
"Analyzing the photo for AI masks…": "正在分析照片以建立 AI 遮色片…",
"Any Searchable Text": "任何可搜尋文字",
"Any camera": "任何相機",
"Any keyword": "任何關鍵字",
"Any lens": "任何鏡頭",
"Aperture": "光圈",
"Application": "應用程式",
"Apply": "套用",
@@ -80,9 +105,11 @@
"Auto Import Now": "立即自動匯入",
"Auto Import Settings": "自動匯入設定",
"Auto Mask": "自動遮色片",
"Auto Select": "自動選取",
"Auto Settings": "自動設定",
"Auto Straighten": "自動拉直",
"Auto Sync": "自動同步",
"Auto settings applied": "已套用自動設定",
"Auto-Stack by Capture Time": "依拍攝時間自動堆疊",
"Auto-Stack by Capture Time…": "依拍攝時間自動堆疊…",
"Auto-Tag Photos from Tracklog": "依軌跡記錄自動標記照片位置",
@@ -110,6 +137,9 @@
"Before/After Split Top/Bottom": "編輯前/後上下分割檢視",
"Before/After Top/Bottom": "編輯前/後上下檢視",
"Begin Interaction": "開始互動",
"Best of Similar Shots": "相似照片中的最佳照片",
"Binary assets added": "新增的二進位資產",
"Binary assets removed": "移除的二進位資產",
"Bit depth": "位元深度",
"Black & White": "黑白",
"Blacks": "黑色色階",
@@ -123,6 +153,7 @@
"Blue Primary": "藍色原色",
"Blue Saturation": "藍色飽和度",
"Blue channel": "藍色通道",
"Blue label": "藍色標籤",
"Blue/Yellow Fringe": "藍/黃色邊緣",
"Blurry photos below the score are flagged as rejects.": "焦點分數低於門檻的模糊照片會標記為排除。",
"Boundary Warp": "邊界變形",
@@ -140,19 +171,33 @@
"By Date": "依日期",
"By day (YYYY/YYYY-MM-DD)": "依日期(YYYY/YYYY-MM-DD)",
"By month (YYYY/YYYY-MM)": "依月份(YYYY/YYYY-MM)",
"CPU threads": "CPU 執行緒",
"Calibration": "校正",
"Camera": "相機",
"Camera make and model": "相機品牌與型號",
"Cameras and Cards": "相機與記憶卡",
"Cancel": "取消",
"Cancel AI Mask Model Download": "取消下載 AI 遮色片模型",
"Cancel Download": "取消下載",
"Cancel Interaction": "取消互動",
"Cancel Preview Build": "取消建立預覽",
"Candidate": "候選照片",
"Caption": "圖說",
"Capture Date": "拍攝日期",
"Capture date and time in your own format (directives below)": "自訂格式的拍攝日期與時間(格式說明見下方)",
"Capture date as YYYYMMDD": "拍攝日期(YYYYMMDD)",
"Captured": "拍攝時間",
"Captured from": "拍攝時間起於",
"Captured until": "拍攝時間止於",
"Catalog Statistics": "目錄統計",
"Catchlight": "眼神光",
"Change": "變更方式",
"Change Component Mode": "變更元件模式",
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "檔名中不允許的字元(/ \\ : * ? \" < > |)會改為 -;與現有名稱重複時加上 -1、-2…。",
"Check All": "全部勾選",
"Check Output Path": "檢驗輸出路徑",
"Check the files of this type": "勾選此類型的所有檔案",
"Checking names…": "正在檢查名稱…",
"Choose Another Library…": "選擇其他照片庫…",
"Choose Edit Settings to Copy": "選擇要複製的編輯設定",
"Choose Edit Settings to Copy…": "選擇要複製的編輯設定…",
@@ -173,12 +218,18 @@
"Clear Reference": "清除參考照片",
"Clear all": "全部清除",
"Clear form": "清除表單",
"Clear rating": "清除評分",
"Clear rating and advance": "清除評分並前進",
"Cleared History": "已清除歷史紀錄",
"Click Zoom Ratio": "點擊縮放比例",
"Click a shortcut, then press the new keys (Esc cancels).": "按一下快速鍵,然後按下新的按鍵(Esc 取消)。",
"Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "點擊標籤即可插入。範例使用 2026 年 1 月 14 日 05:58:48 拍攝的 IMG_0042.CR3。",
"Click photos in the grid to toggle the keyword; Esc stops": "點擊格狀檢視中的照片以加入或移除關鍵字;按 Esc 結束",
"Click the object to select it · ⌥-click leaves a part out": "點擊物件即可選取 · 按住 ⌥ 點擊可排除某個部分",
"Click the photo to pick a colour · ⇧-click adds more": "點擊照片以選取色彩;⇧ 點擊可新增更多色彩",
"Click the photo to pick a colour; ⇧-click adds more (up to 5)": "點擊照片以選取色彩;⇧ 點擊可新增更多色彩(最多 5 個)",
"Click to clear these filters": "點擊以清除這些篩選條件",
"Click to restore · double-click to rename": "點擊以還原 · 按兩下以重新命名",
"Clone": "仿製",
"Close": "關閉",
"Close Panel": "關閉面板",
@@ -187,7 +238,9 @@
"Close file": "關閉檔案",
"Cloudy": "陰天",
"Collapse All Stacks": "收合所有堆疊",
"Collapse Stack": "收合堆疊",
"Color": "色彩",
"Color Label": "色彩標籤",
"Color label cleared": "已清除色彩標籤",
"Color Grading": "色彩分級",
"Color Label Names": "色彩標籤名稱",
@@ -198,9 +251,12 @@
"Color Range": "色彩範圍",
"Color space": "色彩空間",
"Color space: sRGB (AVIF)": "色彩空間:sRGB(AVIF)",
"Colors": "色彩",
"Combine files…": "合併檔案…",
"Command Journal": "指令記錄",
"Comments panel": "註解面板",
"Commits": "提交",
"Company": "公司",
"Compare": "比較",
"Compare (Shift+C)": "比較(Shift+C)",
"Compare Before and After": "比較編輯前與編輯後",
@@ -211,13 +267,16 @@
"Confirm before deleting": "刪除前確認",
"Confirm before moving photos to Recently Deleted": "將照片移至「最近刪除」前確認",
"Constrain Crop": "限制裁切範圍",
"Continue": "繼續",
"Continue Without Saving": "繼續使用但不儲存",
"Contrast": "對比",
"Contributors": "貢獻者",
"Convert to DNG": "轉換為 DNG",
"Cool Fade": "冷調褪色",
"Cool Morning": "清冷晨光",
"Copy": "複製",
"Copy After's Settings to Before": "將編輯後設定複製至編輯前",
"Copy Before to After": "將編輯前複製到編輯後",
"Copy Before's Settings to After": "將編輯前設定複製至編輯後",
"Copy Edit Settings": "複製編輯設定",
"Copy History Step Settings to Before": "將歷史步驟設定複製至編輯前",
@@ -252,16 +311,19 @@
"Create Version from Step": "依此步驟建立版本",
"Create Virtual Copy": "建立虛擬副本",
"Creator": "創作者",
"Creator metadata": "中繼資料中的創作者",
"Crisp Landscape": "清晰風景",
"Crisp Vista": "清晰遠景",
"Crop": "裁切",
"Crop & Rotate": "裁切與旋轉",
"Crop & Rotate (C)": "裁切與旋轉(C)",
"Crop Aspect": "裁切長寬比",
"Crop, Rotate, Geometry": "裁切、旋轉、幾何",
"Cross Process": "交叉沖洗",
"Culling": "挑片",
"Curve": "曲線",
"Custom": "自訂",
"Custom template…": "自訂範本…",
"Custom…": "自訂…",
"Cut": "剪下",
"Cut pages": "剪下頁面",
@@ -274,6 +336,7 @@
"Dark & Moody": "暗調氛圍",
"Darken": "變暗",
"Darks": "暗部",
"Date": "日期",
"Date Groups": "日期群組",
"Day": "日",
"Daylight": "日光",
@@ -288,6 +351,7 @@
"Delete Album": "刪除相簿",
"Delete All Masks": "刪除所有遮色片",
"Delete Color Label Set": "刪除色彩標籤集",
"Delete Component": "刪除元件",
"Delete Export Preset": "刪除匯出預設集",
"Delete Filter Preset": "刪除篩選預設集",
"Delete Imported Profile": "刪除匯入的描述檔",
@@ -298,9 +362,11 @@
"Delete Permanently": "永久刪除",
"Delete Photo": "刪除照片",
"Delete Photos": "刪除照片",
"Delete Point Color": "刪除點色彩",
"Delete Point Color Sample": "刪除點色彩樣本",
"Delete Point Curve Preset": "刪除點曲線預設集",
"Delete Preset": "刪除預設集",
"Delete Red Eye": "刪除紅眼校正",
"Delete Red Eye Correction": "刪除紅眼校正",
"Delete Spot": "刪除斑點",
"Delete Version": "刪除版本",
@@ -311,27 +377,41 @@
"Density": "濃度",
"Depth Range": "深度範圍",
"Descending": "遞減",
"Describe": "描述",
"Describe what to keep": "描述要保留的內容",
"Describe what to leave out": "描述要排除的內容",
"Describe what to select": "描述要選取的內容",
"Describe…": "描述…",
"Deselect": "取消選取",
"Deselect All": "取消全選",
"Desert Heat": "沙漠熱浪",
"Desert Warmth": "沙漠暖色",
"Desktop": "桌面",
"Detail": "詳細檢視",
"Detail (D)": "詳細檢視(D)",
"Details": "詳細資訊",
"Diagonal": "對角線",
"Discard Preview Cache": "捨棄預覽快取",
"Display P3": "Display P3",
"Display name": "顯示名稱",
"Display theme": "顯示主題",
"Distortion": "變形",
"Document properties…": "文件內容…",
"Don't enlarge": "不放大",
"Done": "完成",
"Download": "下載",
"Download AI Mask Model": "下載 AI 遮色片模型",
"Download the SAM 3 Model?": "要下載 SAM 3 模型嗎?",
"Downloads": "下載項目",
"Drag along the horizon; double-click for Auto": "沿水平線拖曳;按兩下可自動校正",
"Drag over an eye on the photo; the pupil inside is found automatically.": "在照片的眼睛上拖曳,會自動尋找範圍內的瞳孔。",
"Draw Guides": "繪製參考線",
"Duplicate": "複製",
"Duplicate Component": "複製元件",
"Duplicate Mask": "複製遮色片",
"Duplicate and Invert Mask": "複製並反轉遮色片",
"Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "每個 / 代表一層資料夾;標籤會依照片填入(缺少中繼資料的層級使用「unknown」)。",
"Edge": "邊緣",
"Edit": "編輯",
"Edit (E)": "編輯(E)",
"Edit Capture Time": "編輯拍攝時間",
@@ -339,27 +419,36 @@
"Edit Color Label Names": "編輯色彩標籤名稱",
"Edit Color Label Names…": "編輯色彩標籤名稱…",
"Edit Copy for External Editor": "編輯外部編輯器副本",
"Edit Date": "編輯日期",
"Edit Info": "編輯資訊",
"Edit Label Names": "編輯標籤名稱",
"Edit Label Names…": "編輯標籤名稱…",
"Edit Mask": "編輯遮色片",
"Edit Mask Component": "編輯遮色片元件",
"Edit Sections": "編輯區段",
"Edit Smart Album": "編輯智慧型相簿",
"Edit Smart Album…": "編輯智慧型相簿…",
"Edit Spot": "編輯斑點",
"Edit in External Editor": "在外部編輯器中編輯",
"Edit settings copied": "已複製編輯設定",
"Edited": "已編輯",
"Edited or not": "不限是否編輯",
"Editing": "編輯",
"Effect": "效果",
"Effects": "效果",
"Empty Recently Deleted": "清空「最近刪除」",
"Enable Lens Corrections": "啟用鏡頭校正",
"Enable Profile Corrections": "啟用描述檔校正",
"End Interaction": "結束互動",
"English": "English",
"Enter Full Screen": "進入全螢幕",
"Enter a date as YYYY-MM-DD HH:MM:SS": "請以 YYYY-MM-DD HH:MM:SS 格式輸入日期",
"Erase": "擦除",
"Every EXIF, GPS and XMP field in the file": "檔案中的所有 EXIF、GPS 與 XMP 欄位",
"Existing previews": "現有預覽",
"Exit": "結束",
"Expand All Stacks": "展開所有堆疊",
"Expand Stack": "展開堆疊",
"Expand/Collapse Stack": "展開/收合堆疊",
"Export": "匯出",
"Export Album with Preset": "使用預設集匯出相簿",
@@ -372,6 +461,7 @@
"Export Presets": "匯出預設集",
"Export Presets…": "匯出預設集…",
"Export User Presets…": "匯出使用者預設集…",
"Export stopped unexpectedly": "匯出意外停止",
"Export with Preset": "使用預設集匯出",
"Export with Previous": "使用上次設定匯出",
"Exporting": "正在匯出",
@@ -380,6 +470,9 @@
"Exposure (shutter · aperture · ISO)": "曝光(快門 · 光圈 · ISO)",
"Extended Description": "延伸說明",
"External Editor": "外部編輯器",
"Face Boxes": "臉部框",
"Face boxes": "臉部框",
"Face names written to XMP by Lightroom and other apps show up here.": "Lightroom 及其他應用程式寫入 XMP 的臉部名稱會顯示在這裡。",
"Faded Matte": "褪色霧面",
"Faded Slide": "褪色正片",
"Faded Urban": "褪色城市",
@@ -389,13 +482,159 @@
"Feather": "羽化",
"File": "檔案",
"File & date": "檔案與日期",
"File Format": "檔案格式",
"File Name": "檔案名稱",
"File Path": "檔案路徑",
"File Size": "檔案大小",
"File Type": "檔案類型",
"File types:": "檔案類型:",
"Filename": "檔名",
"Fill": "填滿",
"First": "最早",
"First commit": "首次提交",
"Five stars": "五顆星",
"Fixed keys": "固定按鍵",
"Flagged as Pick": "已標記為精選",
"Flagged as Reject": "已標記為排除",
"Focal Length": "焦距",
"Focus (assisted culling)": "焦點(輔助挑選)",
"Frame time": "影格時間",
"Frame update": "影格更新",
"GPU rendering": "GPU 算繪",
"Generated demo photo": "產生的示範照片",
"Grab bag": "名單",
"Green label": "綠色標籤",
"Group name": "群組名稱",
"HDR Merge Preview": "HDR 合併預覽",
"HDR Panorama Merge Preview": "HDR 全景合併預覽",
"HDR Panoramas": "HDR 全景",
"HDR panorama": "HDR 全景",
"HDR panoramas": "HDR 全景",
"Has Edits": "有編輯",
"Has GPS": "有 GPS",
"Home": "個人資料夾",
"Hours": "小時",
"ISO Speed": "ISO 感光度",
"ISO speed": "ISO 感光度",
"Import Lightroom Catalog": "匯入 Lightroom 目錄",
"Import Lightroom Catalog…": "匯入 Lightroom 目錄…",
"Import as new": "以新照片匯入",
"Imported": "已匯入",
"Importing Lightroom Catalog": "正在匯入 Lightroom 目錄",
"In Photo Grid and Square Grid: Flag as Pick and advance": "在照片格狀檢視與方格檢視中:標記為精選並前進",
"In Photo Grid and Square Grid: expand or collapse the stack": "在照片格狀檢視與方格檢視中:展開或收合堆疊",
"Info Overlay Off": "關閉資訊疊加",
"Info Overlay: Exposure": "資訊疊加:曝光",
"Info Overlay: File": "資訊疊加:檔案",
"Insert at the cursor": "在游標處插入",
"Inspect Lightroom Catalog": "檢查 Lightroom 目錄",
"Inspecting Lightroom Catalog": "正在檢查 Lightroom 目錄",
"Invert Component": "反轉元件",
"Join the ArtCraft community on Discord": "加入 Discord 上的 ArtCraft 社群",
"Label": "標籤",
"Label Set": "標籤集",
"Last GPU fallback": "最近一次 GPU 備援",
"Last commit": "最近一次提交",
"Last loupe render": "最近一次放大檢視算繪",
"Lens Blur": "鏡頭模糊",
"Library saved": "照片庫已儲存",
"Licence:": "授權:",
"LightCraft could not start": "LightCraft 無法啟動",
"Lightroom Classic Catalog": "Lightroom Classic 目錄",
"Lightroom import cancelled": "已取消 Lightroom 匯入",
"Lightroom inspection cancelled": "已取消 Lightroom 檢查",
"Line delta": "行數差",
"Linear": "線性",
"Lines +/−": "行數 +/−",
"Lines added": "新增行數",
"Lines deleted": "刪除行數",
"Lock": "鎖定",
"Lock Crop Aspect": "鎖定裁切長寬比",
"Locked": "已鎖定",
"Mask Adjustment": "遮色片調整",
"Memory budget": "記憶體預算",
"Merged": "已合併",
"Merged PRs": "已合併的 PR",
"Meta's terms, not LightCraft's. Downloading it means accepting them.": "適用的是 Meta 的條款,而非 LightCraft 的條款。下載即表示接受這些條款。",
"Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "缺少的中繼資料({camera}、{title}…)會留空;產生的名稱若為空白,則保留原始名稱。",
"Model": "型號",
"Models": "模型",
"Move Down": "下移",
"Move Preset to New Group": "將預設集移至新群組",
"Move Up": "上移",
"Move in Stack": "在堆疊中移動",
"Name (A–Z)": "名稱(A–Z)",
"Name of the folder the original is in": "原始檔所在資料夾的名稱",
"Named People": "已命名的人物",
"Nested group": "巢狀群組",
"New time": "新時間",
"No contributor data was built into this copy.": "此版本未內建貢獻者資料。",
"No matching commands": "沒有符合的命令",
"No model credits were built into this copy.": "此版本未內建模型致謝資訊。",
"No named people in these photos": "這些照片中沒有已命名的人物",
"No named people yet": "尚無已命名的人物",
"Not Now": "以後再說",
"Object Mask": "物件遮色片",
"Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.": "物件遮色片與描述遮色片使用 Meta 的分割模型 SAM 3。它不屬於 LightCraft,其他所有功能不需要它也能使用。",
"October 2026": "2026年10月",
"Only": "僅限",
"Original extension without the dot": "不含點的原始副檔名",
"Original file name without its extension": "不含副檔名的原始檔名",
"Panorama": "全景",
"Panorama Merge Preview": "全景合併預覽",
"Panoramas": "全景",
"Paste Settings": "貼上設定",
"Paste from Previous": "貼上前一張照片的設定",
"People": "人物",
"Person": "人物",
"Photos per bracket (0 = auto)": "每組包圍曝光的照片數(0 = 自動)",
"Pick Flag": "精選旗標",
"Pictures": "圖片",
"Prepare AI Masks": "準備 AI 遮色片",
"Presets and profiles": "預設集與描述檔",
"Press keys…": "請按鍵…",
"Preview only: editing the camera's embedded JPEG": "僅預覽:正在編輯相機內嵌的 JPEG",
"Proof copy created": "已建立校樣副本",
"Quit Anyway": "仍要結束",
"Quit with unsaved changes?": "有未儲存的變更,確定要結束嗎?",
"RAW decoding": "RAW 解碼",
"Radial": "放射狀",
"Random": "隨機",
"Rate ★": "評為 ★",
"Rate ★ and advance": "評為 ★ 並前進",
"Rate ★★": "評為 ★★",
"Rate ★★ and advance": "評為 ★★ 並前進",
"Rate ★★★": "評為 ★★★",
"Rate ★★★ and advance": "評為 ★★★ 並前進",
"Rate ★★★★": "評為 ★★★★",
"Rate ★★★★ and advance": "評為 ★★★★ 並前進",
"Rate ★★★★★": "評為 ★★★★★",
"Rate ★★★★★ and advance": "評為 ★★★★★ 並前進",
"Rating cleared": "已清除評分",
"Raw": "RAW",
"Read the SAM License": "閱讀 SAM 授權",
"Reading Lightroom catalog…": "正在讀取 Lightroom 目錄…",
"Real name": "真實姓名",
"Recent": "最近使用",
"Recent Keywords": "最近使用的關鍵字",
"Red label": "紅色標籤",
"Refine": "調整",
"Refine AI Mask Detail": "調整 AI 遮色片細節",
"Refine Edges": "調整邊緣",
"Refine Saturation": "調整飽和度",
"Refining the mask's detail…": "正在調整遮色片細節…",
"Reliability": "可靠度",
"Relink Photo": "重新連結照片",
"Reload Changed Files": "重新載入已變更的檔案",
"Remove Face Box": "移除臉部框",
"Remove a filter above, or choose Clear all": "請移除上方的某個篩選條件,或選擇「全部清除」",
"Remove from Target Album": "從目標相簿移除",
"Remove shortcut": "移除快速鍵",
"Remove this face box (undo with Edit ▸ Undo)": "移除此臉部框(可用「編輯 ▸ 復原」還原)",
"Rename Component": "重新命名元件",
"Reorder Masks": "重新排序遮色片",
"Reset All": "全部重設",
"Reset All Keyboard Shortcuts": "重設所有鍵盤快速鍵",
"Reshuffle": "重新隨機排序",
"File name": "檔案名稱",
"Fill Edges": "填補邊緣",
@@ -570,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": "橫向色差",
@@ -937,25 +1176,32 @@
"Reset Tone Curve": "重設色調曲線",
"Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "重設所有點曲線通道與參數曲線(按兩下通道可只重設該通道)",
"Reset all edits (Cmd+Shift+R)": "重設所有編輯(Cmd+Shift+R)",
"Resize Face Box": "調整臉部框大小",
"Resize to": "調整尺寸為",
"Resolution (ppi)": "解析度(ppi)",
"Restore": "還原",
"Restore (no shortcut)": "還原(無快速鍵)",
"Restore Library from Backup…": "從備份還原照片庫…",
"Restore Version": "還原版本",
"Restore keeps their edits; Import as new deletes the old record first.": "還原會保留編輯;以新照片匯入會先刪除舊紀錄。",
"Restore them": "還原",
"Retouch": "修飾",
"Reverse the order": "反轉順序",
"Revert": "回復至上次儲存",
"Review": "審閱",
"Review Import": "檢視匯入",
"Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "在照片上按右鍵 → 設為參考照片,再選擇「檢視 → 參考檢視」(Shift+R)",
"Right-click a preset to delete it": "在預設集上按右鍵即可刪除",
"Rights Usage Terms": "使用權利條款",
"Rotate": "旋轉",
"Rotate Crop": "旋轉裁切",
"Rotate Crop Aspect": "旋轉裁切長寬比",
"Rotate Left": "向左旋轉",
"Rotate Right": "向右旋轉",
"Roughness": "粗糙度",
"Roundness": "圓度",
"Rules": "規則",
"SAM 3 download stopped: it resumes where it left off next time.": "SAM 3 下載已停止:下次會從中斷處繼續。",
"Same as raw default": "與 RAW 預設值相同",
"Sample Mask Color": "取樣遮色片色彩",
"Sample a colour on the photo": "在照片上取樣色彩",
@@ -976,15 +1222,22 @@
"Save Point Curve Preset": "儲存點曲線預設集",
"Save Point Curve…": "儲存點曲線…",
"Save as Preset": "儲存為預設集",
"Save as Set…": "儲存為標籤集…",
"Save as Smart Album…": "儲存為智慧型相簿…",
"Save as set": "儲存為關鍵字集",
"Save as…": "另存新檔…",
"Save preset": "儲存預設集",
"Saving Lightroom import index…": "正在儲存 Lightroom 匯入索引…",
"Scale": "縮放",
"Scan failed": "掃描失敗",
"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": "搜尋命令或按鍵",
"Search…": "搜尋…",
"Seasons": "四季",
"Second Window": "第二視窗",
@@ -1000,29 +1253,40 @@
"Select photos, then choose View → Survey (N)": "選取照片後,選擇「檢視 → 總覽」(N)",
"Select two photos, then choose View → Compare (Shift+C)": "選取兩張照片後,選擇「檢視 → 比較」(Shift+C)",
"Selected": "已選取",
"Selecting…": "正在選取…",
"Selenium Tone": "硒調色",
"Send Feedback…": "傳送意見回饋…",
"Sepia Tone": "深褐色調",
"Sequence number zero-padded to N digits (1–9)": "補零至 N 位數的序號(1–9)",
"Sequence number, counting from the start number": "從起始編號開始計數的序號",
"Services": "服務",
"Set": "標籤集",
"Set Album Cover": "設定相簿封面",
"Set Before": "設定編輯前",
"Set Color Label": "設定色彩標籤",
"Set Crop": "設定裁切",
"Set Develop Values": "設定顯像參數",
"Set Flag": "設定旗標",
"Set Image Zoom and Pan": "設定影像縮放與平移",
"Set Keyboard Shortcut": "設定鍵盤快速鍵",
"Set Mask Adjustments": "設定遮色片調整",
"Set Point Curve": "設定點曲線",
"Set Profile": "設定描述檔",
"Set Rating": "設定評分",
"Set Top of Stack": "設為堆疊頂端",
"Set as Album Cover": "設為相簿封面",
"Set as Before": "設為編輯前",
"Set as Reference Photo": "設為參考照片",
"Set as Target Album": "設為目標相簿",
"Set as Target Album (B adds to it)": "設為目標相簿(按 B 加入)",
"Set as Top of Stack": "設為堆疊頂端",
"Set date & time": "設定日期與時間",
"Set name": "標籤集名稱",
"Set the mix from the photo's colours": "依照片色彩設定混色",
"Settings": "設定",
"Settings file problem": "設定檔有問題",
"Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "新 RAW 照片使用的初始設定。變更不會影響照片庫中已有的照片。",
"Settings pasted": "已貼上設定",
"Settings to include": "要包含的設定",
"Settings…": "設定…",
"Shade": "陰影處",
@@ -1036,7 +1300,10 @@
"Sharpen": "銳利化",
"Sharpening": "銳利化",
"Sharpness": "銳利度",
"Shift": "位移量",
"Shift by": "位移",
"Short Edge": "短邊",
"Show": "顯示",
"Show All": "全部顯示",
"Show Clipping": "顯示溢色",
"Show Deghost Overlay": "顯示去除鬼影疊加",
@@ -1051,6 +1318,7 @@
"Show Pins": "顯示控制點",
"Show Source": "顯示來源",
"Show Thumbnails": "顯示縮圖",
"Show commands without a shortcut": "顯示沒有快速鍵的命令",
"Show destination gamut warning": "顯示目標色域警告",
"Show display gamut warning": "顯示螢幕色域警告",
"Show file names": "顯示檔案名稱",
@@ -1073,11 +1341,16 @@
"Skip": "略過",
"Sky": "天空",
"Slideshow": "幻燈片播放",
"Slideshow paused": "幻燈片播放已暫停",
"Slideshow resumed": "幻燈片播放已繼續",
"Slideshow · Space pauses · Esc ends": "幻燈片播放 · 空白鍵暫停 · Esc 結束",
"Smart Album": "智慧型相簿",
"Smart Album Rule Fields": "智慧型相簿規則欄位",
"Smart Preview Status": "智慧型預覽狀態",
"Smart Previews Location": "智慧型預覽位置",
"Smart preview available": "有智慧型預覽",
"Smart previews": "智慧型預覽",
"Smart previews folder changed": "智慧型預覽資料夾已變更",
"Smoothness": "平滑度",
"Soft B&W": "柔和黑白",
"Soft Pastel": "柔和粉彩",
@@ -1098,8 +1371,10 @@
"Stack photos taken within this time of each other:": "將拍攝時間間隔在此範圍內的照片堆疊:",
"Stacks": "堆疊",
"Standard": "標準",
"Star rating (0–5)": "星級評分(0–5)",
"Start at": "起始值",
"Start number": "起始編號",
"Starting": "正在開始",
"Startup": "啟動",
"State / Province": "州/省",
"Stop": "停止",
@@ -1114,6 +1389,7 @@
"Subfolder name": "子資料夾名稱",
"Subject": "主體",
"Subtract": "減去",
"Subtract from this mask": "從此遮色片減去",
"Suggestions": "建議",
"Summer Haze": "夏日薄霧",
"Sunday": "星期日",
@@ -1121,13 +1397,16 @@
"Survey (N)": "總覽(N)",
"Swap": "交換",
"Swap Aspect (X)": "交換長寬比(X)",
"Swap Before and After": "交換編輯前與編輯後",
"Swap Before and After Settings": "交換編輯前與編輯後設定",
"Swap Compare Photos": "交換比較照片",
"Switch light / dark theme": "切換淺色/深色主題",
"Sync Settings": "同步設定",
"System": "系統",
"System Info": "系統資訊",
"System Info…": "系統資訊…",
"System default": "系統預設值",
"Table": "表格",
"Tags": "標籤",
"Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "標籤:請參閱範本旁的「標籤」。檔案及其 XMP 附屬檔案會在磁碟上重新命名;遇到同名檔案會加上 -1、-2…",
"Take a snapshot": "擷取快照",
@@ -1143,6 +1422,11 @@
"Text": "文字",
"Text direction": "文字方向",
"Texture": "紋理",
"That key can't be used as a shortcut": "該按鍵無法用作快速鍵",
"The SAM 3 model is installed: Object and Describe masks are ready.": "SAM 3 模型已安裝:物件遮色片與描述遮色片已可使用。",
"The download didn't work:": "下載失敗:",
"The number at the end of the original name (IMG_0042 → 0042)": "原始名稱結尾的數字(IMG_0042 → 0042)",
"The original extension is always added; {ext} only puts it inside the name as well.": "一律會加上原始副檔名;{ext} 只是另外將它放進名稱中。",
"The original files, unchanged, each with an XMP sidecar holding its edits.": "保留未變更的原始檔案,每個檔案附有記錄編輯設定的 XMP 附屬檔案。",
"The photo has these settings": "此照片具有這些設定",
"Theme": "主題",
@@ -1153,9 +1437,12 @@
"Threshold": "臨界值",
"Thumbnail cache": "縮圖快取",
"Thursday": "星期四",
"Time zone": "時區",
"Tint": "色調",
"Title": "標題",
"Title metadata": "中繼資料中的標題",
"Toggle Keyword from Set": "切換關鍵字集中的關鍵字",
"Toggle Mask": "切換遮色片",
"Toggle Section": "切換區段",
"Toggle Zoom": "切換縮放",
"Tone Curve": "色調曲線",
@@ -1165,18 +1452,22 @@
"Treatment": "處理方式",
"Triangle": "三角形",
"Try Again": "再試一次",
"Try Saving Again": "再試一次儲存",
"Tuesday": "星期二",
"Tungsten": "鎢絲燈",
"Turn Off": "關閉",
"Turn on Include subfolders, or pick another folder under Local": "啟用「包含子資料夾」,或在本機中選擇其他資料夾",
"Type": "類型",
"Uncheck All": "全部取消勾選",
"Uncheck the files of this type": "取消勾選此類型的所有檔案",
"Undo": "復原",
"Unedited": "未編輯",
"Unedited raws": "未編輯的 RAW",
"Unflag": "移除旗標",
"Unflagged": "無旗標",
"Ungroup Stack": "取消堆疊",
"Unknown Date": "未知日期",
"Unknown tags stay as typed — check the preview for a {typo}.": "未知的標籤會保持原樣 — 請在預覽中檢查是否有{typo}。",
"Unrated": "未評分",
"Unreadable": "無法讀取",
"Update": "更新",
@@ -1186,6 +1477,7 @@
"Update Version": "更新版本",
"Update Version with Current Settings": "以目前設定更新版本",
"Update with Current Settings": "以目前設定更新",
"Updated the edits saved in the external editor": "已更新在外部編輯器中儲存的編輯內容",
"Upright": "透視校正",
"Urban": "城市",
"Use Keyword Set": "使用關鍵字集",
@@ -1193,6 +1485,7 @@
"Use camera-specific defaults": "使用個別相機的預設值",
"Use the GPU for rendering": "使用 GPU 算繪",
"User Presets": "使用者預設集",
"Username": "使用者名稱",
"Value": "數值",
"Variance": "變異數",
"Version": "版本",
@@ -1204,6 +1497,7 @@
"Vibrance": "自然飽和度",
"Videos": "影片",
"View": "檢視",
"Viewing and sharing": "檢視與分享",
"Vignette": "暗角",
"Vignetting": "暗角",
"Vintage": "復古",
@@ -1242,7 +1536,10 @@
"Yellow Hue": "黃色色相",
"Yellow Luminance": "黃色明度",
"Yellow Saturation": "黃色飽和度",
"Yellow label": "黃色標籤",
"Yes": "是",
"You can close this: the download continues, and resumes if interrupted.": "可以關閉此視窗:下載會繼續進行,中斷後也會接續下載。",
"Your Name": "你的名字",
"Your library couldn't be opened": "無法開啟你的照片庫",
"Zoom": "縮放",
"Zoom 100%": "縮放至 100%",
@@ -1250,13 +1547,82 @@
"Zoom Out": "縮小",
"Zoom to Fit": "縮放至符合視窗",
"Zoom to page level": "符合頁面大小",
"a literal %": "字面上的 %",
"a removed photo": "一張已移除的照片",
"all": "全部",
"and": "且",
"any": "任一",
"are empty": "為空",
"aren't empty": "不為空",
"blue": "藍色",
"comma, separated": "以逗號分隔",
"contains": "包含",
"copyrighted": "受著作權保護",
"day 01–31": "日 01–31",
"days": "天",
"discarding smart previews": "正在捨棄智慧型預覽",
"doesn't contain": "不包含",
"e.g. sky · the red car · car, road": "例如(英文):sky · the red car · car, road",
"ends with": "結尾為",
"false": "否",
"green": "綠色",
"hour 00–23": "時 00–23",
"hours": "小時",
"image": "影像",
"is": "是",
"is <": "小於",
"is >": "大於",
"is after": "晚於",
"is before": "早於",
"is between": "介於",
"is empty": "為空",
"is in the last": "在最近",
"is unknown": "未知",
"is ≤": "小於或等於",
"is ≥": "大於或等於",
"isn't": "不是",
"isn't empty": "不為空",
"isn't in the last": "不在最近",
"keep names — or e.g. {date}_{seq:3}": "保留名稱 — 或例如 {date}_{seq:3}",
"latitude, longitude": "緯度, 經度",
"memory only": "僅記憶體",
"minute": "分",
"month 01–12": "月 01–12",
"months": "個月",
"none": "無",
"none (CPU rendering)": "無(CPU 算繪)",
"none of": "皆不符合",
"not available here": "此處無法使用",
"of the following rules:": "下列規則:",
"or": "或",
"pick": "精選",
"previews": "預覽",
"publicDomain": "公有領域",
"purple": "紫色",
"raw": "RAW",
"red": "紅色",
"reject": "排除",
"saved in": "儲存於",
"second": "秒",
"smart previews": "智慧型預覽",
"starts with": "開頭為",
"the photos in view": "檢視中的照片",
"the selected photos": "選取的照片",
"this photo": "這張照片",
"true": "是",
"unknown": "未知",
"video": "影片",
"weeks": "週",
"year, 2 digits": "年,2 位數",
"year, 4 digits": "年,4 位數",
"years": "年",
"yellow": "黃色",
"{date} is the capture time; a photo without one uses the time it was imported.": "{date} 是拍攝時間;沒有拍攝時間的照片會使用匯入時間。",
"© 2026 Your Name": "© 2026 你的名字",
"© Your Name": "© 你的名字",
"− harder border · + softer border": "− 邊緣較硬 · + 邊緣較柔和",
"≤ at most": "≤ 最多",
"≥ at least": "≥ 至少",
"日本語": "日本語",
"繁體中文(台灣)": "繁體中文(台灣)",
"In Recently Deleted": "在「最近刪除」中",
@@ -1272,5 +1638,8 @@
"Expand": "展開",
"Remove Disk from Library": "從照片庫移除磁碟",
"All the photos imported from this disk are included.": "包含從此磁碟匯入的所有照片。",
"That includes the photos in the folders inside it.": "其中資料夾裡的照片也包含在內。"
"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 放入上方的資料夾:放入後即可使用「物件」與「描述」。"
}
+25 -2
View File
@@ -54,8 +54,21 @@ fn wrap(r: Result<Value, String>) -> Outcome {
}
pub fn all_commands(app: &LightcraftApp) -> Value {
let mut v: Vec<Value> = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
let keymap = &app.ui.settings.keymap;
let mut v: Vec<Value> = app
.session
.commands()
.into_iter()
.map(|c| {
let mut v = serde_json::to_value(&c).unwrap_or_default();
if let Some(o) = v.as_object_mut() {
o.insert("shortcut".into(), json!(crate::shortcuts::shortcut_of(keymap, c.id)));
}
v
})
.collect();
for (id, label, sc, menu) in crate::menus::ui_commands() {
let sc = crate::shortcuts::binding(keymap, id, *sc);
v.push(json!({"id": id, "label": label, "shortcut": sc, "menu": [menu], "enabled": crate::menus::ui_enabled(app, id), "ui": true}));
}
Value::Array(v)
@@ -79,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,
@@ -90,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),
})
}
+19 -15
View File
@@ -125,7 +125,7 @@ impl Contributor {
/// One line with everything we know, for tooltips.
pub fn summary(&self) -> String {
format!(
crate::i18n::tr_format!(
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}",
self.login,
self.prs,
@@ -136,7 +136,7 @@ impl Contributor {
self.binary_added,
self.binary_deleted,
day(self.first_commit),
day(self.last_commit),
day(self.last_commit)
)
}
}
@@ -232,43 +232,43 @@ pub fn contributors_ui(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let id = egui::Id::new("credits_view");
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
ui.horizontal_wrapped(|ui| {
ui.label("Show");
ui.label(crate::i18n::tr("Show"));
for m in NameMode::ALL {
if ui.selectable_label(v.names == m, m.label()).clicked() {
if ui.selectable_label(v.names == m, crate::i18n::tr(m.label())).clicked() {
v.names = m;
}
}
ui.separator();
ui.label("Sort");
egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| {
ui.label(crate::i18n::tr("Sort"));
egui::ComboBox::from_id_salt("credits_sort").selected_text(crate::i18n::tr(v.key.label().0)).show_ui(ui, |ui| {
for k in SortKey::ALL {
if ui.selectable_label(v.key == k, k.label().0).clicked() {
if ui.selectable_label(v.key == k, crate::i18n::tr(k.label().0)).clicked() {
v.key = k;
v.ascending = k.default_ascending();
}
}
});
if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text("Reverse the order").clicked() {
if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text(crate::i18n::tr("Reverse the order")).clicked() {
v.ascending = !v.ascending;
}
ui.separator();
let r = ui.selectable_label(!v.table, "Grab bag");
let r = ui.selectable_label(!v.table, crate::i18n::tr("Grab bag"));
crate::widgets::register(ui.ctx(), "button:creditsGrabBag", r.rect);
if r.clicked() {
v.table = false;
}
let r = ui.selectable_label(v.table, "Table");
let r = ui.selectable_label(v.table, crate::i18n::tr("Table"));
crate::widgets::register(ui.ctx(), "button:creditsTable", r.rect);
if r.clicked() {
v.table = true;
}
});
let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending);
ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().color(t.text_dim));
ui.label(RichText::new(crate::i18n::tr_format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().color(t.text_dim));
ui.separator();
egui::ScrollArea::both().id_salt("credits_scroll").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| {
if list.is_empty() {
ui.label("No contributor data was built into this copy.");
ui.label(crate::i18n::tr("No contributor data was built into this copy."));
} else if v.table {
table(app, ui, &list, &mut v);
} else {
@@ -289,7 +289,11 @@ fn table(app: &mut LightcraftApp, ui: &mut egui::Ui, list: &[&Contributor], v: &
egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
for k in SortKey::ALL {
let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" };
if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() {
if ui
.button(RichText::new(format!("{}{arrow}", crate::i18n::tr(k.label().1))).strong())
.on_hover_text(crate::i18n::tr(k.label().0))
.clicked()
{
if v.key == k {
v.ascending = !v.ascending;
} else {
@@ -318,14 +322,14 @@ fn table(app: &mut LightcraftApp, ui: &mut egui::Ui, list: &[&Contributor], v: &
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
pub fn models_ui(ui: &mut egui::Ui) {
if MODELS.is_empty() {
ui.label("No model credits were built into this copy.");
ui.label(crate::i18n::tr("No model credits were built into this copy."));
return;
}
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
egui::ScrollArea::both().id_salt("credits_models_scroll").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| {
egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| {
for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] {
ui.label(RichText::new(h).strong());
ui.label(RichText::new(crate::i18n::tr(h)).strong());
}
ui.end_row();
for m in MODELS {
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff. Show more