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:
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
//! in `ui/` names an operation or knows a shader exists (FR-DEV-3a).
|
||||
|
||||
mod activity;
|
||||
mod bursts;
|
||||
mod collections_ui;
|
||||
mod derived_sync;
|
||||
mod develop;
|
||||
|
||||
+87
-6
@@ -186,6 +186,26 @@ const VISIBLE_UNALIASED: &str = "shadowed_by IS NULL AND trashed_at IS NULL";
|
||||
/// restore the same frame twice.
|
||||
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
|
||||
/// The order the grid lists photographs in: when they were taken.
|
||||
///
|
||||
@@ -3814,10 +3834,11 @@ pub fn read_cells_scoped(
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let rated = filter.sql();
|
||||
let folded = uncollapsed("i");
|
||||
let sql = format!(
|
||||
"SELECT {CELL_COLUMNS}
|
||||
FROM images i
|
||||
WHERE {VISIBLE}{rated}
|
||||
WHERE {VISIBLE}{rated}{folded}
|
||||
AND i.id IN (SELECT image_id FROM collection_members
|
||||
WHERE collection_id IN ({placeholders}))
|
||||
{GRID_ORDER}
|
||||
@@ -3849,11 +3870,12 @@ fn read_cells_all(
|
||||
limit: usize,
|
||||
) -> Result<Vec<LibraryCell>, dr_catalog::CatalogError> {
|
||||
let rated = filter.sql();
|
||||
let folded = uncollapsed("i");
|
||||
let mut rows = {
|
||||
let mut stmt = catalog.connection().prepare(&format!(
|
||||
"SELECT {CELL_COLUMNS}
|
||||
FROM images i
|
||||
WHERE {VISIBLE}{rated}
|
||||
WHERE {VISIBLE}{rated}{folded}
|
||||
{GRID_ORDER}
|
||||
LIMIT ?1 OFFSET ?2"
|
||||
))?;
|
||||
@@ -3954,10 +3976,11 @@ pub fn read_ids_span(
|
||||
} else {
|
||||
let (clause, params) = scope_clause(catalog, scope)?;
|
||||
let rated = filter.sql();
|
||||
let folded = uncollapsed("i");
|
||||
(
|
||||
format!(
|
||||
"SELECT i.id FROM images i
|
||||
WHERE {VISIBLE}{rated}{clause}
|
||||
WHERE {VISIBLE}{rated}{folded}{clause}
|
||||
{GRID_ORDER}
|
||||
LIMIT ? OFFSET ?"
|
||||
),
|
||||
@@ -4086,9 +4109,10 @@ pub fn total_images_scoped(
|
||||
// Counted through `images` rather than over `collection_members` alone, so
|
||||
// `VISIBLE` applies — a trashed photograph is still a member row, and
|
||||
// counting it made the header claim images the grid would not draw.
|
||||
let folded = uncollapsed("i");
|
||||
let sql = format!(
|
||||
"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
|
||||
WHERE collection_id IN ({placeholders}))"
|
||||
);
|
||||
@@ -4259,8 +4283,9 @@ fn total_images_filtered(
|
||||
filter: &RatingFilter,
|
||||
) -> Result<usize, dr_catalog::CatalogError> {
|
||||
let rated = filter.sql();
|
||||
let folded = uncollapsed("i");
|
||||
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),
|
||||
)?;
|
||||
@@ -4806,12 +4831,16 @@ mod tests {
|
||||
#[test]
|
||||
fn the_window_read_walks_the_ordering_index() {
|
||||
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
|
||||
.connection()
|
||||
.prepare(&format!(
|
||||
"EXPLAIN QUERY PLAN
|
||||
SELECT {CELL_COLUMNS} FROM images i
|
||||
WHERE {VISIBLE}
|
||||
WHERE {VISIBLE}{folded}
|
||||
{GRID_ORDER}
|
||||
LIMIT 10 OFFSET 5"
|
||||
))
|
||||
@@ -4864,6 +4893,58 @@ mod tests {
|
||||
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> {
|
||||
let mut stmt = catalog
|
||||
.connection()
|
||||
|
||||
@@ -2214,6 +2214,11 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
// window, not one per cell.
|
||||
rating: 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();
|
||||
@@ -2246,6 +2251,9 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
.collect();
|
||||
crate::collections_ui::sync_badges(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
|
||||
// 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
|
||||
@@ -3769,6 +3777,28 @@ fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
if !offline {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -4119,10 +4149,14 @@ fn capture_time_from_catalog(catalog: &Catalog, ordinal: usize) -> Option<i64> {
|
||||
catalog
|
||||
.connection()
|
||||
.query_row(
|
||||
"SELECT captured_at FROM images
|
||||
WHERE shadowed_by IS NULL AND captured_at IS NOT NULL
|
||||
ORDER BY captured_at
|
||||
LIMIT 1 OFFSET ?1",
|
||||
&format!(
|
||||
"SELECT captured_at FROM images
|
||||
WHERE shadowed_by IS NULL AND captured_at IS NOT NULL
|
||||
AND {}
|
||||
ORDER BY captured_at
|
||||
LIMIT 1 OFFSET ?1",
|
||||
dr_catalog::bursts::not_collapsed_away("images")
|
||||
),
|
||||
[ordinal as i64],
|
||||
|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
|
||||
// a simple `<`. Shadowed rows are excluded here exactly as the grid
|
||||
// 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
|
||||
.connection()
|
||||
.query_row(
|
||||
"SELECT count(*) FROM images
|
||||
WHERE shadowed_by IS NULL
|
||||
AND captured_at IS NOT NULL
|
||||
AND captured_at < ?1",
|
||||
&format!(
|
||||
"SELECT count(*) FROM images
|
||||
WHERE shadowed_by IS NULL
|
||||
AND captured_at IS NOT NULL
|
||||
AND captured_at < ?1
|
||||
AND {}",
|
||||
dr_catalog::bursts::not_collapsed_away("images")
|
||||
),
|
||||
[when],
|
||||
|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
|
||||
// makes judging a run of frames one keystroke rather than forty.
|
||||
{
|
||||
|
||||
@@ -585,6 +585,8 @@ export component AppWindow inherits Window {
|
||||
callback library-cell-rated(int, int);
|
||||
/// The trash target was clicked on one cell, by row.
|
||||
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.
|
||||
callback library-trash-selection();
|
||||
/// 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-trashed(i) => { root.library-cell-trashed(i); }
|
||||
burst-toggled(i) => { root.library-burst-toggled(i); }
|
||||
trash-selection() => { root.library-trash-selection(); }
|
||||
// Derived from the sidebar's own selection rather than
|
||||
// mirrored in a second property: `-1` is already the sentinel
|
||||
|
||||
@@ -458,6 +458,16 @@ export struct LibraryCell {
|
||||
// 0 unflagged, 1 pick, 2 reject. Independent of the stars: rejecting a
|
||||
// four-star frame is a normal thing to do mid-cull.
|
||||
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.
|
||||
@@ -1155,6 +1165,11 @@ export component LibraryGrid inherits Rectangle {
|
||||
callback cell-clicked(int);
|
||||
/// A star was clicked on a cell: row, and the rating 0..5.
|
||||
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
|
||||
/// library. Suppresses the per-cell trash target, which would be inert
|
||||
/// 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); }
|
||||
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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user