LR-EXP-WATERMARK + LR-LIB-RECENT-ADDED: graphic watermarks, Recently Added by import

- Export watermark can be a graphic (watermark {image, imageWidth}): a logo with transparency,
  scaled premultiplied to a share of the photo's width, converted into the output colour
  space, placed with the same anchor / inset / opacity as text; decoded once per file.
  Export dialog: Text / Graphic, Choose…, width; size and shadow apply to text only.
- Recently Added: everything imported in the 30 days up to the latest import, newest import
  first, grouped by import day (was the latest import day only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 16:37:26 -04:00
co-authored by Claude Opus 5.5
parent b27c3bc20f
commit deff6b6d06
8 changed files with 217 additions and 19 deletions
+8 -1
View File
@@ -40,8 +40,10 @@ pub struct Filter {
/// `dateTo` as a prefix upper bound (`2026-04` includes all of April).
pub date_from: Option<String>,
pub date_to: Option<String>,
/// Import date prefix (Recently Added).
/// Import date prefix.
pub imported: Option<String>,
/// Imported at or after this time (ISO; Recently Added).
pub imported_from: Option<String>,
/// A folder on disk: its files only (browsed ones too); `subfolders` includes everything below.
pub folder: Option<String>,
pub subfolders: bool,
@@ -216,6 +218,11 @@ impl Filter {
{
return false;
}
if let Some(d) = &self.imported_from
&& p.imported.as_str() < d.as_str()
{
return false;
}
if let Some(k) = &self.keyword
&& !p.meta.keywords.iter().any(|x| crate::keywords::is_under(x, k))
{
+118 -4
View File
@@ -213,11 +213,29 @@ pub struct Watermark {
pub color: [u8; 3],
/// Soft dark drop shadow for legibility on bright areas.
pub shadow: bool,
/// A graphic (PNG with transparency, JPEG, …) drawn instead of the text (empty = text).
pub image: String,
/// The graphic's width as a fraction of the photo's width.
pub image_width: f32,
/// The colour space the photo is in (set by the encoder; the graphic is converted into it).
#[serde(skip)]
pub target: Option<OutputSpace>,
}
impl Default for Watermark {
fn default() -> Self {
Self { text: String::new(), size: 0.035, opacity: 0.7, anchor: Anchor::BottomRight, inset: 0.025, color: [255; 3], shadow: true }
Self {
text: String::new(),
size: 0.035,
opacity: 0.7,
anchor: Anchor::BottomRight,
inset: 0.025,
color: [255; 3],
shadow: true,
image: String::new(),
image_width: 0.2,
target: None,
}
}
}
@@ -261,6 +279,10 @@ pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) {
/// every covered pixel (shadow pass first).
fn watermark_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3])) {
use ab_glyph::{Font, FontRef, PxScale, ScaleFont, point};
if !wm.image.trim().is_empty() {
logo_coverage(width, height, wm, blend_px);
return;
}
let text = wm.text.trim();
if text.is_empty() || width == 0 || height == 0 {
return;
@@ -315,6 +337,74 @@ fn watermark_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px:
}
}
/// A watermark graphic, decoded once per file (and again when the file changes).
fn watermark_logo(path: &str) -> Option<std::sync::Arc<Rgba8>> {
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
static CACHE: Mutex<Option<HashMap<String, (std::time::SystemTime, Arc<Rgba8>)>>> = Mutex::new(None);
let modified = std::fs::metadata(path).and_then(|m| m.modified()).ok()?;
let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner());
let map = c.get_or_insert_with(HashMap::new);
if let Some((m, img)) = map.get(path)
&& *m == modified
{
return Some(img.clone());
}
let bytes = std::fs::read(path).ok()?;
let img = Arc::new(lightcraft_codecs::decode(&bytes, Default::default()).ok()?.to_srgb8());
map.insert(path.to_string(), (modified, img.clone()));
Some(img)
}
/// The graphic watermark: scaled (premultiplied, so transparent edges stay clean) to
/// `image_width` of the photo, placed like the text, blended with its alpha × opacity.
fn logo_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3])) {
let Some(logo) = watermark_logo(wm.image.trim()) else {
log::warn!("watermark: can't read {}", wm.image);
return;
};
if width == 0 || height == 0 || logo.width == 0 || logo.height == 0 {
return;
}
let lw = ((wm.image_width.clamp(0.01, 1.0) * width as f32).round() as usize).clamp(1, width);
let lh = ((lw as f32 * logo.height as f32 / logo.width as f32).round() as usize).clamp(1, height);
let pre: lightcraft_raster::Image<[f32; 4]> = lightcraft_raster::Image::from_fn(logo.width, logo.height, |x, y| {
let p = logo.get(x, y);
let a = p[3] as f32 / 255.0;
[p[0] as f32 * a, p[1] as f32 * a, p[2] as f32 * a, a]
});
let scaled = lightcraft_raster::resample::resize(&pre, lw, lh, lightcraft_raster::resample::Filter::Mitchell);
let inset = wm.inset.clamp(0.0, 0.4) * width.min(height) as f32;
let (w, h, lwf, lhf) = (width as f32, height as f32, lw as f32, lh as f32);
use Anchor::*;
let ox = match wm.anchor {
TopLeft | Left | BottomLeft => inset,
Top | Center | Bottom => (w - lwf) / 2.0,
TopRight | Right | BottomRight => w - inset - lwf,
}
.max(0.0) as usize;
let oy = match wm.anchor {
TopLeft | Top | TopRight => inset,
Left | Center | Right => (h - lhf) / 2.0,
BottomLeft | Bottom | BottomRight => h - inset - lhf,
}
.max(0.0) as usize;
let space = wm.target.unwrap_or(OutputSpace::Srgb);
let op = wm.opacity.clamp(0.0, 1.0);
for y in 0..lh {
for x in 0..lw {
let [r, g, b, a] = scaled.get(x, y);
let a = a.clamp(0.0, 1.0);
let (px, py) = (ox + x, oy + y);
if a <= 1e-4 || px >= width || py >= height {
continue;
}
let c = [r, g, b].map(|v| (v / a).round().clamp(0.0, 255.0) as u8);
blend_px(px, py, a * op, srgb8_in(space, c));
}
}
}
/// Which metadata is embedded in exported files.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -430,7 +520,7 @@ impl ExportOptions {
Some(v @ Value::Object(_)) => serde_json::from_value(v.clone()).ok(),
_ => None,
}
.filter(|w: &Watermark| !w.text.trim().is_empty()),
.filter(|w: &Watermark| !w.text.trim().is_empty() || !w.image.trim().is_empty()),
color_space: s("colorSpace").and_then(OutputSpace::parse).unwrap_or(d.color_space),
bit_depth: u("bitDepth").filter(|b| matches!(b, 8 | 10 | 16 | 32)).map(|b| b as u8),
}
@@ -648,7 +738,7 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
output_sharpen(&mut img, o.sharpen, o.sharpen_amount);
let space = o.effective_space();
if let Some(wm) = &o.watermark {
let wm = Watermark { color: srgb8_in(space, wm.color), ..wm.clone() };
let wm = Watermark { color: srgb8_in(space, wm.color), target: Some(space), ..wm.clone() };
draw_watermark(&mut img, &wm);
}
let profile = icc::write_named(named_space(space));
@@ -732,7 +822,7 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>)
let mut img = img.clone();
output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount);
if let Some(wm) = &o.watermark {
let wm = Watermark { color: srgb8_in(img.space, wm.color), ..wm.clone() };
let wm = Watermark { color: srgb8_in(img.space, wm.color), target: Some(img.space), ..wm.clone() };
draw_watermark_deep(&mut img, &wm);
}
let profile = match img.samples {
@@ -1108,6 +1198,30 @@ mod tests {
assert!(ExportOptions::from_json(&serde_json::json!({"watermark": ""})).watermark.is_none());
}
#[test]
fn graphic_watermark_is_placed_scaled_and_blended() {
let dir = std::env::temp_dir().join(format!("lc-wm-logo-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let logo_path = dir.join("logo.png");
// 40×20: left half opaque red, right half fully transparent
let logo = Rgba8::from_fn(40, 20, |x, _| if x < 20 { [255, 0, 0, 255] } else { [0, 255, 0, 0] });
let png = lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap();
std::fs::write(&logo_path, png).unwrap();
let mut img = Rgba8::new(400, 300);
img.data.iter_mut().for_each(|p| *p = [0, 0, 0, 255]);
let wm = Watermark { image: logo_path.to_string_lossy().into(), image_width: 0.25, opacity: 1.0, inset: 0.0, ..Default::default() };
draw_watermark(&mut img, &wm);
// 100 px wide × 50 px high in the bottom-right corner
assert!(img.get(310, 280)[0] > 200, "opaque half: {:?}", img.get(310, 280));
assert_eq!(img.get(390, 280), [0, 0, 0, 255], "transparent half leaves the photo");
assert_eq!(img.get(310, 240), [0, 0, 0, 255], "above the logo");
assert_eq!(img.get(10, 10), [0, 0, 0, 255]);
// an image-only watermark survives the params (no text needed)
let o = ExportOptions::from_json(&json!({"watermark": {"image": logo_path.to_string_lossy(), "imageWidth": 0.1}}));
assert_eq!(o.watermark.as_ref().map(|w| w.image_width), Some(0.1));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn naming_and_params() {
let o = ExportOptions::from_json(&serde_json::json!({"format": "jpg", "quality": 150, "longEdge": 2048, "naming": "{name}-{seq}"}));
+8
View File
@@ -385,6 +385,14 @@ impl Session {
self.visible.reverse();
}
}
if self.source == LibrarySource::RecentlyAdded {
// newest import first, whatever the sort (the grid groups by import day)
let cat = &self.catalog;
self.visible.sort_by(|a, b| {
let key = |id: &PhotoId| cat.photo(*id).map(|p| p.imported.clone()).unwrap_or_default();
key(b).cmp(&key(a)).then(a.cmp(b))
});
}
if self.source == LibrarySource::Missing {
let missing: std::collections::HashSet<PhotoId> = cmd::missing::missing(self).into_iter().map(|m| m.0).collect();
self.visible.retain(|id| missing.contains(id));
+26
View File
@@ -185,3 +185,29 @@ fn missing_files_are_found_and_relinked() {
assert!(dir.join("moved/a.png").exists() && dir.join("moved/deeper/b.png").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn recently_added_covers_recent_imports_newest_first() {
let dir = std::env::temp_dir().join(format!("lc-recent-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let png = |name: &str, seed: u8| {
let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, seed, 255]);
let b = crate::export::encode_image(&img, &crate::export::ExportOptions { format: crate::export::ExportFormat::Png, ..Default::default() })
.unwrap();
std::fs::write(dir.join(name), b).unwrap();
dir.join(name).to_string_lossy().to_string()
};
let mut s = Session::new().with_fs();
// three imports: 60 days ago, 10 days ago, today
for (when, name, seed) in [("2026-08-01T09:00:00", "old.png", 1), ("2026-09-20T09:00:00", "mid.png", 2), ("2026-09-30T09:00:00", "new.png", 3)] {
let path = png(name, seed);
let w = when.to_string();
s.clock = Box::new(move || w.clone());
s.execute("library.import", &serde_json::json!({"paths": [path]})).unwrap();
}
s.execute("library.source", &serde_json::json!({"kind": "recentlyAdded"})).unwrap();
let names: Vec<String> = s.visible_cloned().iter().map(|id| s.catalog.photo(*id).unwrap().file_name.clone()).collect();
assert_eq!(names, ["new.png", "mid.png"], "the last 30 days, newest import first");
let _ = std::fs::remove_dir_all(&dir);
}
+9 -2
View File
@@ -29,14 +29,21 @@ pub struct Browse {
pub subfolders: bool,
}
/// How far back Recently Added reaches from the latest import.
pub const RECENT_DAYS: i64 = 30;
impl LibrarySource {
pub fn to_filter(&self, f: &Filter, cat: &Catalog) -> Filter {
let mut f = f.clone();
match self {
LibrarySource::All => {}
// everything imported in the 30 days up to the latest import (by import, newest first)
LibrarySource::RecentlyAdded => {
let latest = cat.photos().map(|p| p.imported.clone()).max().unwrap_or_default();
f.imported = Some(latest.get(..10).unwrap_or("").to_string());
let latest = cat.photos().filter(|p| p.in_library()).map(|p| p.imported.clone()).max().unwrap_or_default();
let from = lightcraft_catalog::stacks::iso_seconds(&latest)
.map(|s| lightcraft_catalog::dates::civil(s - RECENT_DAYS * 86_400))
.unwrap_or_else(|| latest.get(..10).unwrap_or("").to_string());
f.imported_from = Some(from);
}
LibrarySource::Album(a) => f.album = Some(*a),
LibrarySource::RecentlyDeleted => f.deleted = true,
+36 -5
View File
@@ -377,9 +377,37 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
opts.watermark = wm_on.then(|| lightcraft_engine::export::Watermark { text: "© ".into(), ..Default::default() });
}
if let Some(wm) = &mut opts.watermark {
field(ui, "Text", |ui| {
ui.add(egui::TextEdit::singleline(&mut wm.text).hint_text("© Your Name").desired_width(f32::INFINITY))
});
// text, or a graphic (a logo with transparency)
let mut graphic = !wm.image.is_empty() || ui.data(|d| d.get_temp::<bool>(egui::Id::new("wm-graphic"))).unwrap_or(false);
let before = graphic;
choices(ui, "Style", "exportWmStyle", &[(false, "Text"), (true, "Graphic")], &mut graphic);
if graphic != before {
ui.data_mut(|d| d.insert_temp(egui::Id::new("wm-graphic"), graphic));
if !graphic {
wm.image.clear();
}
}
if graphic {
field(ui, "Graphic", |ui| {
let can_pick = app.services.pick_files.is_some();
let w = ui.available_width() - if can_pick { 76.0 } else { 0.0 };
ui.add(egui::TextEdit::singleline(&mut wm.image).hint_text("logo.png").desired_width(w));
if can_pick
&& crate::widgets::text_button(ui, "exportWmChoose", "Choose…", false).clicked()
&& let Some(f) = app.services.pick_files.as_mut().and_then(|pick| pick().into_iter().next())
{
wm.image = f;
}
});
let mut width = wm.image_width as f64 * 100.0;
if num(ui, &WM_IMAGE_WIDTH, &mut width) {
wm.image_width = (width / 100.0) as f32;
}
} else {
field(ui, "Text", |ui| {
ui.add(egui::TextEdit::singleline(&mut wm.text).hint_text("© Your Name").desired_width(f32::INFINITY))
});
}
choices(
ui,
"Position",
@@ -388,14 +416,16 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
&mut wm.anchor,
);
let mut size = wm.size as f64 * 100.0;
if num(ui, &WM_SIZE, &mut size) {
if !graphic && num(ui, &WM_SIZE, &mut size) {
wm.size = (size / 100.0) as f32;
}
let mut op = wm.opacity as f64 * 100.0;
if num(ui, &WM_OPACITY, &mut op) {
wm.opacity = (op / 100.0) as f32;
}
ui.checkbox(&mut wm.shadow, "Shadow");
if !graphic {
ui.checkbox(&mut wm.shadow, "Shadow");
}
}
}
ui.add_space(4.0);
@@ -758,6 +788,7 @@ fn export_size(ui: &mut egui::Ui, full: &mut bool, r: &mut lightcraft_engine::ex
*ppi = p as u16;
}
}
const WM_IMAGE_WIDTH: ControlSpec = spec("export.watermarkImageWidth", "Width (% of photo)", 2.0, 100.0, 20.0, 1.0);
const WM_SIZE: ControlSpec = spec("export.watermarkSize", "Size (% of short edge)", 1.0, 15.0, 3.5, 0.5);
const WM_OPACITY: ControlSpec = spec("export.watermarkOpacity", "Opacity (%)", 5.0, 100.0, 70.0, 1.0);
+6 -1
View File
@@ -77,7 +77,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let runs = if app.session.source == lightcraft_engine::LibrarySource::RecentlyDeleted {
Vec::new()
} else {
app.session.catalog.date_runs(&ids, app.session.sort.key, by)
let key = if app.session.source == lightcraft_engine::LibrarySource::RecentlyAdded {
lightcraft_catalog::SortKey::ImportDate
} else {
app.session.sort.key
};
app.session.catalog.date_runs(&ids, key, by)
};
let spans: Vec<(usize, usize)> = runs.iter().map(|r| (r.start, r.count)).collect();
let lay = layout(&aspects, &spans, avail_w, target, square);
+6 -6
View File
@@ -22,7 +22,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done |
|---|---:|---:|---:|---:|---:|---:|
| A. Import (IMP) | 7 | 2 | 4 | 1 | 3/4 (75%) | 3/4 (75%) |
| B. Library management (LIB) | 18 | 1 | 6 | 2 | 9/9 (100%) | 8/9 (89%) |
| B. Library management (LIB) | 19 | 0 | 6 | 2 | 9/9 (100%) | 9/9 (100%) |
| C. Views & navigation (VIEW) | 14 | 0 | 3 | 0 | 9/9 (100%) | 4/4 (100%) |
| D. Search & filter (FILT) | 7 | 2 | 4 | 0 | 4/4 (100%) | 3/4 (75%) |
| E. Metadata (META) | 4 | 2 | 0 | 0 | 2/2 (100%) | 1/2 (50%) |
@@ -39,7 +39,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| P. Enhance (ENH) | 0 | 0 | 2 | 0 | — | — |
| Q. HDR (HDR) | 0 | 0 | 5 | 0 | — | — |
| R. Video (VID) | 0 | 0 | 5 | 1 | — | 0/2 (0%) |
| S. Export (EXP) | 13 | 1 | 4 | 0 | 7/7 (100%) | 6/7 (86%) |
| S. Export (EXP) | 14 | 0 | 4 | 0 | 7/7 (100%) | 7/7 (100%) |
| T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — |
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
| V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) |
@@ -48,9 +48,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| Y. Menus | 70 | 7 | 8 | 8 | 46/47 (98%) | 19/23 (83%) |
| Z. Keyboard shortcuts (desktop) | 65 | 10 | 5 | 1 | 49/52 (94%) | 14/23 (61%) |
| Lightroom Classic extras | 12 | 17 | 50 | 9 | — | 11/21 (52%) |
| **Total** | 317 | 56 | 127 | 37 | 193/198 (97%) | 105/144 (73%) |
| **Total** | 319 | 54 | 127 | 37 | 193/198 (97%) | 107/144 (74%) |
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **69.0%** of 500 in-scope rows — P0 98.7% of 198 · P1 84.4% of 144 · P2 17.7% of 158.
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **69.2%** of 500 in-scope rows — P0 98.7% of 198 · P1 85.1% of 144 · P2 17.7% of 158.
<!-- /parity:summary -->
## Top gaps
@@ -118,7 +118,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-LIB-ALLPHOTOS | All photos | P0 | ✅ | `cmd:library.source`, `crates/ui-egui/src/panels/left.rs` | |
| LR-LIB-RECENT-ADDED | Recently added | P1 | 🟡 | `cmd:library.source` (`recentlyAdded`) | not grouped by import session |
| LR-LIB-RECENT-ADDED | Recently added | P1 | ✅ | `cmd:library.source` (`recentlyAdded`), `crates/engine/src/view.rs` (`RECENT_DAYS`) | photos imported in the 30 days up to the latest import, newest import first, grouped by import day |
| LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with counts; a row filters by that date |
| LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum` | no drag photos onto album (LR-BEHAV-DRAGDROP), no album sort |
| LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`), `cmd:album.move` | moving is command-only (no drag, no "Move to" menu) |
@@ -416,7 +416,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-EXP-HDR | HDR output | P2 | ⬜ | | |
| LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | |
| LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | |
| LR-EXP-WATERMARK | Watermark | P1 | 🟡 | `crates/engine/src/export.rs` (`Watermark`) | text only; no graphic watermark |
| LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text or `image` + `imageWidth`), `crates/engine/src/export.rs` (`Watermark`) | text (size, colour, shadow) or a graphic with transparency (width as % of the photo, converted to the output colour space); position, inset, opacity |
| LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`: `{name}`, `{seq}`, `{date}`; `startNumber`) | free-form template rather than a list of named schemes |
| LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `subfolder`, `conflict`: unique / overwrite / skip), `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker) | folder picker on the desktop; existing files get `-2`, `-3`… by default |
| LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet |