Skip to content

Sharper drafts and no wait before the 1:1 render while editing - #25

Merged
marrasen merged 42 commits into
gunim-cullfrom
edit-speed
Oct 22, 2026
Merged

marrasen merged 42 commits into
gunim-cullfrom
edit-speed

Conversation

@marrasen

@marrasen marrasen commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

Editing in the gunim build shows sharper drafts and starts the full-resolution render right after each edit. It builds on server PR #24, which is merged in.

What changes

  • Drafts sized to the screen. Drag drafts render through Edits.PreviewDraft at what the photo covers at fit, in device pixels, from 1024 to 2560. They were a fixed 1024. On a 2050-pixel-high window, a portrait 42 MP ARW drafts at 1367x2048 in about 150 ms. Preset-hover drafts use it too. The settle stays the exact 2048.
  • The editor holds its photo open. After a 350 ms dwell on a photo, the panel holds Edits.OpenInEditor open. That warms the full-size decode, and lets the photo's 1:1 renders keep it. Leaving the photo or closing the panel cancels the hold. A hold that fails is made again after 1 s, 5 s and 5 s, then every 30 s.
  • No wait before the 1:1 render after an edit. Once the panel's own save is confirmed, that photo's 1:1 render starts at once. The dwell still guards skimming.
  • Saves confirmed by what the server returns. SetEditParams returns the saved hash and a save number, and every edit patch carries its number. The app recognises its own saves' patches by number, and orders all edit patches by number. This replaces counting patches and guessing from hashes.
    • A failed save is reported and sent again while the panel stays on the photo. Leaving sends it one last time.
    • A reset goes through the same save path, then reloads the edit.
    • Code: cmd/marraw/saves.go, with tests in saves_test.go.
  • Test script. -edit takes ~key=value to drag a slider without letting go.

Checked

  • go build ./..., go vet ./cmd/marraw, gofmt and go test ./cmd/marraw pass.
  • Scripted run on a 42 MP ARW at 3840x2050:
    • One hold.
    • Drafts at 1367x2048.
    • The commit was confirmed by the save's answer.
    • The 1:1 render logged render from the kept decode.
    • No thumbnail RAW render ran beside the settle.
    • No reload as an outside edit.
  • Not exercised live: failed saves and their retries, patches arriving out of order, hold retries after a reconnect. Unit tests cover the patch-judging rules.

13 review rounds; the last two found nothing new.

🤖 Generated with Claude Code

Marcus Johansson and others added 30 commits October 20, 2026 11:07
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Drafts render at the size the photo covers on screen at fit, in device
  pixels, from 1024 to 2560. On a 2050-pixel-high window a portrait photo
  now drafts at 1367x2048 instead of 683x1024, in about 150 ms a frame on
  a 42 MP ARW.
- After the panel's own save is confirmed, the photo's 1:1 render starts
  at once, without the 350 ms dwell that guards skimming. A new edit
  still calls it off.
- The panel asks the backend to get the photo's full-resolution decode
  ready (Edits.WarmFull) as it opens on a photo, and calls that off when
  it closes or moves to another photo. The first edit's 1:1 render then
  skips the demosaic.
- The test script takes ~key=value in -edit to drag a slider without
  letting go, for a draft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
…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>
…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>
Review round 2 on the editor's save tracking and hold:

- A failed save no longer counts as saved. The tiles stay held back, the
  failure is reported, and the edit is sent again up to three times, two
  seconds apart. A later save that goes through clears it.
- A save's patch that never comes is no longer assumed. After the grace,
  the app asks the backend for the photo's saved edit and settles on
  that hash. A timer from an earlier round of saves can't act on a later
  one. A failed call keeps its patch owed, since the backend may have
  applied it.
- The hold on the photo is made again, after 1 s then every 5 s, if the
  call ends while the panel is still on the photo.
- Loading the same photo again with the same edit keeps the hold and its
  full-resolution decode. Only another photo, or an edit changed
  elsewhere, starts a new hold.
- A comment says why the 2048 settle may be softer than the drafts past
  2048: the 1:1 tiles take its place there.

The extra thumbnail render beside the settle is fixed on the server.

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>
The retry machinery for failed saves had grown tangled, and review found
ways it could overwrite newer edits or swallow edits made elsewhere.

- A failed own save is reported and not sent again. Its patch is given
  back, and the 1:1 tiles stay held back until a later own save of the
  photo goes through or the panel leaves it.
- A failed reset is reported and changes nothing.
- The lost-patch check always clears the round's owed patches, even when
  the photo has left the view or the backend does not answer. Only one
  grace timer runs per round.
- A saved edit hash found that way, or from the panel's own patch, is
  set in the folder's whole list too, so a re-filter keeps it.
- Holding a photo open in the editor is tried again at most three times
  after an error (1 s, 5 s, 5 s), each failure logged, then given up.

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>
SetEditParams now returns the saved edit hash, so the panel no longer
guesses which patch is its own:

- The answer to a photo's latest own save sets its edit hash and counts
  the edit as saved. Older answers only record their hash.
- A patch that comes while own saves of the photo are out is held until
  they have all answered. It is dropped if its hash is one they came
  back with, and taken as an edit made elsewhere if not. No patch counts,
  grace timers or folder re-listing remain.
- A failed save is told once and sent again after 1 s, 5 s, then every
  15 s while the panel stays on the photo. A newer own save or an edit
  made elsewhere stops that. Leaving the photo or closing the panel sends
  it one last time, so a short drop does not lose the edit.
- Reset saves the neutral edit through the same path, then reloads the
  edit from the backend, asking again while that fails. It counts as
  saved only once the panel shows it. A failed reset changes nothing.
- Opening the panel again starts a new session, so an old edit holds no
  tiles back.
- Holding the photo open in the editor is retried after 1 s, 5 s and 5 s
  when it fails quickly, then every 30 s, instead of giving up. A hold
  that lasted 30 s starts the count over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review's findings all came from remembering own save hashes: a stale
hash could swallow a real edit made elsewhere, an earlier save that landed
while the latest failed never set the photo's hash, and answers reaching
the app out of order could leave the older hash.

- Own saves are numbered. The answer of the highest-numbered save that
  answered well sets the photo's hash, in whatever order answers come and
  whether or not the panel is still on the photo.
- The edit counts as saved when the latest own save is that one and no
  save of the photo is failing.
- While own saves are out, only the latest patch is held. Once none is
  out, a patch whose hash differs from the photo's is an edit made
  elsewhere. The set of own hashes is gone.
- Reset records its save through the same helper as every own save. A
  failed reset puts back the save before it, which is then counted as
  saved if it answered well meanwhile. A reset that saved but could not
  reload the panel says so once, and keeps trying.
- The counting is a small saveLedger, with unit tests.

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

Patches and save answers reach the culler loop in any order. A late patch
of an earlier own save was compared only with the photo's current hash,
taken for an edit made elsewhere, and reloaded the panel mid-edit.

- While own saves of a photo are out, all its patches are held in order
  (up to 16), not only the latest. Once none is out, each is judged.
- A hash an own save answered with in the last 10 s is the panel's own:
  ignored, never written. Any other hash than the photo's is an edit made
  elsewhere. The answers alone set the hash.
- Photos not showing go through the same judging, so a late own patch can
  no longer write an old hash into the folder's whole list.
- A failed reset is always reported, even after leaving the photo.
- Tests for answers before patches, held patches judged in order, a fresh
  outside hash, and an old own hash coming back from elsewhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A patch held while own saves are out is judged as of when it came, not
  when the last save answers. An earlier own patch held behind a slow
  save no longer counts as an edit made elsewhere, and no longer rolls
  the photo back to the older hash. An answer that comes while the patch
  is held counts too: the patch was held for it.
- An own save's hash stays the panel's own for 60 s instead of 10 s, so a
  patch held up in a busy folder queue is still known.
- A failed reset is told only while it is the latest save or the panel
  has left the photo. A newer save carries the reset's state and says.
- A held patch of a photo not showing, judged an edit made elsewhere,
  updates the sidebar cover and the filtered view, as patched() does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
The backend now numbers every edit save: SetEditParams answers with the
hash and the number, and the photo's patch carries the number too. So the
panel knows its own patches exactly, instead of guessing from the hash
and a time window that each review tuned one way or the other.

- The ledger keeps the numbers own saves answered with, the last 32 per
  photo. A patch is the panel's own when its number is one of them.
- While own saves are out their numbers aren't known yet, so patches are
  still held in order and judged once none is out.
- A reset or paste made outside the panel has its own number, so it is
  never taken for the panel's own, even when its hash matches.
- The hash TTL, the arrival times and the hash-based check are gone.

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>
Marcus Johansson and others added 12 commits October 20, 2026 20:22
…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>
A patch numbered below the newest own save that answered well came before
that save, which overwrote it. A seed patch arriving late, or a late own
patch whose number fell out of the kept list, used to count as an edit
made elsewhere: the photo went back to the older hash and the panel
reloaded. Such patches are now ignored.

The judging rule is a ledger function (saveLedger.outside) that judgeHash
calls. New tests play saves, answers and patches through it: an outside
patch and an own one held together, own patches arriving after both
answers, stale and fresh outside patches, and an own number out of the
kept list. isOwn uses slices.Contains.

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>
The backend now numbers edit saves in the order it writes them, so a
patch numbered at or below the newest own save that answered well is
either that save's own or one it overwrote. One comparison says so, and
the list of own numbers, its bound and the own-number test are gone.
A patch numbered above it, or with no number, is an edit made elsewhere
when its hash differs from the photo's.

Numbers start from the clock at each backend start; a clock stepped back
across a restart is assumed not to happen, as a comment notes.

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>
The ledger now keeps the highest save number applied to a photo from
any source: the panel's own answers and edits made elsewhere alike. A
patch numbered at or below it is one a later save overwrote, and is
ignored. Before, only own saves raised it, so two outside edits that
aprot delivered in reverse order left the photo on the older one.

An own answer numbered before an outside edit already applied no longer
moves the photo back to the own hash.

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>
PasteEditParams and ResetEdits now return each photo's EditSaved. The
app's three batch call sites (grid preset apply, grid reset, grid paste)
are not the panel's own saves, so they ignore the result; the patches
update the view as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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