Feature/star ratings - #252
Open
zorange-CN wants to merge 5 commits into
Open
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
.xmpsidecar 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
Rate0–Rate5are ordinary rebindable hotkeys under their own heading in Settings ▸ Shortcuts, defaulting to Numpad 0–5, with 0 clearing the rating.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:
ReplaceFileWthen fails with a sharing violation.IWICMetadataQueryWriteris released before the swap as well, since it holds the frame encoder, which holds the temp file's stream, andReplaceFileWneeds the replacement to itself.Only
System.SimpleRating(0–5) is written, into the Exif tag andxmp: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 derivedSystem.Rating= 75 withoutRatingPercentorMicrosoftPhoto:Ratingever being written.A RAW is rated through its sidecar. An existing sidecar carries that photo's develop settings, so the update rewrites the
xmp:Ratingproperty 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
RatingStoreowns its cache, queue and worker; nothing is called fromCImageLoader::ReadMetadataor the thumbnail path. A read costs one 128 KB header scan, or 16 KB of a sidecar.SHGetPropertyStoreis never called on a RAW — or on anything else.ReleaseImageResourcesis never called on this path, so the viewport neither flickers nor reloads.WICDecodeMetadataCacheOnDemandplusWriteSource(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:Ratingonly, never a regeneration, and a refusal rather than a guess when the document is unfamiliar.Two places I went beyond the brief
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.xmp:Rating="-1"is read as "rejected" rather than mistaken for a star count. QuickView never writes it.Hotkeys
Per your reply, the hardcoded
1and0shortcuts for 100% and Fit are gone and ratings default to Numpad 0–5.ZandFremain 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
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.Known limitations, stated plainly
Ratingto 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.Notes
dev.