Halcyon is a Flutter desktop application for photographers to triage RAW and JPG photo folders: browse with the keyboard, mark photos star/trash, then batch-copy or move the starred files.
The main triage screen, macOS 15.6.1: the sidebar lists the folder's 628 photos, and
the viewer fills the rest of the window with no app bar — only the star and trash
buttons float over the image. Arrow keys move; S and X mark.
The Rename by EXIF dialog on the same folder. The left pane holds the presets and the editable rule template with live validation; the right pane previews five randomly sampled files, showing each current filename above the name it would be renamed to.
Halcyon and Ceyx are both kingfisher genera. In Greek myth, Alcyone and Ceyx were transformed into kingfishers — the two repositories are named as a pair: Ceyx the decoding engine, Halcyon the application built on it.
- Culling is a throughput problem, not a viewing problem. The photographer's loop is
look, judge, advance — arrow keys move between photos,
Sstars,Xtrashes, and nothing in that loop asks for a dialog or a mouse click. Anything that stalls that loop is the whole cost of the tool. - Lineage: FastPictureViewer. The keyboard-driven marking model — browse and mark without leaving the keyboard — is directly inspired by FastPictureViewer, a paid Windows tool from an earlier era that photographers still miss.
- Preview area maximized, chrome minimized. The main screen has no app bar: the
Scaffoldbody is aStackwith the image viewer positioned to fill the screen and only a floating action bar and status line overlaid on top of it. The macOS window's default size is computed directly from a 3:2 preview area plus a 270px sidebar (previewWidth = defaultHeight * 1.5,defaultWidth = 270.0 + previewWidth), targeting a wide desktop window rather than a narrow one. The sidebar itself is user-resizable between 180px and 600px by dragging a handle. - Decoding is delegated, not reimplemented. RAW decode belongs to the sister project Ceyx; Halcyon is the application that consumes it under real product constraints — UI thread responsiveness, tiered preview/full-size loading, and folder-scale batch workflows.
- Honest about scope. Desktop is the target platform. Mobile and web build targets exist and compile, but the interface itself is not adapted for touch.
Halcyon depends on Ceyx as an ordinary Dart path dependency on Ceyx's plugin/
directory:
ceyx:
path: ../ceyx/pluginThis is a plain dependency, not a fork or a subproject: Ceyx must exist as a sibling
checkout next to this repository for flutter pub get to succeed, and Halcyon's own
comment on the dependency records that it deliberately depends on the plugin/ package
rather than Ceyx's own app/, to avoid dragging that app's harness dependencies into
Halcyon's build.
- The triage workflow
- Persistence, resume and batch actions
- Renaming by EXIF
- RAW format support and decode routing
- Measured performance
- Cache and memory management
- Architecture
- Architecture diagrams
- Platform support
- Building from source
- Testing and quality gates
- Third-party attribution
- Document maintenance
This is the core loop: open a folder, browse it with the keyboard, mark keepers and rejects, and move on. Everything below is what actually happens when you sit down with a card full of RAW and JPG files.
flowchart TD
A(["Open a photo folder"]) --> B["Scan the folder<br/>group RAW + JPG siblings"]
B --> C(["Browse with ← / →"])
C --> D{"How does this<br/>photo look?"}
D -- "Keeper" --> E["Press S to star<br/>auto-advance if enabled"]
D -- "Reject" --> F["Press X to trash-mark<br/>auto-advance if enabled"]
D -- "Undecided" --> C
E --> C
F --> C
E --> G(["Every mark saved to disk instantly"])
F --> G
classDef start fill:#a5f3fc,stroke:#22d3ee,stroke-width:2px,color:#0e2a33;
classDef decision fill:#fde68a,stroke:#fbbf24,stroke-width:2px,color:#3a2a04;
classDef fast fill:#86efac,stroke:#4ade80,stroke-width:2px,color:#0b3320;
classDef slow fill:#c4b5fd,stroke:#a78bfa,stroke-width:2px,color:#2a1c4d;
classDef done fill:#a5f3fc,stroke:#22d3ee,stroke-width:2px,color:#0e2a33;
class A start;
class B slow;
class C start;
class D decision;
class E fast;
class F slow;
class G done;
Point Halcyon at a folder and it lists the photos sitting directly inside it — it does not descend into subfolders. Hidden files (anything whose name starts with a dot, including the AppleDouble sidecars macOS scatters on some cards) are skipped, and only files in a supported format are shown.
Supported formats:
| Category | Formats |
|---|---|
| Everyday image files | JPG, PNG, WebP, TIFF, HEIC / HEIF |
| RAW, fully decoded | DNG, ARW, CR3, NEF, RAF, RW2, ORF, PEF, SRW, X3F |
| RAW, browse-only | CR2, IIQ, MRW |
The everyday image formats have a few things worth knowing:
- WebP displays on every platform. Animated WebP shows its first frame only.
- TIFF supports the common flavours (stripped and tiled, 8/16/32-bit, LZW / PackBits / Deflate / uncompressed); 16-bit is shown as 8-bit, and multi-page files show page 1 only. A few exotic compressions (CCITT fax, JPEG-in-TIFF) are not supported and show as unreadable.
- HEIC / HEIF uses a bundled decoder, so a HEIC looks the same on every platform instead of depending on the OS. Multi-image files (bursts, Live Photos, depth images) show the main image only; HDR gain maps and depth maps are ignored. AVIF is not supported.
The RAW formats and the difference between "fully decoded" and "browse-only" are covered in "RAW format support and decode routing" below. Everything that scans and lists — including the browse-only RAW formats — can be starred, trashed, renamed and batch-moved like any other photo.
If you shoot RAW+JPG, each shutter press writes two files that share a name and differ only by extension. Halcyon groups them: the RAW and its same-named JPG (and any hidden sidecar) become one entry in the sidebar, with one star/trash mark and one row you interact with, no matter how many files are behind it.
For display, Halcyon prefers a JPG or PNG sibling when one exists (it opens fastest), falling back to the RAW when the group is RAW-only.
Grouping also changes the default deletion behaviour: a folder that contains any RAW+JPG
pair automatically starts in recycle mode (in-folder .trash, described below) rather than
permanent delete, so a card you're culling can't lose a RAW to a mis-click. Every mark or
delete acts on the whole group, so a RAW and its JPG sibling always move together as one
unit.
Beyond "unmarked", a photo can be starred (a keeper) or trashed (a reject). Marking toggles: press the same mark again to clear it; press the other mark to switch. Clearing a mark never moves you; setting a new mark advances to the next photo when auto-advance is turned on (off by default, and remembered between sessions).
Left/right move through the folder in order, with no wraparound at either end. Zoom steps in and out by ×1.25 per press up to 5×, and zooming back down snaps cleanly to fit rather than leaving the image drifted off-centre. Your zoom level stays put as you move between photos — switching from one photo to the next doesn't reset it.
The whole triage loop is designed to run without leaving the keyboard:
| Key | Action |
|---|---|
← |
Previous photo |
→ |
Next photo |
↑ |
Zoom in (×1.25 per step, up to 5×) |
↓ |
Zoom out (×1.25 per step, snaps to fit near 1×) |
S |
Toggle star mark on the current photo |
X |
Toggle trash mark on the current photo |
R |
Toggle recycle mode (in-folder .trash vs. system/permanent delete) |
The star and trash buttons also float over the image if you'd rather click. Recycle mode
can be toggled from the keyboard with R, or by right-clicking the trash button — a
left-click on it just marks the current photo as usual.
Short status messages appear at the bottom of the window, fully visible for a couple of seconds and then fading out, so they never pile up or block the view. Two of them come straight from the triage loop:
- A one-time warning if the folder you opened turns out to be read-only — shown once when the folder opens, not once per mark. Halcyon checks this by actually trying to write a small file and delete it, because a card's permission bits can lie (an exFAT card can look writable while its physical lock switch blocks every write).
- An error message if the folder can't be scanned at all (for example a permissions error), showing what went wrong.
Close Halcyon in the middle of a folder, reopen the same folder later, and it comes back on the same photo with every star and trash mark intact. Marks aren't kept only in memory — each one is written to disk the instant you make it, and the photo you were on is remembered too.
flowchart TD
A(["Mark or navigate"]) --> B["Update the folder's<br/>status file on disk"]
B --> C["Write to a temp file,<br/>then rename into place"]
C --> D(["Folder always holds one<br/>complete status file"])
D -. "later" .-> E(["Reopen the same folder"])
E --> F{"Is the saved photo<br/>still in the folder?"}
F -- "Yes" --> G["Resume on that exact photo<br/>with all marks restored"]
F -- "No" --> H["Open at the start<br/>marks still restored"]
classDef start fill:#a5f3fc,stroke:#22d3ee,stroke-width:2px,color:#0e2a33;
classDef decision fill:#fde68a,stroke:#fbbf24,stroke-width:2px,color:#3a2a04;
classDef fast fill:#86efac,stroke:#4ade80,stroke-width:2px,color:#0b3320;
classDef slow fill:#c4b5fd,stroke:#a78bfa,stroke-width:2px,color:#2a1c4d;
classDef done fill:#a5f3fc,stroke:#22d3ee,stroke-width:2px,color:#0e2a33;
class A,E start;
class B,C slow;
class D done;
class F decision;
class G fast;
class H slow;
Every folder Halcyon opens gets its own small status file (.halcyon_status.json) written
right next to the photos. It's plain, human-readable JSON: each marked photo maps to
"starred" or "trashed" (unmarked photos simply aren't listed), plus a note of which photo
you last viewed and the folder's saved rename pattern. Plain JSON was chosen over a
database on purpose — the file lives with the photos, so it travels with the folder when you
copy it to another machine or back it up, and you can read it in a diff.
Because the marks live inside each folder, they stay self-contained: open a second folder and it keeps its own separate marks — two shoots never bleed into each other. And if the status file is ever corrupt or unreadable, the folder still opens (just with no marks restored) — losing marks is recoverable, losing access to the photos is not.
On reopening a folder, Halcyon returns you to the photo you last viewed, as long as that photo is still there. It records where you are a few seconds after you settle on a photo, so rapid arrow-key browsing doesn't hammer the disk on every keystroke.
Marks and the resume pointer are saved through a single ordered queue, so two saves can never race and overwrite each other. Each save is written to a temporary file and then renamed into place, so pulling the card or a crash mid-write can never leave a half-written file behind — the folder always holds either the complete old file or the complete new one, never a torn one.
Marks are tied to filenames. If you rename photos with some other tool, the marks tied to the old names are orphaned — they no longer match anything in the folder. Halcyon's own rename feature avoids this by moving every mark (and the resume pointer) onto the new names as part of the rename, so stars and trash marks survive a rename done inside the app.
Once you've starred your keepers, Halcyon acts on them as a batch:
flowchart TD
A(["Starred keepers"]) --> B{"What do you<br/>want to do?"}
B -- "Copy / Move" --> C["Copy or move to a<br/>destination folder"]
B -- "Share" --> D["Export resized JPEGs<br/>for social media"]
A2(["Trash-marked rejects"]) --> E{"Which delete<br/>path?"}
E -- "System Trash<br/>(macOS / Windows)" --> F["Recoverable from<br/>the OS trash"]
E -- "Recycle mode<br/>(any platform)" --> G["Moved into an in-folder<br/>.trash subfolder"]
classDef start fill:#a5f3fc,stroke:#22d3ee,stroke-width:2px,color:#0e2a33;
classDef decision fill:#fde68a,stroke:#fbbf24,stroke-width:2px,color:#3a2a04;
classDef fast fill:#86efac,stroke:#4ade80,stroke-width:2px,color:#0b3320;
classDef slow fill:#c4b5fd,stroke:#a78bfa,stroke-width:2px,color:#2a1c4d;
class A,A2 start;
class B,E decision;
class C,F fast;
class D,G slow;
Your starred photos can be copied or moved as a batch to a folder you choose. A RAW and its same-named JPG travel together as one unit (and any hidden macOS sidecar file is cleaned up alongside them rather than left behind at the destination). If a file with the same name already exists at the destination it's left untouched rather than overwritten, and one failure never stops the batch — every remaining file is still attempted, and anything that did fail is collected and shown to you rather than swallowed silently.
Starred photos can also be exported as resized JPEGs sized for social media — one file per photo, with the long edge capped at 2048px, aspect ratio preserved, and encoded at JPEG quality 90. The important EXIF fields (camera make and model, capture date, artist, exposure, aperture, focal length, lens, ISO and GPS) are re-read from the original and reattached to the resized copy. Exports run a few at a time to keep memory in check on large batches.
Halcyon offers two genuinely different ways to delete:
| Path | What it does | Recoverable? | Platform |
|---|---|---|---|
| System Trash | Moves the file to the OS trash | Yes, from the OS trash | macOS, Windows |
| Recycle mode (in-folder) | Moves the file into a .trash subfolder next to the photos |
Yes, still on the card | Any platform |
Recycle mode is the safety-first option: it moves every file of a rejected photo — the RAW
sibling and any hidden sidecar included — into a .trash subfolder right next to the
photos. Because that's a move within the same drive, it's instant (nothing is copied) and it
works even on cards where the system trash isn't available. If a name clashes with an
earlier recycle batch, the file is never overwritten — a -1, -2 suffix is added until
the name is free. Recycle mode is per-folder and is turned on automatically for folders that
contain RAW+JPG pairs; you can toggle it any time with R.
If a delete fails, Halcyon stops and shows exactly which files failed and why — a delete
that silently did nothing would look just like a working app. A successful recycle-mode
batch instead shows a brief message with the moved count, reminding you the files are still
on disk in .trash and were not permanently removed.
Photographers name files by shoot date, camera, lens, sequence number, or some mix of those — and the format is usually a house convention, not whatever the camera wrote to the SD card. Halcyon's rename feature lets you write one naming template and apply it to a whole folder. Every RAW, its JPG twin, and any hidden sidecar file move together under the same new base name, so a RAW+JPG pair never gets split apart.
A template is just a piece of text with {placeholders} in it, such as
{YYYY}-{MM}-{DD}-{hh}-{mm}-{ss}. Halcyon fills each placeholder from the photo's EXIF
metadata (or, for dates, the file's own timestamp as a fallback). Here is every
placeholder you can use, grouped the same way the "Insert variable" panel groups them:
| Group | Placeholder | What it becomes | Example |
|---|---|---|---|
| Date & time | {YYYY} |
Capture year, 4 digits | 2026 |
| Date & time | {MM} |
Capture month, 2 digits | 08 |
| Date & time | {DD} |
Capture day, 2 digits | 26 |
| Date & time | {hh} |
Capture hour, 2 digits | 14 |
| Date & time | {mm} |
Capture minute, 2 digits | 07 |
| Date & time | {ss} |
Capture second, 2 digits | 33 |
| Camera | {camera} |
Camera model | Z 8 |
| Camera | {lens} |
Lens model | NIKKOR Z 24-70mm f_2.8 S |
| Camera | {make} |
Camera maker | NIKON CORPORATION |
| Camera | {artist} |
Artist / copyright tag | J. Chen |
| Shooting | {f} |
Aperture, as f<value> |
f2.8 |
| Shooting | {focal} |
Focal length, as <value>mm |
35mm |
| Shooting | {iso} |
ISO, as ISO<value> |
ISO400 |
| Shooting | {shutter} |
Shutter speed | 1/250 |
| Shooting | {direction} |
GPS heading, whole degrees | 187 |
| File | {seq} |
Sequence number for files that would otherwise collide; pad with a width like {seq:3} → 007 |
1 |
| File | {orig} |
The original filename (without extension) | DSC_0431 |
A few things worth knowing:
- Dates always resolve — if EXIF has no capture date, or can't be read, date/time placeholders fall back to the file's own last-modified time.
- Missing tags become blank, never a literal
{camera}in the name. - Typos are caught before anything happens — an unknown placeholder is flagged "Unknown variable {name}" and the Run button stays disabled.
- Names stay filesystem-safe — characters that would break a filename (
/,:,\, NUL) become_, so a1/250shutter speed can't accidentally create a subfolder.
Four ready-made presets ship with the app. Rendered against a photo shot 2026-08-26
14:07:33 with the original name DSC_0431:
| Preset | Template | Example result |
|---|---|---|
| Date & time | {YYYY}-{MM}-{DD}-{hh}-{mm}-{ss} |
2026-08-26-14-07-33 |
| Compact | {YYYY}{MM}{DD}_{hh}{mm}{ss} |
20260826_140733 |
| Camera-style | IMG_{YYYY}{MM}{DD}_{hh}{mm}{ss} |
IMG_20260826_140733 |
| Date + sequence | {YYYY}-{MM}-{DD}_{seq} |
2026-08-26_1 |
"Date & time" is what a fresh dialog opens with. The moment you edit the template text (or
tap a variable chip), the selection switches to Custom.... Your custom rule is remembered
per folder — reopen the dialog on a folder you last renamed with a custom rule and that
exact template comes back.
The rename dialog has two panes: the preset picker, template field, and variable chips on the left, and a live preview on the right.
flowchart TD
A(["Open the Rename dialog"]) --> B["Halcyon samples five photos<br/>and reads their EXIF once"]
B --> C["Pick a preset or type<br/>your own template"]
C --> D{"Is the template valid?"}
D -- "Yes" --> E["Live preview updates instantly<br/>old name → new name, per photo"]
D -- "No (typo / empty)" --> F["Run button disabled<br/>error shown in the editor"]
E --> G["Run Rename<br/>applies to the whole folder"]
F --> C
G --> H(["Files renamed<br/>Undo available"])
classDef start fill:#a5f3fc,stroke:#22d3ee,stroke-width:2px,color:#0e2a33;
classDef decision fill:#fde68a,stroke:#fbbf24,stroke-width:2px,color:#3a2a04;
classDef fast fill:#86efac,stroke:#4ade80,stroke-width:2px,color:#0b3320;
classDef slow fill:#c4b5fd,stroke:#a78bfa,stroke-width:2px,color:#2a1c4d;
classDef limited fill:#fda4af,stroke:#fb7185,stroke-width:2px,color:#40101a;
classDef done fill:#a5f3fc,stroke:#22d3ee,stroke-width:2px,color:#0e2a33;
class A start;
class D decision;
class B,C,E slow;
class G fast;
class F limited;
class H done;
After the initial read, every keystroke re-renders those five preview rows instantly with no re-reading of metadata, so typing stays snappy even on a big folder. A "Re-roll" button swaps in a fresh set of five random photos with a fresh metadata read.
Each preview row shows old name → new name, plus badges for sibling extensions (so a RAW+JPG pair's move is visible before you commit) and a "no camera tag" badge when applicable. The rename always applies to the whole folder — there's no per-item selection.
Halcyon reads EXIF once per photo (covering its RAW, JPG twin, and any sidecar together), preferring the JPG twin's EXIF when one exists, otherwise reading the RAW header directly. Reading a RAW header runs in the background in batches, with progress in the status line, so the interface never freezes on a large folder.
If a RAW format's header can't be parsed, that photo gets no camera metadata — its EXIF placeholders render blank, though date and time still resolve from the file timestamp.
Halcyon works out every move first, then performs them one at a time:
- Photos that would collide are numbered with
{seq}in a stable order; any remaining clash gets a-1,-2, … suffix. - A photo whose new name equals its current name is skipped.
- All files belonging to one photo are renamed to the same base name, so pairs never split.
Each move is journaled, which powers Undo (replays the journal in reverse). Star/trash marks and the last-viewed photo follow renames automatically. A non-writable folder can't open the rename dialog at all.
- A placeholder with no matching EXIF tag renders as a blank rather than substituting another field.
- A RAW with no JPG twin and an unparseable header yields no camera metadata at all.
Halcyon supports RAW files from nearly every major camera brand, plus the universal Adobe DNG:
| Camera brand | Format | How it's shown |
|---|---|---|
| Sony | ARW | Fully decoded |
| Canon | CR3 | Fully decoded |
| Nikon | NEF | Fully decoded |
| Fujifilm | RAF | Fully decoded |
| Panasonic | RW2 | Fully decoded |
| Olympus | ORF | Fully decoded |
| Pentax | PEF | Fully decoded |
| Samsung | SRW | Fully decoded |
| Sigma | X3F | Fully decoded |
| Adobe (universal) | DNG | Fully decoded |
| Canon (older) | CR2 | Thumbnail only |
| Phase One | IIQ | Thumbnail only |
| Minolta | MRW | Thumbnail only |
The three "thumbnail only" formats can still be starred, deleted, and moved just like any other photo — they just don't show full decoded quality yet.
Most of the time you won't notice a difference: Halcyon automatically picks the fastest way to display each photo.
flowchart TD
A(["Open a RAW photo"]) --> B{"Does the file have a<br/>built-in preview image?"}
B -- "Yes, large enough" --> C["Read the embedded preview<br/>fast"]
B -- "No, or too small" --> D{"Does this format<br/>support full decode?"}
D -- "Yes" --> E["Fully decode the RAW sensor data<br/>slower, full quality"]
D -- "No (CR2 / IIQ / MRW)" --> F["Show thumbnail only<br/>full quality not available"]
C --> G(["Photo appears on screen"])
E --> G
F --> G
classDef start fill:#a5f3fc,stroke:#22d3ee,stroke-width:2px,color:#0e2a33;
classDef decision fill:#fde68a,stroke:#fbbf24,stroke-width:2px,color:#3a2a04;
classDef fast fill:#86efac,stroke:#4ade80,stroke-width:2px,color:#0b3320;
classDef slow fill:#c4b5fd,stroke:#a78bfa,stroke-width:2px,color:#2a1c4d;
classDef limited fill:#fda4af,stroke:#fb7185,stroke-width:2px,color:#40101a;
classDef done fill:#a5f3fc,stroke:#22d3ee,stroke-width:2px,color:#0e2a33;
class A start;
class B,D decision;
class C fast;
class E slow;
class F limited;
class G done;
In short:
- Has a built-in preview → use it. Many RAW files (especially DNGs processed by Lightroom or DxO PureRAW, and Panasonic's RW2 files) already contain a ready-made JPEG preview inside them. Halcyon uses it instantly when it finds one.
- No usable preview → full decode. When no preview is large enough and the format supports full decoding, Halcyon decodes the complete RAW sensor data — a bit slower, but full quality.
- Format doesn't support full decode → thumbnail only. CR2, IIQ, and MRW currently can only be browsed as thumbnails.
Current platform support:
| Platform | Full RAW decode |
|---|---|
| macOS | ✅ Supported |
| Windows | ✅ Supported |
| Android | ✅ Supported |
| Linux | ✅ Supported |
| iOS | ⏳ Not yet |
| Web | ⏳ Not yet |
On platforms without full decode support, a RAW file with no built-in preview will temporarily show as unavailable — that's a missing platform feature, not a broken photo.
When you're triaging photos the loop is simple: look, judge, move on. The number that actually matters is how long it takes from pressing an arrow key to a usable full-resolution image on screen — not decode throughput in the abstract.
Two very different costs hide behind that one number:
- Photos with a built-in JPEG preview take the cheap path — Halcyon just shows the preview, no RAW decoding at all. This is the majority of files in a typical folder, and it lands in single-digit milliseconds.
- Photos with no usable preview (bare-sensor DNGs, mostly from phones) go through a full RAW decode via the sister decoder, Ceyx. This is the expensive path.
Because of that split — plus the difference between a cold first decode and a warm repeat decode — any single number is only meaningful with its conditions attached. Here's what's actually been recorded.
| What's being measured | Time | Conditions |
|---|---|---|
| Full RAW decode, key-press to full-res on screen (12 MP phone DNG) | cold 491–601 ms; warm 150–159 ms | macOS release build, 2026-08-17, machine not recorded |
| Sidebar thumbnail decode, bare-sensor DNG (no built-in preview) | warm ~56–100 ms per photo | Test harness, target 200 px long edge |
| Sidebar thumbnail, DNG with a built-in preview (fast path) | warm ~0.3–0.4 ms | Same harness |
| Sidebar thumbnail, JPEG files | warm ~22–26 ms | Same harness |
| Ceyx full decode, 24 MP DNG, lossless | ~177 ms | macOS (Metal), warm |
| Ceyx full decode, 24 MP DNG, lossy | ~105 ms | macOS (Metal), warm |
| Ceyx cold first decode in a GUI app, 24 MP (6000×4000) lossless DNG | 291 ms | Apple M3 Ultra, macOS 15.6.1, release, cold |
| Switching between JPEG-preview photos (no RAW decode) | 2.8 ms (was 127.5 ms before optimisation) | Historical baseline, kept to show the size of the win |
If you want a single figure, it's about 300 ms for a cold, GPU-accelerated full RAW decode — from one clean recorded run: Ceyx cold-decoding a 24 MP lossless DNG inside a real app, 291 ms on an Apple M3 Ultra.
Everything else in the table answers a slightly different question, and the differences are worth keeping in mind:
- Warm decode is roughly half that. The one full end-to-end run that reached full-res paint measured 150–159 ms warm, and Ceyx's warm figures sit at 105–177 ms for 24 MP. A photographer flicking back and forth across a handful of frames lives in this warm regime, not the cold one.
- A cold decode on an older/unrecorded machine ran higher — 491–601 ms in one 2026 run. Treat that as a weak data point (its own notes flag it for a re-run that never happened), not a contradiction of the 300 ms figure.
- Most files never decode at all. A RAW carrying a usable built-in JPEG preview skips the decoder entirely and appears in single-digit milliseconds. The 300 ms figure only describes the expensive path, which is the minority of files in a normal folder.
Honest summary: quote ~300 ms cold / ~150 ms warm for full RAW decode, and don't treat either as a universal benchmark — no measurement here cleanly separates cold from warm across a range of machines and sensor sizes.
A few things simply have no recorded number yet, and it's better to say so than to guess:
- Full-decode timing for large-sensor RAW files (full-frame, 40+ MP) running through Halcyon's own pipeline. The recorded samples top out around 24 MP.
- The machine (chip, RAM) behind most of the Halcyon-side figures — only the 291 ms M3 Ultra data point names its hardware.
- Export timing (decode → resize → re-encode JPEG).
- Interactive switch-latency and memory usage under real UI navigation — these are reserved for the project owner to measure personally rather than in automated runs.
Reviewing a shoot means holding an arrow key down and flying through dozens of frames a second. That only feels good if two things are true at once: each photo appears the instant you land on it, and browsing a folder of any size never runs the app out of memory. Those goals pull in opposite directions — a full-resolution frame from a modern 24 MP sensor is around 90 MB once decoded, so decoding every frame at full quality on every keystroke would stall, while keeping every frame you've seen would eventually exhaust memory.
Halcyon's answer is to keep only the photos near where you're looking, show each one at the right level of detail for what you're doing, and quietly upgrade to full quality the moment you pause. You never wait for the "heavy" work while you're moving.
The main preview is drawn in two passes:
- Tier one — instant. As soon as you land on a photo, Halcyon shows it decoded to your window's resolution. This is quick, so rapid arrow-key browsing stays smooth and every photo shows something immediately.
- Tier two — full quality. If you stop on a photo for about a quarter of a second, Halcyon decodes the full-resolution version and swaps it in. Because it waits for that brief pause, blowing past a hundred photos never kicks off a hundred heavy full-frame decodes for images you only glanced at.
flowchart TD
A(["Land on a photo"]) --> B["Show window-resolution preview<br/>immediately (tier one)"]
B --> C{"Did you pause here<br/>for about a quarter second?"}
C -- "No, still browsing" --> D["Keep the quick preview<br/>stay responsive"]
C -- "Yes, you stopped" --> E["Decode full resolution<br/>and swap it in (tier two)"]
D --> F(["Next photo"])
E --> G(["Full-quality image on screen"])
classDef start fill:#a5f3fc,stroke:#22d3ee,stroke-width:2px,color:#0e2a33;
classDef decision fill:#fde68a,stroke:#fbbf24,stroke-width:2px,color:#3a2a04;
classDef fast fill:#86efac,stroke:#4ade80,stroke-width:2px,color:#0b3320;
classDef slow fill:#c4b5fd,stroke:#a78bfa,stroke-width:2px,color:#2a1c4d;
classDef done fill:#a5f3fc,stroke:#22d3ee,stroke-width:2px,color:#0e2a33;
class A start;
class C decision;
class B,D fast;
class E slow;
class F,G done;
The filmstrip of thumbnails down the side is loaded separately from the main image. Halcyon only fetches the thumbnails that are actually on screen, plus a margin just above and below so scrolling stays ahead of you, and it drops thumbnails once they scroll well out of view. Thumbnails re-appear on their own after any action that reloads the folder — starring, trashing, copying or moving — so the sidebar never gets stuck blank. Small embedded previews are used as-is; larger images are shrunk to a compact thumbnail once and kept in that lightweight form, so the sidebar stays cheap even for a big folder.
Rather than holding every photo you've opened, Halcyon keeps a moving window of photos around the one you're on — a few behind you and a few more ahead, since browsing runs overwhelmingly forward. As you move, photos entering the window are loaded and photos falling out the back are released. This window is also capped by an overall memory budget, so even with unusually large files the app releases the oldest held photo first and stays within bounds. The net effect: memory use stays roughly flat no matter how long the folder is or how long you browse.
| Lane | What it holds | How much is kept | When it's released |
|---|---|---|---|
| Sidebar thumbnails | Small thumbnail images for the filmstrip | The rows on screen plus a margin above and below | Trimmed to what's currently needed on every update |
| Main image, both tiers | The photos near the one you're viewing | A moving window: a few behind, a few more ahead, capped by a memory budget | Oldest / farthest photo released first as you move or when over budget |
| Decoded frames | The window-resolution and full-resolution images being shown | Bounded by a share of the machine's memory | Released automatically once a photo leaves the active window |
Halcyon is a strictly one-way layered app — views/ → providers/app_state.dart → services/ → models/ — with a small number of frozen seams where a contributor should not casually change the shape of things.
views/ builds the UI and owns only view-local state (keyboard shortcuts, the zoom transform, dialog scaffolding). It reads AppState through the provider package and calls its methods; it has no knowledge of how a photo gets scanned, decoded, or deleted. View-local, animation-driven state such as zoom and pointer position lives in view-owned controllers (e.g. lib/views/zoom_controller.dart's ZoomController extends ChangeNotifier, owned and disposed by MainScreen) rather than in AppState — AppState holds only state that represents the photo-library model.
providers/app_state.dart defines AppState extends ChangeNotifier (lib/providers/app_state.dart:61), the single coordination point for application logic — folder loading, selection, star/trash marking, settings, and dispatch into the service layer. It takes its collaborators via constructor injection rather than hardcoding them as fields:
AppState({
PhotoLibraryScanner? scanner,
PhotoStatusStore? statusStore,
PhotoFileActions? fileActions,
ImagePreloadController? preloadController,
NativeImageLoad? imageLoader,
DngFullDecoder? dngDecoder,
PhotoExportService? exportService,
ExifBatchReader? exifReader,
})Each parameter falls back to the real implementation when omitted (e.g. _scanner = scanner ?? PhotoLibraryScanner()), so production gets the real collaborators for free while tests substitute fakes for any of them — this is what lets AppState be unit-tested without touching a real filesystem or platform channel.
services/ implements the actual work — scanning, status persistence, image decode/cache, file operations, EXIF/rename, and the two platform bridges — and is forbidden from reaching back up into views/ or AppState; it's called, and only calls back through the callback/supplier parameters AppState hands it explicitly. models/ holds pure data shapes and functions with no I/O — PhotoItem, the format registry, RenameRule's template rendering — and does not import from services/ or views/.
services/ is split into four purpose-named subfolders:
| Folder | Owns |
|---|---|
image_pipeline/ |
tier-1/tier-2 sliding-window preload, DNG decode integration, image cache bookkeeping |
library/ |
folder scanning, status persistence, file copy/move/trash, star-photo export |
rename/ |
EXIF-driven rename planning, EXIF metadata reading, the rename coordinator |
platform/ |
the two macOS MethodChannel bridges (Trash, Open With) |
These are the load-bearing constraints in the image pipeline; changing them casually breaks the tier-1/tier-2 contract described elsewhere in this README.
The Ceyx integration seam. DNG full-size decoding — for DNGs with no usable embedded preview — is delegated to the sister project Ceyx through a typedef, not a concrete class:
typedef DngFullDecoder = Future<DecodedRgba> Function(String path);This seam is what lets the image pipeline be unit-tested against a fake decoder instead of loading the real native dylib.
Paired with it, image_source_types.dart declares a sealed class with exactly three variants describing the outcome of any image-bytes request: NativeImageBytes (encoded bytes, the happy path), NativeImageNeedsRawDecode (a DNG with no embedded preview — not a failure, a signal to run the real RAW decoder), and NativeImageFailure (a genuine failure). This set is frozen at three variants.
Image loading is pure Dart on every platform. dartImageLoad (lib/services/image_pipeline/dart_image_loader.dart:17) is the sole producer of image bytes; there is no native thumbnail channel on any platform. Photo behaviour — which files load, what pixels appear, what deletion does, what export produces — is implemented once in Dart and behaves the same on every platform, with exactly three closed native-bridge exceptions: system Trash (macOS/Windows native), the Open With transport layer (macOS/Windows/Android/iOS, excluding Linux), and file association registration (Windows/macOS).
Single-owner invariants. Two classes each hold exactly one piece of tier-2 state, so it can be reasoned about and tested in one place instead of drifting across call sites:
TierTwoRegistry(lib/services/image_pipeline/tier_two_registry.dart:26) is the single holder of tier-two readiness bookkeeping — which ids have a full-size cache entry, which payload object it was decoded for, and whether that decode has failed.TierTwoScheduler(lib/services/image_pipeline/tier_two_scheduler.dart:58) is the single holder of tier-two scheduling — the ±2 window, the 250ms navigation debounce, and the serialized decode queue.
Native bridges. macos/Runner/AppDelegate.swift registers exactly two MethodChannels:
FlutterMethodChannel(name: "halcyon/trash", ...)
FlutterMethodChannel(name: "halcyon/open_with", ...)halcyon/open_with is push-only: native calls into Dart to deliver a file path, and Dart has no method to ask native "is anything pending?". Flutter buffers native→Dart messages until a Dart handler registers, which makes push-only the reliable direction even at cold start; an event that arrives before the channel object exists is held in a pendingOpenFile variable and flushed the moment the channel is created.
One EXIF orientation table. exif_orientation.dart's exifTransformFor is the project's only 8-case Orientation-tag lookup table; both the package:image-based export path and the dart:ui-based full-size RGBA provider translate through it rather than encoding their own orientation logic, and both apply rotation before mirroring, in that fixed order.
Halcyon/
├── lib/
│ ├── main.dart # ChangeNotifierProvider + MaterialApp setup
│ ├── models/ # PhotoItem, format registry, RenameRule (pure, no I/O)
│ ├── perf/ # opt-in performance instrumentation
│ ├── providers/
│ │ └── app_state.dart # AppState: the single coordination point
│ ├── services/
│ │ ├── image_pipeline/ # tier-1/tier-2 preload, DNG decode, cache bookkeeping
│ │ ├── library/ # folder scan, status persistence, file ops, export
│ │ ├── rename/ # EXIF-driven rename planning + coordinator
│ │ └── platform/ # the two macOS MethodChannel bridges
│ └── views/ # UI, keyboard shortcuts, dialogs
├── test/ # mirrors the lib/ tree above, plus test/support/
├── macos/ ios/ android/ web/ windows/ linux/ # per-platform runner shells
├── scripts/
│ └── build_apps.py # the single build entry point for all six targets
├── docs/
│ ├── logs/YYYY-MM-DD/ # dated task logs; recorded measurements live here
│ └── sop/ # untracked internal maintenance docs; absent from a fresh clone
└── README.md
Halcyon also maintains a set of internal process documents — architecture decisions and gotchas, task tracking, phase milestones, a handoff summary, and the test strategy and test-case matrix — under docs/sop/ in a working checkout. They're git-ignored, so a fresh clone won't contain them.
Licensing and third-party attribution are covered in Third-party attribution at the end of this document.
Three diagrams cover the system: how the modules depend on each other, how a
photo's bytes travel from disk to the screen, and how a keypress turns into a
mark that later drives a batch action on the filesystem. Together they should
let a new reader place any file in lib/ within thirty seconds.
Shapes (consistent across all three diagrams):
| Shape | Meaning |
|---|---|
Stadium ([ ]) |
Entry point / user action |
Rectangle [ ] |
Module, service, or class |
Subroutine [[ ]] |
In-memory cache |
Cylinder [( )] |
Persistent storage (file on disk) |
Rhombus { } |
Decision / routing point |
Hexagon {{ }} |
Native / FFI boundary crossing |
Colour (one hue per architectural layer, Tailwind 200-shade fill / 400-shade
stroke, text forced to #1e293b, Tailwind slate-800):
| Layer | Fill (200) | Stroke (400) |
|---|---|---|
| Views / entry points | #bfdbfe (blue-200) |
#60a5fa (blue-400) |
Providers (AppState) |
#e9d5ff (purple-200) |
#c084fc (purple-400) |
| Services — image pipeline | #bbf7d0 (green-200) |
#4ade80 (green-400) |
| Services — library/platform/rename | #fed7aa (orange-200) |
#fb923c (orange-400) |
| Models | #fef08a (yellow-200) |
#facc15 (yellow-400) |
| Native / FFI boundary (Ceyx, AppDelegate) | #fecaca (red-200) |
#f87171 (red-400) |
| Caches | #a5f3fc (cyan-200) |
#22d3ee (cyan-400) |
| Persistent storage | #e2e8f0 (slate-200) |
#94a3b8 (slate-400) |
Edges: a solid arrow is a direct call or import dependency; a dashed arrow is a data/file dependency (something read from or written to disk) rather than a function call.
flowchart TD
classDef viewLayer fill:#bfdbfe,stroke:#60a5fa,color:#1e293b
classDef providerLayer fill:#e9d5ff,stroke:#c084fc,color:#1e293b
classDef pipelineLayer fill:#bbf7d0,stroke:#4ade80,color:#1e293b
classDef serviceLayer fill:#fed7aa,stroke:#fb923c,color:#1e293b
classDef modelLayer fill:#fef08a,stroke:#facc15,color:#1e293b
classDef nativeLayer fill:#fecaca,stroke:#f87171,color:#1e293b
Views["views/<br/>(MainScreen, SidebarView,<br/>PhotoActionBar, RenameDialog)"]:::viewLayer
AppState["providers/app_state.dart<br/>AppState extends ChangeNotifier"]:::providerLayer
subgraph Services["services/"]
direction TB
subgraph ImagePipeline["image_pipeline/"]
Preload["ImagePreloadController"]:::pipelineLayer
PhotoSourceSvc["PhotoSource"]:::pipelineLayer
DngContract["DngFullDecoder<br/>(frozen seam)"]:::pipelineLayer
end
subgraph Library["library/"]
Scanner["PhotoLibraryScanner"]:::serviceLayer
StatusStore["PhotoStatusStore"]:::serviceLayer
FileActions["PhotoFileActions"]:::serviceLayer
ExportSvc["PhotoExportService"]:::serviceLayer
end
subgraph Rename["rename/"]
RenameCoord["RenameCoordinator"]:::serviceLayer
ExifSvc["ExifMetadataService"]:::serviceLayer
end
subgraph Platform["platform/"]
TrashSvc["TrashService"]:::serviceLayer
OpenWith["OpenWithChannel"]:::serviceLayer
end
end
Models["models/<br/>(PhotoItem, SupportedPhotoFormats,<br/>RenameRule)"]:::modelLayer
NativeBridge{{"macOS native bridges<br/>AppDelegate.swift<br/>(halcyon/trash, halcyon/open_with)"}}:::nativeLayer
CeyxEngine{{"Ceyx (external package)<br/>GPU RAW decode engine"}}:::nativeLayer
Views -->|reads/calls| AppState
AppState -->|constructor-injects & calls| Preload
AppState --> Scanner
AppState --> StatusStore
AppState --> FileActions
AppState --> ExportSvc
AppState --> RenameCoord
Preload --> PhotoSourceSvc
PhotoSourceSvc -.->|typedef DngFullDecoder| DngContract
ExportSvc -.->|typedef DngFullDecoder| DngContract
DngContract -.->|implemented by dng_decode_service.dart| CeyxEngine
FileActions --> TrashSvc
TrashSvc --> NativeBridge
RenameCoord --> ExifSvc
Preload --> Models
Scanner --> Models
FileActions --> Models
ExportSvc --> Models
RenameCoord --> Models
Views -.->|receives pushed file path| OpenWith
OpenWith --> NativeBridge
Caption: dependencies flow one way, top to bottom. views calls into
AppState, AppState composes every services/ collaborator by constructor
injection, and those collaborators depend only on models/. Nothing in
services/ or models/ imports views/ or providers/. The only two native crossings are
the DngFullDecoder seam into the external Ceyx package (RAW decode) and the
two MethodChannels registered in AppDelegate.swift (system Trash and
"Open With" file delivery).
Evidence:
AppStatecomposes its collaborators via constructor injection —lib/providers/app_state.dart:61-104.ImagePreloadControllerdepends onPhotoSource, which is the one type-aware layer —lib/services/image_pipeline/photo_source.dart:82-93.DngFullDecoder/DngSizedDecoderare the frozen integration seam between the pipeline and the native decoder —lib/services/image_pipeline/dng_decode_contract.dart:30,39.- The Ceyx adapter implementing that seam imports
package:ceyx/ceyx.dart—lib/services/image_pipeline/dng_decode_service.dart:1,12-14. PhotoExportServicealso takes an optionalDngFullDecoderfor its own RAW export path —lib/services/library/photo_export_service.dart:38-39.PhotoFileActionsdefaults toTrashService.trashFile—lib/services/library/photo_file_actions.dart:40.AppDelegate.swiftregisters exactly two channels,halcyon/trashandhalcyon/open_with—macos/Runner/AppDelegate.swift:23,42.RenameCoordinatoris constructed byAppStatewithreadMetadata: readMetadataForwired toExifMetadataService.readBatch—lib/providers/app_state.dart:71-102.
This is the centrepiece: the complete path a photo's bytes take from a folder scan to a painted frame, including the two-tier decode strategy and the routing decision between an embedded preview and a full RAW decode.
flowchart TD
classDef entry fill:#bfdbfe,stroke:#60a5fa,color:#1e293b
classDef service fill:#bbf7d0,stroke:#4ade80,color:#1e293b
classDef decision fill:#bbf7d0,stroke:#4ade80,color:#1e293b
classDef native fill:#fecaca,stroke:#f87171,color:#1e293b
classDef cache fill:#a5f3fc,stroke:#22d3ee,color:#1e293b
classDef storage fill:#e2e8f0,stroke:#94a3b8,color:#1e293b
classDef render fill:#bfdbfe,stroke:#60a5fa,color:#1e293b
Open(["User opens a folder"]):::entry
Scan["PhotoLibraryScanner.scan()<br/>lists files, groups siblings by<br/>basenameWithoutExtension"]:::service
Disk1[("photo folder<br/>(RAW + JPG siblings)")]:::storage
Open --> Scan
Disk1 -.-> Scan
Select(["User selects / navigates<br/>to a PhotoItem"]):::entry
Ensure["ImagePreloadController._ensurePayload()"]:::service
Probe{"DngEmbeddedJpegExtractor.probeContent()<br/>bounded IFD walk: cheap or expensive?"}
class Probe decision
Scan --> Select
Select --> Ensure
Ensure --> Probe
Route{"PhotoSource.load()<br/>native loader result"}
class Route decision
Probe --> Route
Bytes["NativeImageBytes<br/>(JPEG file itself, or<br/>largest embedded preview)"]:::service
NeedsRaw{{"NativeImageNeedsRawDecode<br/>(no usable embedded JPEG)"}}
class NeedsRaw native
Failure["NativeImageFailure<br/>-> pure-Dart embedded-JPEG<br/>fallback, else permanent miss"]:::service
Route -->|encoded bitstream found| Bytes
Route -->|DNG with no preview| NeedsRaw
Route -->|unreadable| Failure
CeyxDecode{{"Ceyx DngDecoderService<br/>.decodeOnWorker()<br/>GPU RAW decode on worker isolate"}}
class CeyxDecode native
NeedsRaw --> CeyxDecode
PixelPayloadNode["decodedRgbaToPixelPayload()<br/>orient + downscale to window size"]:::service
CeyxDecode --> PixelPayloadNode
PayloadCache[["PhotoPayloadCache<br/>-3..+5 retention window,<br/>byteCost-only eviction"]]:::cache
Bytes --> PayloadCache
PixelPayloadNode --> PayloadCache
TierOne["Tier-1 decode<br/>tierOneProviderFor()<br/>ResizeImage @ window resolution"]:::service
PayloadCache --> TierOne
Debounce{"250ms navigation-quiet<br/>debounce elapsed?"}
class Debounce decision
PayloadCache --> Debounce
TierTwo["Tier-2 decode<br/>fullSizeProviderFor() / RawFullResImage<br/>full-size, -2..+2 window"]:::service
Debounce -->|yes, TierTwoScheduler.schedule| TierTwo
ImageCacheNode[["Flutter ImageCache<br/>(tier-1 + tier-2 keys,<br/>separate namespaces)"]]:::cache
TierOne --> ImageCacheNode
TierTwo --> ImageCacheNode
ThumbCache[["_thumbCache<br/>sidebar thumbnail bytes"]]:::cache
Ensure -.->|separate sweep,<br/>ImageRequestPurpose.sidebarThumbnail| ThumbCache
Render(["MainDetailView paints<br/>AppState.displayProvider<br/>(tier-2 if ready, else tier-1)"]):::render
ImageCacheNode --> Render
Caption: the scan groups sibling RAW/JPG files into a single PhotoItem.
When you select an item, a bounded content probe runs before any decode and
sorts the file into cheap or expensive. Cheap files — JPEGs, or DNGs whose
embedded preview is already large enough — skip the native decoder entirely,
while a DNG with no usable preview crosses into Ceyx's GPU decoder on a worker
isolate. Every decoded result, whether encoded bytes or downscaled pixels,
lands in a single byte-budgeted retention cache, and the display path always
paints from there: window resolution (tier-1) right away, then an upgrade to
full size (tier-2) once navigation has been quiet for 250ms.
Evidence:
- Sibling grouping by
basenameWithoutExtension—lib/services/library/photo_library_scanner.dart:14-19, id definition atlib/models/supported_photo_formats.dart:44. - The probe-first content classification and its cost/orientation dual output
—
lib/services/image_pipeline/photo_source.dart:274-317. - The three-way
NativeImageResultrouting (bytes / needs-raw-decode / failure) —lib/services/image_pipeline/image_source_types.dart:48-87, and the switch that acts on it —lib/services/image_pipeline/photo_source.dart:116-201. - The Ceyx crossing —
lib/services/image_pipeline/dng_decode_service.dart:12-14. - Tier-1/tier-2 provider factories and the identity/key-match requirement —
lib/services/image_pipeline/image_preload_controller.dart:28-49. - The 250ms navigation debounce constant —
lib/services/image_pipeline/image_preload_controller.dart:49. - The -3..+5 retention window and byteCost-only eviction —
lib/services/image_pipeline/photo_payload_cache.dart:6-10(window) and class doc atlib/services/image_pipeline/photo_payload_cache.dart:36-49. - Sidebar thumbnails use a separate cache/miss set from the detail path —
lib/services/image_pipeline/image_preload_controller.dart:91,173. displayProviderpicks tier-2 when ready, else tier-1 —lib/providers/app_state.dart:214-215.
flowchart TD
classDef entry fill:#bfdbfe,stroke:#60a5fa,color:#1e293b
classDef provider fill:#e9d5ff,stroke:#c084fc,color:#1e293b
classDef service fill:#fed7aa,stroke:#fb923c,color:#1e293b
classDef storage fill:#e2e8f0,stroke:#94a3b8,color:#1e293b
classDef decision fill:#fed7aa,stroke:#fb923c,color:#1e293b
classDef native fill:#fecaca,stroke:#f87171,color:#1e293b
KeyPress(["Keypress or PhotoActionBar click<br/>(star / trash)"]):::entry
Mark["AppState.markCurrent(status)<br/>toggles PhotoItem.status in memory"]:::provider
StatusFile[(".halcyon_status.json<br/>in the photo folder root")]:::storage
KeyPress --> Mark
Mark -->|_saveStatusCache -> PhotoStatusStore.saveStatuses<br/>tmp-file + atomic rename| StatusFile
BatchTrigger(["User triggers a batch action<br/>(copy/move starred, delete trashed,<br/>export starred)"]):::entry
RouteAction{"Which batch action?"}
class RouteAction decision
BatchTrigger --> RouteAction
ProcessStarred["AppState.processStarred()<br/>-> PhotoFileActions.processStarred()"]:::service
DeleteTrashed["AppState.deleteTrashed()<br/>-> PhotoFileActions.deleteTrashed() /<br/>recycleTrashed()"]:::service
ExportStarred["AppState.exportStarredThumbnails()<br/>-> PhotoExportService.exportStarred()"]:::service
RouteAction -->|copy/move| ProcessStarred
RouteAction -->|trash| DeleteTrashed
RouteAction -->|export| ExportStarred
RouteAction -.->|reads PhotoItem.status<br/>filtered from _items| Mark
DestDir[("Destination folder<br/>(copy/move/export target)")]:::storage
TrashDir[(".trash/ subfolder<br/>(recycle mode)")]:::storage
SystemTrash{{"System Trash / Recycle Bin<br/>via halcyon/trash channel<br/>(macOS and Windows only)"}}:::native
ProcessStarred -->|file.copy / file.rename| DestDir
DeleteTrashed -->|recycle mode: same-volume rename| TrashDir
DeleteTrashed -->|system Trash mode| SystemTrash
ExportStarred -->|decode -> resize -> JPEG q90| DestDir
Reload["AppState.loadFolder() re-scans<br/>and re-applies .halcyon_status.json"]:::provider
ProcessStarred --> Reload
DeleteTrashed --> Reload
StatusFile -.->|re-read on next loadFolder| Reload
Caption: a mark is pure in-memory state on PhotoItem until
_saveStatusCache persists it to .halcyon_status.json via an atomic
tmp-file-plus-rename write. Every batch action reads status directly off the
live _items list, not the file, and re-triggers a folder reload afterward,
which is what re-reads the JSON back in. Copy/move and export write into a
user-chosen destination; trash either moves files into a .trash/ sibling
folder (recycle mode, same-volume rename) or hands them to the operating
system's own Trash through the native halcyon/trash channel, which is
registered on macOS and Windows only.
Evidence:
markCurrenttoggles status and calls_saveStatusCache—lib/providers/app_state.dart:367-392.- Atomic tmp-file + rename write —
lib/services/library/photo_status_store.dart:68-76,132-148. processStarredfiltersitem.status != PhotoStatus.starredand copies or renames each file —lib/services/library/photo_file_actions.dart:50-87.deleteTrashedbranches onrecycleModebetweenTrashService.trashFileandrecycleTrashed's same-volume rename into.trash/—lib/providers/app_state.dart:498-538,lib/services/library/photo_file_actions.dart:89-155.TrashService.trashFileis the default forPhotoFileActionsand is the system-Trash bridge, registered on macOS and Windows —lib/services/library/photo_file_actions.dart:40, channel registration atmacos/Runner/AppDelegate.swift:23.exportStarred's decode/resize/encode path —lib/services/library/photo_export_service.dart:53-142.- Batch actions reload the folder afterward, which re-applies saved statuses
—
lib/providers/app_state.dart:467-474,524-530, re-application atlib/services/library/photo_status_store.dart:93-130.
Halcyon is a desktop app first, but it runs on more than that. Full RAW decode now works on macOS, Windows, Android and Linux — only iOS and the web build don't have a native decoder yet. The desktop targets are the ones the interface was designed for; the mobile and web builds run but haven't been adapted for touch.
| Platform | Runs | Interface | Full RAW decode | System Trash / Recycle Bin | "Open With" from the file manager |
|---|---|---|---|---|---|
| macOS | ✅ (arm64) | Designed for this | ✅ Yes | ✅ Yes | ✅ Yes |
| Windows | ✅ | Desktop layout, less exercised | ✅ Yes | ✅ Yes | ➖ No |
| Linux | ✅ | Desktop layout, less exercised | ✅ Yes | ➖ In-folder recycle mode | ➖ No |
| Android | ✅ | Runs; not adapted for touch | ✅ Yes | ➖ In-folder recycle mode | ➖ No |
| iOS | ✅ | Runs; not adapted for touch | ⏳ Not yet | ➖ In-folder recycle mode | ➖ No |
| Web | ✅ | Runs; not adapted | ⏳ Not yet | ➖ In-folder recycle mode | ➖ No |
Full RAW decode is ready on four platforms. macOS, Windows, Android and Linux all decode RAW files completely. On Linux the decoder isn't compiled on your machine — the build tool downloads a prebuilt, version-pinned copy automatically — but the end result is the same full-quality decode as the other three.
Only iOS and the web build lack a native decoder for now. On those two, a RAW file is viewable only when it carries an embedded JPEG preview large enough to use. Most modern cameras write such a preview, so browsing usually still works — but a RAW file without one can't be shown there yet.
System Trash is macOS and Windows; everywhere else uses recycle mode. On macOS and
Windows, deleting sends files to the real system Trash / Recycle Bin. On Linux, Android,
iOS and web, delete uses Halcyon's in-folder recycle mode instead — files move to a
.trash subfolder in the same place. It's a complete feature, not a degraded fallback:
nothing is lost, and you can recover files by hand.
"Open With" from the file manager is macOS only. Launching Halcyon by opening a photo from Finder is wired up on macOS; on the other platforms you open the folder from inside the app.
macOS builds are arm64 only, because the bundled decoder is built for Apple Silicon. An Intel Mac build would need an x86_64 decoder first.
Halcyon's declared minimum macOS version is 11. The bundled native decoder stack it ships with, however, requires macOS 15 on Apple silicon and macOS 14 on Intel at runtime — the two are separate facts, not a correction of one by the other: the application's own declared minimum is unchanged, and the higher figure describes what the bundled decoder libraries themselves need to load.
| Requirement | Version verified in this tree | Notes |
|---|---|---|
| Flutter SDK | 3.44.6 | Dart 3.12.2; pubspec.yaml declares sdk: ^3.9.0 |
| Ceyx checkout | sibling directory | Must be at ../ceyx relative to this repository |
| JDK (Android only) | Temurin 25, or Homebrew openjdk@21 / openjdk@17 |
Auto-selected by the build script in that order |
| Gradle (Android only) | 9.1.0 | Pinned by the wrapper |
| Android Gradle Plugin | 9.0.1 | Kotlin 2.3.21 |
The Ceyx sibling checkout is not optional. pubspec.yaml declares the decoder as a
relative path dependency on ../ceyx/plugin, so flutter pub get fails outright if that
directory is missing. Clone Ceyx next to Halcyon, not inside it.
Android builds additionally require compatibility mode to be left enabled —
android.newDsl=false and android.builtInKotlin=false in android/gradle.properties —
because Flutter's Gradle plugin does not yet support AGP 9's new DSL. Removing those two
lines breaks the Android build.
flutter pub get
flutter run -d macos # also: -d chrome, or a connected device id
flutter analyze # must report 0 issues
flutter test # full suitescripts/build_apps.py is the single build entry point. It builds the native decoder and
the Flutter application for every target, and it replaced the earlier per-platform shell
and PowerShell scripts, which were deleted. Do not reintroduce a per-platform script.
python3 scripts/build_apps.py # macOS release, the default target
python3 scripts/build_apps.py android --release
python3 scripts/build_apps.py web
python3 scripts/build_apps.py all # every target this host can build
python3 scripts/build_apps.py --check # toolchain check only, builds nothingTargets are macos, ios, android / android-apk / android-aab, web, windows,
linux, and all. The all target is host-filtered and skips rather than fails on
targets this host cannot build; ios is deliberately excluded from it so that an
unattended run never has to make a code-signing decision. windows and linux must be
built on their own operating system.
A native decoder library isn't trusted until it has passed the runbook S4 colour gate — a blue-sky sample check that asserts the blue channel dominates the red one, which is enough to catch a decoder whose colour matrix has been wired up wrong. Phase 0 of the build refuses to place an ungated library.
- Pass a blue-sky DNG with
--cfa-sample-dng <file>whenever a native build is due. --no-colour-gateis the loud opt-out. A run that uses it exits 2, never 0, and the resulting library is marked unvalidated.
Build outputs land under the root build/ directory. The android/, ios/, macos/,
web/, windows/ and linux/ directories are source and configuration, not build output
— they stay in version control.
scripts/build_apps.py has never driven the Windows native build end to end. Treat the
first real Windows run of the script as first contact rather than a regression test. The
underlying CMake/MSVC path itself is not unproven — an upstream commit added it and built
the shipped dng_decoder_native.dll by hand on a real Windows machine — but that build
recorded no S4 colour-gate run, so the DLL is trust-on-first-use.
flutter analyze # must report 0 issues
flutter test # full suite
flutter test test/providers/app_state_test.dart # a single file
flutter test --coverageThe suite is 45 test files under test/, organised to mirror lib/: models/,
providers/, services/, views/, perf/, plus shared fakes in test/support/. Each
test carries a 10-second timeout.
flutter analyze reporting zero issues is a gate, not a preference — work is not
considered done while it reports anything. Note that analysis covers lib/, test/ and
tool/, so a symbol rename that only sweeps lib/ and test/ will still break the gate.
AppState receives every collaborator through its constructor — the library scanner, the
status store, the file actions, the preload controller, the image-loading function and the
optional full decoder. Tests substitute fakes for all of them, so the application logic is
exercised without touching the filesystem or a platform channel. The decoder seam is the
same story: the pipeline is tested against a fake decoder rather than by loading the real
native library.
The project keeps an internal test strategy document under docs/sop/: the TC-NNN
test-case matrix, with per-case pass/fail history and the coverage priorities. It is
untracked, so a fresh clone won't have it. When you do have it, every test you add to
this repository is expected to get a matching entry in that matrix. It also records cases
that were tried and deliberately dropped — a full keyboard widget test that hung the test
runner's timers, for instance — so it's worth a read before you re-attempt one.
Two traps in this codebase have cost real time and are documented in the project's
internal architecture notes (docs/sop/memory.md in a working checkout; not present in
a fresh clone):
- A
testWidgetsbody that performs realdart:iowork must be wrapped intester.runAsync, and awaiting a real engine future insideFakeAsynchangs forever. - Tapping a
PopupMenuIteminsidetestWidgetshangs underFakeAsync.
Halcyon's own source in this repository carries no declared license — there is no
LICENSE file at the repository root and no license: field in pubspec.yaml.
What Halcyon does bundle is the set of Dart packages declared in pubspec.yaml, and —
transitively, through the sister project Ceyx — the native RAW/DNG decoding stack. Ceyx
compiles that stack, and Halcyon ships it inside its own app binary on every platform.
| Component | License | Notes |
|---|---|---|
Direct Dart dependencies (provider, path, image, exif, desktop_drop, etc.) |
Mostly MIT / BSD-3-Clause / Apache-2.0 | Per-package identification in the linked document; not an ecosystem assumption |
| Adobe DNG SDK | Adobe DNG SDK License Agreement | Transitive, via ceyx |
| LibRaw, RawSpeed3 | LGPL-2.1 (statically linked) | Transitive, via ceyx; carries a source-offer obligation — see open question below |
| Halide, pugixml, LibRaw-cmake | MIT | Transitive, via ceyx |
| libjpeg-turbo, zlib, x3f-tools | Permissive (IJG/BSD/zlib/BSD-3-Clause) | Transitive, via ceyx |
The full accounting — exact versions, per-package license text sources, and the
reasoning behind each attribution — lives in
docs/legal/THIRD_PARTY_LICENSES.md.
One item there is an open legal question rather than a settled fact, and it's flagged as such. LibRaw and RawSpeed3 are LGPL-2.1 and statically linked into the native library Halcyon ships, which obligates making source or relinkable objects available to anyone who receives the binary. It's not yet clear whether Ceyx's own source offer already covers a distributed Halcyon build, or whether Halcyon's release process needs one of its own. That needs legal review before Halcyon is distributed outside this development environment.
The project maintains a set of internal, timestamp-driven process documents under
docs/sop/ in a working checkout; they are deliberately untracked, so a fresh clone will
not contain them. This README owns the project's outward-facing description: what
Halcyon is, what it does, how it is built, and what it depends on.
Update it when a feature ships, when the architecture changes shape, or when a phase in
the internal plan document (docs/sop/plan.md in a working checkout) completes. In a
working checkout, keep it in sync with docs/sop/file_index.md (the directory map) and
docs/sop/plan.md (phase progress). Behavioural claims here carry inline
<!-- evidence: path:line --> notes; when you change a claim, re-verify its evidence
rather than carrying the old note forward.

