M1.3: import (recursive folders, content-hash duplicates, add vs copy)
- engine::import: expand files/folders recursively (sorted; hidden entries
and the library folder skipped), skip known paths, probe + hash in
parallel (std:🧵:scope), skip duplicates by content hash (against
the library and within the batch), add in place or copy into
<library>/Originals/YYYY/YYYY-MM-DD/ with unique names; one undoable op.
- library.import {paths, mode: add|copy, album?} returns
{imported, duplicates[{path, existing, reason}], failed, scanned}.
- probe_bytes records the content hash (SipHash-1-3 128) on every photo.
- system clock for import/edit times on native (Session::with_system_clock).
- app: command-line folders are expanded by the engine (re-importing the
same folder is a no-op); example gen_photos writes procedural test JPEGs
(never committed).
- Measured (200 procedural 2000 px JPEGs, M-series): import 2.25 s; reopen:
library load 1.7 ms, control ready 0.12 s, first thumbnail 0.17 s,
visible grid drawn 0.48 s (thumbnails from the disk cache).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
bcecab7d84
commit
759d3c7f8a
|
|
@ -222,7 +222,7 @@ LightCraft is young and moving fast — see the [roadmap](ROADMAP.md) for estima
|
|||
```sh
|
||||
git clone https://github.com/storytold/lightcraft && cd lightcraft
|
||||
cargo run --release -p lightcraft # opens your library (~/Pictures/LightCraft Library; a new one starts with demo photos)
|
||||
cargo run --release -p lightcraft -- ~/Pictures/trip # import your photos (folders are scanned)
|
||||
cargo run --release -p lightcraft -- ~/Pictures/trip # import your photos (folders are scanned, duplicates skipped)
|
||||
cargo run --release -p lightcraft -- --memory # a throwaway in-memory demo session (writes nothing)
|
||||
cargo run --release -p lightcraft -- --control 7980 # with the automation channel
|
||||
cargo xtask ci # fmt, clippy, tests, layering, wasm checks
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
//! Generate a folder of procedural test photos (JPEG) for import/startup benchmarks.
|
||||
//!
|
||||
//! `cargo run --release -p lightcraft --example gen_photos -- OUT_DIR [COUNT=200] [LONG_EDGE=2000]`
|
||||
//!
|
||||
//! Each photo is a `lightcraft-scenes` demo scene rendered through the pipeline with a varied
|
||||
//! look (exposure, temperature, vibrance), so every file has distinct bytes. The output is our own
|
||||
//! procedural content; keep it out of the repository (write to a scratch or gitignored folder).
|
||||
|
||||
use lightcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let out = std::path::PathBuf::from(args.next().expect("usage: gen_photos OUT_DIR [COUNT] [LONG_EDGE]"));
|
||||
let count: usize = args.next().and_then(|c| c.parse().ok()).unwrap_or(200);
|
||||
let edge: usize = args.next().and_then(|c| c.parse().ok()).unwrap_or(2000);
|
||||
std::fs::create_dir_all(&out).expect("create output folder");
|
||||
let mut s = Session::with_demo();
|
||||
let ids = s.visible_cloned();
|
||||
let t0 = std::time::Instant::now();
|
||||
for i in 0..count {
|
||||
let id = ids[i % ids.len()];
|
||||
let round = i / ids.len();
|
||||
s.execute("library.select", &json!({"ids": [id.0]})).expect("select");
|
||||
s.execute("develop.reset", &json!({})).expect("reset");
|
||||
let v = json!({
|
||||
"light.exposure": (round as f64 * 0.37).sin() * 0.8,
|
||||
"wb.temp": 6500.0 + (round as f64 * 1.3).cos() * 1500.0,
|
||||
"color.vibrance": (round * 13 % 50) as f64,
|
||||
});
|
||||
let _ = s.execute("develop.set", &json!({"values": v}));
|
||||
let img = s.render_now(id, edge, edge).expect("render");
|
||||
let bytes = lightcraft_codecs::encode_jpeg(
|
||||
&lightcraft_codecs::EncodeImage::rgba8(&img.image),
|
||||
88,
|
||||
lightcraft_codecs::ChromaSubsampling::S420,
|
||||
&lightcraft_codecs::EncodeMeta::default(),
|
||||
)
|
||||
.expect("encode");
|
||||
let dir = out.join(format!("roll-{:02}", i / 50 + 1));
|
||||
std::fs::create_dir_all(&dir).expect("mkdir");
|
||||
std::fs::write(dir.join(format!("LCG{:05}.jpg", i + 1)), bytes).expect("write");
|
||||
}
|
||||
eprintln!("wrote {count} photos ({edge} px) to {} in {:.1} s", out.display(), t0.elapsed().as_secs_f64());
|
||||
}
|
||||
|
|
@ -92,29 +92,9 @@ fn services() -> Services {
|
|||
}
|
||||
}
|
||||
|
||||
/// Expand folders (recursively) into photo files.
|
||||
fn collect(path: &std::path::Path, out: &mut Vec<String>) {
|
||||
if path.is_dir() {
|
||||
if let Ok(rd) = std::fs::read_dir(path) {
|
||||
let mut v: Vec<_> = rd.flatten().map(|e| e.path()).collect();
|
||||
v.sort();
|
||||
for p in v {
|
||||
collect(&p, out);
|
||||
}
|
||||
}
|
||||
} else if path.extension().is_some_and(|e| {
|
||||
matches!(
|
||||
e.to_string_lossy().to_lowercase().as_str(),
|
||||
"jpg" | "jpeg" | "png" | "tif" | "tiff" | "webp" | "dng" | "cr2" | "nef" | "arw" | "psd" | "jxl" | "gif" | "bmp"
|
||||
)
|
||||
}) {
|
||||
out.push(path.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// The persistent library session (or an in-memory one with `--memory` / if the library can't open).
|
||||
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> Session {
|
||||
let fallback = || if seed_demo { Session::with_demo() } else { Session::new() }.with_fs();
|
||||
let fallback = || if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_system_clock();
|
||||
if in_memory {
|
||||
return fallback();
|
||||
}
|
||||
|
|
@ -123,7 +103,7 @@ fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: boo
|
|||
return fallback();
|
||||
};
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut s = Session::new().with_fs();
|
||||
let mut s = Session::new().with_fs().with_system_clock();
|
||||
match s.open_library(&dir, seed_demo) {
|
||||
Ok(r) => {
|
||||
let (replayed, torn) = (r.replayed, r.torn_bytes);
|
||||
|
|
@ -160,7 +140,7 @@ fn main() -> eframe::Result {
|
|||
println!("lightcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
_ => collect(std::path::Path::new(&a), &mut files),
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
let options = eframe::NativeOptions {
|
||||
|
|
|
|||
|
|
@ -364,24 +364,39 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
ok()
|
||||
}),
|
||||
// ---- import
|
||||
cmd!("library.import", "Add Photos…", ["File"], Some("Cmd+Shift+I"), "{paths: [path], album?: albumId}", always, |s, p| {
|
||||
let paths: Vec<String> = p
|
||||
.get("paths")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
|
||||
.unwrap_or_default();
|
||||
if paths.is_empty() {
|
||||
return Err(bad("library.import", "no paths"));
|
||||
cmd!(
|
||||
"library.import",
|
||||
"Add Photos…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+I"),
|
||||
"{paths: [file or folder (recursive)], mode?: add|copy (copy into the library's Originals/), album?: albumId} → {imported, duplicates, failed}",
|
||||
always,
|
||||
|s, p| {
|
||||
let paths: Vec<String> = p
|
||||
.get("paths")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
|
||||
.unwrap_or_default();
|
||||
if paths.is_empty() {
|
||||
return Err(bad("library.import", "no paths"));
|
||||
}
|
||||
let mode = match str_param(p, "mode").unwrap_or("add") {
|
||||
"add" => crate::import::ImportMode::Add,
|
||||
"copy" => crate::import::ImportMode::Copy,
|
||||
other => return Err(bad("library.import", format!("unknown mode `{other}` (add|copy)"))),
|
||||
};
|
||||
let report = crate::import::import(s, &paths, mode)?;
|
||||
if let Some(a) = p.get("album").and_then(Value::as_u64)
|
||||
&& !report.imported.is_empty()
|
||||
{
|
||||
s.execute("album.addPhotos", &json!({"id": a, "ids": report.imported}))?;
|
||||
}
|
||||
if let Some(f) = report.imported.first() {
|
||||
s.selection = Selection::single(PhotoId(*f));
|
||||
}
|
||||
Ok(serde_json::to_value(&report).unwrap_or_default())
|
||||
}
|
||||
let imported = crate::media::import_paths(s, &paths)?;
|
||||
if let Some(a) = p.get("album").and_then(Value::as_u64) {
|
||||
s.execute("album.addPhotos", &json!({"id": a, "ids": imported.iter().map(|i| i.0).collect::<Vec<_>>()}))?;
|
||||
}
|
||||
if let Some(f) = imported.first() {
|
||||
s.selection = Selection::single(*f);
|
||||
}
|
||||
Ok(json!({"imported": imported.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}),
|
||||
),
|
||||
// ---- persistence
|
||||
cmd!(query "library.info", "Library Info", [], None, "{}", always, |s, _| {
|
||||
let photos = s.catalog.len();
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ fn ext_upper(name: &str) -> String {
|
|||
|
||||
/// Probe a file's bytes: kind, dimensions (oriented), metadata.
|
||||
pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
let content_hash = Some(lightcraft_preview::hash_bytes(bytes).to_string());
|
||||
let m = lightcraft_meta::extract(bytes);
|
||||
let (meta, captured) = meta_of(&m);
|
||||
if lightcraft_raw::probe(bytes).is_some() {
|
||||
|
|
@ -62,6 +63,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
|||
captured,
|
||||
meta,
|
||||
as_shot_wb,
|
||||
content_hash,
|
||||
});
|
||||
}
|
||||
let fmt = lightcraft_codecs::sniff(bytes).ok_or("unrecognized file format")?;
|
||||
|
|
@ -79,7 +81,17 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
|||
"" => format!("{fmt:?}").to_uppercase(),
|
||||
e => e.to_string(),
|
||||
};
|
||||
Ok(ProbeInfo { width: w, height: h, format, kind: MediaKind::Image, file_size: bytes.len() as u64, captured, meta, as_shot_wb: None })
|
||||
Ok(ProbeInfo {
|
||||
width: w,
|
||||
height: h,
|
||||
format,
|
||||
kind: MediaKind::Image,
|
||||
file_size: bytes.len() as u64,
|
||||
captured,
|
||||
meta,
|
||||
as_shot_wb: None,
|
||||
content_hash,
|
||||
})
|
||||
}
|
||||
|
||||
/// Decode a file into a linear Rec.2020 image no larger than `max_edge`, oriented.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,267 @@
|
|||
//! Import: files and folders (recursive) → photos.
|
||||
//!
|
||||
//! 1. Expand folders recursively into supported files (hidden entries and the library's own
|
||||
//! folder are skipped), sorted for a stable order.
|
||||
//! 2. Skip paths already in the catalog.
|
||||
//! 3. Probe the rest — dimensions, metadata, and the **content hash** of the bytes — in parallel
|
||||
//! on native targets.
|
||||
//! 4. Skip **duplicates by content** (same bytes already in the library, or twice in this batch).
|
||||
//! 5. *Add* in place (the photo points at the original file) or *copy* into the library's
|
||||
//! `Originals/YYYY/YYYY-MM-DD/` folder (names made unique) and point at the copy.
|
||||
//! 6. Commit all new photos as one undoable op.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::Session;
|
||||
use crate::media::ProbeInfo;
|
||||
|
||||
/// File extensions LightCraft imports (lower case).
|
||||
pub const EXTENSIONS: &[&str] = &[
|
||||
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "pef", "psd", "jxl", "gif", "bmp", "heic",
|
||||
"avif",
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum ImportMode {
|
||||
/// Reference the files where they are.
|
||||
#[default]
|
||||
Add,
|
||||
/// Copy the files into the library's `Originals/` folder.
|
||||
Copy,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct Duplicate {
|
||||
pub path: String,
|
||||
/// The photo that already has these bytes (or this path).
|
||||
pub existing: Option<u64>,
|
||||
/// `"path"` (already imported from there) or `"content"` (same bytes elsewhere).
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct ImportReport {
|
||||
pub imported: Vec<u64>,
|
||||
pub duplicates: Vec<Duplicate>,
|
||||
/// (path, error)
|
||||
pub failed: Vec<(String, String)>,
|
||||
/// Files found after expanding folders.
|
||||
pub scanned: usize,
|
||||
}
|
||||
|
||||
pub fn is_supported(path: &Path) -> bool {
|
||||
path.extension().is_some_and(|e| EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str()))
|
||||
}
|
||||
|
||||
/// Expand files and folders (recursively) into supported files. `skip` (e.g. the library folder)
|
||||
/// is never descended into.
|
||||
pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec<String> {
|
||||
fn walk(p: &Path, skip: Option<&Path>, out: &mut Vec<String>, top: bool) {
|
||||
let hidden = p.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.'));
|
||||
if (hidden && !top) || skip.is_some_and(|s| p == s) {
|
||||
return;
|
||||
}
|
||||
if p.is_dir() {
|
||||
let Ok(rd) = std::fs::read_dir(p) else { return };
|
||||
let mut v: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
|
||||
v.sort();
|
||||
for c in v {
|
||||
walk(&c, skip, out, false);
|
||||
}
|
||||
} else if top || is_supported(p) {
|
||||
// explicitly named files are attempted even with an unknown extension (sniffed)
|
||||
out.push(p.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for p in paths {
|
||||
walk(Path::new(p), skip, &mut out, true);
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
out.retain(|p| seen.insert(p.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
fn probe_all(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
|
||||
let Some(probe) = s.media.file_probe.clone() else {
|
||||
return paths
|
||||
.iter()
|
||||
.map(|p| {
|
||||
Ok(ProbeInfo {
|
||||
format: Path::new(p).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default(),
|
||||
..Default::default()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let n = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8).min(paths.len().max(1));
|
||||
if n > 1 {
|
||||
let mut results: Vec<Option<Result<ProbeInfo, String>>> = vec![None; paths.len()];
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
let out = std::sync::Mutex::new(&mut results);
|
||||
std::thread::scope(|sc| {
|
||||
for _ in 0..n {
|
||||
sc.spawn(|| {
|
||||
loop {
|
||||
let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if i >= paths.len() {
|
||||
break;
|
||||
}
|
||||
let r = probe(&paths[i]);
|
||||
out.lock().unwrap_or_else(|e| e.into_inner())[i] = Some(r);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return results.into_iter().map(|r| r.unwrap_or_else(|| Err("not probed".into()))).collect();
|
||||
}
|
||||
}
|
||||
paths.iter().map(|p| probe(p)).collect()
|
||||
}
|
||||
|
||||
/// `Originals/YYYY/YYYY-MM-DD/name`, made unique.
|
||||
fn copy_into_library(lib: &Path, src: &str, date: &str) -> Result<String, String> {
|
||||
let day = date.get(..10).filter(|d| d.len() == 10).unwrap_or("undated");
|
||||
let year = day.get(..4).unwrap_or("undated");
|
||||
let dir = lib.join("Originals").join(year).join(day);
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let name = Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into());
|
||||
let (stem, ext) = match name.rsplit_once('.') {
|
||||
Some((s, e)) => (s.to_string(), format!(".{e}")),
|
||||
None => (name.clone(), String::new()),
|
||||
};
|
||||
let mut dst = dir.join(&name);
|
||||
let mut i = 1;
|
||||
while dst.exists() {
|
||||
dst = dir.join(format!("{stem}-{i}{ext}"));
|
||||
i += 1;
|
||||
}
|
||||
std::fs::copy(src, &dst).map_err(|e| format!("copy {src}: {e}"))?;
|
||||
Ok(dst.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Import files/folders. See the module docs.
|
||||
pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Result<ImportReport> {
|
||||
let lib_dir = s.library.as_ref().map(|l| l.dir.clone());
|
||||
if mode == ImportMode::Copy && lib_dir.is_none() {
|
||||
return Err(crate::EngineError::Other("copying into the library needs an open library".into()));
|
||||
}
|
||||
let files = expand(paths, lib_dir.as_deref());
|
||||
let mut report = ImportReport { scanned: files.len(), ..Default::default() };
|
||||
|
||||
// existing paths and content hashes
|
||||
let mut by_path: HashMap<&str, PhotoId> = HashMap::new();
|
||||
let mut by_hash: HashMap<String, PhotoId> = HashMap::new();
|
||||
for p in s.catalog.photos() {
|
||||
if let Source::File { path } = &p.source {
|
||||
by_path.insert(path.as_str(), p.id);
|
||||
}
|
||||
if let Some(h) = &p.content_hash {
|
||||
by_hash.insert(h.clone(), p.id);
|
||||
}
|
||||
}
|
||||
let mut todo = Vec::new();
|
||||
for f in files {
|
||||
match by_path.get(f.as_str()) {
|
||||
Some(id) => report.duplicates.push(Duplicate { path: f, existing: Some(id.0), reason: "path" }),
|
||||
None => todo.push(f),
|
||||
}
|
||||
}
|
||||
|
||||
let probed = probe_all(s, &todo);
|
||||
let now = (s.clock)();
|
||||
let mut ops = Vec::new();
|
||||
for (path, info) in todo.into_iter().zip(probed) {
|
||||
let info = match info {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
log::warn!("import {path}: {e}");
|
||||
report.failed.push((path, e));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(h) = &info.content_hash
|
||||
&& let Some(id) = by_hash.get(h)
|
||||
{
|
||||
report.duplicates.push(Duplicate { path, existing: Some(id.0), reason: "content" });
|
||||
continue;
|
||||
}
|
||||
let stored = match (mode, &lib_dir) {
|
||||
(ImportMode::Copy, Some(lib)) if !Path::new(&path).starts_with(lib) => {
|
||||
match copy_into_library(lib, &path, info.captured.as_deref().unwrap_or(&now)) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
report.failed.push((path, e));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => path.clone(),
|
||||
};
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
if let Some(h) = &info.content_hash {
|
||||
by_hash.insert(h.clone(), id);
|
||||
}
|
||||
let name = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
|
||||
let mut p = Photo::new(id, Source::File { path: stored }, &name, &info.format, info.width, info.height, &now);
|
||||
p.kind = info.kind;
|
||||
p.file_size = info.file_size;
|
||||
p.captured = info.captured;
|
||||
p.meta = info.meta;
|
||||
p.as_shot_wb = info.as_shot_wb;
|
||||
p.content_hash = info.content_hash;
|
||||
if let Some((t, tint)) = info.as_shot_wb {
|
||||
p.develop = std::sync::Arc::new(lightcraft_develop::DevelopSettings::for_raw(t, tint));
|
||||
}
|
||||
report.imported.push(id.0);
|
||||
ops.push(Op::AddPhoto { photo: Box::new(p) });
|
||||
}
|
||||
if !ops.is_empty() {
|
||||
s.commit(&format!("Add {} Photo{}", ops.len(), if ops.len() == 1 { "" } else { "s" }), Op::Batch { ops })?;
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// The current local time as ISO 8601 (`YYYY-MM-DDTHH:MM:SS`, UTC on targets without a clock
|
||||
/// offset); for [`Session::clock`] on native hosts.
|
||||
pub fn system_clock() -> String {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
|
||||
civil(secs)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
"2026-01-01T00:00:00".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Unix seconds → `YYYY-MM-DDTHH:MM:SS` (UTC), proleptic Gregorian (H. Hinnant's algorithm).
|
||||
pub fn civil(secs: i64) -> String {
|
||||
let days = secs.div_euclid(86_400);
|
||||
let rem = secs.rem_euclid(86_400);
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + i64::from(m <= 2);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}", rem / 3600, rem % 3600 / 60, rem % 60)
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Use the system clock for import/edit times (native hosts).
|
||||
pub fn with_system_clock(mut self) -> Self {
|
||||
self.clock = Box::new(system_clock);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
pub mod cmd;
|
||||
pub mod demo;
|
||||
pub mod files;
|
||||
pub mod import;
|
||||
pub mod library;
|
||||
pub mod media;
|
||||
pub mod presets;
|
||||
|
|
@ -306,4 +307,6 @@ impl Session {
|
|||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_import;
|
||||
#[cfg(test)]
|
||||
mod tests_library;
|
||||
|
|
|
|||
|
|
@ -299,49 +299,8 @@ pub struct ProbeInfo {
|
|||
pub captured: Option<String>,
|
||||
pub meta: lightcraft_catalog::Meta,
|
||||
pub as_shot_wb: Option<(f64, f64)>,
|
||||
/// Hash of the file's bytes (hex), for duplicate detection.
|
||||
pub content_hash: Option<String>,
|
||||
}
|
||||
|
||||
pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync>;
|
||||
|
||||
/// Register files as photos (duplicates by path are skipped). Returns the new ids.
|
||||
pub fn import_paths(s: &mut crate::Session, paths: &[String]) -> crate::Result<Vec<PhotoId>> {
|
||||
let now = (s.clock)();
|
||||
let mut ops = Vec::new();
|
||||
let mut ids = Vec::new();
|
||||
for path in paths {
|
||||
let exists = s.catalog.photos().any(|p| matches!(&p.source, Source::File { path: q } if q == path));
|
||||
if exists {
|
||||
continue;
|
||||
}
|
||||
let info = match &s.media.file_probe {
|
||||
Some(probe) => match probe(path) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
log::warn!("import {path}: {e}");
|
||||
continue;
|
||||
}
|
||||
},
|
||||
None => ProbeInfo {
|
||||
format: std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
|
||||
let mut p = Photo::new(id, Source::File { path: path.clone() }, &name, &info.format, info.width, info.height, &now);
|
||||
p.kind = info.kind;
|
||||
p.file_size = info.file_size;
|
||||
p.captured = info.captured;
|
||||
p.meta = info.meta;
|
||||
p.as_shot_wb = info.as_shot_wb;
|
||||
if let Some((t, tint)) = info.as_shot_wb {
|
||||
p.develop = std::sync::Arc::new(lightcraft_develop::DevelopSettings::for_raw(t, tint));
|
||||
}
|
||||
ids.push(id);
|
||||
ops.push(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) });
|
||||
}
|
||||
if !ops.is_empty() {
|
||||
s.commit("Add Photos", lightcraft_catalog::Op::Batch { ops })?;
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
//! Import: recursive folders, duplicate detection by content, add vs copy into the library.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-import-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// A small procedural PNG (distinct per seed).
|
||||
fn write_png(path: &Path, seed: u8) {
|
||||
let (w, h) = (48usize, 32usize);
|
||||
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 5) as u8, (i / w * 7) as u8, seed, 255]).collect();
|
||||
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
|
||||
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
fn ids(v: &Value, key: &str) -> usize {
|
||||
v[key].as_array().map(Vec::len).unwrap_or(0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recursive_import_with_duplicates() {
|
||||
let src = temp_dir("src");
|
||||
write_png(&src.join("a.png"), 1);
|
||||
write_png(&src.join("trip/b.png"), 2);
|
||||
write_png(&src.join("trip/day2/c.PNG"), 3);
|
||||
std::fs::copy(src.join("a.png"), src.join("trip/a-copy.png")).unwrap(); // same bytes
|
||||
write_png(&src.join(".hidden/x.png"), 9);
|
||||
std::fs::write(src.join("notes.txt"), "not a photo").unwrap();
|
||||
std::fs::write(src.join("trip/broken.jpg"), "garbage").unwrap();
|
||||
|
||||
let mut s = Session::new().with_fs();
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
assert_eq!(r["scanned"], 5, "{r}"); // a, b, c, a-copy, broken (hidden + txt skipped)
|
||||
assert_eq!(ids(&r, "imported"), 3, "{r}");
|
||||
assert_eq!(ids(&r, "duplicates"), 1, "{r}");
|
||||
assert_eq!(r["duplicates"][0]["reason"], "content");
|
||||
assert_eq!(ids(&r, "failed"), 1, "{r}");
|
||||
assert_eq!(s.catalog.len(), 3);
|
||||
assert!(s.catalog.photos().all(|p| p.content_hash.as_ref().is_some_and(|h| h.len() == 32) && p.width == 48));
|
||||
|
||||
// importing again: everything is a duplicate (by path), one undo step for the first import
|
||||
let r2 = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
assert_eq!(ids(&r2, "imported"), 0);
|
||||
assert_eq!(r2["duplicates"].as_array().unwrap().iter().filter(|d| d["reason"] == "path").count(), 3);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.len(), 0);
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_into_library_and_persist() {
|
||||
let src = temp_dir("copysrc");
|
||||
let lib = temp_dir("copylib");
|
||||
write_png(&src.join("one.png"), 4);
|
||||
write_png(&src.join("sub/one.png"), 5); // same name, different content
|
||||
let mut s = Session::new().with_fs();
|
||||
assert!(s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "mode": "copy"})).is_err(), "copy needs a library");
|
||||
s.open_library(&lib, false).unwrap();
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "mode": "copy"})).unwrap();
|
||||
assert_eq!(ids(&r, "imported"), 2, "{r}");
|
||||
let paths: Vec<String> = s
|
||||
.catalog
|
||||
.photos()
|
||||
.map(|p| match &p.source {
|
||||
lightcraft_catalog::Source::File { path } => path.clone(),
|
||||
_ => panic!(),
|
||||
})
|
||||
.collect();
|
||||
for p in &paths {
|
||||
assert!(Path::new(p).starts_with(lib.join("Originals")), "{p}");
|
||||
assert!(Path::new(p).exists());
|
||||
}
|
||||
assert_ne!(paths[0], paths[1], "unique names");
|
||||
// originals deleted from the source: the library copies remain usable after a restart
|
||||
std::fs::remove_dir_all(&src).unwrap();
|
||||
drop(s);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert_eq!(s.catalog.len(), 2);
|
||||
let id = s.catalog.photos().next().unwrap().id;
|
||||
assert!(s.render_now(id, 32, 32).is_ok());
|
||||
// the library folder itself is never re-imported
|
||||
let r = s.execute("library.import", &json!({"paths": [lib.to_string_lossy()]})).unwrap();
|
||||
assert_eq!(r["scanned"], 0, "{r}");
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn civil_dates() {
|
||||
assert_eq!(crate::import::civil(0), "1970-01-01T00:00:00");
|
||||
assert_eq!(crate::import::civil(951_782_400), "2000-02-29T00:00:00");
|
||||
assert_eq!(crate::import::civil(1_790_000_000), "2026-09-21T14:13:20");
|
||||
}
|
||||
Loading…
Reference in New Issue