Merge pull request #975: integration batch 26 (31 reviewed contributor PRs)
Integration batch 26: 31 reviewed contributor PRs
This commit is contained in:
51 files changed
+1935
-128
No files matched your search
@@ -1,6 +1,6 @@
|
||||
//! LightCraft in the browser: the same egui UI as the desktop app, compiled to WASM.
|
||||
//!
|
||||
//! Build with `cargo xtask web` (→ `target/web/`), then serve that folder over HTTP (see
|
||||
//! Build with `cargo xtask web` (→ `target/web-site/`), then serve that folder over HTTP (see
|
||||
//! `docs/web.md`). Differences from the desktop host:
|
||||
//! - the library lives in browser storage (OPFS, or IndexedDB where OPFS can't be written from the
|
||||
//! main thread): catalog journal, presets, view state and prefs are mirrored in memory and
|
||||
|
||||
@@ -98,6 +98,8 @@ fn window_wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = &mut c.wgpu_setup {
|
||||
n.instance_descriptor.backends = lightcraft_engine::gpu::backend::window_backends();
|
||||
n.instance_descriptor.backend_options = lightcraft_engine::gpu::backend::backend_options();
|
||||
// no indirect-call validation pipeline: Intel Metal fails to compile it and the window never opens (#250)
|
||||
n.instance_descriptor.flags = lightcraft_engine::gpu::backend::instance_flags_from_env(n.instance_descriptor.flags);
|
||||
}
|
||||
c
|
||||
}
|
||||
@@ -1086,6 +1088,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #250: the window's device is created without wgpu's indirect-call validation pipeline,
|
||||
/// which Intel Macs' Metal compiler fails to build (the device was lost, the window never opened).
|
||||
#[test]
|
||||
fn window_has_no_indirect_call_validation_pipeline() {
|
||||
let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") };
|
||||
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
|
||||
assert!(!n.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #234: Windows opens links with the URL protocol handler (the default browser), not
|
||||
/// File Explorer, and passes the whole map link, `?`, `&` and `#` included, as one argument.
|
||||
#[test]
|
||||
|
||||
@@ -833,6 +833,22 @@ fn to_prophoto() -> Mat3 {
|
||||
PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz())
|
||||
}
|
||||
|
||||
/// [`HueSat::apply`] restores the luminance in full only if the table kept at least this share of it. A colour
|
||||
/// the table darkens to [`NO_LUMINANCE_KEPT`] or less (only near-black noise, whose channels go negative) keeps
|
||||
/// its colour; in between the two are blended, so noisy shadows show no step.
|
||||
const MIN_LUMINANCE_KEPT: f32 = 0.25;
|
||||
const NO_LUMINANCE_KEPT: f32 = 0.15;
|
||||
|
||||
/// How much of the table's colour a pixel gets when the table kept `share` of its luminance: 0 up to
|
||||
/// [`NO_LUMINANCE_KEPT`], 1 from [`MIN_LUMINANCE_KEPT`], a smoothstep between.
|
||||
fn restore_weight(share: f32) -> f32 {
|
||||
if !share.is_finite() {
|
||||
return 0.0;
|
||||
}
|
||||
let t = ((share - NO_LUMINANCE_KEPT) / (MIN_LUMINANCE_KEPT - NO_LUMINANCE_KEPT)).clamp(0.0, 1.0);
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// A fitted [`CameraLook::hue_sat`] table, ready to apply to linear Rec.2020 pixels. It changes
|
||||
/// hue and saturation only: luminance is restored, the camera tone curve owns it.
|
||||
pub(crate) struct HueSat<'a> {
|
||||
@@ -852,7 +868,19 @@ impl<'a> HueSat<'a> {
|
||||
let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) };
|
||||
let out = mul(&self.from, self.table.apply(mul(&self.to, rgb)));
|
||||
let (before, after) = (luminance_2020(rgb), luminance_2020(out));
|
||||
if before > 0.0 && after > 0.0 && out.iter().all(|v| v.is_finite()) { out.map(|v| v * before / after) } else { rgb }
|
||||
// restoring the luminance divides by the table's: near black, where noise makes some channels
|
||||
// negative, the table can bring it close to 0, and the gain would turn the pixel white (#820)
|
||||
if before <= 0.0 || after <= 0.0 || !out.iter().all(|v| v.is_finite()) {
|
||||
return rgb;
|
||||
}
|
||||
let w = restore_weight(after / before);
|
||||
if w >= 1.0 {
|
||||
return out.map(|v| v * before / after);
|
||||
}
|
||||
if w <= 0.0 {
|
||||
return rgb;
|
||||
}
|
||||
std::array::from_fn(|i| rgb[i] + (out[i] * before / after - rgb[i]) * w)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1439,6 +1467,61 @@ mod tests {
|
||||
assert!(fit_hue_sat(&[], &Mat3::IDENTITY).is_none(), "too few samples");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_sat_never_turns_near_black_noise_white() {
|
||||
// a saturating table, as fitted to high-ISO Sony files; near-black noise with mixed-sign channels
|
||||
let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
|
||||
let hue_sat = HueSat::new(&table).unwrap();
|
||||
let mut seed = 0x9e37_79b9_7f4a_7c15u64;
|
||||
let mut next = || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
(seed >> 11) as f32 / (1u64 << 53) as f32 * 2.0 - 1.0
|
||||
};
|
||||
let mut worst = 0.0f32;
|
||||
for _ in 0..200_000 {
|
||||
let rgb = [0.002 + 0.004 * next(), 0.002 + 0.004 * next(), 0.002 + 0.004 * next()];
|
||||
if luminance_2020(rgb) <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let out = hue_sat.apply(rgb);
|
||||
let scale = rgb.iter().fold(0.0f32, |m, v| m.max(v.abs()));
|
||||
worst = worst.max(out.iter().fold(0.0f32, |m, v| m.max(v.abs())) / scale);
|
||||
}
|
||||
assert!(worst < 20.0, "a near-black pixel came out {worst}× its largest channel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_sat_restore_weight_blends_smoothly_between_the_thresholds() {
|
||||
assert_eq!(restore_weight(0.0), 0.0);
|
||||
assert_eq!(restore_weight(NO_LUMINANCE_KEPT), 0.0);
|
||||
assert_eq!(restore_weight(MIN_LUMINANCE_KEPT), 1.0);
|
||||
assert_eq!(restore_weight(1.0), 1.0);
|
||||
assert_eq!(restore_weight(f32::NAN), 0.0);
|
||||
// monotonic and without a step: neighbouring shares 0.001 apart differ by little
|
||||
let mut last = 0.0f32;
|
||||
for i in 0..=400 {
|
||||
let w = restore_weight(i as f32 * 0.001);
|
||||
assert!(w >= last && w - last < 0.02, "step at {}: {last} → {w}", i as f32 * 0.001);
|
||||
last = w;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_sat_keeps_colours_the_table_leaves_bright_exactly_as_before() {
|
||||
// the blend only touches colours whose luminance the table cut below MIN_LUMINANCE_KEPT
|
||||
let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
|
||||
let hue_sat = HueSat::new(&table).unwrap();
|
||||
let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) };
|
||||
for rgb in [[0.4, 0.2, 0.1], [0.05, 0.3, 0.2], [0.18, 0.18, 0.18], [0.6, 0.1, 0.5]] {
|
||||
let out = mul(&hue_sat.from, table.apply(mul(&hue_sat.to, rgb)));
|
||||
let (before, after) = (luminance_2020(rgb), luminance_2020(out));
|
||||
assert!(after >= MIN_LUMINANCE_KEPT * before);
|
||||
assert_eq!(hue_sat.apply(rgb), out.map(|v| v * before / after));
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_of(format: RawFormat, make: &str, model: &str, color: lightcraft_raw::ColorData) -> RawImage {
|
||||
let area = lightcraft_raw::Rect::new(0, 0, 2, 2);
|
||||
RawImage {
|
||||
|
||||
@@ -239,10 +239,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let fresh = match look {
|
||||
Some(l) => (*l).clone(),
|
||||
None => {
|
||||
// the photo's built-in defaults (raws: capture sharpening and colour noise
|
||||
// reduction, embedded lens corrections), as at import, with the white balance
|
||||
// of its current as-shot reference
|
||||
let info = s.source_info(id);
|
||||
let defaults = s.catalog.photo(id).map(|p| p.camera_defaults()).unwrap_or_default();
|
||||
DevelopSettings {
|
||||
wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, temp: info.as_shot_temp, tint: info.as_shot_tint },
|
||||
..Default::default()
|
||||
..defaults
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -59,6 +59,12 @@ fn photo_arg(s: &Session, p: &Value, c: &str) -> crate::Result<PhotoId> {
|
||||
}
|
||||
}
|
||||
|
||||
// Catalog queries also run in the 32-bit web build. A narrowing cast wraps
|
||||
// an out-of-range offset to zero, returning the first page instead of no photos.
|
||||
fn pagination_usize(value: u64) -> usize {
|
||||
usize::try_from(value).unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "catalog.query", "Query Photos", [], None, "{filter?: Filter, sort?: Sort, offset?, limit?} — omit filter to list the current view", always, |s, p| {
|
||||
@@ -69,8 +75,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
} else {
|
||||
s.visible_cloned()
|
||||
};
|
||||
let off = p.get("offset").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let lim = p.get("limit").and_then(Value::as_u64).unwrap_or(200) as usize;
|
||||
let off = pagination_usize(p.get("offset").and_then(Value::as_u64).unwrap_or(0));
|
||||
let lim = pagination_usize(p.get("limit").and_then(Value::as_u64).unwrap_or(200));
|
||||
let items: Vec<Value> = ids.iter().skip(off).take(lim).filter_map(|id| s.catalog.photo(*id)).map(|p| photo_summary(p)).collect();
|
||||
Ok(json!({"total": ids.len(), "photos": items}))
|
||||
}),
|
||||
@@ -237,3 +243,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pagination_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn offsets_and_limits_saturate_instead_of_wrapping_on_wasm() {
|
||||
assert_eq!(pagination_usize(0), 0);
|
||||
assert_eq!(pagination_usize(200), 200);
|
||||
assert_eq!(pagination_usize(u64::MAX), usize::MAX);
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
assert_eq!(pagination_usize(u64::from(u32::MAX) + 1), usize::MAX);
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ impl SettingsHashes {
|
||||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 42;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 50;
|
||||
|
||||
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
|
||||
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
|
||||
|
||||
@@ -37,6 +37,33 @@ fn changing_one_wb_control_resolves_as_shot_without_stale_tint() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset Edits on a raw goes back to its import defaults, capture sharpening and colour noise
|
||||
/// reduction included (#763: reset left both at 0 while import sets 40 / 25); a rendered file
|
||||
/// keeps none.
|
||||
#[test]
|
||||
fn reset_edits_restores_a_raws_import_defaults() {
|
||||
use lightcraft_catalog::{Photo, PhotoId, Source};
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
let mut s = demo();
|
||||
for (id, format, raw) in [(200u64, "NEF", true), (201, "JPG", false)] {
|
||||
let name = format!("p{id}.{format}");
|
||||
let mut p = Photo::new(PhotoId(id), Source::File { path: name.clone() }, &name, format, 16, 16, "");
|
||||
if raw {
|
||||
p.as_shot_wb = Some((5200.0, 4.0));
|
||||
p.develop = std::sync::Arc::new(DevelopSettings::for_raw(5200.0, 4.0));
|
||||
}
|
||||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
for (id, want) in [(200, (40.0, 25.0)), (201, (0.0, 0.0))] {
|
||||
s.execute("library.select", &json!({"ids": [id], "active": id})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "detail.sharpenAmount", "value": 90})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "detail.nrColor", "value": 70})).unwrap();
|
||||
s.execute("develop.reset", &json!({})).unwrap();
|
||||
let d = active_dev(&s);
|
||||
assert_eq!((d.detail.sharpen_amount, d.detail.nr_color), want, "photo {id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_library_loads() {
|
||||
let mut s = demo();
|
||||
|
||||
@@ -32,7 +32,9 @@ impl Scratch {
|
||||
Scratch(dir)
|
||||
}
|
||||
fn path(&self, rel: &str) -> String {
|
||||
self.0.join(rel).to_string_lossy().to_string()
|
||||
// Fixtures use `/`; rebuild components so expected paths match the native separators
|
||||
// returned by directory scans, including for files that have already been removed.
|
||||
self.0.join(rel).components().collect::<PathBuf>().to_string_lossy().into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +105,68 @@ fn an_unchanged_folder_has_no_changes() {
|
||||
assert!(paths(&r, "new").is_empty() && paths(&r, "missing").is_empty() && paths(&r, "metadata").is_empty(), "{r}");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn importing_and_synchronizing_with_different_windows_separators_keeps_photo_identity() {
|
||||
for forward_import in [true, false] {
|
||||
for import_folder in [true, false] {
|
||||
let dir = Scratch::new(&format!("separators-{forward_import}-{import_folder}"));
|
||||
let folder = dir.path("trip");
|
||||
let a = dir.path("trip/a.png");
|
||||
let b = dir.path("trip/day1/b.png");
|
||||
write_png(&a, 1);
|
||||
write_png(&b, 2);
|
||||
let spelling = |path: &str, forward: bool| if forward { path.replace('\\', "/") } else { path.to_string() };
|
||||
let import_path = spelling(&folder, forward_import);
|
||||
let sync_path = spelling(&folder, !forward_import);
|
||||
assert_ne!(import_path, sync_path, "exercise distinct Windows path spellings");
|
||||
let inputs = if import_folder { vec![import_path.clone()] } else { vec![spelling(&a, forward_import), spelling(&b, forward_import)] };
|
||||
let mut s = Session::new().with_fs();
|
||||
let imported = s.execute("library.import", &json!({"paths": inputs})).unwrap();
|
||||
assert_eq!(imported["imported"].as_array().unwrap().len(), 2, "{imported}");
|
||||
let before = s.execute("catalog.query", &json!({})).unwrap();
|
||||
let photos = before["photos"].as_array().unwrap();
|
||||
let a_id = photos.iter().find(|p| p["fileName"] == "a.png").unwrap()["id"].clone();
|
||||
let b_id = photos.iter().find(|p| p["fileName"] == "b.png").unwrap()["id"].clone();
|
||||
|
||||
let duplicate = s.execute("library.import", &json!({"paths": [sync_path]})).unwrap();
|
||||
assert!(duplicate["imported"].as_array().unwrap().is_empty(), "{duplicate}");
|
||||
assert_eq!(duplicate["duplicates"].as_array().unwrap().len(), 2, "{duplicate}");
|
||||
assert_eq!(s.execute("catalog.query", &json!({})).unwrap(), before, "reimport preserves the photos and their ids");
|
||||
|
||||
std::fs::remove_file(&b).unwrap();
|
||||
let c = dir.path("trip/day2/c.png");
|
||||
write_png(&c, 3);
|
||||
let changes = scan(&mut s, &sync_path);
|
||||
assert_eq!(changes["new"].as_array().unwrap().len(), 1, "{changes}");
|
||||
assert_eq!(Path::new(changes["new"][0]["path"].as_str().unwrap()), Path::new(&c));
|
||||
assert_eq!(changes["missing"].as_array().unwrap().len(), 1, "{changes}");
|
||||
assert_eq!(changes["missing"][0]["id"], b_id, "the missing photo keeps its id");
|
||||
assert!(changes["moved"].as_array().unwrap().is_empty(), "{changes}");
|
||||
|
||||
// Switching spelling again must consume the confirmed scan of this same folder.
|
||||
let synced = s.execute("folder.synchronize", &json!({"path": import_path, "scanned": true, "removeMissing": true})).unwrap();
|
||||
assert_eq!(
|
||||
(synced["imported"].as_u64(), synced["removed"].as_u64(), synced["relinked"].as_u64()),
|
||||
(Some(1), Some(1), Some(0)),
|
||||
"{synced}"
|
||||
);
|
||||
assert!(synced["failed"].as_array().unwrap().is_empty(), "{synced}");
|
||||
let after = s.execute("catalog.query", &json!({})).unwrap();
|
||||
let photos = after["photos"].as_array().unwrap();
|
||||
assert_eq!(photos.len(), 2, "{after}");
|
||||
assert!(photos.iter().any(|p| p["id"] == a_id && p["fileName"] == "a.png"), "{after}");
|
||||
assert!(photos.iter().any(|p| p["fileName"] == "c.png" && p["id"] != a_id && p["id"] != b_id), "{after}");
|
||||
for path in [&import_path, &sync_path] {
|
||||
let changes = scan(&mut s, path);
|
||||
for kind in ["new", "missing", "moved", "metadata"] {
|
||||
assert!(changes[kind].as_array().unwrap().is_empty(), "{path}: {changes}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_added_to_the_folder_or_a_folder_inside_it_are_new() {
|
||||
let dir = Scratch::new("new");
|
||||
|
||||
@@ -145,6 +145,25 @@ pub fn backend_options() -> wgpu::BackendOptions {
|
||||
o
|
||||
}
|
||||
|
||||
/// wgpu's instance flags for every instance LightCraft creates, from `flags` (wgpu's defaults with its
|
||||
/// environment variables applied) without `VALIDATION_INDIRECT_CALL`, unless
|
||||
/// `WGPU_VALIDATION_INDIRECT_CALL` asks for it (any value but `0`). That validation builds an internal
|
||||
/// compute pipeline when each device is created; Intel Macs' Metal compiler fails on it ("Compiler
|
||||
/// encountered an internal error"), the device is lost and the window never opens (#250). It only
|
||||
/// guards indirect draws and dispatches, which neither LightCraft nor egui issue.
|
||||
pub fn instance_flags(flags: wgpu::InstanceFlags, env: Option<&str>) -> wgpu::InstanceFlags {
|
||||
if env.is_some_and(|v| v != "0") {
|
||||
flags | wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL
|
||||
} else {
|
||||
flags - wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL
|
||||
}
|
||||
}
|
||||
|
||||
/// [`instance_flags`] with `WGPU_VALIDATION_INDIRECT_CALL` read from the environment.
|
||||
pub fn instance_flags_from_env(flags: wgpu::InstanceFlags) -> wgpu::InstanceFlags {
|
||||
instance_flags(flags, std::env::var("WGPU_VALIDATION_INDIRECT_CALL").ok().as_deref())
|
||||
}
|
||||
|
||||
/// `LIGHTCRAFT_GPU_BACKEND=off`.
|
||||
pub(crate) fn env_off() -> bool {
|
||||
env_choice() == BackendChoice::Off
|
||||
@@ -195,6 +214,17 @@ pub fn read_init_marker(path: &std::path::Path) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
|
||||
/// #250: the indirect-call validation pipeline is off unless asked for; other flags are kept.
|
||||
#[test]
|
||||
fn indirect_call_validation_is_off_unless_asked_for() {
|
||||
use wgpu::InstanceFlags as F;
|
||||
let base = F::VALIDATION_INDIRECT_CALL | F::DEBUG;
|
||||
assert_eq!(instance_flags(base, None), F::DEBUG);
|
||||
assert_eq!(instance_flags(base, Some("0")), F::DEBUG);
|
||||
assert_eq!(instance_flags(F::empty(), Some("1")), F::VALIDATION_INDIRECT_CALL);
|
||||
assert_eq!(instance_flags(F::empty(), None), F::empty());
|
||||
}
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -284,6 +284,8 @@ impl Gpu {
|
||||
desc.backends = backends;
|
||||
// DX12 shaders compile with FXC (issue #471)
|
||||
desc.backend_options = crate::backend::backend_options();
|
||||
// no indirect-call validation pipeline: Intel Metal fails to compile it (#250)
|
||||
desc.flags = crate::backend::instance_flags_from_env(desc.flags);
|
||||
let instance = wgpu::Instance::new(desc);
|
||||
let adapter = pollster::block_on(
|
||||
instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }),
|
||||
|
||||
@@ -86,6 +86,8 @@ fn make_device(backends: wgpu::Backends) -> Result<Dev, String> {
|
||||
desc.backends = backends;
|
||||
// DX12 shaders compile with FXC (issue #471)
|
||||
desc.backend_options = crate::backend::backend_options();
|
||||
// no indirect-call validation pipeline: Intel Metal fails to compile it (#250)
|
||||
desc.flags = crate::backend::instance_flags_from_env(desc.flags);
|
||||
let instance = wgpu::Instance::new(desc);
|
||||
let wanted = std::env::var("LIGHTCRAFT_GPU_ADAPTER").ok().map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty());
|
||||
let adapter = match &wanted {
|
||||
|
||||
+55
-4
@@ -4,8 +4,11 @@
|
||||
|
||||
use std::io::{BufRead, BufReader, Cursor, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use lightcraft_mcp::{Backend, Headless, Remote, Server, base64_decode};
|
||||
use lightcraft_mcp::{Backend, Headless, ProgressHook, Remote, Server, base64_decode};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Feed a whole session through `Server::serve` and return the replies by id.
|
||||
@@ -154,8 +157,50 @@ fn remote_unreachable_is_a_tool_error() {
|
||||
assert!(r[0]["result"]["content"][0]["text"].as_str().unwrap().contains("not reachable"));
|
||||
}
|
||||
|
||||
/// [`Headless`] whose batch export, while `hold` is set, waits once its first photo is written:
|
||||
/// it keeps asking the server's progress hook at that photo boundary (which answers pings and
|
||||
/// reads cancels meanwhile) until the request is cancelled or the test clears `hold`. A ping or
|
||||
/// cancel sent after the first progress then lands mid-batch, however fast the machine exports.
|
||||
struct HoldAfterFirst {
|
||||
inner: Headless,
|
||||
hold: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Backend for HoldAfterFirst {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
self.inner.call(method, params)
|
||||
}
|
||||
|
||||
fn has_ui(&self) -> bool {
|
||||
self.inner.has_ui()
|
||||
}
|
||||
|
||||
fn describe(&self) -> String {
|
||||
self.inner.describe()
|
||||
}
|
||||
|
||||
fn set_progress(&mut self, hook: Option<ProgressHook>) {
|
||||
let hold = self.hold.clone();
|
||||
self.inner.set_progress(hook.map(|mut hook| -> ProgressHook {
|
||||
Box::new(move |done, total, name| {
|
||||
// bounded, so a cancel that is never read fails the test instead of hanging it
|
||||
let until = Instant::now() + Duration::from_secs(20);
|
||||
while done == 1 && done < total && hold.load(Ordering::SeqCst) && Instant::now() < until {
|
||||
if !hook(done, total, name) {
|
||||
return false;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
hook(done, total, name)
|
||||
})
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Real stdio framing and real atomic exports: progress, ping during a batch, cancellation
|
||||
/// between complete photos, unrelated output preservation, and a usable session afterwards.
|
||||
/// Both exports wait after their first photo ([`HoldAfterFirst`]) until the ping is answered or
|
||||
/// the cancel is read, so neither can finish first on a fast machine.
|
||||
#[test]
|
||||
fn export_progress_and_cancel() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-mcp-progress-{}", std::process::id()));
|
||||
@@ -163,7 +208,9 @@ fn export_progress_and_cancel() {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (input, mut send) = std::io::pipe().unwrap();
|
||||
let (output, write) = std::io::pipe().unwrap();
|
||||
let server = std::thread::spawn(move || Server::new(Box::new(Headless::demo())).serve(BufReader::new(input), write).unwrap());
|
||||
let hold = Arc::new(AtomicBool::new(true));
|
||||
let backend = HoldAfterFirst { inner: Headless::demo(), hold: hold.clone() };
|
||||
let server = std::thread::spawn(move || Server::new(Box::new(backend)).serve(BufReader::new(input), write).unwrap());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let reader = std::thread::spawn(move || {
|
||||
for line in BufReader::new(output).lines() {
|
||||
@@ -173,7 +220,7 @@ fn export_progress_and_cancel() {
|
||||
}
|
||||
}
|
||||
});
|
||||
let next = || rx.recv_timeout(std::time::Duration::from_secs(30)).expect("MCP reply/progress");
|
||||
let next = || rx.recv_timeout(Duration::from_secs(30)).expect("MCP reply/progress");
|
||||
writeln!(send, "{}", call(1, "query_photos", json!({"limit":8}))).unwrap();
|
||||
let query = next();
|
||||
let ids: Vec<_> = query["result"]["structuredContent"]["photos"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect();
|
||||
@@ -197,6 +244,8 @@ fn export_progress_and_cancel() {
|
||||
}
|
||||
} else if value["id"] == 3 {
|
||||
answered = true;
|
||||
// the ping was answered mid-batch: let the export run to the end
|
||||
hold.store(false, Ordering::SeqCst);
|
||||
} else {
|
||||
assert_eq!(value["id"], 2, "{value}");
|
||||
assert_eq!(value["result"]["isError"], false, "{value}");
|
||||
@@ -210,6 +259,8 @@ fn export_progress_and_cancel() {
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
let decoy = out.join("photo999.png");
|
||||
std::fs::write(&decoy, b"unrelated existing output").unwrap();
|
||||
// this export waits after its first photo until the cancel below is read
|
||||
hold.store(true, Ordering::SeqCst);
|
||||
let mut request = call(4, "command_run", json!({"id":"app.export","params":{"ids":ids,"dir":out,"longEdge":1200,"format":"png"}}));
|
||||
request["params"]["_meta"] = json!({"progressToken":44});
|
||||
writeln!(send, "{request}").unwrap();
|
||||
@@ -234,7 +285,7 @@ fn export_progress_and_cancel() {
|
||||
}
|
||||
assert_eq!(std::fs::read(&decoy).unwrap(), b"unrelated existing output");
|
||||
let photos: Vec<_> = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().path()).filter(|p| *p != decoy).collect();
|
||||
assert!(!photos.is_empty() && photos.len() < ids.len(), "only completed photos remain: {photos:?}");
|
||||
assert_eq!(photos.len(), 1, "only the photo completed before the cancel remains: {photos:?}");
|
||||
for path in &photos {
|
||||
lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).expect("complete image, no partial/temp file");
|
||||
}
|
||||
|
||||
+135
-2
@@ -170,6 +170,9 @@ fn enumerated(group: &str, tag: u16, v: i64) -> Option<&'static str> {
|
||||
(0x0103, 8) => "Deflate",
|
||||
(0x0103, 32773) => "PackBits",
|
||||
(0x0103, 34892) => "Lossy JPEG",
|
||||
(0x0103, 34713) => "Nikon NEF Compressed",
|
||||
(0x0103, 32767) => "Sony ARW Compressed",
|
||||
(0x0103, 52546) => "JPEG XL",
|
||||
(0x0106, 0) => "WhiteIsZero",
|
||||
(0x0106, 1) => "BlackIsZero",
|
||||
(0x0106, 2) => "RGB",
|
||||
@@ -269,12 +272,56 @@ fn rows_of(group: &'static str, ifd: &Ifd, out: &mut Vec<TagRow>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The IFD holding a raw file's sensor data when it is not IFD0 (a colour-filter-array or linear-raw
|
||||
/// image in a sub-IFD). IFD0 of such files describes a thumbnail, so its Compression is not the raw's.
|
||||
fn raw_image_ifd(ifd0: &Ifd) -> Option<&Ifd> {
|
||||
ifd0.sub_ifds.iter().find(|s| matches!(s.u32(0x0106), Some(32803 | 34892)))
|
||||
}
|
||||
|
||||
/// Nikon maker note `NEFCompression` (0x0093) spelled out, per the public Nikon tag documentation.
|
||||
fn nef_compression(v: u32) -> Option<&'static str> {
|
||||
Some(match v {
|
||||
1 => "Lossy (type 1)",
|
||||
2 => "Uncompressed",
|
||||
3 => "Lossless",
|
||||
4 => "Lossy (type 2)",
|
||||
5 => "Striped packed 12 bits",
|
||||
6 => "Uncompressed (reduced to 12 bit)",
|
||||
7 => "Unpacked 12 bits",
|
||||
8 => "Small",
|
||||
9 => "Packed 12 bits",
|
||||
10 => "Packed 14 bits",
|
||||
13 => "High Efficiency",
|
||||
14 => "High Efficiency*",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Nikon maker note NEFCompression of a raw file, spelled out; `None` for other files or values.
|
||||
fn nikon_nef_compression(tiff: &Tiff, bytes: &[u8]) -> Option<&'static str> {
|
||||
let ifd0 = tiff.ifds.first()?;
|
||||
let make = ifd0.string(0x010f)?;
|
||||
if !make.to_ascii_uppercase().starts_with("NIKON") {
|
||||
return None;
|
||||
}
|
||||
let e = ifd0.exif.as_ref()?.get(0x927c)?;
|
||||
let mn = lightcraft_tiff::makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make)?;
|
||||
nef_compression(mn.ifd.u32(0x0093)?)
|
||||
}
|
||||
|
||||
/// Every tag of a parsed TIFF structure (a TIFF-based raw, a TIFF, or an EXIF block): IFD0,
|
||||
/// then the EXIF, GPS and interoperability IFDs.
|
||||
/// then the EXIF, GPS and interoperability IFDs. A raw file's Compression row is the sensor
|
||||
/// image's, not IFD0's thumbnail's.
|
||||
pub fn tag_rows(tiff: &Tiff) -> Vec<TagRow> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(ifd0) = tiff.ifds.first() {
|
||||
rows_of("TIFF", ifd0, &mut out);
|
||||
if let Some(c) = raw_image_ifd(ifd0).and_then(|r| r.value(0x0103))
|
||||
&& let Some(row) = out.iter_mut().find(|r| r.tag == 0x0103)
|
||||
{
|
||||
// lossless JPEG (DNG, CR2) on the sensor image; plain "JPEG" would read as lossy
|
||||
row.value = if c.get_u64(0) == Some(7) { "Lossless JPEG".to_string() } else { value_text("TIFF", 0x0103, c) };
|
||||
}
|
||||
if let Some(e) = &ifd0.exif {
|
||||
rows_of("EXIF", e, &mut out);
|
||||
if let Some(i) = &e.interop {
|
||||
@@ -294,7 +341,13 @@ pub fn file_tag_rows(bytes: &[u8]) -> Vec<TagRow> {
|
||||
if Tiff::sniff(bytes).is_some()
|
||||
&& let Ok(t) = Tiff::parse(bytes)
|
||||
{
|
||||
return tag_rows(&t);
|
||||
let mut rows = tag_rows(&t);
|
||||
if let Some(words) = nikon_nef_compression(&t, bytes)
|
||||
&& let Some(row) = rows.iter_mut().find(|r| r.group == "TIFF" && r.tag == 0x0103)
|
||||
{
|
||||
row.value = words.to_string();
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
let Some(exif) = crate::embedded(bytes).exif else { return Vec::new() };
|
||||
Tiff::parse(crate::strip_exif_header(&exif)).map(|t| tag_rows(&t)).unwrap_or_default()
|
||||
@@ -328,4 +381,84 @@ mod tests {
|
||||
assert!(rows.iter().any(|r| r.group == "GPS" && r.name == "GPS Latitude"), "{rows:?}");
|
||||
assert!(!rows.iter().any(|r| r.tag == 0x8769), "IFD pointers are left out");
|
||||
}
|
||||
|
||||
fn entry(tag: u16, ty: u16, count: u32, val: u32) -> Vec<u8> {
|
||||
let mut e = Vec::new();
|
||||
e.extend(tag.to_le_bytes());
|
||||
e.extend(ty.to_le_bytes());
|
||||
e.extend(count.to_le_bytes());
|
||||
e.extend(val.to_le_bytes());
|
||||
e
|
||||
}
|
||||
|
||||
fn ifd(entries: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut b = (entries.len() as u16).to_le_bytes().to_vec();
|
||||
entries.iter().for_each(|e| b.extend(e));
|
||||
b.extend(0u32.to_le_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
/// A little-endian raw with a thumbnail IFD0 (`Compression` 1), a CFA sub-IFD (`Compression`
|
||||
/// 34713) and, when `nef` is set, a Nikon maker note whose NEFCompression is that value.
|
||||
fn synthetic_raw(nef: Option<u16>) -> Vec<u8> {
|
||||
let make = b"NIKON CORPORATION | ||||