From 65e4d6d84e6796a7b60890461278cbd4934eacf6 Mon Sep 17 00:00:00 2001 From: stmtc2333 Date: Sat, 5 Sep 2026 11:31:54 +0800 Subject: [PATCH] feat: replace the "fast preview" mode with an explicit embedded-JPG mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "FAST" named a layer whose content varies per file: usually the camera's embedded JPEG, but a half-size RAW decode when the file has no embedded preview. The label therefore told the user nothing about what was on screen, and the embedded JPEG had no view mode of its own despite being what that button usually showed. Split the two things it conflated: - RawLayer.thumbnail (was fastPreview) keeps the cheap-image-with-fallback behaviour and stays internal — grid tiles, filmstrip, preload, and the preview's first frame, always at a bounded targetWidth. - RawLayer.embeddedJpeg is new: the container's JPEG with no fallback, so a null result is the authoritative "this file has none". It backs both the new view mode and the availability probe that greys it out. User-facing modes are now RawViewMode {embeddedJpeg, decodedRaw, pairedJpeg}, set only from the preview's top-right switch. The settings page's "RAW Preview Source" section is gone — two controls for one value is what made the old model confusing. The mode is app-wide and persisted, and every mode is always listed in the menu with unavailable ones disabled rather than removed, so the menu no longer changes shape between files. The switch also had to take effect immediately. ImagePreviewPage is a pushed route, so its pageBuilder runs once and its settings object is frozen at open time; writing the mode only through the persistence callback left the visible page unchanged until reopened. The mode is now owned by the page's own state for the visible effect and reported upward for persistence. The field is renamed to `initialSettings` so the snapshot boundary is stated at every use site, and documented as an invariant — the remaining reads of it are correct only because the settings dialog cannot be open at the same time as the preview. Also drops a redundant decode: the preview no longer requests a full-resolution thumbnail layer, whose fallback path ran the same half_size=1 processing as the decoded layer it was about to be replaced by. --- AGENTS.md | 223 +++++++++++++++++- android/app/src/main/cpp/wrapper.cpp | 17 +- lib/core/preferences_repository.dart | 11 + lib/core/raw_view_mode.dart | 56 +++++ lib/gallery/widgets/media_thumbnail_tile.dart | 4 +- lib/home_page.dart | 14 +- lib/image_store.dart | 28 ++- lib/l10n/app_en.arb | 10 +- lib/l10n/app_localizations.dart | 54 ++--- lib/l10n/app_localizations_en.dart | 26 +- lib/l10n/app_localizations_zh.dart | 26 +- lib/l10n/app_zh.arb | 10 +- lib/native_lib.dart | 46 ++-- lib/preview/image_preview_page.dart | 190 +++++++++------ lib/preview/preview_models.dart | 2 - lib/preview/single_image_preview.dart | 209 +++++++++------- lib/preview/widgets/preview_filmstrip.dart | 2 +- lib/settings_page.dart | 43 +--- lib/worker_service.dart | 35 ++- linux/native_lib/wrapper.cpp | 17 +- macos/native_lib/wrapper.cpp | 17 +- test/core/preferences_repository_test.dart | 26 ++ test/core/raw_view_mode_test.dart | 98 ++++++++ test/widget_test.dart | 37 +-- tool/native_decode_check.dart | 39 ++- windows/native_lib/wrapper.cpp | 19 +- 26 files changed, 887 insertions(+), 372 deletions(-) create mode 100644 lib/core/raw_view_mode.dart create mode 100644 test/core/raw_view_mode_test.dart diff --git a/AGENTS.md b/AGENTS.md index b8d94df..a5443d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,8 @@ lib/ platform_channels.dart desktopOpenChannel, windowsShellChannel pointer_modifiers.dart isZoomModifierPressed preferences_repository.dart PreferencesRepository — all SharedPreferences keys + raw_view_mode.dart RawViewMode, resolveRawViewMode, + isRawViewModeAvailable gallery/ grid_zoom_accumulator.dart GridZoomAccumulator — scroll + pinch → column delta @@ -36,8 +38,7 @@ lib/ preview/ image_preview_page.dart ImagePreviewPage — full-screen preview shell preview_geometry.dart Pure functions + constants (scale, navigation, timing) - preview_models.dart PreviewSource, PreviewAction, PreviewDisplayControl, - PreviewScaleDirection + preview_models.dart PreviewAction, PreviewDisplayControl single_image_preview.dart SingleImagePreview — zoomed single image widgets/ preview_filmstrip.dart PreviewFilmstrip, PreviewFilmstripThumbnail @@ -61,6 +62,7 @@ lib/ test/ core/ preferences_repository_test.dart + raw_view_mode_test.dart gallery/ grid_zoom_accumulator_test.dart media_library_test.dart @@ -90,6 +92,189 @@ New code belongs in the most specific layer it can live in. If it is used by both gallery and preview it goes in `core/`. If it has no business semantics it goes in `ui/`. State that belongs to a single widget stays private in that file. +## Image Model and Terminology + +Several things are easy to confuse because the code, the native ABI, and the UI +strings all use overlapping words for them. This is the mapping. + +### Layers — how an image is obtained (`RawLayer`, internal) + +| Layer | native | What it is | Requested by | +| --- | --- | --- | --- | +| `RawLayer.thumbnail` | `get_thumbnail` | The cheapest image LibRaw can produce: embedded preview data when present, a half-size RAW decode otherwise. Content therefore varies per file. | Grid tiles, filmstrip, neighbour preload, the preview's first frame | +| `RawLayer.embeddedJpeg` | `get_embedded_jpeg` | The JPEG stored in the RAW container, passed through verbatim. **No fallback** — a null result means this file has none. | Preview (display + availability probe), export | +| `RawLayer.decoded` | `get_preview` | The final high-quality image, from `unpack()` + `dcraw_process()`. Always RGBA8888. | Preview | + +`RawLayer.thumbnail` is only ever requested with a bounded `targetWidth`. The +full-screen layers (`embeddedJpeg`, `decoded`) are requested without one, at +their own resolution. + +### View modes — what the user chose to look at (`RawViewMode`, user-facing) + +| Mode | UI label (en / zh) | Source | Unavailable when | +| --- | --- | --- | --- | +| `embeddedJpeg` | Embedded JPG / 内嵌 JPG | `RawLayer.embeddedJpeg` | The RAW carries no embedded JPEG | +| `decodedRaw` | RAW / RAW | `RawLayer.decoded` | Never — always available for a RAW file | +| `pairedJpeg` | JPG / JPG | The sibling file on disk, via Flutter's image pipeline | The group has no paired JPEG | + +There is no "fast" mode and no `FAST` label: that name described a layer whose +content varies per file, so it told the user nothing about what they were +looking at. The two things it conflated — the embedded JPEG and a cheap RAW +decode — are now separate. + +### Two words that are not layers + +**Thumbnail (缩略图) is also a size.** `RawLayer.thumbnail` is a layer, but "the +grid thumbnail" is that layer at a particular `targetWidth`. `targetWidth` is +part of `ImageStore.cacheKey`, so one file's grid-sized and filmstrip-sized +entries are two cache entries of one layer; downscaling happens once in +`decodeToUiImage`, never in native code. + +`WorkerService` dedupes by `'$path:${type.name}:$halfSize'`, which does **not** +include the width. Two widgets wanting the same layer at different widths share +a single native decode and then produce two `ui.Image` cache entries from its +bytes. + +For bitmap files there is no ImageStore involvement at all: a thumbnail is +`ResizeImage(FileImage(...), width: ...)` and Flutter's own image cache owns it. + +**Paired JPEG is a display source, not a layer.** +`buildAdaptiveMediaGroups` matches a bitmap file to a RAW file by lowercased +stem (RAW+JPEG shooting). The bitmap becomes `MediaGroup.pairedJpeg`, is removed +from the grid as its own entry, and the RAW group is badged `R&J` instead of +`RAW`. It is a sibling file read from disk — unrelated to the RAW's embedded +JPEG, which lives inside the RAW container. + +### Embedded JPEG is reached three ways + +The same bytes, three call paths, and conflating them is the easiest mistake to +make here: + +- **As its own view mode** — `RawLayer.embeddedJpeg`, no fallback. This is also + the availability probe: the preview loads it on every RAW page and reports the + result via `onEmbeddedJpegAvailability`, which is what greys out the mode. +- **As one possible input to the thumbnail layer** — `process_thumbnail()` tries + `unpack_thumb()` first, but **falls back** to `half_size = 1` RAW processing + when there is nothing usable. So a thumbnail-layer image is not necessarily + the embedded JPEG. +- **As a file export** — `extractEmbeddedJpeg()` on a one-off `Isolate.run`, + returning encoded bytes rather than a `ui.Image`. Not through `WorkerService`: + a user-initiated export must not occupy a decode worker. + +### Legacy native names + +The native ABI names predate this vocabulary and are kept for compatibility: + +- `get_thumbnail` returns the **thumbnail layer**, which may be a RAW decode + rather than an embedded thumbnail. +- `get_preview` returns the **decoded RAW layer**, not "a preview". +- `ThumbnailResult.format` carries either encoded bytes or RGBA pixels, so the + struct name says nothing about the payload. Read `RawPixelFormat`. + +Do not rename these without changing every platform wrapper together +(`windows/`, `macos/`, `linux/`, `android/`). + +## Loading Flows + +### Gallery grid — `MediaThumbnailTile` + +``` +bitmap → Image(ResizeImage(FileImage(path), width: resizeWidth)) + + a separate ImageStream used only to report the aspect ratio + back to the justified grid layout + +RAW → imageStore.peek(thumbnail, targetWidth: resizeWidth) // sync + ↳ hit : painted on the tile's very first frame + ↳ miss : imageStore.load(thumbnail, targetWidth, TaskPriority.high) +``` + +`resizeWidth` is `bucketDecodeWidth(cellWidth * dpr)` clamped to 100–800 logical +px, computed in `_HomePageState.build`. + +The task is **not cancelled** when a tile is recycled — see the invariant below. +Staleness is handled by comparing `_generation` instead. + +### Full-screen preview, RAW — `SingleImagePreview` + +``` +initState peek(thumbnail, thumbnailResizeWidth) // grid-sized, soft + ?? peek(thumbnail, previewThumbnailResizeWidth) + → something on screen in the first frame; soft beats black + +_loadRawDisplayLayers() + 1. load(embeddedJpeg), no targetWidth → wanted in BOTH RAW modes: + the image itself in embedded mode, the interim sharp image while a + decode runs, and the availability probe either way. + priority = low while isFastScrolling, otherwise high + 2. skip the decode when embedded mode already has its image + 3. otherwise load(decoded, halfSize: useHalfSizeRawDecode ? 1 : 0) + tracked in _decodedRawTask so it can be cancelled +``` + +`_displayedImage` picks the one layer to paint, best first: in embedded mode the +embedded JPEG wins (falling through to the decode only when this file has none); +in decoded mode the decode wins once it lands. The cached thumbnail is the last +resort in both. `_buildRawPreview` paints exactly that one — stacking layers +would pay for a full-screen overdraw every frame. A small corner spinner means +"decoding in the background"; the centred spinner means nothing is available +yet. + +Only the decoded-RAW task is cancellable (on page deactivation, on fast +scrolling, or when the mode switches away from `decodedRaw`). + +### Full-screen preview, bitmap or paired JPEG — `_buildBitmapPreview` + +Two stacked `Image` widgets, both `FileImage` + `ResizeImage`: + +1. `thumbnailResizeWidth` — the entry the grid already warmed, so the page + switch paints immediately. +2. `bucketDecodeWidth(viewportWidth * 2.0)` clamped to 4096 physical px, added + only when the page is active and not fast-scrolling. + +The cap exists because a full-resolution bitmap decode costs `w * h * 4` bytes +regardless of window size — an 8000×6000 JPEG is ~192 MB. The `2.0` factor is +zoom headroom. + +### Filmstrip and preload + +`PreviewFilmstripThumbnail`: RAW → `load(thumbnail, targetWidth: decodeWidth, +TaskPriority.low)`; bitmap → `ResizeImage(FileImage(...))`. + +`ImagePreviewPage._preloadIndex` warms neighbours (±3, or ±1 while fast +scrolling) as soon as navigation *intent* is seen, not when the animation +reaches halfway: + +- RAW → `load(thumbnail, targetWidth: filmstripWidth, low)`, then disposes the + handle immediately. The point is populating the cache, not holding an image; + the page that lands there peeks it synchronously. +- bitmap → `precacheImage` at `thumbnailResizeWidth`. + +Preloading a *bounded* width matters: preloading full-resolution layers for six +neighbours would blow the cache budget on images nobody looks at. + +### View mode selection and persistence + +One app-wide mode, not per-file state. It is set **only** from the preview's +top-right switch — there is deliberately no settings-page equivalent, because +two controls for one value is what the old "RAW Preview Source" section got +wrong. + +``` +switch tapped + → _ImagePreviewPageState._rawViewMode (setState: this screen changes now) + → onRawViewModeChanged → _updateSettings → PreferencesRepository + (persists for the next launch) +``` + +Both halves are load-bearing. See the route-snapshot invariant below for why the +local copy cannot be dropped in favour of reading the settings. + +`resolveRawViewMode()` narrows the preference to something this file can show; +`isRawViewModeAvailable()` decides which menu items are greyed out. Every mode +is always rendered in the menu — unavailable ones are disabled, never removed, +so the menu does not change shape between files. `decodedRaw` is the fallback +because it is the only mode available for every RAW file. + ## Development Workflow ```bash @@ -124,8 +309,11 @@ for `ViewerImage` values must do the same, or eviction leaks GPU textures. **All RAW decoding goes through WorkerService** — `WorkerService` runs a pool of background isolates and handles cancellation, deduplication, and priority queuing. -Never call `getRawFastPreviewSync` or `getDecodedRawPreviewSync` on the main -isolate. Use `ImageStore.load` or `WorkerService.request*` instead. +Never call `getRawThumbnailSync`, `getEmbeddedJpegImageSync`, or +`getDecodedRawPreviewSync` on the main isolate. Use `ImageStore.load` or +`WorkerService.request*` instead. (`extractEmbeddedJpeg()` is the one sanctioned +exception: it runs on its own `Isolate.run`, because a user-initiated export +returns encoded bytes and must not occupy a decode worker.) **Decode width must go through `bucketDecodeWidth`** — this function rounds a pixel width up to the nearest 128-pixel boundary. Decode widths also serve as @@ -133,10 +321,37 @@ cache keys, so a raw `constraints.maxWidth` that changes pixel-by-pixel on every window resize would invalidate every cached image. Always bucket the width before passing it to `ImageStore` or `WorkerService`. +**Shared cheap layers must never be cancelled** — `WorkerService` dedupes by +path, so one widget cancelling its `RawLayer.thumbnail` or +`RawLayer.embeddedJpeg` task resolves every other widget's shared request to +`null`, leaving unrelated tiles showing a broken image. Grid tiles and the +preview both handle "no longer wanted" by ignoring a stale result (a +`_generation` counter), not by cancelling. Only the decoded-RAW task is +cancellable. See **Loading Flows**. + **All SharedPreferences keys live in `PreferencesRepository`** — the keys are private constants there. Never add a `SharedPreferences.getInstance()` call outside that class; a mistyped key silently drops the setting. +**A pushed route's settings are a snapshot** — `ImagePreviewPage` is opened with +`Navigator.push`, and a `PageRouteBuilder`'s `pageBuilder` runs once. Its +settings object is therefore frozen at open time; later changes on `HomePage` +never reach it. The field is named `initialSettings` to say so at every use site. + +This is why the RAW view mode is *also* held in `_ImagePreviewPageState`: writing +it only through `onRawViewModeChanged` persists it correctly but leaves the +current screen unchanged until the preview is reopened. Anything the user can +change from **inside** the preview needs the same treatment — local state for +the visible effect, a callback for persistence. Making a setting genuinely live +across this boundary means lifting it into an `InheritedWidget` or a +`ValueListenable`, not reading `initialSettings` again. + +The remaining reads of `initialSettings` (`pageSwitchAnimationEnabled`, +`previewOverlayOpacity`, `timeDisplaySource`) are correct today only because the +settings dialog is reachable from the gallery toolbar alone, so it cannot be +open at the same time as the preview. Adding a settings entry point to the +preview would break all three at once. + ## Naming Conventions Dart privacy is library-scoped: a `_` prefix makes a symbol private to its file. diff --git a/android/app/src/main/cpp/wrapper.cpp b/android/app/src/main/cpp/wrapper.cpp index 2abd329..71cc49c 100644 --- a/android/app/src/main/cpp/wrapper.cpp +++ b/android/app/src/main/cpp/wrapper.cpp @@ -61,8 +61,9 @@ bool is_cancelled(void* cancel_token) { return flag != nullptr && flag->load(std::memory_order_relaxed); } -// Extract only the JPEG bytes stored in the RAW container. Unlike the fast -// preview path below, this deliberately does not fall back to RAW processing. +// Extract only the JPEG bytes stored in the RAW container. Unlike the thumbnail +// layer below, this deliberately does not fall back to RAW processing, so a +// null result is the authoritative "this file has no embedded JPEG". ThumbnailResult extract_embedded_jpeg(LibRaw& RawProcessor) { ThumbnailResult result = empty_thumbnail(); @@ -174,12 +175,14 @@ extern "C" { delete static_cast*>(token); } - // Build the RAW fast preview layer. + // Build the RAW thumbnail layer: the cheapest image we can produce. // // Prefer the embedded preview via unpack_thumb(). Encoded JPEG previews are // passed straight through because the engine decodes JPEG efficiently // already. If the file does not expose one, fall back to a half-size RAW - // decode so the UI still gets a fast first image. + // decode so the UI still gets an image quickly. The payload is therefore + // not necessarily the embedded JPEG — get_embedded_jpeg() is the + // no-fallback path for callers that need that distinction. ThumbnailResult process_thumbnail(LibRaw& RawProcessor, void* cancel_token) { ThumbnailResult result = empty_thumbnail(); @@ -225,7 +228,7 @@ extern "C" { return result; } - // Fallback: generate a RAW fast preview from decoded RAW data. + // Fallback: no usable embedded preview, so decode the RAW at half size. RawProcessor.imgdata.params.use_camera_wb = 1; RawProcessor.imgdata.params.half_size = 1; // Half size for speed RawProcessor.imgdata.params.output_bps = 8; @@ -257,8 +260,8 @@ extern "C" { return result; } - // Despite the ABI name, get_thumbnail semantically returns the RAW fast - // preview layer. + // Despite the ABI name, get_thumbnail returns the RAW thumbnail layer, which + // may be a RAW decode rather than an embedded thumbnail. EXPORT void get_thumbnail(const char* file_path, void* cancel_token, ThumbnailResult* out) { if (!out) return; diff --git a/lib/core/preferences_repository.dart b/lib/core/preferences_repository.dart index ef67d3a..5eb10b6 100644 --- a/lib/core/preferences_repository.dart +++ b/lib/core/preferences_repository.dart @@ -1,6 +1,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../settings_page.dart'; +import 'raw_view_mode.dart'; /// Default column count for the thumbnail grid. const int kDefaultGridCrossAxisCount = 4; @@ -34,12 +35,14 @@ class StoredViewPreferences { final GridAspectRatio? gridAspectRatio; final bool pageSwitchAnimationEnabled; final double previewOverlayOpacity; + final RawViewMode? rawViewMode; const StoredViewPreferences({ required this.crossAxisCount, required this.gridAspectRatio, required this.pageSwitchAnimationEnabled, required this.previewOverlayOpacity, + required this.rawViewMode, }); } @@ -64,6 +67,7 @@ class PreferencesRepository { static const String _pageSwitchAnimationEnabled = 'page_switch_animation_enabled'; static const String _previewOverlayOpacity = 'preview_overlay_opacity'; + static const String _rawViewMode = 'raw_view_mode'; /// Superseded by [_previewOverlayOpacity]. Read only, to migrate installs /// that predate the continuous opacity slider. @@ -122,6 +126,8 @@ class PreferencesRepository { legacyAutoTransparencyEnabled: prefs.getBool(_legacyPreviewOverlayAutoTransparency), ), + rawViewMode: + RawViewMode.values.asNameMap()[prefs.getString(_rawViewMode)], ); } @@ -162,4 +168,9 @@ class PreferencesRepository { final prefs = await _prefs; await prefs.setDouble(_previewOverlayOpacity, opacity); } + + Future saveRawViewMode(RawViewMode mode) async { + final prefs = await _prefs; + await prefs.setString(_rawViewMode, mode.name); + } } diff --git a/lib/core/raw_view_mode.dart b/lib/core/raw_view_mode.dart new file mode 100644 index 0000000..cd9f1bb --- /dev/null +++ b/lib/core/raw_view_mode.dart @@ -0,0 +1,56 @@ +/// Which image the preview shows for a RAW file. +/// +/// This is the user-facing choice behind the preview's top-right switch. It is +/// a single app-wide preference (persisted in `PreferencesRepository`), not +/// per-file state. +/// +/// Lives in `core/` because the settings model, the preferences repository and +/// the preview all need it, and `core/` may not depend on `preview/`. +enum RawViewMode { + /// The JPEG the camera embedded in the RAW container, shown as-is. + /// + /// Unavailable for RAW files that carry no embedded JPEG. + embeddedJpeg, + + /// LibRaw's own decode of the RAW data. Always available for a RAW file. + decodedRaw, + + /// The sibling `.jpg` shot alongside the RAW (RAW+JPEG shooting). + /// + /// Unavailable when the group has no paired JPEG. + pairedJpeg, +} + +/// Narrows [preferred] to a mode this particular file can actually display. +/// +/// Only [RawViewMode.decodedRaw] is available for every RAW file, so it is the +/// fallback: an app-wide preference of `embeddedJpeg` must still show something +/// for a file whose RAW carries no embedded JPEG, and likewise for `pairedJpeg` +/// on a file that was not shot RAW+JPEG. +RawViewMode resolveRawViewMode({ + required RawViewMode preferred, + required bool hasEmbeddedJpeg, + required bool hasPairedJpeg, +}) { + return switch (preferred) { + RawViewMode.embeddedJpeg when !hasEmbeddedJpeg => RawViewMode.decodedRaw, + RawViewMode.pairedJpeg when !hasPairedJpeg => RawViewMode.decodedRaw, + _ => preferred, + }; +} + +/// Whether [mode] can be selected for a file with these sources. +/// +/// The preview renders every mode in its switch and greys out the unavailable +/// ones, so the menu never changes shape between files. +bool isRawViewModeAvailable( + RawViewMode mode, { + required bool hasEmbeddedJpeg, + required bool hasPairedJpeg, +}) { + return switch (mode) { + RawViewMode.embeddedJpeg => hasEmbeddedJpeg, + RawViewMode.decodedRaw => true, + RawViewMode.pairedJpeg => hasPairedJpeg, + }; +} diff --git a/lib/gallery/widgets/media_thumbnail_tile.dart b/lib/gallery/widgets/media_thumbnail_tile.dart index 6193e35..5958e2e 100644 --- a/lib/gallery/widgets/media_thumbnail_tile.dart +++ b/lib/gallery/widgets/media_thumbnail_tile.dart @@ -112,7 +112,7 @@ class _MediaThumbnailTileState extends State { // A cache hit resolves synchronously, so the very first frame already has // the image rather than flashing a placeholder. - final cached = widget.imageStore.peek(widget.filePath, RawLayer.fastPreview, + final cached = widget.imageStore.peek(widget.filePath, RawLayer.thumbnail, targetWidth: widget.resizeWidth); if (cached != null) { _fastPreview = cached; @@ -133,7 +133,7 @@ class _MediaThumbnailTileState extends State { // by ignoring a stale result via [generation]. final image = await widget.imageStore.load( widget.filePath, - RawLayer.fastPreview, + RawLayer.thumbnail, targetWidth: widget.resizeWidth, ); diff --git a/lib/home_page.dart b/lib/home_page.dart index c2a3829..04ba511 100644 --- a/lib/home_page.dart +++ b/lib/home_page.dart @@ -14,6 +14,7 @@ import 'core/decode_target.dart'; import 'core/media_timestamps.dart'; import 'core/media_types.dart'; import 'core/preferences_repository.dart'; +import 'core/raw_view_mode.dart'; import 'gallery/grid_zoom_accumulator.dart'; import 'gallery/media_library.dart'; import 'core/platform_channels.dart'; @@ -96,6 +97,7 @@ class _HomePageState extends State { _settings = _settings.copyWith( pageSwitchAnimationEnabled: stored.pageSwitchAnimationEnabled, previewOverlayOpacity: stored.previewOverlayOpacity, + rawViewMode: stored.rawViewMode, ); }); } @@ -158,6 +160,9 @@ class _HomePageState extends State { Future _persistPreviewOverlayOpacity(double opacity) => const PreferencesRepository().savePreviewOverlayOpacity(opacity); + Future _persistRawViewMode(RawViewMode mode) => + const PreferencesRepository().saveRawViewMode(mode); + void _updateSettings(ViewerSettings settings) { final cacheSizeChanged = _settings.maxCacheSize != settings.maxCacheSize; final appLanguageChanged = _settings.appLanguage != settings.appLanguage; @@ -167,6 +172,7 @@ class _HomePageState extends State { settings.pageSwitchAnimationEnabled; final previewOverlayOpacityChanged = _settings.previewOverlayOpacity != settings.previewOverlayOpacity; + final rawViewModeChanged = _settings.rawViewMode != settings.rawViewMode; setState(() { _settings = settings; @@ -196,6 +202,9 @@ class _HomePageState extends State { ), ); } + if (rawViewModeChanged) { + unawaited(_persistRawViewMode(settings.rawViewMode)); + } } void _initCache() { @@ -587,7 +596,10 @@ class _HomePageState extends State { thumbnailResizeWidth: thumbnailResizeWidth, imageStore: _imageStore, timestampRepository: _timestampRepository, - settings: _settings, + initialSettings: _settings, + onRawViewModeChanged: (mode) => _updateSettings( + _settings.copyWith(rawViewMode: mode), + ), onClose: () { Navigator.pop(context); }, diff --git a/lib/image_store.dart b/lib/image_store.dart index 37612ba..498110b 100644 --- a/lib/image_store.dart +++ b/lib/image_store.dart @@ -7,8 +7,16 @@ import 'worker_service.dart'; /// Which RAW layer an image represents. enum RawLayer { - /// Embedded preview, or a fast half-size decode when none exists. - fastPreview, + /// The cheapest image LibRaw can produce: embedded preview data when the file + /// has it, a half-size RAW decode otherwise. + /// + /// Only ever requested at a bounded [ImageStore.load] `targetWidth` — grid + /// tiles, filmstrip, and the preview's first frame. + thumbnail, + + /// The JPEG embedded in the RAW container, with no fallback. A failed load + /// means the file carries no embedded JPEG. + embeddedJpeg, /// Full RAW decode used as the final high-quality image. decoded, @@ -46,7 +54,8 @@ class ImageStore { }) { final width = targetWidth ?? 0; return switch (layer) { - RawLayer.fastPreview => '$filePath:fast-preview:$width', + RawLayer.thumbnail => '$filePath:thumbnail:$width', + RawLayer.embeddedJpeg => '$filePath:embedded-jpeg:$width', RawLayer.decoded => '$filePath:decoded-raw:$halfSize:$width', }; } @@ -95,10 +104,15 @@ class ImageStore { final completer = Completer(); _inFlight[key] = completer.future; try { - final task = layer == RawLayer.fastPreview - ? WorkerService().requestRawFastPreview(filePath, priority: priority) - : WorkerService().requestDecodedRawPreview(filePath, - halfSize: halfSize, priority: priority); + final service = WorkerService(); + final task = switch (layer) { + RawLayer.thumbnail => + service.requestRawThumbnail(filePath, priority: priority), + RawLayer.embeddedJpeg => + service.requestEmbeddedJpeg(filePath, priority: priority), + RawLayer.decoded => service.requestDecodedRawPreview(filePath, + halfSize: halfSize, priority: priority), + }; onTaskStarted?.call(task); final decoded = await task.result; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6b7eb1f..faa163d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -22,11 +22,6 @@ "languageSystem": "Follow system", "languageChineseSimplified": "简体中文", "languageEnglish": "English", - "rawPreviewSourceSectionTitle": "RAW Preview Source", - "fastPreviewTitle": "Fast Preview", - "fastPreviewSubtitle": "Show the cached fast preview first, then keep using the fast preview layer. This usually uses the embedded preview and falls back to fast RAW processing when unavailable.", - "decodedRawPreviewTitle": "Decoded RAW", - "decodedRawPreviewSubtitle": "Show the cached fast preview first, then decode RAW for the final image.", "rawProcessingSectionTitle": "RAW Processing", "halfSizeRawDecodeTitle": "Half-size RAW Decode", "halfSizeRawDecodeSubtitle": "Decode the final RAW image at 50% resolution for better speed. Disable for full resolution.", @@ -165,7 +160,10 @@ } }, "mediaFilterEmptyState": "No images match the current filter", - "fastPreviewShortLabel": "FAST", + "rawViewModeTooltip": "View mode", + "embeddedJpegModeLabel": "Embedded JPG", + "decodedRawModeLabel": "RAW", + "pairedJpegModeLabel": "JPG", "rawShortLabel": "RAW", "rawJpegShortLabel": "R&J", "imageShortLabel": "IMG" diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index f92ded3..370f31f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -185,36 +185,6 @@ abstract class AppLocalizations { /// **'English'** String get languageEnglish; - /// No description provided for @rawPreviewSourceSectionTitle. - /// - /// In en, this message translates to: - /// **'RAW Preview Source'** - String get rawPreviewSourceSectionTitle; - - /// No description provided for @fastPreviewTitle. - /// - /// In en, this message translates to: - /// **'Fast Preview'** - String get fastPreviewTitle; - - /// No description provided for @fastPreviewSubtitle. - /// - /// In en, this message translates to: - /// **'Show the cached fast preview first, then keep using the fast preview layer. This usually uses the embedded preview and falls back to fast RAW processing when unavailable.'** - String get fastPreviewSubtitle; - - /// No description provided for @decodedRawPreviewTitle. - /// - /// In en, this message translates to: - /// **'Decoded RAW'** - String get decodedRawPreviewTitle; - - /// No description provided for @decodedRawPreviewSubtitle. - /// - /// In en, this message translates to: - /// **'Show the cached fast preview first, then decode RAW for the final image.'** - String get decodedRawPreviewSubtitle; - /// No description provided for @rawProcessingSectionTitle. /// /// In en, this message translates to: @@ -581,11 +551,29 @@ abstract class AppLocalizations { /// **'No images match the current filter'** String get mediaFilterEmptyState; - /// No description provided for @fastPreviewShortLabel. + /// No description provided for @rawViewModeTooltip. + /// + /// In en, this message translates to: + /// **'View mode'** + String get rawViewModeTooltip; + + /// No description provided for @embeddedJpegModeLabel. + /// + /// In en, this message translates to: + /// **'Embedded JPG'** + String get embeddedJpegModeLabel; + + /// No description provided for @decodedRawModeLabel. + /// + /// In en, this message translates to: + /// **'RAW'** + String get decodedRawModeLabel; + + /// No description provided for @pairedJpegModeLabel. /// /// In en, this message translates to: - /// **'FAST'** - String get fastPreviewShortLabel; + /// **'JPG'** + String get pairedJpegModeLabel; /// No description provided for @rawShortLabel. /// diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 0f9b656..b52fe7f 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -55,21 +55,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get languageEnglish => 'English'; - @override - String get rawPreviewSourceSectionTitle => 'RAW Preview Source'; - - @override - String get fastPreviewTitle => 'Fast Preview'; - - @override - String get fastPreviewSubtitle => 'Show the cached fast preview first, then keep using the fast preview layer. This usually uses the embedded preview and falls back to fast RAW processing when unavailable.'; - - @override - String get decodedRawPreviewTitle => 'Decoded RAW'; - - @override - String get decodedRawPreviewSubtitle => 'Show the cached fast preview first, then decode RAW for the final image.'; - @override String get rawProcessingSectionTitle => 'RAW Processing'; @@ -294,7 +279,16 @@ class AppLocalizationsEn extends AppLocalizations { String get mediaFilterEmptyState => 'No images match the current filter'; @override - String get fastPreviewShortLabel => 'FAST'; + String get rawViewModeTooltip => 'View mode'; + + @override + String get embeddedJpegModeLabel => 'Embedded JPG'; + + @override + String get decodedRawModeLabel => 'RAW'; + + @override + String get pairedJpegModeLabel => 'JPG'; @override String get rawShortLabel => 'RAW'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index a94fe4a..3315708 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -55,21 +55,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get languageEnglish => 'English'; - @override - String get rawPreviewSourceSectionTitle => 'RAW 预览来源'; - - @override - String get fastPreviewTitle => '快速预览'; - - @override - String get fastPreviewSubtitle => '先显示缓存的快速预览,再继续使用快速预览层。通常优先使用内嵌预览,缺失时回退到快速 RAW 处理。'; - - @override - String get decodedRawPreviewTitle => 'RAW 解码图像'; - - @override - String get decodedRawPreviewSubtitle => '先显示缓存的快速预览,再解码 RAW 作为最终图像。'; - @override String get rawProcessingSectionTitle => 'RAW 处理'; @@ -288,7 +273,16 @@ class AppLocalizationsZh extends AppLocalizations { String get mediaFilterEmptyState => '没有符合当前筛选条件的图片'; @override - String get fastPreviewShortLabel => 'FAST'; + String get rawViewModeTooltip => '查看模式'; + + @override + String get embeddedJpegModeLabel => '内嵌 JPG'; + + @override + String get decodedRawModeLabel => 'RAW'; + + @override + String get pairedJpegModeLabel => 'JPG'; @override String get rawShortLabel => 'RAW'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f2883a2..330cfdd 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -22,11 +22,6 @@ "languageSystem": "跟随系统", "languageChineseSimplified": "简体中文", "languageEnglish": "English", - "rawPreviewSourceSectionTitle": "RAW 预览来源", - "fastPreviewTitle": "快速预览", - "fastPreviewSubtitle": "先显示缓存的快速预览,再继续使用快速预览层。通常优先使用内嵌预览,缺失时回退到快速 RAW 处理。", - "decodedRawPreviewTitle": "RAW 解码图像", - "decodedRawPreviewSubtitle": "先显示缓存的快速预览,再解码 RAW 作为最终图像。", "rawProcessingSectionTitle": "RAW 处理", "halfSizeRawDecodeTitle": "半尺寸 RAW 解码", "halfSizeRawDecodeSubtitle": "将 RAW 最终图像按 50% 分辨率解码以提升速度。关闭后使用完整分辨率。", @@ -165,7 +160,10 @@ } }, "mediaFilterEmptyState": "没有符合当前筛选条件的图片", - "fastPreviewShortLabel": "FAST", + "rawViewModeTooltip": "查看模式", + "embeddedJpegModeLabel": "内嵌 JPG", + "decodedRawModeLabel": "RAW", + "pairedJpegModeLabel": "JPG", "rawShortLabel": "RAW", "rawJpegShortLabel": "R&J", "imageShortLabel": "IMG" diff --git a/lib/native_lib.dart b/lib/native_lib.dart index d5a7aeb..004ed52 100644 --- a/lib/native_lib.dart +++ b/lib/native_lib.dart @@ -217,12 +217,17 @@ class LibRawImage { bool get isRgba => format == RawPixelFormat.rgba8888; } -// Returns the RAW fast preview layer. -// -// Despite the legacy `thumbnail` naming, this is not limited to an embedded -// JPEG. Native code first tries to extract embedded preview data and then falls -// back to a fast RAW-generated preview when the file has no embedded preview. -LibRawImage? getRawFastPreviewSync(String filePath, +/// Returns the RAW thumbnail layer: the cheapest image LibRaw can produce for +/// this file. +/// +/// Native code first tries to extract embedded preview data and then falls back +/// to a half-size RAW decode when the file has no embedded preview. The result +/// is therefore *not* necessarily the embedded JPEG — use +/// [getEmbeddedJpegImageSync] when that distinction matters. +/// +/// This layer is only ever rendered at a bounded `ImageStore` target width +/// (grid tiles, filmstrip, the preview's first frame). +LibRawImage? getRawThumbnailSync(String filePath, {RawCancelToken? cancelToken}) { final token = cancelToken?.handle ?? nullptr; final resultPtr = calloc(); @@ -269,12 +274,17 @@ LibRawImage? getRawFastPreviewSync(String filePath, } } -/// Returns the JPEG bytes embedded in a RAW file, without processing or -/// re-encoding them. Returns null when the file has no embedded JPEG. +/// Returns the JPEG embedded in a RAW file, without processing or re-encoding +/// it. Returns null when the file has no embedded JPEG. /// -/// Call [extractEmbeddedJpeg] from UI code so LibRaw work stays off the main -/// isolate. -Uint8List? getEmbeddedJpegSync(String filePath) { +/// Unlike [getRawThumbnailSync] this deliberately has **no fallback**: a null +/// result is the authoritative answer to "does this RAW carry an embedded +/// JPEG?", which is what lets the preview grey out that view mode. +/// +/// Never call this on the main isolate — go through +/// `WorkerService.requestEmbeddedJpeg` (display) or [extractEmbeddedJpeg] +/// (file export). +LibRawImage? getEmbeddedJpegImageSync(String filePath) { final resultPtr = calloc(); try { if (Platform.isWindows) { @@ -311,13 +321,21 @@ Uint8List? getEmbeddedJpegSync(String filePath) { } final image = _processThumbnailResult(resultPtr.ref); - return image?.format == RawPixelFormat.encoded ? image!.data : null; + return image?.format == RawPixelFormat.encoded ? image : null; } finally { calloc.free(resultPtr); } } +/// Returns the raw bytes of the embedded JPEG, for writing to a file. +Uint8List? getEmbeddedJpegSync(String filePath) => + getEmbeddedJpegImageSync(filePath)?.data; + /// Extracts an embedded JPEG on a helper isolate for use by the UI. +/// +/// Export is a one-off user action that yields encoded bytes rather than a +/// `ui.Image`, so it runs on its own isolate instead of occupying a +/// [WorkerService] decode worker. Future extractEmbeddedJpeg(String filePath) { return Isolate.run(() => getEmbeddedJpegSync(filePath)); } @@ -371,8 +389,8 @@ class DecodedRawPreviewRequest { // Returns the decoded RAW layer used as the final high-quality image. // -// `halfSize` only affects this decoded RAW layer. The fast preview layer is -// loaded through [getRawFastPreviewSync]. +// `halfSize` only affects this decoded RAW layer. The thumbnail layer is loaded +// through [getRawThumbnailSync]. LibRawImage? getDecodedRawPreviewSync(String filePath, {int halfSize = 1, RawCancelToken? cancelToken}) { final token = cancelToken?.handle ?? nullptr; diff --git a/lib/preview/image_preview_page.dart b/lib/preview/image_preview_page.dart index ac420f5..541e738 100644 --- a/lib/preview/image_preview_page.dart +++ b/lib/preview/image_preview_page.dart @@ -10,6 +10,7 @@ import 'package:path/path.dart' as path; import '../core/decode_target.dart'; import '../core/media_timestamps.dart'; import '../core/media_types.dart'; +import '../core/raw_view_mode.dart'; import '../image_store.dart'; import '../l10n/app_localizations.dart'; import '../media_group.dart'; @@ -31,9 +32,23 @@ class ImagePreviewPage extends StatefulWidget { final int thumbnailResizeWidth; final ImageStore imageStore; final TimestampRepository timestampRepository; - final ViewerSettings settings; + /// Settings as they were when this route was pushed — a snapshot, not a + /// live view. + /// + /// This page is a route: its `pageBuilder` runs once, so later changes to the + /// app's settings never reach this object. Read it for values that only need + /// to be right at open time, and seed local state from it for anything the + /// user can change from inside the preview (see `_rawViewMode`). Making a + /// setting live here means lifting it into an InheritedWidget or a + /// ValueListenable, not reading this field again. + final ViewerSettings initialSettings; + final VoidCallback onClose; + /// Reports a mode change so it can be persisted. The chosen mode is app-wide, + /// not per-file: this switch is the only place it is set. + final ValueChanged onRawViewModeChanged; + const ImagePreviewPage({ super.key, required this.mediaGroups, @@ -41,8 +56,9 @@ class ImagePreviewPage extends StatefulWidget { required this.thumbnailResizeWidth, required this.imageStore, required this.timestampRepository, - required this.settings, + required this.initialSettings, required this.onClose, + required this.onRawViewModeChanged, }); @override @@ -57,8 +73,20 @@ class _ImagePreviewPageState extends State { bool _showPreviewFilmstrip = true; bool _showPreviewOverview = true; final Map _rotationQuarterTurns = {}; - final Map _previewSources = - {}; + + /// The app-wide view mode, held here as well as in the settings. + /// + /// [ImagePreviewPage.initialSettings] is a snapshot taken when the route was + /// pushed, so writing the mode only into the settings would not change what + /// is on screen until the preview was reopened. Owning it here is what makes + /// the switch take effect immediately; the change is still reported upward so + /// it persists and outlives this route. + late RawViewMode _rawViewMode; + + /// Which RAW files were found to carry an embedded JPEG, learned from the + /// preview's own load of that layer. Absent means "not probed yet", which is + /// treated as available so the switch does not flicker to greyed and back. + final Map _hasEmbeddedJpeg = {}; bool _isExportingEmbeddedJpeg = false; DateTime? _lastSwitchTime; @@ -78,6 +106,7 @@ class _ImagePreviewPageState extends State { super.initState(); _currentIndex = widget.initialIndex; _targetPage = widget.initialIndex; + _rawViewMode = widget.initialSettings.rawViewMode; _pageController = PageController(initialPage: widget.initialIndex); _currentTimestampFuture = widget.timestampRepository.load( widget.mediaGroups[_currentIndex].primary.path, @@ -215,7 +244,7 @@ class _ImagePreviewPageState extends State { unawaited(widget.imageStore .load( filePath, - RawLayer.fastPreview, + RawLayer.thumbnail, targetWidth: _previewFilmstripDecodeWidth, priority: TaskPriority.low, ) @@ -272,7 +301,7 @@ class _ImagePreviewPageState extends State { // Touch and trackpad swipes move the PageView directly and never take this // branch. Disabling the setting therefore only removes animation from // discrete mouse-wheel navigation. - if (!widget.settings.pageSwitchAnimationEnabled) { + if (!widget.initialSettings.pageSwitchAnimationEnabled) { _scrollStopTimer?.cancel(); _isFastScrolling.value = false; _pageController.jumpToPage(_targetPage); @@ -317,7 +346,7 @@ class _ImagePreviewPageState extends State { _scrollStopTimer?.cancel(); _isFastScrolling.value = false; - if (!widget.settings.pageSwitchAnimationEnabled) { + if (!widget.initialSettings.pageSwitchAnimationEnabled) { _pageController.jumpToPage(index); return; } @@ -344,21 +373,47 @@ class _ImagePreviewPageState extends State { }); } - PreviewSource _previewSourceFor(MediaGroup mediaGroup) { - return _previewSources[mediaGroup.primary.path] ?? - (widget.settings.preferFastPreviewForRaw - ? PreviewSource.fastPreview - : PreviewSource.decodedRaw); + /// Whether [mediaGroup] is known *not* to have an embedded JPEG. + /// + /// Unprobed files count as having one: the preview asks for that layer on + /// every RAW page, so the answer arrives before the user can act on it. + bool _hasEmbeddedJpegFor(MediaGroup mediaGroup) => + _hasEmbeddedJpeg[mediaGroup.primary.path] ?? true; + + /// The mode this file can actually display, which may fall back from the + /// app-wide preference when the preferred source is missing here. + RawViewMode _effectiveViewModeFor(MediaGroup mediaGroup) { + return resolveRawViewMode( + preferred: _rawViewMode, + hasEmbeddedJpeg: _hasEmbeddedJpegFor(mediaGroup), + hasPairedJpeg: mediaGroup.hasPairedJpeg, + ); + } + + void _recordEmbeddedJpegAvailability(String filePath, bool hasEmbeddedJpeg) { + if (_hasEmbeddedJpeg[filePath] == hasEmbeddedJpeg) return; + setState(() { + _hasEmbeddedJpeg[filePath] = hasEmbeddedJpeg; + }); } - void _selectPreviewSource(MediaGroup mediaGroup, PreviewSource source) { - if (!mediaGroup.isRaw || - (source == PreviewSource.jpeg && !mediaGroup.hasPairedJpeg)) { + void _selectViewMode(MediaGroup mediaGroup, RawViewMode mode) { + if (!mediaGroup.isRaw || mode == _rawViewMode) { return; } + if (!isRawViewModeAvailable( + mode, + hasEmbeddedJpeg: _hasEmbeddedJpegFor(mediaGroup), + hasPairedJpeg: mediaGroup.hasPairedJpeg, + )) { + return; + } + // Apply here so the visible page changes on the next frame, and report it + // so it is persisted for the next file and the next launch. setState(() { - _previewSources[mediaGroup.primary.path] = source; + _rawViewMode = mode; }); + widget.onRawViewModeChanged(mode); } Future _exportEmbeddedJpeg(String filePath) async { @@ -416,28 +471,25 @@ class _ImagePreviewPageState extends State { return error.toString(); } - IconData _previewSourceIcon(PreviewSource source) { - switch (source) { - case PreviewSource.fastPreview: - return Icons.bolt_outlined; - case PreviewSource.decodedRaw: + IconData _viewModeIcon(RawViewMode mode) { + switch (mode) { + case RawViewMode.embeddedJpeg: + return Icons.photo_camera_back_outlined; + case RawViewMode.decodedRaw: return Icons.camera_alt_outlined; - case PreviewSource.jpeg: + case RawViewMode.pairedJpeg: return Icons.image_outlined; } } - String _previewSourceLabel( - AppLocalizations l10n, - PreviewSource source, - ) { - switch (source) { - case PreviewSource.fastPreview: - return l10n.fastPreviewShortLabel; - case PreviewSource.decodedRaw: - return l10n.rawShortLabel; - case PreviewSource.jpeg: - return 'JPG'; + String _viewModeLabel(AppLocalizations l10n, RawViewMode mode) { + switch (mode) { + case RawViewMode.embeddedJpeg: + return l10n.embeddedJpegModeLabel; + case RawViewMode.decodedRaw: + return l10n.decodedRawModeLabel; + case RawViewMode.pairedJpeg: + return l10n.pairedJpegModeLabel; } } @@ -446,7 +498,7 @@ class _ImagePreviewPageState extends State { final l10n = AppLocalizations.of(context)!; final currentMediaGroup = widget.mediaGroups[_currentIndex]; final currentFilePath = widget.mediaGroups[_currentIndex].primary.path; - final currentPreviewSource = _previewSourceFor(currentMediaGroup); + final currentViewMode = _effectiveViewModeFor(currentMediaGroup); final previewTop = MediaQuery.paddingOf(context).top + kImagePreviewToolbarHeight; final pageDragDevices = @@ -485,9 +537,14 @@ class _ImagePreviewPageState extends State { thumbnailResizeWidth: widget.thumbnailResizeWidth, previewThumbnailResizeWidth: _previewFilmstripDecodeWidth, imageStore: widget.imageStore, - settings: widget.settings, + // Safe to forward the snapshot: the child is rebuilt from + // this build method, so it is never staler than this page. + settings: widget.initialSettings, rotationQuarterTurns: _rotationQuarterTurns[filePath] ?? 0, - previewSource: _previewSourceFor(mediaGroup), + viewMode: _effectiveViewModeFor(mediaGroup), + onEmbeddedJpegAvailability: (hasEmbeddedJpeg) => + _recordEmbeddedJpegAvailability( + filePath, hasEmbeddedJpeg), onRotationRequested: (quarterTurns) => _rotateImage(filePath, quarterTurns), onResetRotationRequested: () => _resetImageRotation(filePath), @@ -517,7 +574,7 @@ class _ImagePreviewPageState extends State { right: 0, bottom: 0, child: PreviewHoverReveal( - restingOpacity: widget.settings.previewOverlayOpacity, + restingOpacity: widget.initialSettings.previewOverlayOpacity, child: PreviewFilmstrip( mediaGroups: widget.mediaGroups, currentIndex: _currentIndex, @@ -534,12 +591,13 @@ class _ImagePreviewPageState extends State { left: 0, right: 0, child: PreviewHoverReveal( - restingOpacity: widget.settings.previewOverlayOpacity, + restingOpacity: widget.initialSettings.previewOverlayOpacity, child: FutureBuilder( future: _currentTimestampFuture, builder: (context, snapshot) { final timestampText = snapshot.hasData - ? snapshot.data!.format(widget.settings.timeDisplaySource) + ? snapshot.data! + .format(widget.initialSettings.timeDisplaySource) : '---- -- -- --:--:--'; return Container( decoration: BoxDecoration( @@ -629,44 +687,38 @@ class _ImagePreviewPageState extends State { ), if (currentMediaGroup.isRaw) ...[ const SizedBox(width: 8), - DesktopPopupMenuButton( - tooltip: l10n.rawPreviewSourceSectionTitle, - initialValue: currentPreviewSource, - onSelected: (source) => _selectPreviewSource( + DesktopPopupMenuButton( + tooltip: l10n.rawViewModeTooltip, + initialValue: currentViewMode, + onSelected: (mode) => _selectViewMode( currentMediaGroup, - source, + mode, ), child: DesktopPopupMenuLabelTrigger( - icon: _previewSourceIcon( - currentPreviewSource, - ), - label: _previewSourceLabel( + icon: _viewModeIcon(currentViewMode), + label: _viewModeLabel( l10n, - currentPreviewSource, + currentViewMode, ), ), + // Every mode is always listed; the ones this + // file cannot show are greyed out rather than + // removed, so the menu never changes shape. itemBuilder: (context) => [ - desktopPopupMenuItem( - value: PreviewSource.fastPreview, - icon: Icons.bolt_outlined, - selected: currentPreviewSource == - PreviewSource.fastPreview, - label: l10n.fastPreviewShortLabel, - ), - desktopPopupMenuItem( - value: PreviewSource.decodedRaw, - icon: Icons.camera_alt_outlined, - selected: currentPreviewSource == - PreviewSource.decodedRaw, - label: l10n.rawShortLabel, - ), - if (currentMediaGroup.hasPairedJpeg) + for (final mode in RawViewMode.values) desktopPopupMenuItem( - value: PreviewSource.jpeg, - icon: Icons.image_outlined, - selected: currentPreviewSource == - PreviewSource.jpeg, - label: 'JPG', + value: mode, + icon: _viewModeIcon(mode), + selected: currentViewMode == mode, + enabled: isRawViewModeAvailable( + mode, + hasEmbeddedJpeg: _hasEmbeddedJpegFor( + currentMediaGroup, + ), + hasPairedJpeg: + currentMediaGroup.hasPairedJpeg, + ), + label: _viewModeLabel(l10n, mode), ), ], ), diff --git a/lib/preview/preview_models.dart b/lib/preview/preview_models.dart index 9c0cacd..4583878 100644 --- a/lib/preview/preview_models.dart +++ b/lib/preview/preview_models.dart @@ -1,5 +1,3 @@ enum PreviewDisplayControl { filmstrip, overview } enum PreviewAction { exportEmbeddedJpeg } - -enum PreviewSource { fastPreview, decodedRaw, jpeg } diff --git a/lib/preview/single_image_preview.dart b/lib/preview/single_image_preview.dart index d3f6132..37a7dd4 100644 --- a/lib/preview/single_image_preview.dart +++ b/lib/preview/single_image_preview.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import '../core/decode_target.dart'; import '../core/pointer_modifiers.dart'; +import '../core/raw_view_mode.dart'; import '../image_store.dart'; import '../l10n/app_localizations.dart'; import '../media_group.dart'; @@ -17,7 +18,6 @@ import '../ui/raw_image_widget.dart'; import '../viewer_image.dart'; import '../worker_service.dart'; import 'preview_geometry.dart'; -import 'preview_models.dart'; import 'widgets/preview_hover_reveal.dart'; import 'widgets/preview_overview_map.dart'; @@ -28,7 +28,7 @@ class SingleImagePreview extends StatefulWidget { final ImageStore imageStore; final ViewerSettings settings; final int rotationQuarterTurns; - final PreviewSource previewSource; + final RawViewMode viewMode; final ValueChanged onRotationRequested; final VoidCallback onResetRotationRequested; final ValueChanged onSwitchRequest; @@ -42,6 +42,10 @@ class SingleImagePreview extends StatefulWidget { final ValueNotifier isFastScrolling; final ValueChanged? onScaleStateChanged; + /// Reports whether this RAW turned out to carry an embedded JPEG, so the + /// preview's mode switch can grey out that option. + final ValueChanged? onEmbeddedJpegAvailability; + const SingleImagePreview({ super.key, required this.mediaGroup, @@ -50,7 +54,7 @@ class SingleImagePreview extends StatefulWidget { required this.imageStore, required this.settings, required this.rotationQuarterTurns, - required this.previewSource, + required this.viewMode, required this.onRotationRequested, required this.onResetRotationRequested, required this.onSwitchRequest, @@ -63,6 +67,7 @@ class SingleImagePreview extends StatefulWidget { required this.overviewBottomInset, required this.isFastScrolling, this.onScaleStateChanged, + this.onEmbeddedJpegAvailability, }); String get filePath => mediaGroup.primary.path; @@ -74,11 +79,21 @@ class SingleImagePreview extends StatefulWidget { } class _SingleImagePreviewState extends State { - ViewerImage? _fastPreviewImage; + /// A cached thumbnail-layer image, peeked synchronously so the first frame of + /// a page switch is never blank. Soft, and only ever a stand-in. + ViewerImage? _thumbnailImage; + + /// The RAW's embedded JPEG. Both a display mode of its own and the interim + /// sharp image while a decode runs. + ViewerImage? _embeddedJpegImage; ViewerImage? _decodedRawPreviewImage; - bool _hasFullResolutionFastPreview = false; - bool _fullResolutionFastPreviewRequested = false; + + bool _embeddedJpegRequested = false; bool _isLoadingDecodedRawPreview = false; + + /// Captured once, on purpose: this doubles as part of the decode cache key, + /// so changing it mid-page would strand the image already on screen under a + /// key nothing asks for again. A new value takes effect on the next preview. late int _rawDecodeHalfSize; final TransformationController _transformationController = TransformationController(); @@ -98,43 +113,36 @@ class _SingleImagePreviewState extends State { /// Only the expensive decoded-RAW task is tracked for cancellation. /// - /// The fast preview is deliberately never cancelled: it is cheap, and the - /// worker dedupes it by path, so cancelling ours would also resolve a grid - /// tile's shared request to null and leave that tile showing a broken image. + /// The thumbnail and embedded-JPEG layers are deliberately never cancelled: + /// they are cheap, and the worker dedupes them by path, so cancelling ours + /// would also resolve a grid tile's shared request to null and leave that + /// tile showing a broken image. WorkerTask? _decodedRawTask; - bool get _isShowingPairedJpeg => widget.previewSource == PreviewSource.jpeg; - bool get _preferFastPreviewForRaw => - widget.previewSource == PreviewSource.fastPreview; + bool get _isShowingPairedJpeg => widget.viewMode == RawViewMode.pairedJpeg; + bool get _isShowingEmbeddedJpeg => + widget.viewMode == RawViewMode.embeddedJpeg; @override void initState() { super.initState(); _rawDecodeHalfSize = widget.settings.useHalfSizeRawDecode ? 1 : 0; - // Take a cached fast preview synchronously so the first frame of a page - // switch already paints an image instead of an empty preview area. Prefer - // the full-resolution entry, but fall back to the grid's thumbnail-sized - // one: showing it slightly soft for a moment beats showing black. + // Take a cached thumbnail-layer image synchronously so the first frame of a + // page switch already paints something instead of an empty preview area. + // It is soft, but soft beats black, and it is replaced as soon as the real + // layer for this view mode arrives. if (widget.isRaw) { - _fastPreviewImage = widget.imageStore.peek( - widget.filePath, - RawLayer.fastPreview, - ); - if (_fastPreviewImage != null) { - _hasFullResolutionFastPreview = true; - } else { - _fastPreviewImage = widget.imageStore.peek( - widget.filePath, - RawLayer.fastPreview, - targetWidth: widget.thumbnailResizeWidth, - ) ?? - widget.imageStore.peek( - widget.filePath, - RawLayer.fastPreview, - targetWidth: widget.previewThumbnailResizeWidth, - ); - } + _thumbnailImage = widget.imageStore.peek( + widget.filePath, + RawLayer.thumbnail, + targetWidth: widget.thumbnailResizeWidth, + ) ?? + widget.imageStore.peek( + widget.filePath, + RawLayer.thumbnail, + targetWidth: widget.previewThumbnailResizeWidth, + ); } unawaited(_loadRawDisplayLayers()); @@ -162,16 +170,16 @@ class _SingleImagePreviewState extends State { _transformationController.value = Matrix4.identity(); } - if (widget.previewSource != oldWidget.previewSource) { - if (widget.previewSource != PreviewSource.decodedRaw) { + if (widget.viewMode != oldWidget.viewMode) { + if (widget.viewMode != RawViewMode.decodedRaw) { _decodedRawTask?.cancel(); _decodedRawTask = null; _isLoadingDecodedRawPreview = false; - } else if (widget.isActive && - !widget.isFastScrolling.value && - _decodedRawPreviewImage == null) { - unawaited(_loadRawDisplayLayers()); } + // The embedded JPEG serves both its own mode and the interim image while + // a decode runs, so a mode change may need a layer that was never asked + // for yet. + unawaited(_loadRawDisplayLayers()); } if (widget.isActive && !oldWidget.isActive) { @@ -185,7 +193,8 @@ class _SingleImagePreviewState extends State { widget.isFastScrolling.removeListener(_onFastScrollingChanged); _clearFitScaleLock(); _decodedRawTask?.cancel(); - _fastPreviewImage?.dispose(); + _thumbnailImage?.dispose(); + _embeddedJpegImage?.dispose(); _decodedRawPreviewImage?.dispose(); _transformationController.removeListener(_onTransformationChange); _transformationController.dispose(); @@ -246,42 +255,18 @@ class _SingleImagePreviewState extends State { } Future _loadRawDisplayLayers() async { - // For non-RAW files, we rely entirely on Flutter's Image.file - if (!widget.isRaw || !widget.isActive) return; - - if (!_hasFullResolutionFastPreview && - !_fullResolutionFastPreviewRequested) { - _fullResolutionFastPreviewRequested = true; - final fastPreviewPriority = - widget.isFastScrolling.value ? TaskPriority.low : TaskPriority.high; - - // No targetWidth: this is the full-screen layer, so keep the preview's - // own resolution rather than the grid's thumbnail size. - final fastPreviewImage = await widget.imageStore.load( - widget.filePath, - RawLayer.fastPreview, - priority: fastPreviewPriority, - ); - - if (!mounted) { - fastPreviewImage?.dispose(); - return; - } - - if (fastPreviewImage != null) { - setState(() { - _fastPreviewImage?.dispose(); - _fastPreviewImage = fastPreviewImage; - _hasFullResolutionFastPreview = true; - }); - } - } - - if (!widget.isActive || widget.isFastScrolling.value) { - return; - } - - if (_preferFastPreviewForRaw || _isShowingPairedJpeg) return; + // Bitmap files and the paired-JPEG mode rely entirely on Flutter's own + // file/image pipeline. + if (!widget.isRaw || !widget.isActive || _isShowingPairedJpeg) return; + + // The embedded JPEG is wanted in both RAW modes: as the image itself in + // embedded mode, and as the interim sharp image while a decode runs. It is + // also the probe that tells the mode switch whether this file has one. + await _loadEmbeddedJpeg(); + + if (!widget.isActive || widget.isFastScrolling.value) return; + if (_isShowingEmbeddedJpeg && _embeddedJpegImage != null) return; + if (_isShowingPairedJpeg) return; if (_decodedRawPreviewImage != null) return; setState(() { @@ -299,8 +284,8 @@ class _SingleImagePreviewState extends State { // A cancelled or superseded load must not overwrite what is on screen. if (!mounted || !widget.isActive || - _preferFastPreviewForRaw || - _isShowingPairedJpeg) { + _isShowingPairedJpeg || + (_isShowingEmbeddedJpeg && _embeddedJpegImage != null)) { decodedRawPreviewImage?.dispose(); if (mounted && _isLoadingDecodedRawPreview) { setState(() { @@ -319,6 +304,36 @@ class _SingleImagePreviewState extends State { }); } + /// Loads the embedded JPEG once per file and reports whether it exists. + /// + /// No `targetWidth`: this is a full-screen layer, so it keeps the embedded + /// JPEG's own resolution rather than the grid's thumbnail size. + Future _loadEmbeddedJpeg() async { + if (_embeddedJpegRequested) return; + _embeddedJpegRequested = true; + + final priority = + widget.isFastScrolling.value ? TaskPriority.low : TaskPriority.high; + final image = await widget.imageStore.load( + widget.filePath, + RawLayer.embeddedJpeg, + priority: priority, + ); + + if (!mounted) { + image?.dispose(); + return; + } + + if (image != null) { + setState(() { + _embeddedJpegImage?.dispose(); + _embeddedJpegImage = image; + }); + } + widget.onEmbeddedJpegAvailability?.call(image != null); + } + void _applyScale( double scaleChange, Offset focalPoint, { @@ -642,14 +657,26 @@ class _SingleImagePreviewState extends State { ); } + /// The single RAW image to paint, best available first. + /// + /// In embedded-JPEG mode the embedded JPEG wins, and the decoded layer only + /// appears when this file has no embedded JPEG at all (the mode is greyed out + /// and falls back rather than showing nothing). In decoded mode the decode + /// wins once it lands, with the embedded JPEG as the interim sharp image. + /// The cached thumbnail is the last resort in both. + ViewerImage? get _displayedImage { + if (_isShowingEmbeddedJpeg) { + return _embeddedJpegImage ?? _decodedRawPreviewImage ?? _thumbnailImage; + } + return _decodedRawPreviewImage ?? _embeddedJpegImage ?? _thumbnailImage; + } + Widget _buildOverviewImage() { if (_isShowingPairedJpeg) { return _buildOverviewBitmap(widget.mediaGroup.pairedJpeg!.path); } if (widget.isRaw) { - final image = _preferFastPreviewForRaw - ? _fastPreviewImage - : _decodedRawPreviewImage ?? _fastPreviewImage; + final image = _displayedImage; if (image != null) { return RawImage( image: image.image, @@ -678,13 +705,15 @@ class _SingleImagePreviewState extends State { /// Paints exactly one RAW image layer. /// - /// The decoded layer fully covers the fast preview, so stacking both would - /// pay for a large overdraw every frame with nothing to show for it. The - /// spinner only appears when there is genuinely nothing to display yet. + /// The best available layer fully covers the ones beneath it, so stacking + /// them would pay for a large overdraw every frame with nothing to show for + /// it. The centred spinner only appears when there is genuinely nothing to + /// display yet. Widget _buildRawPreview() { - final showDecoded = - _decodedRawPreviewImage != null && !_preferFastPreviewForRaw; - final displayed = showDecoded ? _decodedRawPreviewImage : _fastPreviewImage; + final displayed = _displayedImage; + final isSharpeningInBackground = _isLoadingDecodedRawPreview && + displayed != null && + displayed != _decodedRawPreviewImage; return Stack( fit: StackFit.expand, @@ -699,8 +728,8 @@ class _SingleImagePreviewState extends State { const Center( child: ExcludeSemantics(child: CircularProgressIndicator()), ) - else if (_isLoadingDecodedRawPreview && !showDecoded) - // Sharpening in the background; keep showing the fast preview. + else if (isSharpeningInBackground) + // Decoding in the background; keep showing what we already have. const Positioned( top: 24, left: 16, diff --git a/lib/preview/widgets/preview_filmstrip.dart b/lib/preview/widgets/preview_filmstrip.dart index 5e6b410..a1be4e1 100644 --- a/lib/preview/widgets/preview_filmstrip.dart +++ b/lib/preview/widgets/preview_filmstrip.dart @@ -326,7 +326,7 @@ class _PreviewFilmstripThumbnailState unawaited(widget.imageStore .load( _filePath, - RawLayer.fastPreview, + RawLayer.thumbnail, targetWidth: widget.decodeWidth, priority: TaskPriority.low, ) diff --git a/lib/settings_page.dart b/lib/settings_page.dart index 1d3ce0e..f55e019 100644 --- a/lib/settings_page.dart +++ b/lib/settings_page.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'core/raw_view_mode.dart'; import 'l10n/app_localizations.dart'; import 'ui/app_theme.dart'; import 'ui/desktop_controls.dart'; @@ -97,11 +98,10 @@ typedef WindowsContextMenuToggleHandler = Future Function(bool enabled); class ViewerSettings { - // true: stop at the RAW fast preview layer. This usually uses the embedded - // preview and falls back to a fast RAW-generated preview when unavailable. - // false: continue decoding RAW for the final image layer. - final bool preferFastPreviewForRaw; - // Controls the decoded RAW layer only. This does not affect the fast preview + // Which image the preview shows for RAW files. Chosen from the preview's own + // top-right switch rather than this settings page, and persisted. + final RawViewMode rawViewMode; + // Controls the decoded RAW layer only. This does not affect the thumbnail // layer shown first while browsing RAW files. final bool useHalfSizeRawDecode; final int maxCacheSize; // in MB @@ -116,7 +116,7 @@ class ViewerSettings { final WindowsContextMenuSettings windowsContextMenu; const ViewerSettings({ - this.preferFastPreviewForRaw = false, + this.rawViewMode = RawViewMode.decodedRaw, this.useHalfSizeRawDecode = true, this.maxCacheSize = 512, this.timeDisplaySource = TimeDisplaySource.capturedAt, @@ -128,7 +128,7 @@ class ViewerSettings { }); ViewerSettings copyWith({ - bool? preferFastPreviewForRaw, + RawViewMode? rawViewMode, bool? useHalfSizeRawDecode, int? maxCacheSize, TimeDisplaySource? timeDisplaySource, @@ -139,8 +139,7 @@ class ViewerSettings { WindowsContextMenuSettings? windowsContextMenu, }) { return ViewerSettings( - preferFastPreviewForRaw: - preferFastPreviewForRaw ?? this.preferFastPreviewForRaw, + rawViewMode: rawViewMode ?? this.rawViewMode, useHalfSizeRawDecode: useHalfSizeRawDecode ?? this.useHalfSizeRawDecode, maxCacheSize: maxCacheSize ?? this.maxCacheSize, timeDisplaySource: timeDisplaySource ?? this.timeDisplaySource, @@ -403,32 +402,6 @@ class _SettingsPageState extends State { ), ], ), - DesktopSettingsSection( - title: l10n.rawPreviewSourceSectionTitle, - children: _withDividers([ - DesktopSettingsOption( - title: l10n.fastPreviewTitle, - subtitle: l10n.fastPreviewSubtitle, - selected: _currentSettings.preferFastPreviewForRaw, - onTap: () => _updateSettings( - _currentSettings.copyWith( - preferFastPreviewForRaw: true, - ), - ), - ), - DesktopSettingsOption( - key: const ValueKey('raw-preview-decoded'), - title: l10n.decodedRawPreviewTitle, - subtitle: l10n.decodedRawPreviewSubtitle, - selected: !_currentSettings.preferFastPreviewForRaw, - onTap: () => _updateSettings( - _currentSettings.copyWith( - preferFastPreviewForRaw: false, - ), - ), - ), - ]), - ), DesktopSettingsSection( title: l10n.rawProcessingSectionTitle, children: [ diff --git a/lib/worker_service.dart b/lib/worker_service.dart index 66a17cf..2975aba 100644 --- a/lib/worker_service.dart +++ b/lib/worker_service.dart @@ -7,7 +7,7 @@ import 'native_lib.dart'; enum TaskPriority { high, low } -enum _RequestType { rawFastPreview, decodedRawPreview } +enum _RequestType { rawThumbnail, embeddedJpeg, decodedRawPreview } /// Decodes RAW previews on a pool of isolates. /// @@ -126,12 +126,21 @@ class WorkerService { } } - // RAW fast preview layer: prefer embedded preview data and fall back to a - // fast RAW-generated preview when the file has no embedded preview. - WorkerTask requestRawFastPreview(String path, + // RAW thumbnail layer: the cheapest image LibRaw can produce — embedded + // preview data when present, a half-size RAW decode otherwise. + WorkerTask requestRawThumbnail(String path, {TaskPriority priority = TaskPriority.high}) { return WorkerTask._( - this, _nextRequestId++, path, _RequestType.rawFastPreview, + this, _nextRequestId++, path, _RequestType.rawThumbnail, + priority: priority); + } + + // The JPEG embedded in the RAW container, with no fallback: a null result + // means this file carries no embedded JPEG. + WorkerTask requestEmbeddedJpeg(String path, + {TaskPriority priority = TaskPriority.high}) { + return WorkerTask._( + this, _nextRequestId++, path, _RequestType.embeddedJpeg, priority: priority); } @@ -358,13 +367,15 @@ void _workerEntry(SendPort mainSendPort) { activeTokens[message.requestId] = token; try { - final LibRawImage? result; - if (message.type == _RequestType.rawFastPreview) { - result = getRawFastPreviewSync(message.path, cancelToken: token); - } else { - result = getDecodedRawPreviewSync(message.path, - halfSize: message.halfSize, cancelToken: token); - } + // The embedded-JPEG extraction ABI takes no cancel token: it is a + // container read with no demosaic, so there is nothing worth aborting. + final result = switch (message.type) { + _RequestType.rawThumbnail => + getRawThumbnailSync(message.path, cancelToken: token), + _RequestType.embeddedJpeg => getEmbeddedJpegImageSync(message.path), + _RequestType.decodedRawPreview => getDecodedRawPreviewSync(message.path, + halfSize: message.halfSize, cancelToken: token), + }; port.send(_WorkerResponse(requestId: message.requestId, image: result)); } catch (e) { diff --git a/linux/native_lib/wrapper.cpp b/linux/native_lib/wrapper.cpp index be833cc..be6dfe0 100644 --- a/linux/native_lib/wrapper.cpp +++ b/linux/native_lib/wrapper.cpp @@ -56,8 +56,9 @@ bool is_cancelled(void* cancel_token) { return flag != nullptr && flag->load(std::memory_order_relaxed); } -// Extract only the JPEG bytes stored in the RAW container. Unlike the fast -// preview path below, this deliberately does not fall back to RAW processing. +// Extract only the JPEG bytes stored in the RAW container. Unlike the thumbnail +// layer below, this deliberately does not fall back to RAW processing, so a +// null result is the authoritative "this file has no embedded JPEG". ThumbnailResult extract_embedded_jpeg(LibRaw& raw_processor) { ThumbnailResult result = empty_thumbnail(); @@ -140,12 +141,14 @@ bool fill_rgba_from_processed(ImageResult& result, return true; } -// Build the RAW fast preview layer. +// Build the RAW thumbnail layer: the cheapest image we can produce. // // Prefer the embedded preview via `unpack_thumb()`. Encoded JPEG previews are // passed straight through because the engine decodes JPEG efficiently already. -// If the file exposes no usable preview, fall back to a half-size RAW decode so -// the UI still gets a fast first image. +// If the file exposes no usable preview, fall back to a half-size RAW decode +// so the UI still gets an image quickly. The payload is therefore not +// necessarily the embedded JPEG — get_embedded_jpeg() is the no-fallback +// path for callers that need that distinction. ThumbnailResult process_thumbnail(LibRaw& raw_processor, void* cancel_token) { ThumbnailResult result = empty_thumbnail(); @@ -265,8 +268,8 @@ EXPORT void destroy_cancel_token(void* token) { delete static_cast*>(token); } -// Despite the ABI name, `get_thumbnail` semantically returns the RAW fast -// preview layer. +// Despite the ABI name, `get_thumbnail` returns the RAW thumbnail layer, +// which may be a RAW decode rather than an embedded thumbnail. EXPORT void get_thumbnail(const char* file_path, void* cancel_token, ThumbnailResult* out) { if (out == nullptr) { diff --git a/macos/native_lib/wrapper.cpp b/macos/native_lib/wrapper.cpp index fdfcb22..cf31b62 100644 --- a/macos/native_lib/wrapper.cpp +++ b/macos/native_lib/wrapper.cpp @@ -52,8 +52,9 @@ bool is_cancelled(void* cancel_token) { return flag != nullptr && flag->load(std::memory_order_relaxed); } -// Extract only the JPEG bytes stored in the RAW container. Unlike the fast -// preview path below, this deliberately does not fall back to RAW processing. +// Extract only the JPEG bytes stored in the RAW container. Unlike the thumbnail +// layer below, this deliberately does not fall back to RAW processing, so a +// null result is the authoritative "this file has no embedded JPEG". ThumbnailResult extract_embedded_jpeg(LibRaw& raw_processor) { ThumbnailResult result = empty_thumbnail(); @@ -136,12 +137,14 @@ bool fill_rgba_from_processed(ImageResult& result, return true; } -// Build the RAW fast preview layer. +// Build the RAW thumbnail layer: the cheapest image we can produce. // // Prefer the embedded preview via `unpack_thumb()`. Encoded JPEG previews are // passed straight through because the engine decodes JPEG efficiently already. -// If the file exposes no usable preview, fall back to a half-size RAW decode so -// the UI still gets a fast first image. +// If the file exposes no usable preview, fall back to a half-size RAW decode +// so the UI still gets an image quickly. The payload is therefore not +// necessarily the embedded JPEG — get_embedded_jpeg() is the no-fallback +// path for callers that need that distinction. ThumbnailResult process_thumbnail(LibRaw& raw_processor, void* cancel_token) { ThumbnailResult result = empty_thumbnail(); @@ -263,8 +266,8 @@ EXPORT void destroy_cancel_token(void* token) { // Keep macOS exported ABI aligned with other POSIX targets because Dart FFI // calls these functions using void/out-parameter signatures. -// Despite the ABI name, `get_thumbnail` semantically returns the RAW fast -// preview layer. +// Despite the ABI name, `get_thumbnail` returns the RAW thumbnail layer, +// which may be a RAW decode rather than an embedded thumbnail. EXPORT void get_thumbnail(const char* file_path, void* cancel_token, ThumbnailResult* out) { if (out == nullptr) { diff --git a/test/core/preferences_repository_test.dart b/test/core/preferences_repository_test.dart index d84dc33..3126267 100644 --- a/test/core/preferences_repository_test.dart +++ b/test/core/preferences_repository_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:rawviewer/core/preferences_repository.dart'; +import 'package:rawviewer/core/raw_view_mode.dart'; import 'package:rawviewer/settings_page.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -118,6 +119,31 @@ void main() { expect(stored.previewOverlayOpacity, closeTo(0.8, 0.001)); }); + test('round-trips the RAW view mode', () async { + SharedPreferences.setMockInitialValues({}); + final repo = const PreferencesRepository(); + await repo.saveRawViewMode(RawViewMode.embeddedJpeg); + final stored = await repo.loadViewPreferences(); + expect(stored.rawViewMode, RawViewMode.embeddedJpeg); + }); + + test('leaves the RAW view mode null when nothing is stored', () async { + // Null means "never chosen", so the caller keeps the ViewerSettings + // default rather than being forced onto a mode. + SharedPreferences.setMockInitialValues({}); + final stored = + await const PreferencesRepository().loadViewPreferences(); + expect(stored.rawViewMode, isNull); + }); + + test('ignores an unrecognised stored RAW view mode', () async { + // A key written by a newer build must not crash an older one. + SharedPreferences.setMockInitialValues({'raw_view_mode': 'fastPreview'}); + final stored = + await const PreferencesRepository().loadViewPreferences(); + expect(stored.rawViewMode, isNull); + }); + test('migrates legacy auto-transparency-disabled key on first load', () async { SharedPreferences.setMockInitialValues({ 'preview_overlay_auto_transparency_enabled': false, diff --git a/test/core/raw_view_mode_test.dart b/test/core/raw_view_mode_test.dart new file mode 100644 index 0000000..a41dfe4 --- /dev/null +++ b/test/core/raw_view_mode_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rawviewer/core/raw_view_mode.dart'; + +void main() { + group('resolveRawViewMode', () { + test('keeps the preferred mode when this file can show it', () { + for (final mode in RawViewMode.values) { + expect( + resolveRawViewMode( + preferred: mode, + hasEmbeddedJpeg: true, + hasPairedJpeg: true, + ), + mode, + ); + } + }); + + test('falls back to decoded RAW when there is no embedded JPEG', () { + expect( + resolveRawViewMode( + preferred: RawViewMode.embeddedJpeg, + hasEmbeddedJpeg: false, + hasPairedJpeg: true, + ), + RawViewMode.decodedRaw, + ); + }); + + test('falls back to decoded RAW when there is no paired JPEG', () { + expect( + resolveRawViewMode( + preferred: RawViewMode.pairedJpeg, + hasEmbeddedJpeg: true, + hasPairedJpeg: false, + ), + RawViewMode.decodedRaw, + ); + }); + + test('decoded RAW is always reachable, even with no other source', () { + // Every RAW file can be decoded, so this mode never needs a fallback and + // is what the other two fall back to. + expect( + resolveRawViewMode( + preferred: RawViewMode.decodedRaw, + hasEmbeddedJpeg: false, + hasPairedJpeg: false, + ), + RawViewMode.decodedRaw, + ); + }); + + test('resolves to an available mode for every combination', () { + for (final preferred in RawViewMode.values) { + for (final hasEmbeddedJpeg in [true, false]) { + for (final hasPairedJpeg in [true, false]) { + final resolved = resolveRawViewMode( + preferred: preferred, + hasEmbeddedJpeg: hasEmbeddedJpeg, + hasPairedJpeg: hasPairedJpeg, + ); + expect( + isRawViewModeAvailable( + resolved, + hasEmbeddedJpeg: hasEmbeddedJpeg, + hasPairedJpeg: hasPairedJpeg, + ), + isTrue, + reason: 'preferred=$preferred embedded=$hasEmbeddedJpeg ' + 'paired=$hasPairedJpeg resolved to unavailable $resolved', + ); + } + } + } + }); + }); + + group('isRawViewModeAvailable', () { + test('gates each mode on its own source', () { + expect( + isRawViewModeAvailable(RawViewMode.embeddedJpeg, + hasEmbeddedJpeg: false, hasPairedJpeg: true), + isFalse, + ); + expect( + isRawViewModeAvailable(RawViewMode.pairedJpeg, + hasEmbeddedJpeg: true, hasPairedJpeg: false), + isFalse, + ); + expect( + isRawViewModeAvailable(RawViewMode.decodedRaw, + hasEmbeddedJpeg: false, hasPairedJpeg: false), + isTrue, + ); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index ecb037b..666f71d 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -265,16 +265,9 @@ void main() { await tester.pump(); expect(updatedSettings!.gridAspectRatio, GridAspectRatio.adaptive); - final decodedPreview = find.byKey(const ValueKey('raw-preview-decoded')); - await tester.scrollUntilVisible( - decodedPreview, - 300, - scrollable: settingsList, - ); - await tester.pumpAndSettle(); - await tester.tap(decodedPreview); - await tester.pump(); - expect(updatedSettings!.preferFastPreviewForRaw, isFalse); + // The RAW view mode is chosen from the preview's own switch and + // persisted; it is deliberately absent from this page. + expect(find.byKey(const ValueKey('raw-preview-decoded')), findsNothing); final pageSwitchAnimation = find.byKey(const ValueKey('page-switch-animation')); @@ -446,27 +439,39 @@ void main() { const path = '/photos/a.arw'; test('separates layers and half-size variants', () { - final fast = ImageStore.cacheKey(path, RawLayer.fastPreview); + final thumbnail = ImageStore.cacheKey(path, RawLayer.thumbnail); + final embedded = ImageStore.cacheKey(path, RawLayer.embeddedJpeg); final half = ImageStore.cacheKey(path, RawLayer.decoded, halfSize: 1); final full = ImageStore.cacheKey(path, RawLayer.decoded, halfSize: 0); - expect({fast, half, full}, hasLength(3)); + expect({thumbnail, embedded, half, full}, hasLength(4)); + }); + + test('separates the thumbnail layer from the embedded JPEG', () { + // The thumbnail layer falls back to a RAW decode, so it may hold pixels + // that are not the embedded JPEG at all. Sharing one entry would let a + // fallback image answer a request for the real embedded JPEG. + expect( + ImageStore.cacheKey(path, RawLayer.thumbnail, targetWidth: 512), + isNot(ImageStore.cacheKey(path, RawLayer.embeddedJpeg, + targetWidth: 512)), + ); }); test('separates resolutions of the same layer', () { // The grid and the full-screen preview want the same source at different // sizes; sharing one entry would show a blurry preview. final thumb = - ImageStore.cacheKey(path, RawLayer.fastPreview, targetWidth: 512); - final fullRes = ImageStore.cacheKey(path, RawLayer.fastPreview); + ImageStore.cacheKey(path, RawLayer.thumbnail, targetWidth: 512); + final fullRes = ImageStore.cacheKey(path, RawLayer.thumbnail); expect(thumb, isNot(fullRes)); }); test('is stable for identical requests', () { expect( - ImageStore.cacheKey(path, RawLayer.fastPreview, targetWidth: 512), - ImageStore.cacheKey(path, RawLayer.fastPreview, targetWidth: 512), + ImageStore.cacheKey(path, RawLayer.thumbnail, targetWidth: 512), + ImageStore.cacheKey(path, RawLayer.thumbnail, targetWidth: 512), ); }); }); diff --git a/tool/native_decode_check.dart b/tool/native_decode_check.dart index 9390e98..4a2d7d6 100644 --- a/tool/native_decode_check.dart +++ b/tool/native_decode_check.dart @@ -46,33 +46,46 @@ void main(List args) { final name = file.path.split(RegExp(r'[/\\]')).last; stdout.writeln('--- $name (${file.lengthSync() ~/ 1024} KB)'); - final fastWatch = Stopwatch()..start(); - final fast = getRawFastPreviewSync(file.path); - fastWatch.stop(); + final thumbWatch = Stopwatch()..start(); + final thumb = getRawThumbnailSync(file.path); + thumbWatch.stop(); - if (fast == null) { - stdout.writeln(' fast preview: FAILED'); + if (thumb == null) { + stdout.writeln(' thumbnail: FAILED'); failures++; } else { - final layout = fast.isRgba - ? 'rgba ${fast.width}x${fast.height} stride=${fast.stride}' + final layout = thumb.isRgba + ? 'rgba ${thumb.width}x${thumb.height} stride=${thumb.stride}' : 'encoded (jpeg)'; - stdout.writeln(' fast preview: $layout, ' - '${fast.data.length ~/ 1024} KB in ${fastWatch.elapsedMilliseconds}ms'); + stdout.writeln(' thumbnail: $layout, ' + '${thumb.data.length ~/ 1024} KB in ' + '${thumbWatch.elapsedMilliseconds}ms'); - if (fast.isRgba) { - final expected = fast.width * fast.height * 4; - if (fast.data.length != expected) { + if (thumb.isRgba) { + final expected = thumb.width * thumb.height * 4; + if (thumb.data.length != expected) { stdout.writeln(' MISMATCH: expected $expected bytes'); failures++; } - if (fast.stride != fast.width * 4) { + if (thumb.stride != thumb.width * 4) { stdout.writeln(' MISMATCH: stride != width*4'); failures++; } } } + // Reported separately from the thumbnail layer: that one falls back to a + // RAW decode, this one is the authoritative "does the container carry a + // JPEG?" answer the preview greys its view mode on. + final embeddedWatch = Stopwatch()..start(); + final embedded = getEmbeddedJpegImageSync(file.path); + embeddedWatch.stop(); + stdout.writeln(embedded == null + ? ' embedded jpeg: none' + : ' embedded jpeg: ${embedded.width}x${embedded.height} ' + '${embedded.data.length ~/ 1024} KB in ' + '${embeddedWatch.elapsedMilliseconds}ms'); + final halfWatch = Stopwatch()..start(); final half = getDecodedRawPreviewSync(file.path, halfSize: 1); halfWatch.stop(); diff --git a/windows/native_lib/wrapper.cpp b/windows/native_lib/wrapper.cpp index 1de2ba8..90f8610 100644 --- a/windows/native_lib/wrapper.cpp +++ b/windows/native_lib/wrapper.cpp @@ -61,8 +61,9 @@ bool is_cancelled(void* cancel_token) { return flag != nullptr && flag->load(std::memory_order_relaxed); } -// Extract only the JPEG bytes stored in the RAW container. Unlike the fast -// preview path below, this deliberately does not fall back to RAW processing. +// Extract only the JPEG bytes stored in the RAW container. Unlike the thumbnail +// layer below, this deliberately does not fall back to RAW processing, so a +// null result is the authoritative "this file has no embedded JPEG". ThumbnailResult extract_embedded_jpeg(LibRaw& RawProcessor) { ThumbnailResult result = empty_thumbnail(); @@ -143,12 +144,14 @@ bool fill_rgba_from_processed(ImageResult& result, return true; } -// Build the RAW fast preview layer. +// Build the RAW thumbnail layer: the cheapest image we can produce. // // Prefer the embedded preview via unpack_thumb(). Encoded JPEG previews are // passed straight through because the engine decodes JPEG efficiently already. -// If the file exposes no usable preview, fall back to a half-size RAW decode so -// the UI still gets a fast first image. +// If the file exposes no usable preview, fall back to a half-size RAW decode +// so the UI still gets an image quickly. The payload is therefore not +// necessarily the embedded JPEG — get_embedded_jpeg() is the no-fallback +// path for callers that need that distinction. ThumbnailResult process_thumbnail(LibRaw& raw_processor, void* cancel_token) { ThumbnailResult result = empty_thumbnail(); @@ -192,7 +195,7 @@ ThumbnailResult process_thumbnail(LibRaw& raw_processor, void* cancel_token) { return result; } - // Fallback: generate a RAW fast preview from decoded RAW data. + // Fallback: no usable embedded preview, so decode the RAW at half size. raw_processor.imgdata.params.use_camera_wb = 1; raw_processor.imgdata.params.half_size = 1; raw_processor.imgdata.params.output_bps = 8; @@ -271,8 +274,8 @@ extern "C" { delete static_cast*>(token); } - // Despite the ABI name, get_thumbnail semantically returns the RAW fast - // preview layer. + // Despite the ABI name, get_thumbnail returns the RAW thumbnail layer, + // which may be a RAW decode rather than an embedded thumbnail. EXPORT void get_thumbnail(const wchar_t* file_path, void* cancel_token, ThumbnailResult* out) { if (out == nullptr) return;