Skip to content

Repository files navigation

Halcyon

繁體中文版本 / Traditional Chinese

Halcyon is a GPU-accelerated RAW photo triage app for Windows, macOS, and Linux — cull, mark, and rename full-resolution RAW files at JPG speed, entirely with the keyboard.

Main page

The main triage screen

Rename by EXIF dialog

The Rename by EXIF dialog

Export settings

Export settings

Performance settings

Performance settings

Theme settings

Theme settings

Shortcut settings

Shortcut settings

The name

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.

Key Features

  • JPG-speed full-resolution RAW decoding. The RAW decode pipeline is a from-scratch rewrite of libraw/Adobe DNG SDK logic in Halide, offloading the heavy compute to the idle GPU wherever possible. Measured on local storage: a 24 MP RAW costs ~56 ms of decode compute, and a batch sustains ~30–34 decodes per second — see Measured performance.
  • True cross-platform desktop. Built with Flutter for Windows, macOS, and Linux. The decode kernel is C++/Halide, accelerated via Metal or Vulkan depending on platform. Android already compiles (phone-sized UI not yet designed) and iOS support remains open.
  • Broad RAW format coverage. Ten RAW containers decode all the way to full resolution on the GPU, including dedicated Halide kernels for the notoriously slow Fuji X-Trans 6x6 mosaic and Sigma Foveon linear-RGB layouts rather than a CPU fallback; three older containers (CR2, IIQ, MRW) are browsable from their embedded previews only — see RAW format support and decode routing. Halcyon also corrects libraw's default washed-out tone curve so RAW previews track a camera's own JPEG rendering more closely.
  • Display-aware decode routing. Halcyon always prefers an existing JPG, or a RAW/DNG file's embedded full-size JPEG preview, for display. The full hardware-accelerated RAW decode path only runs when no full-size preview is embedded.
  • Tuned preload for zero-latency browsing. Flipping between RAW files at 1:1 zoom is instant, so a photographer can move through a shoot without ever waiting on a decode — and without breaking flow.
  • A culling model with exactly two marks. Star or trash — nothing else — built for fast on-location review right after a shoot. Marks and browse position are written straight into the photo folder, so closing and reopening the app resumes exactly where you left off, even after a crash.
  • Interface built around vertical space. Most viewers dock the thumbnail strip below the preview, which eats into vertical space that photos (typically 3:2 or 4:3) need most on a 16:9 screen. Halcyon keeps thumbnails and controls in a side rail instead, giving the image the full height of the window.
  • Built-in EXIF renaming. Every camera maker's default sequential filenames become a headache once you're culling. Halcyon ships a full EXIF-based rename tool, so mixed- source shoots can be renamed by a custom EXIF template without leaving the app.

Sister project: Ceyx

Halcyon depends on Ceyx as an ordinary Dart path dependency on Ceyx's plugin/ directory:

ceyx:
  path: ../ceyx/plugin

This 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.


Table of contents


The triage workflow

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;
Loading

Opening a folder

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.

RAW and JPG sibling grouping

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.

Marking, navigating and zooming

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.

Keyboard shortcuts

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.

On-screen feedback during triage

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.

Persistence, resume and batch actions

A culling session is never lost

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;
Loading

The status file

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.

Resume on reopen

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.

Durability: safe against crashes and pulled cards

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.

Renaming and marks

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.

Batch actions

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;
Loading

Copy and move starred photos

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.

Social-media export

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.

Two deletion paths

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.


Renaming by EXIF

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.

How the template works

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 a 1/250 shutter speed can't accidentally create a subfolder.

Built-in presets

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 dialog and its live preview

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;
Loading

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.

Where the EXIF comes from

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.

Applying the rename — and undoing it

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.

Limitations

  • 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.

RAW format support and decode routing

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;
Loading

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.


Measured performance

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.

The numbers

What's being measured Time Conditions
Native decoder alone, 24 MP Sony ARW, uncontended ~73 ms wall (~56 ms inside the decoder: ~19 ms RAW decompress + ~37 ms Halide pipeline) Mac Studio (28-core), local SSD, headless bench, 2026-09-08
Native decoder concurrency ceiling ~30–34 decodes/s locally (plateaus width 4–16, in-native Halide contention) Mac Studio, headless bench, 2026-09-08
Tier-2 full-size re-decode, 24 MP Sony ARW (production route) median 171–173 ms, p95 ~208–285 ms (n=20) macOS, external drive, 2026-09-03
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
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

The one number to remember

The decoder itself is fast: ~73 ms, uncontended, for a 24 MP RAW file on local storage. Of that, ~56 ms is inside the decoder itself — ~19 ms RAW decompression plus ~37 ms in the Halide processing pipeline — with the remainder being IPC and buffer hand-back. That per-image cost scales well with concurrency: 2.75× throughput going from 1 to 8 decodes in flight, reaching ~30–34 decodes/s on local storage.

Full end-to-end paint naturally costs more than the bare decoder number, once Dart-side resizing and encoding are included: the warm figures in the table above sit at 150–173 ms, and a cold first decode — nothing cached, kernels not yet warm — runs 491–601 ms.

What hasn't been measured

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.
  • 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.

Cache and memory management

Why this matters for triage

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.

Two levels of detail for the main image

The main preview is drawn in two passes, but only for the photo you're on and its immediate neighbour on each side — one step back, one step forward. That narrow band is what actually gets decoded to pixels; everything else you're holding onto (see "Keeping only what's nearby" below) sits as compressed JPEG bytes until you arrive next to it.

  • Tier one — instant. The moment a photo enters that band — including the one you just landed on — Halcyon shows it decoded to your window's resolution. This is quick, so rapid arrow-key browsing stays smooth and every nearby photo shows something immediately, without waiting for a pause.
  • 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;
Loading

The sidebar thumbnails

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.

Keeping only what's nearby

There are two windows in play here, and they are deliberately different sizes.

The retention window is the wide one: a moving range of photos around the one you're on — a few behind you and several more ahead, since browsing runs overwhelmingly forward. On the smallest machines that's 3 behind and 5 ahead; on a machine with more RAM it widens (up to 3 behind and 11 ahead) and its byte budget grows with it, so a bigger machine holds more of the folder before anything has to be dropped. Everything in this window is a compressed JPEG (the photo's own file, or a re-encoded version of a RAW) — cheap to hold, but not yet decoded to pixels.

The decode band is much narrower and fixed regardless of machine: only the selected photo and its immediate neighbour on each side. Stepping a photo into that one-either-side band decodes its compressed JPEG to pixels on the spot — fast, because JPEG decode is far cheaper than a RAW decode — while stepping it back out just drops the decoded copy and keeps the compressed one. This is what keeps the memory bill flat: the app is sized around the small number of images it actually paints pixels for, not the much larger number it merely holds in reserve to avoid re-reading the file from disk.

When the retention window's budget is exceeded, photos are dropped farthest-from-your-position first, so the one you're looking at is always the last thing to go — but "farthest" is judged over a slightly wider stretch than the decode band itself (one photo behind, three ahead), so a couple of photos just outside the decode band are treated as still-close and protected from early eviction along with it.

If macOS or Windows reports that the system as a whole is low on memory, Halcyon reacts immediately rather than waiting for you to hit its own budget: it halves how many compressed photos it's holding and drops any decoded full-resolution frames outside that immediate band, then restores the normal budget once the pressure clears.

Summary

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, retention window Compressed JPEG bytes for photos near the one you're viewing A moving window, sized to the machine's memory (3 behind / 5–11 ahead) Farthest-from-selection photo released first when the budget is exceeded
Main image, decoded pixels The window-resolution and full-resolution images actually on screen Only the selected photo and its immediate neighbour on each side Dropped the moment a photo leaves that immediate band, or under OS memory pressure

Architecture

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.

Layering and dependency direction

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)

Seams and invariants

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:115) is the single holder of tier-two scheduling — the ±1 full-resolution decode band (kFullResolutionBandRadius, prefetch_scheduler.dart:23), 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.

Repository layout

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/
│   ├── images/                 # README screenshots
│   ├── legal/                  # THIRD_PARTY_LICENSES.md
│   ├── 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.


Architecture diagrams

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.

Legend

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.


1. Module dependency and layering

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
Loading

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:

  • AppState composes its collaborators via constructor injection — lib/providers/app_state.dart:61-104.
  • ImagePreloadController depends on PhotoSource, which is the one type-aware layer — lib/services/image_pipeline/photo_source.dart:82-93.
  • DngFullDecoder/DngSizedDecoder are 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.
  • PhotoExportService also takes an optional DngFullDecoder for its own RAW export path — lib/services/library/photo_export_service.dart:38-39.
  • PhotoFileActions defaults to TrashService.trashFile — lib/services/library/photo_file_actions.dart:40.
  • AppDelegate.swift registers exactly two channels, halcyon/trash and halcyon/open_with — macos/Runner/AppDelegate.swift:23,42.
  • RenameCoordinator is constructed by AppState with readMetadata: readMetadataFor wired to ExifMetadataService.readBatch — lib/providers/app_state.dart:71-102.

2. Image pipeline data flow — file on disk to pixels on screen

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/>retention window sized to RAM<br/>(-3..+5 floor, wider per tier),<br/>distance-priority eviction"]]:::cache
  Bytes --> PayloadCache
  PixelPayloadNode --> PayloadCache

  TierOne["Tier-1 decode<br/>tierOneProviderFor()<br/>ResizeImage @ window resolution,<br/>only for the +/-1 band"]:::service
  PayloadCache --> TierOne

  Debounce{"250ms navigation-quiet<br/>debounce elapsed?<br/>(band entrants decode immediately)"}
  class Debounce decision
  PayloadCache --> Debounce

  TierTwo["Tier-2 decode<br/>fullSizeProviderFor() / RawFullResImage<br/>full-size, -1..+1 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
Loading

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 result lands in a single byte-budgeted retention cache as compressed bytes; only the selected item and its immediate neighbour on each side (the +/-1 band) are additionally decoded to pixels — window resolution (tier-1) as soon as the item enters that band, 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 at lib/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 NativeImageResult routing (bytes / needs-raw-decode / failure) — lib/services/image_pipeline/image_source_types.dart:52-118, 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, and the immediate (non-debounced) decode for an item newly entering the +/-1 band — lib/services/image_pipeline/image_preload_controller.dart:115 (the constant itself) and lib/services/image_pipeline/tier_two_scheduler.dart:412-427,449-505.
  • The +/-1 full-resolution band radius — lib/services/image_pipeline/prefetch_scheduler.dart:23.
  • The retention window (-3..+5 floor, wider on higher-RAM tiers) and distance-priority eviction (farthest from the selection is evicted first) — lib/services/image_pipeline/photo_payload_cache.dart:6-10,99-108,226-251, tier table at lib/services/image_pipeline/retention_policy.dart:73,99-116.
  • Eviction distance is ranked over a THIRD, separate band (-1 behind..+3 ahead) from the +/-1 decode band above — frozen at the pre-2026-08-30 decode band values on purpose, so narrowing the decode band did not also narrow which nearby ids are protected from early eviction — lib/services/image_pipeline/prefetch_scheduler.dart:25-39, ranking implementation at lib/services/image_pipeline/image_preload_controller.dart:1784-1797.
  • Retained slots outside the +/-1 band hold a compressed payload only, no decoded pixels (window-resolution retention was abolished) — lib/services/image_pipeline/prefetch_scheduler.dart:41-52.
  • The image cache's own decode-pixel budget is derived from the +/-1 band's working set, not a percentage of machine memory — lib/services/image_pipeline/cache_budget.dart:130-178.
  • Sidebar thumbnails use a separate cache/miss set from the detail path — lib/services/image_pipeline/image_preload_controller.dart:91,173.
  • displayProvider picks tier-2 when ready, else tier-1 — lib/providers/app_state.dart:214-215.

3. Triage action flow — keypress to mark to batch action

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
Loading

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:

  • markCurrent toggles 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.
  • processStarred filters item.status != PhotoStatus.starred and copies or renames each file — lib/services/library/photo_file_actions.dart:50-87.
  • deleteTrashed branches on recycleMode between TrashService.trashFile and recycleTrashed's same-volume rename into .trash/ — lib/providers/app_state.dart:498-538, lib/services/library/photo_file_actions.dart:89-155.
  • TrashService.trashFile is the default for PhotoFileActions and is the system-Trash bridge, registered on macOS and Windows — lib/services/library/photo_file_actions.dart:40, channel registration at macos/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 at lib/services/library/photo_status_store.dart:93-130.

Platform support

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.

Support matrix

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

What the gaps mean in practice

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.

macOS system requirements

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. The floor is measured from the libraries' own version load commands, not inferred: on Apple silicon the binding constraint is the bundled OpenMP runtime's macOS 15 requirement, while on Intel the highest requirement in the set is macOS 14.


Building from source

Prerequisites

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.

For linux and windows, the native decoder is not compiled locally by default: the build script fetches the prebuilt library (Windows ships three — the decoder plus heif.dll/libde265.dll) from a pinned Ceyx GitHub release, verifying each asset's sha256 against scripts/ceyx_release_pin.json. A fetch happens automatically when a destination library is absent or its on-disk sha256 no longer matches the pin, so both a first checkout and a locally-modified or stale cached library get replaced. --fetch-native forces a re-fetch that overwrites any committed copy; --native always compiles from source instead. If a pin entry doesn't carry a digest for every asset on a platform, the mismatch check degrades to absent-only-plus-a-warning rather than silently trusting whatever is on disk, and the warning it prints points at --native always as the way to keep a locally built library instead of the fetched one. To move the pin itself to a newer Ceyx release, run python3 scripts/build_apps.py --ceyx-release latest: it resolves the latest tag and rewrites the pin's digests, then stops without building, so the diff can be reviewed before committing.

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.

Running in development

flutter pub get
flutter run -d macos     # also: -d chrome, or a connected device id
flutter analyze          # must report 0 issues
flutter test             # full suite

Release builds

scripts/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 nothing

Targets 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.

The colour gate

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-gate is the loud opt-out. A run that uses it exits 2, never 0, and the resulting library is marked unvalidated.

Build outputs and what is source

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.

A note on the Windows path

On Windows, scripts/build_apps.py does not compile the decoder at all. It downloads the three prebuilt DLLs — dng_decoder_native.dll plus the heif.dll and libde265.dll it dynamically imports — from the Ceyx release pinned in scripts/ceyx_release_pin.json, and refuses to place any file whose SHA-256 does not match that pin. Those DLLs are produced by Ceyx's own Windows CI, which asserts the expected exported symbols and runs a functional codec-capability probe against the built DLL before publishing.

What that CI does not run is a colour gate. The runbook S4 check (decode a CFA sample, assert blue-sky B ≫ R) only guards a library compiled locally on this host; for a fetched prebuilt the integrity control is the pinned digest, not a rendered image. So the pinned Windows DLL is verified to be the exact bytes the release published and to export the capabilities Halcyon needs, but its colour output has not been gated on Windows itself.


Testing and quality gates

flutter analyze                                   # must report 0 issues
flutter test                                      # full suite
flutter test test/providers/app_state_test.dart   # a single file
flutter test --coverage

The suite is 108 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.

What makes the suite possible

AppState's constructor-injected collaborators (see Architecture) are what let this suite substitute fakes instead of touching a filesystem or platform channel; the decoder seam is tested the same way.

Test strategy documentation

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.

Known testing hazards

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 testWidgets body that performs real dart:io work must be wrapped in tester.runAsync, and awaiting a real engine future inside FakeAsync hangs forever.
  • Tapping a PopupMenuItem inside testWidgets hangs under FakeAsync.

Third-party attribution

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.


Document maintenance

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.

About

Flutter RAW/JPG photo triage tool — browse, star, batch copy/move; pure-Dart RAW preview pipeline with serial decode lane

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages