mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-15 03:36:57 -04:00
Merge pull request #512 from storytold/claude/integrate-batch-17
Integrate #489, #493, #495, #494, #444, #484, #477
This commit is contained in:
14 files changed
+496
-68
No files matched your search
@@ -93,25 +93,41 @@ pub fn install_panic_hook() {
|
||||
const TAB_LOCK: &str = "lightcraft-library";
|
||||
|
||||
/// Hold the library lock for as long as this page lives (Web Locks API). `Some(true)`: ours;
|
||||
/// `Some(false)`: another tab of this site has it; `None`: the browser can't tell (no Web Locks).
|
||||
/// `Some(false)`: another tab of this site has it; `None`: the browser can't tell (no Web Locks, or
|
||||
/// it refused the request).
|
||||
pub async fn acquire_tab_lock() -> Option<bool> {
|
||||
let nav = Reflect::get(&js_sys::global(), &"navigator".into()).ok()?;
|
||||
let locks = Reflect::get(&nav, &"locks".into()).ok().filter(|l| !l.is_undefined() && !l.is_null())?;
|
||||
let request: js_sys::Function = Reflect::get(&locks, &"request".into()).ok()?.dyn_into().ok()?;
|
||||
let opts = js_sys::Object::new();
|
||||
Reflect::set(&opts, &"ifAvailable".into(), &true.into()).ok()?;
|
||||
let granted = js_sys::Promise::new(&mut |resolve, _reject| {
|
||||
let granted = js_sys::Promise::new(&mut |resolve, reject| {
|
||||
let cb = Closure::once_into_js(move |lock: JsValue| -> JsValue {
|
||||
let ours = !lock.is_null() && !lock.is_undefined();
|
||||
let _ = resolve.call1(&JsValue::NULL, &ours.into());
|
||||
// keep the lock until the page goes away: a promise that never settles
|
||||
if ours { js_sys::Promise::new(&mut |_, _| {}).into() } else { JsValue::NULL }
|
||||
});
|
||||
if let Err(e) = request.call3(&locks, &TAB_LOCK.into(), &opts, &cb) {
|
||||
log::warn!("Web Locks request failed: {e:?}");
|
||||
// a refused request never calls `cb`: settle with its error instead of waiting forever
|
||||
match request.call3(&locks, &TAB_LOCK.into(), &opts, &cb) {
|
||||
Ok(pending) => {
|
||||
let catch = Reflect::get(&pending, &"catch".into()).ok().and_then(|c| c.dyn_into::<js_sys::Function>().ok());
|
||||
if let Some(catch) = catch {
|
||||
let _ = catch.call1(&pending, &reject);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = reject.call1(&JsValue::NULL, &e);
|
||||
}
|
||||
}
|
||||
});
|
||||
JsFuture::from(granted).await.ok().map(|v| v.is_truthy())
|
||||
match JsFuture::from(granted).await {
|
||||
Ok(v) => Some(v.is_truthy()),
|
||||
Err(e) => {
|
||||
log::warn!("Web Locks request failed, starting without the one-tab guard: {e:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Offer a blob as a download named `name`.
|
||||
|
||||
@@ -52,8 +52,15 @@ struct App(LightcraftApp, PrefsWriter, #[cfg(target_os = "macos")] Option<native
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(m) = self.2.as_mut() {
|
||||
m.update(&mut self.0, ctx);
|
||||
if let Some(m) = self.2.as_mut()
|
||||
&& m.update(&mut self.0, ctx)
|
||||
&& lightcraft_ui_egui::panels::notices::may_close(&mut self.0)
|
||||
{
|
||||
// Quit from the menu bar: save exactly what closing the window saves (settings and
|
||||
// library, after the unsaved-changes check; otherwise its prompt shows), then end the
|
||||
// process instead of closing the viewport while AppKit is terminating (PR #444).
|
||||
eframe::App::on_exit(self);
|
||||
std::process::exit(0);
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
self.1.tick(&mut self.0, ctx);
|
||||
@@ -76,11 +83,13 @@ impl eframe::App for App {
|
||||
|
||||
/// The window's renderer (egui-wgpu): eframe's defaults, with LightCraft's backend choice — DX12
|
||||
/// alone on Windows unless `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND` say otherwise (issue #136:
|
||||
/// with Vulkan in the set, wgpu loads the Vulkan driver even when it then picks DX12).
|
||||
/// with Vulkan in the set, wgpu loads the Vulkan driver even when it then picks DX12) — and DX12
|
||||
/// shaders compiled with FXC, never a stray `dxcompiler.dll` (issue #471).
|
||||
fn window_wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
|
||||
let mut c = eframe::egui_wgpu::WgpuConfiguration::default();
|
||||
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();
|
||||
}
|
||||
c
|
||||
}
|
||||
@@ -898,6 +907,16 @@ mod tests {
|
||||
assert!(!b.is_empty());
|
||||
}
|
||||
|
||||
/// Issue #471: the window's DX12 shaders compile with FXC — wgpu's default loaded any
|
||||
/// `dxcompiler.dll` on the search path, and one without `dxil.dll` kept the window from opening.
|
||||
#[test]
|
||||
fn window_compiles_dx12_shaders_with_fxc() {
|
||||
let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") };
|
||||
if std::env::var_os("WGPU_DX12_COMPILER").is_none() {
|
||||
assert!(matches!(n.instance_descriptor.backend_options.dx12.shader_compiler, eframe::wgpu::Dx12Compiler::Fxc));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
||||
@@ -355,12 +355,15 @@ impl NativeMenu {
|
||||
}
|
||||
|
||||
/// Per frame: run chosen items, then sync labels / enabled / checked and the text-focus
|
||||
/// accelerators with the app state.
|
||||
pub fn update(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
/// accelerators with the app state. Returns whether Quit was chosen: the caller saves
|
||||
/// everything and exits the process itself (closing the viewport while AppKit terminates the
|
||||
/// app can crash in winit's teardown, PR #444).
|
||||
pub fn update(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) -> bool {
|
||||
lightcraft_ui_egui::i18n::set_language(app.ui.language);
|
||||
let mut quit = false;
|
||||
while let Ok(key) = self.rx.try_recv() {
|
||||
if key == QUIT {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
quit = true;
|
||||
continue;
|
||||
}
|
||||
if key == "app.about" || key == SETTINGS {
|
||||
@@ -375,7 +378,7 @@ impl NativeMenu {
|
||||
let bar = menu_bar(app);
|
||||
if structure_of(&bar) != self.structure {
|
||||
self.rebuild(app);
|
||||
return;
|
||||
return quit;
|
||||
}
|
||||
fn walk(nodes: &[MenuNode], items: &mut HashMap<String, Item>) {
|
||||
for n in nodes {
|
||||
@@ -429,6 +432,7 @@ impl NativeMenu {
|
||||
}
|
||||
self.publish_shortcuts(app);
|
||||
}
|
||||
quit
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,5 +22,8 @@ real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.rock-meister]
|
||||
sync_github_name = true
|
||||
|
||||
[people.sanyamsingh001]
|
||||
display_name = "sanyamsingh001"
|
||||
@@ -10,6 +10,7 @@ use std::sync::{
|
||||
|
||||
use crate::lightroom_sqlite::{Database, LiveTable, Value as SqlValue};
|
||||
use lightcraft_catalog::{Album, Flag, Op, Photo, PhotoId, Source};
|
||||
use lightcraft_geom::{Affine, Orientation, Point, Rect};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -477,17 +478,77 @@ fn ensure_read_active(cancel: &AtomicBool) -> Result<(), String> {
|
||||
if cancel.load(Ordering::Relaxed) { Err("Lightroom import cancelled".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn mapped_settings(text: &str, raw: bool, aspect: f64) -> Result<(Value, Vec<String>), String> {
|
||||
fn mapped_settings(text: &str, raw: bool, aspect: f64, orientation: Orientation, stored_aspect: f64) -> Result<(Value, Vec<String>), String> {
|
||||
let mut root = crate::preset_import::parse_lua(text)?;
|
||||
let deferred = strip_lua_sentinels(&mut root);
|
||||
let (props, values) = crate::preset_import::lua_settings_props(&root)?;
|
||||
let (partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), Some(raw), aspect);
|
||||
let (mut props, values) = crate::preset_import::lua_settings_props(&root)?;
|
||||
// A catalog's develop settings carry the crop edges and angle but no `HasCrop` (XMP has it):
|
||||
// any crop field means the photo is cropped.
|
||||
const CROP: [&str; 5] = ["crs:CropLeft", "crs:CropTop", "crs:CropRight", "crs:CropBottom", "crs:CropAngle"];
|
||||
if !props.contains_key("crs:HasCrop") && CROP.iter().any(|k| props.contains_key(*k)) {
|
||||
props.insert("crs:HasCrop".into(), vec!["True".into()]);
|
||||
}
|
||||
let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), Some(raw), aspect);
|
||||
catalog_crop(&mut partial, orientation, stored_aspect);
|
||||
if deferred > 0 {
|
||||
unknown.push("Deferred Lightroom adjustments (-999999)".into());
|
||||
}
|
||||
Ok((partial, unknown))
|
||||
}
|
||||
|
||||
/// Lightroom's `Adobe_images.orientation`: where the stored image's top-left and top-right corners
|
||||
/// land in the displayed photo, naming the displayed corners A, B, C, D clockwise from the top left
|
||||
/// (`AB` upright, `BC` a quarter turn clockwise, `DA` a quarter turn counter-clockwise, `BA` mirrored).
|
||||
fn lightroom_orientation(code: &str) -> Option<Orientation> {
|
||||
const ALL: [Orientation; 8] = [
|
||||
Orientation::Normal,
|
||||
Orientation::FlipH,
|
||||
Orientation::Rotate180,
|
||||
Orientation::FlipV,
|
||||
Orientation::Transpose,
|
||||
Orientation::Rotate90,
|
||||
Orientation::Transverse,
|
||||
Orientation::Rotate270,
|
||||
];
|
||||
let corner = |o: Orientation, x: f64, y: f64| match o.map(x, y, 1.0, 1.0) {
|
||||
(u, v) if u < 0.5 && v < 0.5 => 'A',
|
||||
(_, v) if v < 0.5 => 'B',
|
||||
(u, _) if u >= 0.5 => 'C',
|
||||
_ => 'D',
|
||||
};
|
||||
ALL.into_iter().find(|&o| code.chars().eq([corner(o, 0.0, 0.0), corner(o, 1.0, 0.0)]))
|
||||
}
|
||||
|
||||
/// Lightroom's crop in LightCraft's frame. `crs` gives the crop's top-left (`CropLeft`, `CropTop`)
|
||||
/// and bottom-right (`CropRight`, `CropBottom`) corners on the stored image (before orientation and
|
||||
/// straightening), normalized to its size, plus a straighten angle that turns the other way to
|
||||
/// LightCraft's. LightCraft's crop is a rectangle in the oriented image rotated by `angle` about its
|
||||
/// centre, normalized to the oriented image's size, so the corners go through the orientation and
|
||||
/// then the rotation; `stored_aspect` (width / height of the stored image) matters once the
|
||||
/// rectangle turns. On a real catalog this reproduced the frame aspect of all 223 straightened crops
|
||||
/// in Lightroom's exports within 1%.
|
||||
fn catalog_crop(partial: &mut Value, orientation: Orientation, stored_aspect: f64) {
|
||||
let Some(geometry) = partial.pointer_mut("/crop/geometry").and_then(Value::as_object_mut) else { return };
|
||||
let edge = |k: &str| geometry.get("rect").and_then(|r| r.get(k)).and_then(Value::as_f64);
|
||||
let (Some(x0), Some(y0), Some(x1), Some(y1)) = (edge("x0"), edge("y0"), edge("x1"), edge("y1")) else { return };
|
||||
let lightroom_angle = geometry.get("angle").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
if orientation == Orientation::Normal && lightroom_angle == 0.0 {
|
||||
return;
|
||||
}
|
||||
let w = if stored_aspect.is_finite() && stored_aspect > 0.0 { stored_aspect } else { 1.0 };
|
||||
let (ow, oh) = if orientation.swaps_axes() { (1.0, w) } else { (w, 1.0) };
|
||||
// a mirror turns the straighten the other way too
|
||||
let angle = if orientation.to_parts().0 { lightroom_angle } else { -lightroom_angle };
|
||||
let to_rotated = Affine::rotate_about(angle.to_radians(), Point::new(ow / 2.0, oh / 2.0));
|
||||
let corner = |x: f64, y: f64| {
|
||||
let (u, v) = orientation.map(x * w, y, w, 1.0);
|
||||
to_rotated.apply(Point::new(u, v))
|
||||
};
|
||||
let r = Rect::from_points(corner(x0, y0), corner(x1, y1));
|
||||
geometry.insert("rect".into(), json!({"x0": r.x0 / ow, "y0": r.y0 / oh, "x1": r.x1 / ow, "y1": r.y1 / oh}));
|
||||
geometry.insert("angle".into(), json!(angle));
|
||||
}
|
||||
|
||||
fn strip_lua_sentinels(value: &mut crate::preset_import::Lua) -> usize {
|
||||
use crate::preset_import::Lua;
|
||||
match value {
|
||||
@@ -680,7 +741,15 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
|
||||
p.meta.keywords = src.keywords.clone();
|
||||
}
|
||||
if !src.settings.is_empty() && !keep_develop {
|
||||
match mapped_settings(&src.settings, p.kind == lightcraft_catalog::MediaKind::Raw, p.width.max(1) as f64 / p.height.max(1) as f64) {
|
||||
let orientation = lightroom_orientation(text(&src.image, "orientation")).unwrap_or_default();
|
||||
let stored_aspect = number(&src.image, "fileWidth") as f64 / number(&src.image, "fileHeight") as f64;
|
||||
match mapped_settings(
|
||||
&src.settings,
|
||||
p.kind == lightcraft_catalog::MediaKind::Raw,
|
||||
p.width.max(1) as f64 / p.height.max(1) as f64,
|
||||
orientation,
|
||||
stored_aspect,
|
||||
) {
|
||||
Ok((partial, unknown)) => {
|
||||
p.develop = Arc::new(lightcraft_develop::apply_partial(&p.develop, &partial, 1.0));
|
||||
p.edited = Some(now.clone());
|
||||
@@ -788,6 +857,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_geom::CropGeometry;
|
||||
fn row(v: Value) -> Row {
|
||||
serde_json::from_value(v).unwrap()
|
||||
}
|
||||
@@ -866,7 +936,7 @@ mod tests {
|
||||
cyclic.collections[0].insert("parent".into(), json!(10));
|
||||
assert!(apply(&mut s, cyclic, false).is_err());
|
||||
assert_eq!(s.catalog.to_snapshot(), before);
|
||||
assert!(mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), true, 1.0).is_err());
|
||||
assert!(mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), true, 1.0, Orientation::Normal, 1.5).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn compressed_xmp_is_bounded_and_settings_are_data_only() {
|
||||
@@ -876,10 +946,156 @@ mod tests {
|
||||
assert_eq!(xmp(Some(&json!(encoded))).unwrap(), String::from_utf8_lossy(packet));
|
||||
encoded[0] = 0xff;
|
||||
assert!(xmp(Some(&json!(encoded))).is_err());
|
||||
let (partial, _) = mapped_settings("s = { Exposure2012 = 0.75, ToneCurvePV2012 = {0,0,128,150,255,255} }", true, 1.5).unwrap();
|
||||
let (partial, _) =
|
||||
mapped_settings("s = { Exposure2012 = 0.75, ToneCurvePV2012 = {0,0,128,150,255,255} }", true, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
assert_eq!(partial["light"]["exposure"], 0.75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_crops_without_has_crop_are_imported() {
|
||||
// Lightroom Classic catalogs store crop edges and angle but never `HasCrop`
|
||||
let (partial, unknown) = mapped_settings(
|
||||
"s = { CropConstrainAspectRatio = true, CropLeft = 0.042702, CropRight = 0.957298, CropTop = 0.1 }",
|
||||
true,
|
||||
1.5,
|
||||
Orientation::Normal,
|
||||
1.5,
|
||||
)
|
||||
.unwrap();
|
||||
let crop = &partial["crop"]["geometry"];
|
||||
assert_eq!(crop["rect"]["x0"], 0.042702);
|
||||
assert_eq!(crop["rect"]["y0"], 0.1);
|
||||
assert_eq!(crop["rect"]["x1"], 0.957298);
|
||||
assert_eq!(crop["rect"]["y1"], 1.0);
|
||||
assert_eq!(crop["angle"], 0.0);
|
||||
assert!(!unknown.iter().any(|k| k.starts_with("CropLeft")), "{unknown:?}");
|
||||
// an angle alone still means a crop (Lightroom's angle turns the other way to LightCraft's)
|
||||
let (partial, unknown) = mapped_settings("s = { CropAngle = -1.5 }", true, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
assert_eq!(partial["crop"]["geometry"]["angle"], 1.5);
|
||||
assert!(!unknown.iter().any(|k| k.starts_with("CropAngle")), "{unknown:?}");
|
||||
// no crop fields: the crop stays untouched
|
||||
let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", true, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
assert!(partial.get("crop").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightroom_orientation_codes() {
|
||||
// the three seen in a real catalog, checked against each file's EXIF orientation (1, 6, 8)
|
||||
assert_eq!(lightroom_orientation("AB"), Some(Orientation::Normal));
|
||||
assert_eq!(lightroom_orientation("BC"), Some(Orientation::Rotate90));
|
||||
assert_eq!(lightroom_orientation("DA"), Some(Orientation::Rotate270));
|
||||
assert_eq!(lightroom_orientation("CD"), Some(Orientation::Rotate180));
|
||||
assert_eq!(lightroom_orientation("BA"), Some(Orientation::FlipH));
|
||||
assert_eq!(lightroom_orientation("DC"), Some(Orientation::FlipV));
|
||||
assert_eq!(lightroom_orientation("AD"), Some(Orientation::Transpose));
|
||||
assert_eq!(lightroom_orientation("CB"), Some(Orientation::Transverse));
|
||||
for code in ["", "A", "ABC", "AC", "ab", "XY"] {
|
||||
assert_eq!(lightroom_orientation(code), None, "{code:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_crops_follow_the_photo_orientation() {
|
||||
let crop = |o: Orientation| {
|
||||
let settings = "s = { CropLeft = 0.1, CropRight = 0.6, CropTop = 0.2, CropBottom = 0.9 }";
|
||||
let (partial, _) = mapped_settings(settings, true, 1.5, o, 1.5).unwrap();
|
||||
let g = &partial["crop"]["geometry"];
|
||||
let r = &g["rect"];
|
||||
[r["x0"].as_f64(), r["y0"].as_f64(), r["x1"].as_f64(), r["y1"].as_f64(), g["angle"].as_f64()].map(|v| (v.unwrap() * 1e9).round() / 1e9)
|
||||
};
|
||||
// (x, y) of the stored image → (1 − y, x) a quarter turn clockwise, (y, 1 − x) counter-clockwise
|
||||
assert_eq!(crop(Orientation::Normal), [0.1, 0.2, 0.6, 0.9, 0.0]);
|
||||
assert_eq!(crop(Orientation::Rotate90), [0.1, 0.1, 0.8, 0.6, 0.0]);
|
||||
assert_eq!(crop(Orientation::Rotate270), [0.2, 0.4, 0.9, 0.9, 0.0]);
|
||||
assert_eq!(crop(Orientation::Rotate180), [0.4, 0.1, 0.9, 0.8, 0.0]);
|
||||
assert_eq!(crop(Orientation::FlipH), [0.4, 0.2, 0.9, 0.9, 0.0]);
|
||||
// a mirror turns the straighten the other way
|
||||
let angle = |o: Orientation| {
|
||||
let (partial, _) = mapped_settings("s = { CropAngle = 2 }", true, 1.5, o, 1.5).unwrap();
|
||||
partial["crop"]["geometry"]["angle"].as_f64().unwrap()
|
||||
};
|
||||
assert_eq!(angle(Orientation::Rotate90), -2.0);
|
||||
assert_eq!(angle(Orientation::FlipH), 2.0);
|
||||
// no crop: nothing to orient
|
||||
let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", true, 1.5, Orientation::Rotate90, 1.5).unwrap();
|
||||
assert!(partial.get("crop").is_none());
|
||||
}
|
||||
|
||||
/// The `crs` corners and angle Lightroom would store for LightCraft crop `g` on a stored image
|
||||
/// of aspect `w` (height 1) shown with orientation `o`.
|
||||
fn lightroom_crop(g: CropGeometry, o: Orientation, w: f64) -> String {
|
||||
let (ow, oh) = if o.swaps_axes() { (1.0, w) } else { (w, 1.0) };
|
||||
let to_source = Affine::rotate_about(-g.angle.to_radians(), Point::new(ow / 2.0, oh / 2.0));
|
||||
let stored = |x: f64, y: f64| {
|
||||
let p = to_source.apply(Point::new(x * ow, y * oh));
|
||||
o.inverse().map(p.x, p.y, ow, oh)
|
||||
};
|
||||
// the rectangle's corners that land top-left and bottom-right on the stored image
|
||||
let corners = [stored(g.rect.x0, g.rect.y0), stored(g.rect.x1, g.rect.y0), stored(g.rect.x1, g.rect.y1), stored(g.rect.x0, g.rect.y1)];
|
||||
let (tl, br) = (0..4).map(|i| (corners[i], corners[(i + 2) % 4])).find(|(a, b)| a.0 < b.0 && a.1 < b.1).unwrap();
|
||||
let angle = if o.to_parts().0 { g.angle } else { -g.angle };
|
||||
format!("s = {{ CropLeft = {}, CropTop = {}, CropRight = {}, CropBottom = {}, CropAngle = {angle} }}", tl.0 / w, tl.1, br.0 / w, br.1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straightened_catalog_crops_round_trip() {
|
||||
let g = CropGeometry { rect: Rect::new(0.2, 0.15, 0.7, 0.8), angle: 6.5 };
|
||||
for o in [Orientation::Normal, Orientation::Rotate90, Orientation::Rotate270, Orientation::Rotate180, Orientation::FlipH] {
|
||||
for angle in [6.5, -6.5] {
|
||||
let g = CropGeometry { angle, ..g };
|
||||
let (partial, _) = mapped_settings(&lightroom_crop(g, o, 1.5), true, 1.5, o, 1.5).unwrap();
|
||||
let got: CropGeometry = serde_json::from_value(partial["crop"]["geometry"].clone()).unwrap();
|
||||
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
|
||||
assert!(
|
||||
close(got.angle, g.angle)
|
||||
&& [(got.rect.x0, g.rect.x0), (got.rect.y0, g.rect.y0), (got.rect.x1, g.rect.x1), (got.rect.y1, g.rect.y1)]
|
||||
.iter()
|
||||
.all(|&(a, b)| close(a, b)),
|
||||
"{o:?} {angle}: {got:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straightened_catalog_crops_match_lightroom_exports() {
|
||||
// Settings and pixel sizes from a real Lightroom Classic catalog (9504 × 6336 stored) and the
|
||||
// JPEGs Lightroom exported from it at full size.
|
||||
for (settings, o, export) in [
|
||||
(
|
||||
"s = { CropAngle = -9.34444, CropBottom = 0.802034, CropLeft = 0.05499, CropRight = 0.94501, CropTop = 0.197966 }",
|
||||
Orientation::Normal,
|
||||
(7725.0, 5150.0),
|
||||
),
|
||||
(
|
||||
"s = { CropAngle = -1.48964, CropBottom = 0.962457, CropLeft = 0.010429, CropRight = 0.989571, CropTop = 0.037543 }",
|
||||
Orientation::Rotate90,
|
||||
(6100.0, 9150.0),
|
||||
),
|
||||
] {
|
||||
let (partial, _) = mapped_settings(settings, true, 1.5, o, 9504.0 / 6336.0).unwrap();
|
||||
let r: Rect = serde_json::from_value(partial["crop"]["geometry"]["rect"].clone()).unwrap();
|
||||
let (ow, oh) = if o.swaps_axes() { (6336.0, 9504.0) } else { (9504.0, 6336.0) };
|
||||
let (w, h) = (r.width() * ow, r.height() * oh);
|
||||
assert!((w - export.0).abs() <= 3.0 && (h - export.1).abs() <= 3.0, "{o:?}: {w:.1} × {h:.1}, Lightroom exported {export:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_crops_use_the_catalog_orientation() {
|
||||
let mut data = sample();
|
||||
data.photos.truncate(1);
|
||||
data.photos[0].image.remove("masterImage");
|
||||
data.photos[0].image.insert("orientation".into(), json!("BC"));
|
||||
data.photos[0].settings = "s = { CropLeft = 0.1, CropRight = 0.6, CropTop = 0.2, CropBottom = 0.9 }".into();
|
||||
data.photos[0].xmp = String::new();
|
||||
let mut s = crate::Session::new();
|
||||
apply(&mut s, data, false).unwrap();
|
||||
let g = s.catalog.photos().next().unwrap().develop.crop.geometry;
|
||||
let r = [g.rect.x0, g.rect.y0, g.rect.x1, g.rect.y1].map(|v| (v * 1e9).round() / 1e9);
|
||||
assert_eq!(r, [0.1, 0.1, 0.8, 0.6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_auto_tone_sentinels_never_become_real_slider_values() {
|
||||
let mut data = sample();
|
||||
@@ -896,7 +1112,7 @@ mod tests {
|
||||
assert_eq!(p.develop.light.shadows, 65.0);
|
||||
assert_eq!(p.develop.light.blacks, -22.0);
|
||||
assert!(report["unmapped"]["2"].as_array().unwrap().iter().any(|v| v.as_str().is_some_and(|v| v.contains("Deferred"))));
|
||||
let (partial, _) = mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", true, 1.0).unwrap();
|
||||
let (partial, _) = mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", true, 1.0, Orientation::Normal, 1.5).unwrap();
|
||||
assert_eq!(partial["light"]["exposure"], -5.0);
|
||||
assert_eq!(partial["light"]["contrast"], -100.0);
|
||||
}
|
||||
|
||||
@@ -121,6 +121,30 @@ pub fn window_backends() -> Backends {
|
||||
resolve(choice, default, wgpu::Instance::enabled_backend_features()).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// The DX12 shader compiler: FXC (`d3dcompiler_47.dll`, part of Windows) unless
|
||||
/// `WGPU_DX12_COMPILER` (`wgpu`, wgpu's own variable: `fxc`, `dxc`, `auto`…) names another.
|
||||
///
|
||||
/// Issue #471: wgpu's default (`Auto`) loads whichever `dxcompiler.dll` the DLL search path finds
|
||||
/// first — LightCraft ships none, so it is some other program's (an SDK's, a folder on `PATH`). A
|
||||
/// copy without its `dxil.dll` beside it warns that the DXIL is unsigned; wgpu takes the warning for
|
||||
/// a compile error, its own validation pipelines fail, the device is lost and the window never
|
||||
/// opens. FXC is always there and compiles everything LightCraft's shaders need.
|
||||
pub fn dx12_compiler(env: Option<&str>) -> wgpu::Dx12Compiler {
|
||||
let Some(v) = env else { return wgpu::Dx12Compiler::Fxc };
|
||||
v.parse().unwrap_or_else(|e| {
|
||||
log::warn!("gpu: WGPU_DX12_COMPILER={v} ignored: {e}");
|
||||
wgpu::Dx12Compiler::Fxc
|
||||
})
|
||||
}
|
||||
|
||||
/// wgpu's backend options for every instance LightCraft creates — the window's and the compute
|
||||
/// devices': wgpu's environment variables, with the DX12 compiler from [`dx12_compiler`].
|
||||
pub fn backend_options() -> wgpu::BackendOptions {
|
||||
let mut o = wgpu::BackendOptions::from_env_or_default();
|
||||
o.dx12.shader_compiler = dx12_compiler(std::env::var("WGPU_DX12_COMPILER").ok().as_deref());
|
||||
o
|
||||
}
|
||||
|
||||
/// `LIGHTCRAFT_GPU_BACKEND=off`.
|
||||
pub(crate) fn env_off() -> bool {
|
||||
env_choice() == BackendChoice::Off
|
||||
@@ -211,6 +235,20 @@ mod tests {
|
||||
assert!(default_window_backends("linux").contains(Backends::VULKAN | Backends::GL));
|
||||
}
|
||||
|
||||
/// Issue #471: DX12 shaders compile with FXC, never a `dxcompiler.dll` found on the search path,
|
||||
/// unless `WGPU_DX12_COMPILER` asks for it.
|
||||
#[test]
|
||||
fn dx12_compiles_with_fxc_unless_asked() {
|
||||
assert!(matches!(dx12_compiler(None), wgpu::Dx12Compiler::Fxc));
|
||||
assert!(matches!(dx12_compiler(Some("bogus")), wgpu::Dx12Compiler::Fxc));
|
||||
assert!(matches!(dx12_compiler(Some("FXC")), wgpu::Dx12Compiler::Fxc));
|
||||
assert!(matches!(dx12_compiler(Some("dxc")), wgpu::Dx12Compiler::DynamicDxc { .. }));
|
||||
assert!(matches!(dx12_compiler(Some("auto")), wgpu::Dx12Compiler::Auto));
|
||||
if std::env::var_os("WGPU_DX12_COMPILER").is_none() {
|
||||
assert!(matches!(backend_options().dx12.shader_compiler, wgpu::Dx12Compiler::Fxc));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_against_the_build() {
|
||||
let compiled = Backends::DX12 | Backends::VULKAN;
|
||||
|
||||
@@ -282,6 +282,8 @@ impl Gpu {
|
||||
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
// only these drivers are loaded (`crate::backend`: never Vulkan on Windows unless asked)
|
||||
desc.backends = backends;
|
||||
// DX12 shaders compile with FXC (issue #471)
|
||||
desc.backend_options = crate::backend::backend_options();
|
||||
let instance = wgpu::Instance::new(desc);
|
||||
let adapter = pollster::block_on(
|
||||
instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }),
|
||||
|
||||
@@ -76,6 +76,8 @@ fn create_device() -> Result<Dev, String> {
|
||||
fn make_device(backends: wgpu::Backends) -> Result<Dev, String> {
|
||||
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
desc.backends = backends;
|
||||
// DX12 shaders compile with FXC (issue #471)
|
||||
desc.backend_options = crate::backend::backend_options();
|
||||
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 {
|
||||
|
||||
@@ -1663,6 +1663,14 @@ mod tests {
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": "button:addToLibrary"}), t)["ok"], true);
|
||||
h.settle(SETTLE);
|
||||
assert_eq!(h.app.session.catalog.photos().filter(|p| !p.local).count(), library_before + 2);
|
||||
// Truncated ancestors remain clickable and still open the original, unabridged path.
|
||||
let inner = dir.join("inner");
|
||||
h.request("engine.execute", json!({"command": "library.browse", "params": {"path": inner.to_string_lossy()}}), t);
|
||||
h.settle(SETTLE);
|
||||
let parent_index = dir.to_string_lossy().split(['/', '\\']).filter(|p| !p.is_empty()).count() - 1;
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": format!("crumb:{parent_index}")}), t)["ok"], true);
|
||||
h.settle(SETTLE);
|
||||
assert_eq!(h.app.session.browse.as_ref().unwrap().path, dir.to_string_lossy().replace('\\', "/"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
//! Photo Grid (justified rows) and Square Grid. Virtualized: only visible cells request thumbnails.
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "grid_header_tests.rs"]
|
||||
mod header_tests;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -137,8 +141,6 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (generation, ids) = app.session.visible_shared();
|
||||
// header: source title + count
|
||||
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover());
|
||||
ui.painter().rect_filled(hr, 0.0, t.canvas);
|
||||
let sel_n = app.session.selection.ids.len();
|
||||
let chips = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog);
|
||||
let total = app.session.source_total();
|
||||
@@ -147,9 +149,11 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
match app.session.browse.clone().filter(|_| app.session.source == lightcraft_engine::LibrarySource::Folder) {
|
||||
Some(b) => {
|
||||
let local = app.caches.grid.local(&app.session.catalog, &ids, generation);
|
||||
folder_header(app, ui, hr, &b, &ids, local, &cnt)
|
||||
folder_header(app, ui, &b, &ids, local, &cnt);
|
||||
}
|
||||
None => {
|
||||
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover());
|
||||
ui.painter().rect_filled(hr, 0.0, t.canvas);
|
||||
let full = crate::i18n::source_title(&app.session);
|
||||
let count_w = ui.painter().layout_no_wrap(cnt.clone(), t.font(12.5), t.text_dim).size().x;
|
||||
// the title gives way to the count: a long album or folder name is trimmed, in full on hover
|
||||
@@ -665,55 +669,86 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
|
||||
|
||||
/// The header of a Local folder view: the path as a breadcrumb (each part opens that folder),
|
||||
/// Include subfolders, Add to My Photos.
|
||||
fn folder_header(app: &mut LightcraftApp, ui: &mut egui::Ui, hr: Rect, b: &lightcraft_engine::Browse, ids: &[PhotoId], local_n: usize, cnt: &str) {
|
||||
fn folder_header(app: &mut LightcraftApp, ui: &mut egui::Ui, b: &lightcraft_engine::Browse, ids: &[PhotoId], local_n: usize, cnt: &str) -> Rect {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut child =
|
||||
ui.new_child(egui::UiBuilder::new().max_rect(hr.shrink2(vec2(16.0, 6.0))).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||||
child.spacing_mut().item_spacing.x = 4.0;
|
||||
let parts: Vec<&str> = b.path.split(['/', '\\']).filter(|p| !p.is_empty()).collect();
|
||||
// the last few parts (the root side is elided)
|
||||
let skip = parts.len().saturating_sub(4);
|
||||
if skip > 0 {
|
||||
child.label(egui::RichText::new("… ›").color(t.text_dim));
|
||||
}
|
||||
for (i, part) in parts.iter().enumerate().skip(skip) {
|
||||
let last = i + 1 == parts.len();
|
||||
let text = egui::RichText::new(*part).size(13.0).color(if last { t.text } else { t.text_label });
|
||||
let r = child.add(egui::Label::new(if last { text.strong() } else { text }).sense(Sense::click()));
|
||||
register(child.ctx(), format!("crumb:{i}"), r.rect);
|
||||
if !last {
|
||||
if r.hovered() {
|
||||
child.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
// The path/count and actions need independent rows. The frame reserves the actual height,
|
||||
// including any wrapped actions, so the grid below never paints under the header.
|
||||
let header = egui::Frame::NONE.fill(t.canvas).inner_margin(egui::Margin::symmetric(16, 6)).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
let count_w =
|
||||
ui.painter().layout_no_wrap(cnt.to_owned(), t.font(12.5), t.text_dim).size().x.ceil().min((ui.available_width() * 0.45).floor());
|
||||
let path_w = (ui.available_width() - count_w - 8.0).max(0.0).floor();
|
||||
ui.allocate_ui_with_layout(vec2(path_w, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
folder_breadcrumbs(app, ui, &b.path);
|
||||
});
|
||||
let count = ui.add_sized(vec2(count_w, 24.0), egui::Label::new(egui::RichText::new(cnt).size(12.5).color(t.text_dim)).truncate());
|
||||
register(ui.ctx(), "grid:count", count.rect);
|
||||
count.on_hover_text(cnt);
|
||||
});
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 12.0;
|
||||
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
|
||||
let mut sub = b.subfolders;
|
||||
let c = ui.checkbox(&mut sub, crate::i18n::tr("Include subfolders"));
|
||||
register(ui.ctx(), "check:includeSubfolders", c.rect);
|
||||
if c.changed() {
|
||||
let _ = app.run("library.browse", json!({"path": b.path, "subfolders": sub}));
|
||||
}
|
||||
if r.clicked() {
|
||||
let prefix = if b.path.starts_with('/') { format!("/{}", parts[..=i].join("/")) } else { parts[..=i].join("/") };
|
||||
let _ = app.run("library.browse", json!({"path": prefix}));
|
||||
}
|
||||
child.label(egui::RichText::new("›").color(t.text_dim));
|
||||
}
|
||||
}
|
||||
child.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(egui::RichText::new(cnt).size(12.5).color(t.text_dim));
|
||||
ui.add_space(10.0);
|
||||
// counted once per view (cached); the ids are only gathered on a click
|
||||
if local_n > 0 {
|
||||
let label = crate::i18n::tr_format!("Add {local_n} to My Photos", local_n = local_n);
|
||||
if crate::widgets::text_button(ui, "addToLibrary", &label, false).clicked() {
|
||||
let local: Vec<u64> = ids.iter().filter(|id| app.session.catalog.photo(**id).is_some_and(|p| p.local)).map(|id| id.0).collect();
|
||||
let n = local.len();
|
||||
match app.run("photo.addToLibrary", json!({"ids": local})) {
|
||||
Ok(_) => app.toast(ui.ctx(), crate::i18n::tr_format!("Added {n} photo{} to My Photos", if n == 1 { "" } else { "s" }, n = n)),
|
||||
Err(e) => app.toast(ui.ctx(), e),
|
||||
// Counted once per view (cached); the ids are only gathered on a click.
|
||||
if local_n > 0 {
|
||||
let label = crate::i18n::tr_format!("Add {local_n} to My Photos", local_n = local_n);
|
||||
let button =
|
||||
ui.add(egui::Button::new(egui::RichText::new(label).font(t.semibold(11.5)).color(t.text)).wrap().min_size(vec2(37.0, 24.0)));
|
||||
register(ui.ctx(), "button:addToLibrary", button.rect);
|
||||
if button.clicked() {
|
||||
let local: Vec<u64> = ids.iter().filter(|id| app.session.catalog.photo(**id).is_some_and(|p| p.local)).map(|id| id.0).collect();
|
||||
let n = local.len();
|
||||
match app.run("photo.addToLibrary", json!({"ids": local})) {
|
||||
Ok(_) => app.toast(ui.ctx(), crate::i18n::tr_format!("Added {n} photo{} to My Photos", if n == 1 { "" } else { "s" }, n = n)),
|
||||
Err(e) => app.toast(ui.ctx(), e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut sub = b.subfolders;
|
||||
let c = ui.checkbox(&mut sub, crate::i18n::tr("Include subfolders"));
|
||||
register(ui.ctx(), "check:includeSubfolders", c.rect);
|
||||
if c.changed() {
|
||||
let _ = app.run("library.browse", json!({"path": b.path, "subfolders": sub}));
|
||||
}
|
||||
});
|
||||
});
|
||||
register(ui.ctx(), "grid:folderHeader", header.response.rect);
|
||||
header.response.rect
|
||||
}
|
||||
|
||||
fn folder_breadcrumbs(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
let parts: Vec<&str> = path.split(['/', '\\']).filter(|p| !p.is_empty()).collect();
|
||||
// With all sidebars open even four ellipses and their separators can exceed the row.
|
||||
// Prefer the current folder, showing more ancestors as space permits.
|
||||
let visible = ((ui.available_width() / 80.0) as usize).clamp(1, 4);
|
||||
let skip = parts.len().saturating_sub(visible);
|
||||
if skip > 0 {
|
||||
ui.label(egui::RichText::new("… ›").color(t.text_dim)).on_hover_text(path);
|
||||
}
|
||||
let separator_w = ui.painter().layout_no_wrap("›".into(), t.font(13.0), t.text_dim).size().x + 8.0;
|
||||
for (i, part) in parts.iter().enumerate().skip(skip) {
|
||||
let remaining = parts.len() - i;
|
||||
let last = remaining == 1;
|
||||
// Share the bounded row among the remaining crumbs, leaving room for every separator.
|
||||
let width = ((ui.available_width() - separator_w * (remaining - 1) as f32) / remaining as f32).max(0.0).floor();
|
||||
let text = egui::RichText::new(*part).size(13.0).color(if last { t.text } else { t.text_label });
|
||||
let r = ui.add_sized(vec2(width, 24.0), egui::Label::new(if last { text.strong() } else { text }).truncate().sense(Sense::click()));
|
||||
register(ui.ctx(), format!("crumb:{i}"), r.rect);
|
||||
if !last {
|
||||
if r.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
if r.clicked() {
|
||||
let prefix = if path.starts_with('/') { format!("/{}", parts[..=i].join("/")) } else { parts[..=i].join("/") };
|
||||
let _ = app.run("library.browse", json!({"path": prefix}));
|
||||
}
|
||||
ui.label(egui::RichText::new("›").size(13.0).color(t.text_dim));
|
||||
}
|
||||
r.on_hover_text(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// While photos are dragged: a badge at the pointer; the drag ends when the button is up
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
use egui::{Rect, vec2};
|
||||
|
||||
use crate::{LightcraftApp, Services, headless::HeadlessView, i18n::Locale};
|
||||
|
||||
fn text_bounds(shape: &egui::Shape, out: &mut Vec<Rect>) {
|
||||
match shape {
|
||||
egui::Shape::Text(text) => out.push(text.visual_bounding_rect()),
|
||||
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| text_bounds(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_folder_header_text_and_controls_do_not_overlap() {
|
||||
for locale in [Locale::En, Locale::De, Locale::PtBr] {
|
||||
for width in [136.0, 200.0, 320.0, 590.0, 1100.0] {
|
||||
for scale in [1.0, 2.0] {
|
||||
for (path, local_n) in [
|
||||
("/Volumes/External drive/Photography archive/Trips and holidays/A very long folder name with many photographs to import", 2408),
|
||||
("/A very long folder name with many photographs to import", 2408),
|
||||
(r"C:\Users\Photographer\Pictures\A very long folder name with many photographs to import", 2408),
|
||||
("/Pictures", 0),
|
||||
] {
|
||||
let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default());
|
||||
let browse = lightcraft_engine::Browse { path: path.into(), subfolders: false };
|
||||
let ctx = egui::Context::default();
|
||||
crate::i18n::set_language(locale);
|
||||
crate::theme::install_fonts(&ctx);
|
||||
crate::theme::apply(&ctx);
|
||||
let counted = crate::i18n::tr_format!("{sel_n} selected · {counted}", sel_n = 2408, counted = "2408 photos");
|
||||
let mut texts = Vec::new();
|
||||
let mut widgets = Vec::new();
|
||||
let mut header = Rect::NOTHING;
|
||||
for frame in 0..3 {
|
||||
let raw = HeadlessView::raw_input(vec2(width, 300.0), scale, frame as f64 / 60.0, vec![]);
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| {
|
||||
header = super::folder_header(&mut app, ui, &browse, &[], local_n, &counted);
|
||||
let (grid, _) = ui.allocate_exact_size(vec2(ui.available_width(), 100.0), egui::Sense::hover());
|
||||
assert!(grid.top() >= header.bottom(), "wrapped controls must reserve space above the grid");
|
||||
});
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
texts.clear();
|
||||
for shape in &out.shapes {
|
||||
text_bounds(&shape.shape, &mut texts);
|
||||
}
|
||||
widgets = crate::widgets::take_registry(&ctx);
|
||||
}
|
||||
crate::i18n::set_language(Locale::En);
|
||||
assert!(header.left() >= 0.0 && header.right() <= width, "header must not expand past the centre pane: {header:?}");
|
||||
let check = widgets.iter().find(|(id, _)| id == "check:includeSubfolders").unwrap().1;
|
||||
assert!(widgets.iter().any(|(id, _)| id.starts_with("crumb:")));
|
||||
assert_eq!(widgets.iter().any(|(id, _)| id == "button:addToLibrary"), local_n > 0);
|
||||
for (id, rect) in &widgets {
|
||||
assert!(header.contains_rect(*rect), "{locale:?} {width} {scale}: {id} outside header: {rect:?}");
|
||||
if id.starts_with("crumb:") {
|
||||
assert!(!rect.intersects(check), "breadcrumb overlaps Include subfolders");
|
||||
}
|
||||
}
|
||||
for (i, a) in texts.iter().enumerate() {
|
||||
assert!(header.expand(1.0).contains_rect(*a), "{locale:?} {width} {scale}: text outside header: {a:?}");
|
||||
for b in texts.iter().skip(i + 1) {
|
||||
assert!(!a.intersects(*b), "{locale:?} {width} {scale}: painted text overlaps: {a:?} / {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,17 @@ Overrides (read once at launch):
|
||||
the compute device always used Vulkan + DX12 (+ Metal).
|
||||
- `LIGHTCRAFT_GPU=0`: GPU rendering off for the process (the window is unaffected).
|
||||
|
||||
**DX12 shader compiler (issue #471).** Every instance (window and compute) compiles DX12 shaders with
|
||||
FXC (`d3dcompiler_47.dll`, part of Windows; `lightcraft_gpu::backend::backend_options`). wgpu's
|
||||
default, `Auto`, loads whichever `dxcompiler.dll` the DLL search path finds first. LightCraft ships
|
||||
none, so that copy belongs to another program (a Windows SDK, a folder on `PATH`). An older copy without
|
||||
`dxil.dll` beside it (e.g. DXC 1.7) warns that the DXIL is unsigned, wgpu treats the warning as a
|
||||
compile error, its indirect-validation pipelines fail, the device is lost, and the window never opened
|
||||
(`WGPU error: Parent device is lost`). `WGPU_DX12_COMPILER=dxc | auto | fxc` (wgpu's variable) still
|
||||
chooses; with `dxc`, put `dxcompiler.dll` *and* `dxil.dll` (DXC ≥ 1.8.2502) next to `lightcraft.exe`.
|
||||
FXC builds the compute kernels somewhat slower than DXC (≈ 9 s vs 7.5 s for the GPU test suite on an
|
||||
RTX 4080), off the startup path.
|
||||
|
||||
**Off the startup path.** The compute device is created on a background thread once the window is
|
||||
up (`gpu::warm_up` from the first frame's settings), and never while GPU rendering is off: with
|
||||
Settings ▸ Performance ▸ *Use the GPU for rendering* unchecked (applied before the window opens),
|
||||
|
||||
+2
-2
@@ -122,8 +122,8 @@ are the same in every version, so serve them with `Cache-Control: no-cache` (not
|
||||
a reload); a notice says why.
|
||||
- One tab at a time: the page holds a Web Lock (`navigator.locks`, `lightcraft-library`); a
|
||||
second tab or window shows "LightCraft is already open in another tab" instead of loading
|
||||
its own copy (two copies would overwrite each other's saves). Browsers without Web Locks
|
||||
aren't protected.
|
||||
its own copy (two copies would overwrite each other's saves). Browsers without Web Locks, or
|
||||
that refuse the lock request, aren't protected: the app starts without the guard.
|
||||
- If the stored library can't be opened, a notice says the session is temporary and nothing
|
||||
is saved; the stored library is left as it was.
|
||||
- A panic (wasm is built with `panic = "abort"`) replaces the page with a message saying the
|
||||
|
||||
@@ -151,6 +151,9 @@
|
||||
ignored before) chooses the graphics backend; `off` renders on the CPU. The GPU now starts after the window
|
||||
is up, and only when Settings ▸ Performance ▸ Use the GPU for rendering is on; if LightCraft ever dies while
|
||||
starting the GPU, the next launch starts with GPU rendering off and says how to turn it back on.
|
||||
- The Windows app no longer quits at launch with "Parent device is lost" when another program's `dxcompiler.dll` is
|
||||
on the DLL search path without its `dxil.dll` (issue #471): DirectX 12 shaders now always compile with the
|
||||
compiler built into Windows (FXC). `WGPU_DX12_COMPILER=dxc` uses a `dxcompiler.dll` instead.
|
||||
- Exports and renders never write over a photo's original (issue #93): exporting into the photo's own folder with
|
||||
the same name and "Overwrite" (or Export with Previous repeating it), an exact output path from the control
|
||||
channel or MCP, a merge preview path or `lightcraft-cli render IMG.jpg -o IMG.jpg` is refused with a clear
|
||||
|
||||
Reference in new issue
Block a user