Skip to content

Keep the full-size decode so a 1:1 render after an edit skips the demosaic - #24

Merged
marrasen merged 19 commits into
mainfrom
full-decode-cache
Oct 22, 2026
Merged

marrasen merged 19 commits into
mainfrom
full-decode-cache

Conversation

@marrasen

@marrasen marrasen commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

A 1:1 render after an edit no longer redoes the full-resolution LibRaw decode when the edit leaves the LibRaw inputs alone. On a 42-megapixel ARW the tiles come back in 1.4 s instead of 3.8 s. This PR also adds the server calls the apps need for faster editing.

Kept full-size decode (internal/pyramid/fulldecode.go)

  • The decode of the photo open in the editor stays in memory, keyed by every LibRaw input. It goes two idle minutes after the editor leaves the photo. A 42 MP frame holds about 170 MB.
  • Crop, lens, masks, healing and every look-stage slider leave the key as it is, so their 1:1 renders skip the demosaic. Exposure, white balance and the other LibRaw controls still decode. The tiles are byte for byte the ones a fresh decode gives.
  • One rule decides what is kept: the decode the editor asked for last, through its warm-up, a 2048 settle or a save. 1:1 renders don't ask, so a share-link guest, the pre-render pass or neighbour prefetch never push it out. A warm-up of a state the editor has left is called off.
  • Renders asking for a decode already under way wait for it instead of decoding twice. A render called off after a kept decode stops between the stages after the decode.

New and changed calls

  • Edits.OpenInEditor(photoID): the editor holds this call open while it shows a photo and cancels it when it leaves. It also decodes the photo at once as the editor will start from it.
  • Edits.PreviewDraft(photoID, params, longEdge): a drag frame at any size up to 4096, always in memory. PreviewEdit treats 2048 and up as the exact cached settle.
  • Edits.PreviewExact(photoID, params, longEdge): exact like the settle, but it doesn't say the editor is on that state. Used for hovered presets.
  • SetEditParams, PasteEditParams and ResetEdits return what they saved: the edit hash and a save number. Every edit patch carries that number (PhotoPatch.EditSave). Numbers follow write order and stay unique across restarts, so a client orders edit patches by number and tells its own saves from edits made elsewhere.

Smaller changes

  • While a photo is held in the editor, its small renditions (the post-save thumbnail warm, the filmstrip) wait for the editor's 2048 settle and derive from it, instead of running a RAW decode beside it.
  • The 1:1 render normalizes a stored edit, as the hash, the preview and the next save do.
  • A save of a photo a rescan removed stays a no-op, so a batch carries on.

Tests

  • Kept-decode reuse with byte-identical tiles, warm-ups, call-offs, other photos and the base look not kept, flight failures shared, settle waits. The concurrency tests also pass with -race.
  • go test ./... passes and CI is green.

17 review rounds; the last ones found nothing new.

App PRs on top: #25 (gunim app, base gunim-cull) and #26 (Electron app, stacked on this one).

🤖 Generated with Claude Code

Marcus Johansson and others added 3 commits October 20, 2026 16:46
…osaic

Every edit used to redo the full-resolution LibRaw decode before its tiles
could render. The decode depends only on the LibRaw inputs, which crop,
lens, masks, healing and every look-stage slider leave alone.

The cache now keeps the last full-size decode in memory, keyed by photo
and LibRaw inputs, and lets it go after two idle minutes. A 1:1 render
that matches it starts from a copy. On a 42 MP ARW that is 1.4 s instead
of 3.8 s. The tiles are byte for byte the ones a fresh decode gives.

Edits.WarmFull decodes a photo under its saved edit ahead of time, so the
editor can start it as a photo opens. A render that needs the same decode
while it runs waits for it instead of decoding twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- WarmFull decodes the state the editor starts from (saved edit, seeded
  exposure or neutral), not the base look, which no edit ever reuses.
- Background and prefetch 1:1 renders no longer replace the kept decode,
  so the pre-render pass can't push out the photo being edited. Their own
  decodes skip the extra copy.
- WarmFull runs at visible priority: a 1:1 render may be waiting on it.
- A full render called off stops between the stages after the decode.
- A kept-decode hit no longer copies 170 MB for a caller that only warms.
- Nothing is kept after Close; the decode counter counts finished decodes.
- One decode-key rule (pyramid.DecodeKey) and one RGBA copy helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dless copy

- decode.JobPriority lets a running job see the priority of a caller that
  joined it, so a background 1:1 render the user comes to wait on keeps
  its decode. A running job's priority now rises on a join too.
- The kept decode is keyed by the LibRaw params themselves plus the edit's
  inputs hash, so an edit stored un-normalized never shares a decode with
  a different one. WarmFull parses a saved edit as the 1:1 render does.
- The idle expiry checks the last use instead of racing a timer reset, and
  only renders someone waits on extend it.
- A render from the kept decode copies the pixels only before the first
  stage that changes them in place; a crop, straighten or lens warp makes
  a new image anyway.
- Tests for a job joined while running, and for a render and a warm-up of
  the same decode at once, with the warm-up called off midway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Marcus Johansson and others added 2 commits October 20, 2026 17:17
Keeping "renders someone waits on" let the loupe's neighbour prefetch,
share-link guests and joined background renders push out the photo being
edited. The rule is now the photo itself: WarmFull names the photo open in
the editor, and only its 1:1 renders keep their decode. The base look's
decode is never kept, since no edit can start from it.

That makes the pool priority tracking unnecessary, so it is gone.

- WarmFull decodes the saved edit normalized, as the next edit is saved.
- keepFull stops the old timer first and ignores re-keeping the same entry.
- The render log says "render from the kept decode" when LibRaw did not run.
- Tests use a catalog of two photos to cover other photos' renders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 4 found that naming the editor's photo last-writer-wins
could leave the wrong photo named, and a slow decode of a photo already
left could still land and push out the right one.

- Edits.OpenInEditor replaces WarmFull. The editor holds the call open
  while it shows a photo and cancels it when it leaves. Only held photos
  keep their decode, so a decode finishing after the user moved on is not
  kept. A held photo's decode does not go idle.
- Of two decodes of the same photo, the one asked for later stays kept,
  so a warm-up finishing after an edit's decode cannot push it out.
- When a decode others wait on fails, they get its error instead of each
  decoding again in turn.
- Renders of other photos use the kept decode when it is exactly theirs.
- The render log names waited-on decodes separately.
- Edits.PreviewDraft renders a drag frame in memory at any size up to
  4096. PreviewEdit treats 2048 and up as the cached exact render, which
  drafts sized to a large screen must not hit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
marrasen pushed a commit that referenced this pull request Oct 20, 2026
…he last save

Follows server PR #24's new calls and fixes the review of the first pass.

- Drafts go through Edits.PreviewDraft, in memory at any size. Sized to a
  large screen they reached 2048, where PreviewEdit renders the exact,
  cached 2048 on every frame.
- The panel holds its photo open with Edits.OpenInEditor, which replaces
  WarmFull. It starts only after the user has stayed on the photo for the
  tiles' dwell, so a skim with the panel open starts no decodes. Moving to
  another photo or closing the panel lets it go at once, not in the
  GetEditParams callback. Loading the same photo again, edited elsewhere,
  holds it from scratch so the backend warms the new edit.
- The edit counts as saved only once every own save of the photo has
  been answered and its patch is in. Before, the patch of an earlier save
  marked a later one saved and started the 1:1 render of a replaced edit
  at once. A failed save sends no patch, and a patch lost to a reconnect
  stops waiting after 2 s, so nothing stays stuck.
- wantTiles and probeTiles take an explicit now flag. A probe already
  waiting for the photo starts again without the wait when one asks for
  now.
- The script's ~ is refused for choices and Auto, and shows in the edit
  log line. The flag help says a trailing ~ leaves the edit unsaved.
- The view sends its fit size in steps of 64 px, and only when it
  changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Marcus Johansson and others added 5 commits October 20, 2026 18:01
…ast ask

- A 1:1 render called off after its own decode completes again, unless
  the decode is kept: stopping early would throw away a paid-for demosaic.
- Coming back to a kept decode (an undo) makes it the latest asked for,
  so a decode of another state finishing later does not replace it.
- Waiters on a decode get its result even when it was not kept, instead
  of decoding again. Those pixels count as shared, since the decoder hands
  them to everyone who waited.
- An edit's decode of another state calls off a warm-up of the photo.
- Leaving the editor restarts the two idle minutes.
- The 1:1 render normalizes a stored edit, as the hash, the preview and
  the next save do, so an old sidecar's edit asks for the same decode as
  the warm-up.
- The flight-failure test can no longer crash if the waiter is late.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each round had added another ordering special case (sequence numbers,
bumps on hits and waits). The reviewer suggested one rule instead, and
it removes the remaining cases.

- Each held photo records the decode the editor asked for last: through
  a 1:1 render, a 2048 settle (WantFull) or the warm-up. Only that decode
  is kept. Asking for another calls off warm-ups of the old state.
- Any 1:1 render, held or not, now reuses the kept decode or a decode
  under way when it is the same one.
- A decode's pixels are copied only when someone else holds them: kept,
  or handed to a waiter.
- WarmFull does nothing for a photo the editor has left or moved past.
  Leaving the photo calls off all its warm-ups, even ones that joined.
- While the photo is held, the post-save thumbnail warm waits for the
  editor's 2048 settle and derives from it, instead of a RAW decode
  beside the settle.
- PreviewDraft reads 0 as 2048, like PreviewEdit.
- One open path in generate; the heal comment matches Normalize now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 1:1 renders no longer ask. A share-link guest, the pre-render pass or
  a render that waited on a called-off decode could rewrite what the
  editor is on. Now only the warm-up and the 2048 settle ask, and a
  render of the state the editor is on keeps its decode.
- The settle asks before its on-disk shortcut, so an undo back to a
  state rendered before is what the editor is on again.
- Settling on the base look calls off a warm-up of the old state, and
  keeps nothing.
- A new hold of a photo starts fresh, so a reopen still warms up while
  the old session lets go.
- The post-save thumbnail warm stops waiting once the editor lets go, and
  waits at most 5 s.
- A warm-up called off by design is not logged as a failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Settling the untouched starting state keeps its warm-up. The settle
  names the neutral state's own decode, the one the first edit starts
  from, not "no decode".
- A save of a photo open in the editor also says what it is on, so a
  reset that renders no settle no longer leaves the old state wanted.
- A settle called off before it gets that far no longer says anything,
  so it cannot override a newer one.
- The post-save thumbnail warm waits only for a settle of the same state
  that is actually under way, and gives up after 0.5 s when none starts.
  It used to wait 5 s after a reset, which renders no settle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t state

- SetEditParams returns the saved edit hash, the one the photo's patch
  carries. The editor can then tell its own save landed, and which tiles
  are the photo's now, without counting patches or guessing which ones
  are its own. The Electron client ignores the value.
- A save that clears the edit says the editor is on the photo's starting
  state (the seeded exposure, when measured), as GetEditParams reloads it,
  not on a plain neutral edit no one renders.
- The post-save thumbnail warm looks again when the settle it waited on
  ends called off, since another settle of the state may have taken its
  place.
- WantFull reads nil as the neutral edit; the unused "no decode" marker
  is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Marcus Johansson and others added 9 commits October 20, 2026 19:55
Found while porting to Electron:

- The filmstrip asks for a just-edited photo's 256 before the editor's
  2048 settle exists, which started a visible-priority RAW render beside
  the settle and the 1:1 render. Small renditions of a photo held open in
  the editor now wait for a settle of the same state, when one starts,
  and derive from its 2048 in ~30 ms. The settle tracking moved from the
  post-save warm into the cache so both use it.
- The post-save warm skips a state a newer save has replaced meanwhile.
- Edits.PreviewExact renders a state exactly like the settle, in memory,
  without telling the backend the editor is on it. A hovered preset must
  look like what applying it gives, but must not move the kept decode or
  call off the warm-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Every edit save gets a number. SetEditParams returns it with the hash,
  and the photo's patch carries it (PhotoPatch.EditSave), so an editor
  tells its own saves' patches from edits made elsewhere exactly. The
  gunim app had been guessing from the hash and a time window, which
  each review round tuned one way or the other.
- PreviewExact reads the editor's cached half-size decode but never
  replaces it, so hovering looks costs the next drag frame nothing.
- A visible small-rendition request waits only for a settle already
  under way, not for one that may never start (a commit while cropping
  settles the flattened frame). The post-save warm still allows half a
  second for the settle to start, and waits once, not twice.
- After the settle lands, the small rendition is derived on the fast
  pool, not queued behind RAW decodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A visible small-rendition request again allows half a second for the
  editor's settle to start. The editor may hold its settle behind a draft
  frame, and a RAW decode beside it costs far more than the wait.
- Save numbers start from the clock, so they stay unique across daemon
  restarts. A client living through one must not take another client's
  save for its own.
- Only renditions someone waits on are derived on the fast pool; prefetch
  and background work leave it to the cull. A derive-only helper replaces
  the call into EnsureFast, which fell back to the embedded JPEG for
  nothing when the 2048 could not be read.
- The post-save warm waits once, inside Ensure; a newer save of the photo
  cancels it, which is what skipping a replaced state needs.
- A hovered look's decode is kept in a slot of its own: it never replaces
  the editor's decode, and applying the look finds it there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed thumbs

- The hovered look's decode slot is freed when the editor settles on a
  state, applied or not, instead of staying pinned for the session.
- The post-save warm checks again that the state is still the photo's
  after waiting for the settle: a synced sidecar can replace it without
  a save that would cancel the warm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Save numbers were taken after the database write, so two saves of one
photo landing together could be numbered against their write order. An
editor that ignores patches numbered below its own newest save would
then keep the overwritten edit. The write and its number now happen
under one lock, for user saves and preset seeds alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
User saves and preset seeds now write, take their number and send their
patch as one step (numberedSave), so numbers follow write order. aprot
delivers to each subscriber on its own goroutine, so arrival order is not
guaranteed; clients order edit patches by number per photo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
saveEdit read the photo before the write to find its folder, and failed
when the row was gone, so a batch paste or reset stopped at the first
photo a rescan had removed. It saves such a photo as a no-op again, with
no patch, and the batch carries on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- PasteEditParams and ResetEdits return each photo's EditSaved, in the
  order of ids, as SetEditParams does for one photo. An editor then knows
  the result of every save it makes, instead of guessing a paste's or a
  reset's from the hash it was sent from.
- saveEdit treats only a missing row as a photo gone meanwhile. Any other
  failure to read the photo fails the save, instead of saving it without
  telling the grid.
- seedDefaultPreset skips a photo a rescan removed, as it did before the
  pre-write read, instead of reporting it as a failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@marrasen
marrasen merged commit d9eb994 into main Oct 22, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant