Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 219 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -124,19 +309,49 @@ 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
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.
Expand Down
17 changes: 10 additions & 7 deletions android/app/src/main/cpp/wrapper.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -174,12 +175,14 @@ extern "C" {
delete static_cast<std::atomic<bool>*>(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();

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions lib/core/preferences_repository.dart
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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,
});
}

Expand All @@ -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.
Expand Down Expand Up @@ -122,6 +126,8 @@ class PreferencesRepository {
legacyAutoTransparencyEnabled:
prefs.getBool(_legacyPreviewOverlayAutoTransparency),
),
rawViewMode:
RawViewMode.values.asNameMap()[prefs.getString(_rawViewMode)],
);
}

Expand Down Expand Up @@ -162,4 +168,9 @@ class PreferencesRepository {
final prefs = await _prefs;
await prefs.setDouble(_previewOverlayOpacity, opacity);
}

Future<void> saveRawViewMode(RawViewMode mode) async {
final prefs = await _prefs;
await prefs.setString(_rawViewMode, mode.name);
}
}
Loading