mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
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:
co-authored by
Claude Opus 5.5
parent
b27c3bc20f
commit
deff6b6d06
@@ -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
@@ -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}"}));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user