Skip to content

Feature/star ratings - #252

Open
zorange-CN wants to merge 5 commits into
justnullname:devfrom
zorange-CN:feature/star-ratings
Open

Feature/star ratings#252
zorange-CN wants to merge 5 commits into
justnullname:devfrom
zorange-CN:feature/star-ratings

Conversation

@zorange-CN

Copy link
Copy Markdown
Contributor

Star ratings (#201)

Summary

Rate a photo 0–5 stars from the numeric keypad and have it stored where the rest of the world can read it: in the file for a JPEG or TIFF, in a same-name .xmp sidecar for a RAW. Ratings are read and shown in the gallery, the compact info panel and the full info panel, and a folded RAW+JPEG pair behaves as one photo with one rating.

No new dependency: reading is a plain header scan, writing goes through WIC, and the XMP editing is a few dozen lines of string work. Nothing here is reachable from the decode pipeline.

What it does

  • Rate from the numpad. Rate0Rate5 are ordinary rebindable hotkeys under their own heading in Settings ▸ Shortcuts, defaulting to Numpad 0–5, with 0 clearing the rating.
  • Interoperates in both directions. A rating set here shows up in Explorer, in Photos and in Lightroom; a rating set by any of them shows up here.
  • Shown where culling needs it. Filled stars on the gallery thumbnail (nothing at all for an unrated photo, so an unrated folder looks exactly as before), a field in the compact panel, and a row in the full panel that shows empty stars rather than coming and going.
  • A pair is one photo. The rendered file and the RAW of a folded pair resolve to the same stars, whichever face is on screen, and rating either writes both.

The write path

Writing a JPEG or TIFF goes through the fast metadata encoder first, which patches the value into the padding the file already has and leaves every pixel byte alone. When there is no room the file is rebuilt with WriteSource(frame, NULL), which copies the compressed frame verbatim.

Two things that rebuild needs, both found the hard way:

  • The source is decoded from a copy in memory. A decoder opened on the path keeps the file open even after every interface has been released, and ReplaceFileW then fails with a sharing violation.
  • The IWICMetadataQueryWriter is released before the swap as well, since it holds the frame encoder, which holds the temp file's stream, and ReplaceFileW needs the replacement to itself.

Only System.SimpleRating (0–5) is written, into the Exif tag and xmp:Rating. I verified on this machine that this alone is enough for both ecosystems: Explorer's Rating column showed 4 stars and the property system derived System.Rating = 75 without RatingPercent or MicrosoftPhoto:Rating ever being written.

A RAW is rated through its sidecar. An existing sidecar carries that photo's develop settings, so the update rewrites the xmp:Rating property where it stands and copies everything else through byte for byte — a test asserts that rating a Lightroom-shaped sidecar changes exactly one character. A document that is not shaped as expected, or too large to read whole, is refused rather than overwritten: someone's develop settings are worth more than one rating. Only a RAW has a sidecar created for it; for a JPEG or TIFF an existing one is updated but never brought into being.

Following the constraints from this thread

  • Isolated from the decode pipeline. RatingStore owns its cache, queue and worker; nothing is called from CImageLoader::ReadMetadata or the thumbnail path. A read costs one 128 KB header scan, or 16 KB of a sidecar. SHGetPropertyStore is never called on a RAW — or on anything else.
  • Optimistic UI, debounced writes. A keypress updates memory and repaints at once; the disk write follows on its own thread behind a 400 ms debounce, so running 1-3-5 through a photo leaves one write. The debounce never costs a rating: navigating away commits it immediately, and anything pending is flushed on shutdown. ReleaseImageResources is never called on this path, so the viewport neither flickers nor reloads.
  • Lossless fallback. WICDecodeMetadataCacheOnDemand plus WriteSource(frame, NULL), as described above. Because the rebuild replaces the file, which is not safe while it is mapped for display, a write for the photo on screen waits until the user navigates away instead of retrying on a timer.
  • XMP safety. In-place update of xmp:Rating only, never a regeneration, and a refusal rather than a guess when the document is unfamiliar.
  • No watcher storms. A sidecar write creates a file in the watched folder. The notification carries no file name, so the navigator asks an injected predicate whether we wrote something a moment ago and skips the rescan when we did. It is injected rather than called directly so that the navigator, and the test binary, stay free of the rating subsystem.

Two places I went beyond the brief

  • Reading falls back to XMP. The Exif tag alone is not enough: Lightroom rates a JPEG by writing only xmp:Rating, so an Exif-only scan reports every Lightroom-rated file as unrated. The scan now tries the Exif tag first and the XMP packet second, and a test file with zero Exif tags but an XMP rating confirms the fallback is what answers.
  • Adobe's rejected mark is recognized. xmp:Rating="-1" is read as "rejected" rather than mistaken for a star count. QuickView never writes it.

Hotkeys

Per your reply, the hardcoded 1 and 0 shortcuts for 100% and Fit are gone and ratings default to Numpad 0–5. Z and F remain the zoom bindings; the help overlay and the context menu labels are updated to match, and the help overlay gains a line for the rating keys.

One dependency surfaced while removing them: the context menu's own "Actual Size" and "Fit to Screen" entries were implemented by faking those keypresses, so they silently relied on the hardcoded block. They now invoke the actions directly.

Testing

  • 40 unit tests for ratings, all green, covering both XMP serializations, both Exif byte orders, Exif-over-XMP precedence, the XMP fallback, out-of-range and malformed values, pair resolution, the surgical sidecar update (including that it preserves crs: develop settings and refuses an unfamiliar document), and a pass over every prefix of a synthetic JPEG so a truncated header read cannot read out of bounds. Test images are assembled in memory, so the suite passes on a fresh clone.
  • Manual, on a folder of files covering every path: 0–5 stars, a Lightroom-style XMP-only rating, a file whose sidecar disagrees with its in-file value, a sidecar-only rating, and a standalone RAW. Ratings round-trip through Explorer and survive a restart; clearing removes the tag; rapid input coalesces; the sidecar's develop settings come through untouched.

Known limitations, stated plainly

  • The full panel's item list is a whitelist stored in the ini, so a one-shot migration adds Rating to it once. If an older build later runs against the same ini it will strip the item again, and the flag will stop the migration from re-adding it — relevant now that the update channel can be downgraded.
  • When a JPEG has no metadata padding, the rebuild rewrites the file: the bytes change and the timestamp moves, though the pixels do not.
  • A rating that lives only in a RAW's sidecar is invisible to Explorer, which does not read sidecars. That is a platform limit, not a bug.
  • HEIC/HIF is read-only: Microsoft's rating policy covers JPEG and TIFF only. In a HIF+RAW pair the rating lives in the RAW's sidecar, so the workflow still works; a standalone HEIC reports through the OSD that the format cannot store a rating rather than swallowing the keystroke.

Notes

  • Default off is not applicable here: reading is free and writing only ever happens on an explicit keypress.
  • Rebase target: dev.

…lname#201)

First stage of star ratings: the isolated reading core, with no UI and no
writes yet.

Ratings stay off the decode pipeline as required -- RatingStore owns its
own cache, queue and worker thread, and nothing here is reachable from
CImageLoader::ReadMetadata or the thumbnail path. A read costs one 128 KB
header scan for a JPEG/TIFF, or 16 KB of a .xmp sidecar for a RAW; never
SHGetPropertyStore, never a decode.

Parsing lives in RatingMetadata as pure functions over a byte span, which
keeps it free of WIC and Win32 and therefore directly unit-testable: the
test binary links it without the imaging stack.

- JPEG: Exif IFD0 tag 0x4746 (SimpleRating) first, then the XMP packet's
  xmp:Rating. The XMP fallback matters -- Lightroom rates a JPEG by writing
  only xmp:Rating, so an Exif-only scan would report those files unrated.
- TIFF: the same IFD scan, entered directly since a TIFF carries no APP1.
- RAW: xmp:Rating from the same-name sidecar.
- Adobe's rejected mark (xmp:Rating="-1") is recognized rather than
  mistaken for a star count.
- Pair resolution follows the ruling on justnullname#201: the sidecar wins, and a
  disagreement is recorded together with the losing value so the full EXIF
  panel can show it later.

24 unit tests cover both serializations of xmp:Rating, both Exif byte
orders, Exif-over-XMP precedence, the XMP fallback, out-of-range and
malformed values, pair resolution, and a pass over every prefix of a
synthetic JPEG so that a truncated header read cannot read out of bounds.
The test images are assembled in memory: a test that depends on a file
outside the repository cannot pass on a fresh clone.

All file access goes through the wide-char API so that non-ASCII paths
open correctly.
…me#201)

Second stage of star ratings: reading is wired up and the rating is
shown, still with no way to change it.

RatingStore is initialized with the window, queued from StartNavigation
(a folded pair is resolved so either face reports the same stars), and
its WM_RATING_READY reply repaints. The full info panel gains a star
row, showing an unrated photo as empty stars so that enabling the item
does not make the row come and go from photo to photo. A disagreement
between the two files of a pair is spelled out here and only here --
"(sidecar; JPG has 2)" -- since the gallery and the viewport must stay
quiet while culling.

Four things were needed to make an asynchronous value survive this UI:

- The panel caches its rows behind a state hash, so the rating takes
  part in that hash; otherwise a rating arriving after the first build
  would never be picked up.
- The panel is drawn on the static layer, so WM_RATING_READY repaints
  static as well as dynamic.
- A read queued before the store is initialized is kept rather than
  dropped, since the first photo can be navigated to that early.
- The directory watcher fires right after a folder is opened, and the
  cache invalidation it triggers used to discard the read for the photo
  on screen; the current photo is now queued again immediately.

The panel's item list is a whitelist that lives in the ini, so on an
existing installation the new row would have been filtered out forever.
A one-shot migration adds the item once and records that it did, which
leaves a later removal by the user alone.
…stnullname#201)

Completes the display side of star ratings.

The gallery draws the filled stars on a dark chip in the bottom-left
corner, opposite the RAW badge and styled exactly like it, because a
coloured badge would shout across a wall of thumbnails. An unrated
photo gets no chip at all, so a folder nobody has rated looks exactly
as it did before. The reads ride along with the thumbnail visibility
pass -- the cache is consulted first and only a miss is queued, and
nothing is queued at all while the columns are being zoomed -- so
opening a folder never turns into a scan of every file in it.

The compact info panel gains the same field, available but off by
default: it is a single terse line with room for eight items, so an
existing layout is left as its owner arranged it. Like the gallery it
stays silent for an unrated photo, while the full panel keeps showing
empty stars.

The compact panel caches its text behind a state hash of its own, so
the rating joins that hash for the same reason it joined the full
panel's: a value that arrives after the first build would otherwise
never be shown.

Also fixes the migration added in the previous commit. It recorded
that it had run before the migrated lists were persisted, which left
them to the next SaveConfig -- so a process that was killed in between
kept the flag and lost the item, hiding the row for good. The lists are
now written together with the flag.
…ustnullname#201)

Ratings become editable. Rate0..Rate5 are ordinary rebindable hotkeys,
defaulting to the numeric keypad with 0 clearing the rating, and they
have their own heading in Settings > Shortcuts.

A keypress updates the in-memory rating and repaints immediately, while
the disk write follows on its own thread behind a 400 ms debounce, so
running 1-3-5 through a photo leaves one write rather than three. The
debounce never costs a rating: navigating away commits it at once
(the photo is no longer held open, which is the moment a rebuild
becomes safe), and anything still pending is written on shutdown
regardless of its deadline. A photo that cannot carry a rating (an
archive entry, an unsupported format, a read-only file) says so through
the OSD instead of swallowing the keystroke, and nothing here releases
image resources, so the viewport neither flickers nor reloads.

Writing goes through the fast metadata encoder first, which patches the
value into the padding the file already has and leaves every pixel byte
alone. When there is no room the file is rebuilt with WriteSource over
the decoded frame, copying the compressed data verbatim -- checked by
comparing ~11k sampled pixels before and after, with zero differences.
Clearing removes the property instead of storing a zero, so the file
goes back to carrying no rating at all.

Two details the rebuild path needs to be correct:

- The source is decoded from a copy in memory, because a decoder opened
  on the path keeps the file open even after every interface has been
  released, and the swap then fails with a sharing violation.
- The metadata query writer is released before the swap as well, since
  it holds the frame encoder, which holds the temp file's stream, and
  ReplaceFileW needs the replacement to itself.

Because the rebuild replaces the file, which is not safe while it is
mapped for display, a write for the photo on screen waits for the user
to navigate away rather than retrying on a timer, and the write thread
sleeps until the next entry is actually due instead of on a fixed tick.
It also initializes its own COM apartment, since WIC is COM and this is
not the UI thread.

As requested on justnullname#201, the hardcoded 1 and 0 shortcuts for 100% and Fit
are gone; Z and F remain as the bindings, and the help overlay and the
context menu labels are updated to match. The context menu's own zoom
entries used to be routed by faking those keypresses, so they now call
the actions directly.
Completes star ratings. A RAW keeps its rating in the same-name .xmp
sidecar that Lightroom, Bridge and Capture One read, since the native
stack cannot write a proprietary RAW.

An existing sidecar carries that photo's develop settings, so the
update is surgical: the xmp:Rating property is rewritten where it
stands and everything else is copied through byte for byte -- a test
pins this down by asserting that rating a Lightroom sidecar changes
exactly one character. Both serializations are handled, clearing
removes the property rather than storing a zero, and a document that is
not shaped as expected is refused outright, because someone's develop
settings are worth more than one rating. A sidecar too large to read
whole is refused for the same reason: writing back a truncated document
would destroy the rest of it. Only a RAW has one created for it; for a
JPEG or TIFF an existing sidecar is updated but never brought into
being, since a rating belongs inside those files.

Which file owns the sidecar mirrors how reading resolves it. Reading
probes the sidecar of whichever file is the rating's carrier, so
writing must reach the same file -- a sidecar that is read but not
written would keep overruling the rating the user just set, which is
exactly what happened before this was made symmetric.

For a folded pair the sidecar is written first and the in-file half
second. The sidecar is the side that wins when the two disagree, so
this order means a failure of the second write still leaves the user
looking at the rating they set, rather than the old one coming back; a
failure of the first leaves both files untouched.

Sidecars are written through a temp file and swapped into place, so a
failure cannot leave half a document where the original was. Since that
creates a file in the watched folder, the watcher would otherwise
rescan the directory for our own write; a notification carries no file
name, so the navigator asks an injected predicate whether we wrote
something a moment ago and skips the scan when we did. The predicate is
injected rather than called directly to keep the navigator, and the
test binary, free of the rating subsystem.
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