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:
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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).
|
//! 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
@@ -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()
|
||||||
|
|||||||
@@ -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.
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user