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;