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
+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).
mod activity;
mod bursts;
mod collections_ui;
mod derived_sync;
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.
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()
+78 -8
View File
@@ -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.
{
+3
View File
@@ -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
+84
View File
@@ -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); }
}
}
}
}
}