Merge: group the frames of one moment, and let a burst fold away

FR-CULL-5. Frames join a burst when they are adjacent in time and look
like the frame before them -- both, because time alone groups a whole
ceremony and similarity alone groups a studio setup across two days.
Adjacent pairs only, chained; there is no all-pairs step and there must
never be one.

No selection of any kind. The representative is the earliest frame, a
fact about the clock rather than a judgement about the photograph, and a
newly found burst arrives open, so the pass never takes a row off the
screen.

Verified: fmt, clippy --workspace --all-targets -D warnings, 346
dr-catalog tests, 511 dr-ui tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 22:07:05 +02:00
co-authored by Claude Opus 5
10 changed files with 2230 additions and 15 deletions
File diff suppressed because it is too large Load Diff
+2
View File
@@ -16,6 +16,7 @@
//! - [`collections`] — the collection tree and membership the UI edits //! - [`collections`] — the collection tree and membership the UI edits
//! - [`keywords`] — the keyword vocabulary and what it is assigned to //! - [`keywords`] — the keyword vocabulary and what it is assigned to
//! - [`faces`] — detected faces, the people they belong to, and who said so //! - [`faces`] — detected faces, the people they belong to, and who said so
//! - [`bursts`] — frames that are one moment, grouped so they judge as one
//! - [`jobs`] — the durable background work queue //! - [`jobs`] — the durable background work queue
//! - [`trash`] — soft delete to a folder, then permanent delete //! - [`trash`] — soft delete to a folder, then permanent delete
//! - [`merge`] / [`sync`] — cross-device merging of collections and keywords //! - [`merge`] / [`sync`] — cross-device merging of collections and keywords
@@ -33,6 +34,7 @@ use std::path::Path;
use dr_types::{Availability, ImageId}; use dr_types::{Availability, ImageId};
use rusqlite::Connection; use rusqlite::Connection;
pub mod bursts;
pub mod cache; pub mod cache;
pub mod collections; pub mod collections;
pub mod dedup; pub mod dedup;
+80 -1
View File
@@ -15,7 +15,7 @@ use rusqlite::Connection;
use crate::error::CatalogError; use crate::error::CatalogError;
/// Schema version this build writes and understands. /// Schema version this build writes and understands.
pub const SCHEMA_VERSION: i64 = 10; pub const SCHEMA_VERSION: i64 = 11;
/// Apply migrations up to [`SCHEMA_VERSION`]. /// Apply migrations up to [`SCHEMA_VERSION`].
/// ///
@@ -98,6 +98,13 @@ pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
tx.commit()?; tx.commit()?;
} }
if from < 11 {
let tx = conn.unchecked_transaction()?;
tx.execute_batch(V11)?;
tx.pragma_update(None, "user_version", 11)?;
tx.commit()?;
}
Ok(from) Ok(from)
} }
@@ -205,6 +212,11 @@ pub fn v1_for_attached(schema_name: &str) -> String {
/// creating it over there would fail on columns that are not there. Nothing is /// creating it over there would fail on columns that are not there. Nothing is
/// lost by its absence — it exists to make the *grid* page quickly, and the /// lost by its absence — it exists to make the *grid* page quickly, and the
/// grid never reads across an attachment. /// grid never reads across an attachment.
///
/// V11 is excluded on the same grounds and for the plainer reason that a merge
/// has nothing to do with it: burst grouping is rebuilt locally from local
/// signatures, and no code reads a remote catalog's `burst_*` tables. Its
/// `ALTER TABLE` would fail here anyway, being unqualifiable by the rewrite.
pub fn for_attached(schema_name: &str) -> String { pub fn for_attached(schema_name: &str) -> String {
// V10 is `ALTER TABLE`, which the textual rewrite cannot qualify, so its // V10 is `ALTER TABLE`, which the textual rewrite cannot qualify, so its
// columns are spelled out. A remote genuinely older than V10 is a real // columns are spelled out. A remote genuinely older than V10 is a real
@@ -397,6 +409,73 @@ ALTER TABLE people ADD COLUMN ignored INTEGER NOT NULL DEFAULT 0;
ALTER TABLE faces ADD COLUMN crop BLOB; ALTER TABLE faces ADD COLUMN crop BLOB;
"#; "#;
/// TRACES: FR-CULL-5
/// Burst grouping: which frames are one moment, and which one stands for it.
///
/// The reasoning behind the grouping itself is in [`crate::bursts`]; what
/// belongs here is why it is stored in three pieces rather than one.
///
/// **`images.perceptual_hash` is a column, not a table**, for the same reason
/// `content_hash` is: it is one number per image, NULL until something has had
/// the pixels in hand, and every query that wants it is already reading the
/// image row. It is local derived state — a rebuilt catalog recomputes it from
/// thumbnails — which is also why it is absent from [`for_attached`], alongside
/// the shadowing and trashing columns V2 through V5 add.
///
/// **`burst_members` is rewritten whole by every pass.** No id of its own: the
/// group is named by the image id of its earliest frame, so a burst that has not
/// changed keeps its name across a regroup and the interface can remember that
/// this one is open. There is no `bursts` table to go with it because a group
/// has no properties beyond its members — inventing a row for it would create an
/// identity that survives the grouping being rebuilt, which is precisely what
/// must not happen.
///
/// **`burst_pick` is the one thing here that is not derived**, and it is a
/// separate table so that rewriting the grouping cannot erase it. A
/// representative stored on `burst_members` would be forgotten every time a
/// frame arrived; the user would be asked the same question after every scan.
/// The same argument `people.ignored` makes in V10, one subsystem over.
///
/// **`burst_expanded` is view state in the catalog**, which is unusual enough to
/// justify. The grid is a window over an ordered query — `LIMIT n OFFSET k` —
/// so what a collapsed burst hides has to be decided by the query, or the row
/// count stops agreeing with the scrollbar and the ordinals a scrub resolves to.
/// Once SQL has to see it, this is where it lives. Nothing else reads it, and it
/// is emptied of stale groups by every pass.
const V11: &str = r#"
-- A 64-bit perceptual signature. Local derived state: NULL until something has
-- decoded the image, recomputed from thumbnails if the catalog is rebuilt, and
-- comparable only to signatures produced by the same build (`bursts`).
ALTER TABLE images ADD COLUMN perceptual_hash INTEGER;
CREATE TABLE burst_members (
-- One burst at most per image: a frame belongs to the moment it was taken
-- in, and nothing else.
image_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE,
-- The image id of the burst's earliest frame. Not a foreign key by
-- accident: the leader is itself a member, so this genuinely references
-- images(id), and cascading its deletion is right.
burst_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
-- The frame the group collapses to. Exactly one per burst.
representative INTEGER NOT NULL DEFAULT 0
);
-- Counting a burst's frames and listing them are what the grid asks for, once
-- per window; without this both are a scan of every grouped frame in the
-- library.
CREATE INDEX burst_members_burst ON burst_members(burst_id);
-- The user's own choice of representative. User data, never rewritten by a
-- grouping pass -- see the module doc above.
CREATE TABLE burst_pick (
image_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE
);
-- Bursts the grid is currently showing in full.
CREATE TABLE burst_expanded (
burst_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE
);
"#;
const V9: &str = r#" const V9: &str = r#"
-- TRACES: FR-CULL-8 -- TRACES: FR-CULL-8
-- A record that face detection has *run* on an image, distinct from what it -- A record that face detection has *run* on an image, distinct from what it
+55
View File
@@ -810,6 +810,60 @@ confidence is unavailable. It does not fall back to an untuned default dressed u
--- ---
## 10a. Bursts and near-duplicates
Specified by FR-CULL-5, implemented in `dr_catalog::bursts` (a v11 migration) with the pass that
feeds it in `dr_ui::bursts`.
A burst is a run of frames that are **adjacent in time and look like the frame before them**. Both
halves are load-bearing. Time alone groups a whole wedding ceremony, because a photographer working
steadily never leaves the gap that would end the run. Similarity alone groups a studio setup shot
across two days, which is a project rather than a moment. The bounds are two seconds and eight bits
of a 64-bit difference hash, and the reasoning for each figure is in the module.
**Two seconds, for a burst that fires ten frames in one.** `images.captured_at` is whole seconds:
EXIF's `DateTimeOriginal` has no sub-second field, and `SubSecTimeOriginal` is optional and widely
omitted. Ten frames of a burst therefore arrive sharing a timestamp, and any threshold finer than a
second is a threshold on information the catalog does not have. Where the pace really is faster than
two seconds, the similarity bound is what separates the frames.
**The signal is a perceptual hash of the thumbnail, not of the original.** `images.perceptual_hash`
is filled from the 256px thumbnails §7 already stores — vastly more resolution than a 9×8 reduction
uses — so a library that has been browsed has already paid for its signatures and no RAW is decoded
for this. The consequence is stated rather than hidden: an image with no thumbnail gets no
signature, and a frame with no signature never joins a burst. It is picked up by the next pass.
**It is a pass, not a job kind**, for exactly the reason §10.2 gives for face clustering: a burst is
a property of a *run* of frames and has no natural `subject_id`, so a per-image job would rebuild
the world once per photograph. It runs when the thumbnail sweep finishes, which is the first moment
the signatures can all be computed.
**A newly found burst arrives open.** The pass marks frames; it never takes them off the screen.
Collapsing on discovery would be tidier and would also mean a background pass removing photographs
from under someone part way through a cull. Folding a burst up is the user's act, it is remembered
(`burst_expanded`), and a burst that is already known keeps whatever state it is in — so the pass
that follows the next import does not spring open a morning's work.
**Nothing here ranks a frame.** The representative of a collapsed burst is its *earliest* frame,
which is a fact about the clock rather than a judgement about the photograph. FR-CULL-5 names the
failure this avoids — rejecting the only frame of an important moment because someone blinked — and
the only judgement in the subsystem is the user's own choice of representative, which lives in its
own table (`burst_pick`) so that rebuilding the grouping cannot erase it. Same argument as
`people.ignored` in §10.
**What the collapse costs the grid.** Which rows a collapsed burst hides has to be decided by the
query rather than by the cells, because the grid is a window (`LIMIT n OFFSET k`) and the frames it
hides are mostly not loaded. So the predicate joins `VISIBLE` in every query that lists or counts
cells, under the same discipline: present in four places of five, the header's count, the
scrollbar, the shift-click range and the scrub's ordinal stop describing the same list.
**What this does not settle.** Bursts are local: the tables ride along in the uploaded catalog
snapshot and nothing on the far side reads them, so a second device rebuilds its own grouping from
its own signatures. Making `burst_pick` cross-device is a merge question of the same shape as §8.4's
and is not answered here.
---
## 11. Requirements touched ## 11. Requirements touched
| ID | How this document addresses it | | ID | How this document addresses it |
@@ -828,6 +882,7 @@ confidence is unavailable. It does not fall back to an untuned default dressed u
| NFR-P1 | §3.1 one stat per directory, not per file | | NFR-P1 | §3.1 one stat per directory, not per file |
| NFR-P3 | §7.1 on-demand generation | | NFR-P3 | §7.1 on-demand generation |
| NFR-ARCH-2 | §6.3 priority classes shared with the GPU scheduler | | NFR-ARCH-2 | §6.3 priority classes shared with the GPU scheduler |
| FR-CULL-5 | §10a burst grouping: capture-time proximity and image similarity, collapse without selection |
| NFR-ARCH-3 | §4.3 query cancellation, §6 job cancellation | | NFR-ARCH-3 | §4.3 query cancellation, §6 job cancellation |
| NFR-RES-4 | §7.3 LRU cap, eviction order | | NFR-RES-4 | §7.3 LRU cap, eviction order |
| FR-CULL-8 | §10.1 `faces` schema, §6.1 `DetectFaces` job kind on the proxy tier | | FR-CULL-8 | §10.1 `faces` schema, §6.1 `DetectFaces` job kind on the proxy tier |
+531
View File
@@ -0,0 +1,531 @@
//! TRACES: FR-CULL-5
//! Burst grouping, as the library screen uses it.
//!
//! [`dr_catalog::bursts`] holds the grouping itself and knows nothing about
//! pixels. This is the other half: where the similarity signal comes from, and
//! how a group reaches a grid cell.
//!
//! # The signal comes out of the thumbnail store
//!
//! A perceptual signature needs pixels, and the cheapest pixels in the app are
//! the ones already sitting in `dr-thumbs`: a 256px JPEG per photograph, built
//! for the grid, shared between devices, and vastly more resolution than a 9×8
//! reduction can use. So this pass decodes thumbnails, never originals. A
//! library that has been browsed — or that has synced somebody else's shards —
//! has already paid for every signature it is about to get.
//!
//! The consequence, stated rather than hidden: **an image with no thumbnail
//! gets no signature, and a frame with no signature never joins a burst.** That
//! is self-correcting rather than permanent — the next pass finds the thumbnail
//! the sweep has since built — and it is the reason this runs when the
//! thumbnail sweep finishes rather than on a timer.
//!
//! # Why a pass and not a job
//!
//! Hashing is per-image and would make a perfectly good job kind. Grouping is
//! not: a burst is a property of a *run* of frames, so a per-image job would
//! regroup the library once per photograph. Since the two have to happen in that
//! order and the second cannot be split, both live in one pass — the same
//! argument docs/catalog.md §10.2 makes for face clustering.
//!
//! # It is never on the UI thread
//!
//! Decoding tens of thousands of thumbnails is bounded only by library size, and
//! the one thing that must not grow with library size is how long the window
//! stops answering (NFR-P9). Cancellation is dropping the receiver; a pass
//! abandoned half way leaves the signatures it did compute — they are permanent
//! and correct — and the previous grouping intact.
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::mpsc::Receiver;
use dr_catalog::bursts::{self, Rules, Signature};
use dr_catalog::Catalog;
use dr_thumbs::{ThumbSize, ThumbStore};
use dr_types::ImageId;
use slint::Model as _;
use crate::AppWindow;
/// How many signatures are written per transaction.
///
/// One transaction per image costs a WAL commit per thumbnail and turns a pass
/// over a real library into minutes of fsync; one transaction for the whole pass
/// holds a write lock for the duration and loses everything if the app closes.
/// A few hundred is the usual answer to that trade.
const WRITE_BATCH: usize = 256;
/// Progress from a grouping pass.
#[derive(Debug, Clone, PartialEq)]
pub enum BurstMessage {
/// How many images still need a signature. Sent once, before any decoding.
Started { to_hash: usize },
/// Cumulative signatures written.
Progress { hashed: usize },
/// The pass finished, and this is what the library now looks like.
Finished {
hashed: usize,
bursts: usize,
frames: usize,
},
/// It did not.
Failed(String),
}
/// Hash whatever is missing a signature, then rebuild the grouping.
///
/// Both halves run on a worker thread. The catalog is opened here rather than
/// shared with the UI's connection: SQLite connections are not `Send`, and WAL
/// is what makes a second one safe while the grid reads (NFR-R1).
pub fn spawn_grouping(catalog_path: PathBuf, thumbs_dir: PathBuf) -> Receiver<BurstMessage> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let catalog = match Catalog::open(&catalog_path) {
Ok(c) => c,
Err(e) => {
let _ = tx.send(BurstMessage::Failed(format!("cannot open catalog: {e}")));
return;
}
};
let conn = catalog.connection();
let outstanding = match bursts::images_without_signature(conn) {
Ok(v) => v,
Err(e) => {
let _ = tx.send(BurstMessage::Failed(e.to_string()));
return;
}
};
if tx
.send(BurstMessage::Started {
to_hash: outstanding.len(),
})
.is_err()
{
return;
}
// A broken or absent store costs signatures, never correctness: the
// grouping still runs over whatever is already hashed, and the images
// that missed out are picked up by the next pass.
let store = match ThumbStore::open(&thumbs_dir) {
Ok(s) => Some(s),
Err(e) => {
log::warn!("thumbnail store unavailable, not hashing: {e}");
None
}
};
let hashed = match store {
Some(store) => hash_all(conn, &store, &outstanding, &tx),
None => 0,
};
match bursts::regroup(conn, Rules::default()) {
Ok(report) => {
log::info!(
"bursts: {hashed} signature(s) added, {} group(s) over {} frame(s), \
largest {}",
report.bursts,
report.frames,
report.largest
);
let _ = tx.send(BurstMessage::Finished {
hashed,
bursts: report.bursts,
frames: report.frames,
});
}
Err(e) => {
let _ = tx.send(BurstMessage::Failed(e.to_string()));
}
}
});
rx
}
thread_local! {
/// The running pass's drain timer, and whether one is running.
///
/// Module-local rather than a pair of fields on the library controller, so
/// that everything this feature needs to run lives in this file and the
/// screen that starts it is left holding nothing. Safe as a thread local
/// because Slint's event loop is single-threaded (NFR-P9) and this is only
/// ever touched from it.
///
/// The flag is separate because stopping a timer does not drop it: a slot
/// tested for emptiness would refuse every pass after the first.
static DRAIN: RefCell<Option<slint::Timer>> = const { RefCell::new(None) };
static RUNNING: Cell<bool> = const { Cell::new(false) };
}
/// Hash what has become hashable, then rebuild the library's burst grouping.
///
/// Fired when the thumbnail sweep finishes, because that is the moment the
/// signatures can all be computed: the pass reads thumbnails, and until the
/// sweep has run most images have none. Not on a timer, and not after every
/// scan — a regroup is cheap but not free, and nothing is waiting on it.
///
/// `grouped` is called once, with the number of bursts the library now has, if
/// the pass finishes. It is where the caller reloads the grid: the cells hold
/// the same photographs they held before — a new burst arrives open — but every
/// run of frames now carries a mark it did not have a moment ago, and only a
/// reload carries it.
///
/// Deliberately silent otherwise. The sweeps around it report progress because
/// they run for tens of minutes; this is seconds, and a status line for it would
/// be a line the user must read in order to learn nothing.
pub fn start_pass(catalog_path: PathBuf, thumbs_dir: PathBuf, grouped: impl Fn(usize) + 'static) {
// A second pass would read the same rows and write the same answer over the
// first one's transactions.
if RUNNING.get() {
return;
}
RUNNING.set(true);
let rx = spawn_grouping(catalog_path, thumbs_dir);
let timer = slint::Timer::default();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(400),
move || loop {
let msg = match rx.try_recv() {
Ok(m) => m,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
finish();
return;
}
};
match msg {
BurstMessage::Started { to_hash } => {
log::info!("burst grouping: {to_hash} image(s) still to hash");
}
// Nothing on screen is showing this. Drained rather than
// ignored, because an unread channel is a worker that stalls.
BurstMessage::Progress { hashed } => {
log::debug!("burst grouping: {hashed} hashed so far");
}
BurstMessage::Finished {
hashed,
bursts,
frames,
} => {
log::info!(
"burst grouping: {hashed} signature(s) added, \
{bursts} group(s) over {frames} frame(s)"
);
finish();
grouped(bursts);
return;
}
BurstMessage::Failed(e) => {
log::warn!("burst grouping: {e}");
finish();
return;
}
}
},
);
DRAIN.with(|slot| *slot.borrow_mut() = Some(timer));
}
/// Stop draining, and let another pass start.
///
/// Stops the timer without dropping it — dropping one from inside its own
/// callback is not something to rely on — which is why the flag beside it is
/// what actually says whether a pass is running.
fn finish() {
RUNNING.set(false);
DRAIN.with(|slot| {
if let Some(timer) = slot.borrow().as_ref() {
timer.stop();
}
});
}
/// Decode each image's stored thumbnail and record its signature.
///
/// Returns how many were written. Anything without a stored thumbnail, or whose
/// blob will not decode, is simply skipped — it keeps its NULL and comes back
/// next time, which is the same treatment `spawn_thumbnails` gives a corrupt
/// blob.
fn hash_all(
conn: &rusqlite::Connection,
store: &ThumbStore,
outstanding: &[ImageId],
tx: &std::sync::mpsc::Sender<BurstMessage>,
) -> usize {
let keys = thumbnail_keys(conn);
let mut pending: Vec<(ImageId, Signature)> = Vec::with_capacity(WRITE_BATCH);
let mut written = 0usize;
for image in outstanding {
let Some(file_id) = keys.get(image).copied() else {
continue;
};
// The grid class, not the large one: 256px is already thirty times the
// detail the reduction keeps, and asking for `Large` would miss most of
// the store, which is filled at `Grid`.
let Ok(Some(thumb)) = store.get(file_id, ThumbSize::Grid) else {
continue;
};
let Ok((w, h, rgba)) = dr_thumbs::decode_rgba(&thumb.bytes) else {
continue;
};
let Some(signature) = bursts::signature_of_rgba(&rgba, w, h) else {
continue;
};
pending.push((*image, signature));
if pending.len() >= WRITE_BATCH {
written += flush(conn, &mut pending);
if tx.send(BurstMessage::Progress { hashed: written }).is_err() {
// The receiver is gone: the screen has moved on, and finishing
// the pass would be work nobody is waiting for.
return written;
}
}
}
written += flush(conn, &mut pending);
written
}
/// Write one batch of signatures, emptying `pending`.
fn flush(conn: &rusqlite::Connection, pending: &mut Vec<(ImageId, Signature)>) -> usize {
if pending.is_empty() {
return 0;
}
let n = pending.len();
let tx = match conn.unchecked_transaction() {
Ok(t) => t,
Err(e) => {
log::warn!("recording signatures: {e}");
pending.clear();
return 0;
}
};
for (image, signature) in pending.drain(..) {
if let Err(e) = bursts::set_signature(&tx, image, signature) {
log::debug!("recording signature for {}: {e}", image.0);
}
}
match tx.commit() {
Ok(()) => n,
Err(e) => {
log::warn!("committing signatures: {e}");
0
}
}
}
/// Every image's thumbnail-store key, in one query.
///
/// Read whole rather than asked per image: the table is one small row per
/// photograph, and a query per image would be tens of thousands of statements
/// to save a megabyte.
fn thumbnail_keys(conn: &rusqlite::Connection) -> HashMap<ImageId, u64> {
let mut out = HashMap::new();
let Ok(mut stmt) = conn.prepare("SELECT image_id, file_id FROM remote") else {
return out;
};
let Ok(rows) = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?))) else {
return out;
};
for (image, file) in rows.flatten() {
out.insert(ImageId(image as u64), file as u64);
}
out
}
/// Fill in the burst badge on every cell of the loaded window.
///
/// One query for the window, in the same style as the collection badges and the
/// rating counts beside it — a grid that asks the catalog a question per cell is
/// a grid that stutters under a finger.
///
/// `ids` is the window in grid order, so the row index into the model is the
/// index into it.
pub fn sync_badges(window: &AppWindow, catalog: &Catalog, ids: &[ImageId]) {
if ids.is_empty() {
return;
}
let found = match bursts::memberships(catalog.connection(), ids) {
Ok(m) => m,
Err(e) => {
log::debug!("reading burst membership: {e}");
return;
}
};
let model = window.get_library_cells();
for (row, id) in ids.iter().enumerate() {
let (count, expanded) = match found.get(id) {
Some(m) => (m.size as i32, m.expanded),
// Not in a burst at all, which is most of a library.
None => (0, false),
};
if let Some(mut cell) = model.row_data(row) {
if cell.burst_count != count || cell.burst_expanded != expanded {
cell.burst_count = count;
cell.burst_expanded = expanded;
model.set_row_data(row, cell);
}
}
}
}
/// Open or close the burst one cell belongs to.
///
/// Which direction is decided from what the catalog says the group is currently
/// doing, rather than from the cell's own `burst-expanded`. The cell is a copy
/// of that state and can be one reload behind; the table cannot.
///
/// The caller reloads the grid rather than repainting it, because collapsing
/// changes what the grid's *query* returns: the row count, the scrollbar and
/// the ordinal a scrub resolves all move together, and they can only stay in
/// step by being read again together.
///
/// Returns whether anything changed, so a click on a cell that is in no burst —
/// an entirely normal thing to happen — costs no round trip through the grid.
pub fn toggle(catalog: &Catalog, image: ImageId) -> bool {
let conn = catalog.connection();
let Ok(found) = bursts::memberships(conn, &[image]) else {
return false;
};
let Some(membership) = found.get(&image) else {
return false;
};
match bursts::set_expanded(conn, membership.burst_id, !membership.expanded) {
Ok(()) => true,
Err(e) => {
log::warn!("toggling burst {}: {e}", membership.burst_id.0);
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A catalog with two frames of one burst, and a thumbnail store holding a
/// picture for each.
///
/// `name` keeps two tests from sharing a directory, since they run in
/// parallel. Same shape as the store tests in `library`, which is also why
/// this reaches for `temp_dir` rather than a crate: nothing else here needs
/// one.
fn library(name: &str) -> (Catalog, PathBuf) {
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
[],
)
.unwrap();
for (id, at) in [(1i64, 1000i64), (2, 1001)] {
c.execute(
"INSERT INTO images(id, root_id, source_ref, captured_at, camera, added_at)
VALUES (?1, 1, ?2, ?3, 'Canon EOS R5', 0)",
rusqlite::params![id, format!("IMG_{id}.CR3"), at],
)
.unwrap();
c.execute(
"INSERT INTO remote(image_id, file_id) VALUES (?1, ?2)",
rusqlite::params![id, 100 + id],
)
.unwrap();
}
let dir = std::env::temp_dir().join(format!("dr-ui-bursts-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
{
let mut store = ThumbStore::open(&dir).unwrap();
for (id, shift) in [(1u64, 0usize), (2, 1)] {
let rgba = picture(shift);
let bytes = dr_thumbs::encode_rgba(64, 64, &rgba).unwrap();
store
.put(
100 + id,
ThumbSize::Grid,
&dr_thumbs::Thumbnail {
width: 64,
height: 64,
bytes,
},
)
.unwrap();
}
}
(cat, dir)
}
/// A blocky scene, moved sideways by `shift` pixels — one frame of a burst
/// and then the next.
fn picture(shift: usize) -> Vec<u8> {
let mut out = vec![0u8; 64 * 64 * 4];
for y in 0..64 {
for x in 0..64 {
let v = (((x + shift) / 8) * 37 + (y / 8) * 91) as u8;
let p = (y * 64 + x) * 4;
out[p] = v;
out[p + 1] = v;
out[p + 2] = v;
out[p + 3] = 255;
}
}
out
}
#[test]
fn the_pass_hashes_from_thumbnails_and_groups_what_it_hashed() {
let (cat, dir) = library("hashes");
let conn = cat.connection();
let store = ThumbStore::open(&dir).unwrap();
let outstanding = bursts::images_without_signature(conn).unwrap();
assert_eq!(outstanding.len(), 2);
let (tx, _rx) = std::sync::mpsc::channel();
let hashed = hash_all(conn, &store, &outstanding, &tx);
assert_eq!(hashed, 2, "both thumbnails should have yielded a signature");
let report = bursts::regroup(conn, Rules::default()).unwrap();
assert_eq!(report.bursts, 1, "the two frames were not grouped");
assert_eq!(report.frames, 2);
}
#[test]
fn an_image_with_no_thumbnail_keeps_its_null() {
let (cat, dir) = library("no-thumb");
let conn = cat.connection();
conn.execute(
"INSERT INTO images(id, root_id, source_ref, captured_at, added_at)
VALUES (9, 1, 'IMG_9.CR3', 1002, 0)",
[],
)
.unwrap();
let store = ThumbStore::open(&dir).unwrap();
let outstanding = bursts::images_without_signature(conn).unwrap();
let (tx, _rx) = std::sync::mpsc::channel();
assert_eq!(hash_all(conn, &store, &outstanding, &tx), 2);
// And it is still offered next time, rather than being written off.
assert_eq!(
bursts::images_without_signature(conn).unwrap(),
vec![ImageId(9)]
);
}
#[test]
fn toggling_a_cell_that_is_in_no_burst_changes_nothing() {
let (cat, _dir) = library("no-burst");
assert!(!toggle(&cat, ImageId(1)));
}
}
+1
View File
@@ -20,6 +20,7 @@
//! in `ui/` names an operation or knows a shader exists (FR-DEV-3a). //! in `ui/` names an operation or knows a shader exists (FR-DEV-3a).
mod activity; mod activity;
mod bursts;
mod collections_ui; mod collections_ui;
mod derived_sync; mod derived_sync;
mod develop; mod develop;
+87 -6
View File
@@ -186,6 +186,26 @@ const VISIBLE_UNALIASED: &str = "shadowed_by IS NULL AND trashed_at IS NULL";
/// restore the same frame twice. /// restore the same frame twice.
const TRASHED: &str = "i.shadowed_by IS NULL AND i.trashed_at IS NOT NULL"; const TRASHED: &str = "i.shadowed_by IS NULL AND i.trashed_at IS NOT NULL";
/// TRACES: FR-CULL-5
/// The clause that hides the frames a collapsed burst is standing in for.
///
/// Subject to exactly the discipline [`VISIBLE`] is under, and for the same
/// reason: the header's count, the scrollbar's size, the run a shift-click
/// resolves and the ordinal a scrub lands on are four answers about one list.
/// A burst folded away in the cells but still counted in the total would leave
/// the grid ending in rows that draw nothing, with no clue why.
///
/// The predicate itself is `dr_catalog::bursts`'s, not this file's, so the
/// interface and the pass that writes the table cannot come to disagree about
/// what collapsed means.
///
/// A function rather than a constant because it has to name the image table,
/// and the grid aliases it as `i` where the timeline's queries do not. `image`
/// is a table name from this file and never anything a user supplied.
fn uncollapsed(image: &str) -> String {
format!(" AND {}", dr_catalog::bursts::not_collapsed_away(image))
}
/// TRACES: FR-CAT-4 /// TRACES: FR-CAT-4
/// The order the grid lists photographs in: when they were taken. /// The order the grid lists photographs in: when they were taken.
/// ///
@@ -3814,10 +3834,11 @@ pub fn read_cells_scoped(
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(","); .join(",");
let rated = filter.sql(); let rated = filter.sql();
let folded = uncollapsed("i");
let sql = format!( let sql = format!(
"SELECT {CELL_COLUMNS} "SELECT {CELL_COLUMNS}
FROM images i FROM images i
WHERE {VISIBLE}{rated} WHERE {VISIBLE}{rated}{folded}
AND i.id IN (SELECT image_id FROM collection_members AND i.id IN (SELECT image_id FROM collection_members
WHERE collection_id IN ({placeholders})) WHERE collection_id IN ({placeholders}))
{GRID_ORDER} {GRID_ORDER}
@@ -3849,11 +3870,12 @@ fn read_cells_all(
limit: usize, limit: usize,
) -> Result<Vec<LibraryCell>, dr_catalog::CatalogError> { ) -> Result<Vec<LibraryCell>, dr_catalog::CatalogError> {
let rated = filter.sql(); let rated = filter.sql();
let folded = uncollapsed("i");
let mut rows = { let mut rows = {
let mut stmt = catalog.connection().prepare(&format!( let mut stmt = catalog.connection().prepare(&format!(
"SELECT {CELL_COLUMNS} "SELECT {CELL_COLUMNS}
FROM images i FROM images i
WHERE {VISIBLE}{rated} WHERE {VISIBLE}{rated}{folded}
{GRID_ORDER} {GRID_ORDER}
LIMIT ?1 OFFSET ?2" LIMIT ?1 OFFSET ?2"
))?; ))?;
@@ -3954,10 +3976,11 @@ pub fn read_ids_span(
} else { } else {
let (clause, params) = scope_clause(catalog, scope)?; let (clause, params) = scope_clause(catalog, scope)?;
let rated = filter.sql(); let rated = filter.sql();
let folded = uncollapsed("i");
( (
format!( format!(
"SELECT i.id FROM images i "SELECT i.id FROM images i
WHERE {VISIBLE}{rated}{clause} WHERE {VISIBLE}{rated}{folded}{clause}
{GRID_ORDER} {GRID_ORDER}
LIMIT ? OFFSET ?" LIMIT ? OFFSET ?"
), ),
@@ -4086,9 +4109,10 @@ pub fn total_images_scoped(
// Counted through `images` rather than over `collection_members` alone, so // Counted through `images` rather than over `collection_members` alone, so
// `VISIBLE` applies — a trashed photograph is still a member row, and // `VISIBLE` applies — a trashed photograph is still a member row, and
// counting it made the header claim images the grid would not draw. // counting it made the header claim images the grid would not draw.
let folded = uncollapsed("i");
let sql = format!( let sql = format!(
"SELECT count(DISTINCT i.id) FROM images i "SELECT count(DISTINCT i.id) FROM images i
WHERE {VISIBLE}{rated} WHERE {VISIBLE}{rated}{folded}
AND i.id IN (SELECT image_id FROM collection_members AND i.id IN (SELECT image_id FROM collection_members
WHERE collection_id IN ({placeholders}))" WHERE collection_id IN ({placeholders}))"
); );
@@ -4259,8 +4283,9 @@ fn total_images_filtered(
filter: &RatingFilter, filter: &RatingFilter,
) -> Result<usize, dr_catalog::CatalogError> { ) -> Result<usize, dr_catalog::CatalogError> {
let rated = filter.sql(); let rated = filter.sql();
let folded = uncollapsed("i");
let n: i64 = catalog.connection().query_row( let n: i64 = catalog.connection().query_row(
&format!("SELECT count(*) FROM images i WHERE {VISIBLE}{rated}"), &format!("SELECT count(*) FROM images i WHERE {VISIBLE}{rated}{folded}"),
[], [],
|r| r.get(0), |r| r.get(0),
)?; )?;
@@ -4806,12 +4831,16 @@ mod tests {
#[test] #[test]
fn the_window_read_walks_the_ordering_index() { fn the_window_read_walks_the_ordering_index() {
let catalog = with_images(20); let catalog = with_images(20);
// Including the burst clause, because the grid includes it: a
// predicate that quietly cost the ordering index would put the sort
// back and this is the only place that would notice.
let folded = uncollapsed("i");
let plan: Vec<String> = catalog let plan: Vec<String> = catalog
.connection() .connection()
.prepare(&format!( .prepare(&format!(
"EXPLAIN QUERY PLAN "EXPLAIN QUERY PLAN
SELECT {CELL_COLUMNS} FROM images i SELECT {CELL_COLUMNS} FROM images i
WHERE {VISIBLE} WHERE {VISIBLE}{folded}
{GRID_ORDER} {GRID_ORDER}
LIMIT 10 OFFSET 5" LIMIT 10 OFFSET 5"
)) ))
@@ -4864,6 +4893,58 @@ mod tests {
catalog catalog
} }
/// TRACES: FR-CULL-5
/// A folded burst takes rows out of the cells, the count and the range a
/// shift-click resolves — all three, together.
///
/// This is the test that would fail if the clause were added to four of
/// the five queries that need it. That failure has no other symptom: the
/// header claims images the grid will not draw, the scrollbar sizes itself
/// for rows that are not there, and neither number looks wrong on its own.
#[test]
fn folding_a_burst_takes_the_same_rows_out_of_every_answer() {
use dr_catalog::bursts::{self, Rules, Signature};
let catalog = with_images(4);
// Three of the four are one burst: a second apart, one signature.
let ids = image_ids(&catalog);
for (n, id) in ids.iter().enumerate() {
let hash = if n < 3 { 0xFF00 } else { 0x00FF };
catalog
.connection()
.execute(
"UPDATE images SET captured_at = ?2, camera = 'Canon EOS R5',
perceptual_hash = ?3
WHERE id = ?1",
rusqlite::params![id.0 as i64, 1_000 + n as i64, Signature(hash).to_stored()],
)
.unwrap();
}
bursts::regroup(catalog.connection(), Rules::default()).unwrap();
let filter = RatingFilter::default();
// Open, as a new burst is: nothing has been taken away yet.
assert_eq!(read_cells(&catalog, 0, 50).unwrap().len(), 4);
assert_eq!(total_images_filtered(&catalog, &filter).unwrap(), 4);
bursts::set_expanded(catalog.connection(), ids[0], false).unwrap();
let cells = read_cells(&catalog, 0, 50).unwrap();
assert_eq!(cells.len(), 2, "the folded frames are still in the cells");
assert_eq!(
total_images_filtered(&catalog, &filter).unwrap(),
cells.len(),
"the header's count and the cells disagree"
);
assert_eq!(
read_ids_span(&catalog, None, &filter, false, 0, 49)
.unwrap()
.len(),
cells.len(),
"a shift-click over the whole grid would select frames it cannot show"
);
}
fn image_ids(catalog: &Catalog) -> Vec<dr_types::ImageId> { fn image_ids(catalog: &Catalog) -> Vec<dr_types::ImageId> {
let mut stmt = catalog let mut stmt = catalog
.connection() .connection()
+78 -8
View File
@@ -2214,6 +2214,11 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
// window, not one per cell. // window, not one per cell.
rating: 0, rating: 0,
flag: 0, flag: 0,
// And by `bursts::sync_badges`, in one more query for the
// window. Zero is "not in a burst", which is what almost every
// photograph in a library is.
burst_count: 0,
burst_expanded: false,
} }
}) })
.collect(); .collect();
@@ -2246,6 +2251,9 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
.collect(); .collect();
crate::collections_ui::sync_badges(window, catalog, &ids); crate::collections_ui::sync_badges(window, catalog, &ids);
sync_ratings(window, catalog, &ids); sync_ratings(window, catalog, &ids);
// How many frames each cell stands for, where it stands for several
// (FR-CULL-5).
crate::bursts::sync_badges(window, catalog, &ids);
// The rebuilt cells all carry `selected: false`, but the selection itself // The rebuilt cells all carry `selected: false`, but the selection itself
// is a set of image ids and survives untouched. Without this the ticks // is a set of image ids and survives untouched. Without this the ticks
// vanished on every scroll — the selection was still there and still acted // vanished on every scroll — the selection was still there and still acted
@@ -3769,6 +3777,28 @@ fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
if !offline { if !offline {
start_derived_sync(&w, &ctl_cb); start_derived_sync(&w, &ctl_cb);
} }
// And now every photograph in the library has a
// thumbnail, which is the only moment all of its burst
// signatures can be computed. See `bursts::start_pass`,
// which owns the pass and everything it needs to drain
// itself; what it wants from here is the paths and a
// way to say the grid has something new to draw.
if let Some((conn, _)) = ctl_cb.session.borrow().clone() {
let weak_after = w.as_weak();
let ctl_after = ctl_cb.clone();
crate::bursts::start_pass(
library::catalog_path(&conn.account),
library::thumbs_dir(&conn.account),
move |bursts| {
let Some(w) = weak_after.upgrade() else {
return;
};
if bursts > 0 && w.get_show_library() {
schedule_reload(&w, &ctl_after);
}
},
);
}
return; return;
} }
} }
@@ -4119,10 +4149,14 @@ fn capture_time_from_catalog(catalog: &Catalog, ordinal: usize) -> Option<i64> {
catalog catalog
.connection() .connection()
.query_row( .query_row(
"SELECT captured_at FROM images &format!(
WHERE shadowed_by IS NULL AND captured_at IS NOT NULL "SELECT captured_at FROM images
ORDER BY captured_at WHERE shadowed_by IS NULL AND captured_at IS NOT NULL
LIMIT 1 OFFSET ?1", AND {}
ORDER BY captured_at
LIMIT 1 OFFSET ?1",
dr_catalog::bursts::not_collapsed_away("images")
),
[ordinal as i64], [ordinal as i64],
|r| r.get::<_, i64>(0), |r| r.get::<_, i64>(0),
) )
@@ -4153,13 +4187,21 @@ fn scrub_to(window: &AppWindow, ctl: &Rc<LibraryController>, when: i64) {
// BY), so they never precede a dated one and the predicate below stays // BY), so they never precede a dated one and the predicate below stays
// a simple `<`. Shadowed rows are excluded here exactly as the grid // a simple `<`. Shadowed rows are excluded here exactly as the grid
// excludes them. // excludes them.
//
// A burst folded up occupies one row of the grid, so it must occupy one
// row of this count as well: an ordinal taken over the unfolded library
// would overshoot by every frame hidden earlier in it.
catalog catalog
.connection() .connection()
.query_row( .query_row(
"SELECT count(*) FROM images &format!(
WHERE shadowed_by IS NULL "SELECT count(*) FROM images
AND captured_at IS NOT NULL WHERE shadowed_by IS NULL
AND captured_at < ?1", AND captured_at IS NOT NULL
AND captured_at < ?1
AND {}",
dr_catalog::bursts::not_collapsed_away("images")
),
[when], [when],
|r| r.get::<_, i64>(0), |r| r.get::<_, i64>(0),
) )
@@ -4946,6 +4988,34 @@ pub fn wire<F>(
}); });
} }
// Fold a burst up, or open it out (FR-CULL-5). A reload rather than a repaint, because
// it changes what the grid's query returns — see [`crate::bursts::toggle`].
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_burst_toggled(move |row| {
let Some(w) = weak.upgrade() else { return };
let id = ctl
.image_ids
.borrow()
.get(row as usize)
.map(|id| dr_types::ImageId(*id as u64));
let Some(id) = id else { return };
let changed = {
let borrow = ctl.catalog.borrow();
match borrow.as_ref() {
Some(catalog) => crate::bursts::toggle(catalog, id),
None => false,
}
};
if changed {
ctl.requested.borrow_mut().clear();
load_window(&w, &ctl);
}
});
}
// A rating or flag key. Applies to the whole selection, which is what // A rating or flag key. Applies to the whole selection, which is what
// makes judging a run of frames one keystroke rather than forty. // makes judging a run of frames one keystroke rather than forty.
{ {
+3
View File
@@ -585,6 +585,8 @@ export component AppWindow inherits Window {
callback library-cell-rated(int, int); callback library-cell-rated(int, int);
/// The trash target was clicked on one cell, by row. /// The trash target was clicked on one cell, by row.
callback library-cell-trashed(int); callback library-cell-trashed(int);
/// The burst mark was clicked on one cell, by row (FR-CULL-5).
callback library-burst-toggled(int);
/// Move the grid selection to the trash — the `Delete` key. /// Move the grid selection to the trash — the `Delete` key.
callback library-trash-selection(); callback library-trash-selection();
/// A judgement key was pressed, applying to the whole selection. One of /// A judgement key was pressed, applying to the whole selection. One of
@@ -1548,6 +1550,7 @@ in property <bool> panel-visible: true;
cell-rated(i, n) => { root.library-cell-rated(i, n); } cell-rated(i, n) => { root.library-cell-rated(i, n); }
cell-trashed(i) => { root.library-cell-trashed(i); } cell-trashed(i) => { root.library-cell-trashed(i); }
burst-toggled(i) => { root.library-burst-toggled(i); }
trash-selection() => { root.library-trash-selection(); } trash-selection() => { root.library-trash-selection(); }
// Derived from the sidebar's own selection rather than // Derived from the sidebar's own selection rather than
// mirrored in a second property: `-1` is already the sentinel // mirrored in a second property: `-1` is already the sentinel
+84
View File
@@ -458,6 +458,16 @@ export struct LibraryCell {
// 0 unflagged, 1 pick, 2 reject. Independent of the stars: rejecting a // 0 unflagged, 1 pick, 2 reject. Independent of the stars: rejecting a
// four-star frame is a normal thing to do mid-cull. // four-star frame is a normal thing to do mid-cull.
flag: int, flag: int,
// Frames in the burst this cell belongs to (FR-CULL-5), itself included; 0 where it
// belongs to none, which is most of a library. A burst that is collapsed
// draws only its representative, so on that cell this is the count of what
// is hidden behind it — the reason it is shown at all.
burst-count: int,
// Whether the group is currently open. Drawn differently rather than
// hidden: a burst the user has expanded is the one thing on screen that
// needs a way back, and a control that disappears once used is a control
// nobody finds twice.
burst-expanded: bool,
} }
// The photo roll: the grid's loaded window along the foot of the develop view. // The photo roll: the grid's loaded window along the foot of the develop view.
@@ -1155,6 +1165,11 @@ export component LibraryGrid inherits Rectangle {
callback cell-clicked(int); callback cell-clicked(int);
/// A star was clicked on a cell: row, and the rating 0..5. /// A star was clicked on a cell: row, and the rating 0..5.
callback cell-rated(int, int); callback cell-rated(int, int);
/// The burst mark on a cell was clicked (FR-CULL-5): open the group, or fold it back
/// up. Which of the two is decided in Rust, from what the catalog says the
/// group is currently doing, so the mark cannot get out of step with the
/// query that actually hides the frames.
callback burst-toggled(int);
/// Whether the grid is currently listing the trash rather than the /// Whether the grid is currently listing the trash rather than the
/// library. Suppresses the per-cell trash target, which would be inert /// library. Suppresses the per-cell trash target, which would be inert
/// there — `plan_trash` skips an already-trashed image — and offering a /// there — `plan_trash` skips an already-trashed image — and offering a
@@ -2925,6 +2940,75 @@ export component LibraryGrid inherits Rectangle {
rate(n) => { root.cell-rated(i, n); } rate(n) => { root.cell-rated(i, n); }
trash() => { root.cell-trashed(i); } trash() => { root.cell-trashed(i); }
} }
// The burst mark (FR-CULL-5): how many frames this
// moment holds,
// and the way in and out of them.
//
// A child of `cell-touch` for exactly the reason the
// stars above are: a click here must not also reach
// `cell-clicked` and throw the user into develop, and
// children are hit-tested before the element they sit
// in. Unlike the stars it is never hidden — a collapsed
// burst is standing in for frames that are not on
// screen, and there has to be something visible saying
// so whether or not a pointer is anywhere near.
//
// Bottom left, clear of the centred star strip and of
// both top corners, which the flag and the collection
// badge already have.
if cell.burst-count > 1: Rectangle {
x: 6px;
y: parent.height - self.height - 26px;
width: 30px;
height: 18px;
// The pile behind the top card, drawn only while the
// group is folded up. It is the whole of the "there
// is more than one of these" cue; once the burst is
// open the frames themselves say it.
Rectangle {
x: 3px;
y: -3px;
width: parent.width - 3px;
height: parent.height;
visible: !cell.burst-expanded;
background: Theme.surface;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: Theme.rule;
}
Rectangle {
width: 100%;
height: 100%;
background: cell.burst-expanded ? Theme.selected
: Theme.surface;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: cell.burst-expanded ? Theme.selected-ring
: Theme.rule;
Text {
width: 100%;
height: 100%;
horizontal-alignment: center;
vertical-alignment: center;
// No "of": the number is the size of the
// group, and a cell this small cannot
// afford a word to say so.
text: cell.burst-count;
color: Theme.ink;
font-size: 10px;
font-weight: 700;
}
}
TouchArea {
mouse-cursor: pointer;
clicked => { root.burst-toggled(i); }
}
}
} }
} }
} }