mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-31 21:48:53 -06:00
Merge track r3-t04
# Conflicts: # docs/gaps.md # docs/parity-checklist.md # docs/whats-new.md
This commit is contained in:
@@ -451,6 +451,36 @@ information combined with LTO and one codegen unit on low-memory builders. A PKG
|
||||
its own profile settings can override inherited variables; adjust the packaging recipe locally or
|
||||
contact its maintainer rather than assuming an environment prefix on `makepkg` will take precedence.
|
||||
|
||||
**Linux/Arch build troubleshooting: rustc SIGILL with ThinLTO.**
|
||||
In [#2811](https://github.com/storytold/lightcraft/issues/2811), an Arch Linux x86_64 build on an
|
||||
AMD Ryzen 9 7900X3D with 64 GB RAM failed with rustc 1.99.0 / LLVM 23.1.1, AUR `lightcraft`
|
||||
`0.7.1-1` and `0.13.0-1`, `RUSTFLAGS="-C opt-level=3 -C target-cpu=native"`, and release settings
|
||||
`opt-level=3`, `lto=thin`, `codegen-units=1`. `cargo build --frozen --release` reached the executable
|
||||
crates before rustc crashed with signal 4 (`SIGILL` / `ILL_ILLOPN`). The core dumps point into LLVM's
|
||||
SelectionDAG / vector-combine optimization path; the exact compiler root cause has not been isolated.
|
||||
This does not mean every machine with that hardware or toolchain will fail. It is a different observed
|
||||
failure from the 16 GB OOM kills in [#1196](https://github.com/storytold/lightcraft/issues/1196) above;
|
||||
there is no evidence establishing a shared root cause.
|
||||
|
||||
For a direct Cargo source build, the equivalent command-local LTO override is:
|
||||
|
||||
```sh
|
||||
CARGO_PROFILE_RELEASE_LTO=off cargo build --frozen --release
|
||||
```
|
||||
|
||||
Reporter @rwarmstr verified the workaround by exporting `CARGO_PROFILE_RELEASE_LTO=off` **inside the
|
||||
AUR PKGBUILD's `_srcenv()` function** and rebuilding both package versions, without changing
|
||||
`RUSTFLAGS`. The reporter tested the AUR recipe, not the direct Cargo invocation above.
|
||||
A variable prefixed to `makepkg` alone may be overwritten by the PKGBUILD's own exports; the recipe
|
||||
must honor or set the override. The AUR `!lto` opt-out behavior is tracked separately in
|
||||
[#2813](https://github.com/storytold/lightcraft/issues/2813).
|
||||
|
||||
[Cargo's LTO documentation](https://doc.rust-lang.org/cargo/reference/profiles.html#lto) distinguishes
|
||||
`false` from `off`: `false` can still perform thin local LTO across codegen units (unless
|
||||
`codegen-units=1` or `opt-level=0`); `off` disables LTO explicitly. The low-memory recipe above is
|
||||
unchanged. This workaround is for affected builds, not a new default; disabling LTO may change
|
||||
performance, binary size and build behavior.
|
||||
|
||||
Tests that need a GPU, the corpus, the craft-fonts faces or a model pass without checking anything when
|
||||
those are missing (libtest has no "skipped" status), so `cargo xtask ci` lists them: tests record a skip
|
||||
with `lightcraft_testkit::skip!`, and the run ends with what the machine offered (OS, CPUs, RAM, GPU
|
||||
|
||||
@@ -384,6 +384,34 @@ CARGO_BUILD_JOBS=1 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_PROFILE_RELEASE_LTO=false
|
||||
设置可以覆盖继承的环境变量;应在本地调整打包配方或联系维护者,而不要假定给
|
||||
`makepkg` 添加环境变量前缀就能优先生效。
|
||||
|
||||
**Linux/Arch 构建故障:rustc 在 ThinLTO 阶段发生 SIGILL。**
|
||||
在 [#2811](https://github.com/storytold/lightcraft/issues/2811) 中,报告者的 Arch Linux x86_64
|
||||
机器配备 AMD Ryzen 9 7900X3D 和 64 GB RAM,使用 rustc 1.99.0 / LLVM 23.1.1、AUR
|
||||
`lightcraft` `0.7.1-1` 和 `0.13.0-1`、`RUSTFLAGS="-C opt-level=3 -C target-cpu=native"`,
|
||||
以及 release 设置 `opt-level=3`、`lto=thin`、`codegen-units=1`。
|
||||
`cargo build --frozen --release` 在编译到可执行 crate 时,rustc 因 signal 4
|
||||
(`SIGILL` / `ILL_ILLOPN`)崩溃。core dump 指向 LLVM 的 SelectionDAG / vector-combine
|
||||
优化路径;编译器的具体根因尚未确定。这不表示所有使用这些硬件或工具链的机器都会失败。
|
||||
此故障与上文 [#1196](https://github.com/storytold/lightcraft/issues/1196) 中 16 GB 机器上的
|
||||
OOM 终止表现不同,没有证据表明两者根因相同。
|
||||
|
||||
直接使用 Cargo 从源码构建时,等价的单条命令 LTO 覆盖方式是:
|
||||
|
||||
```sh
|
||||
CARGO_PROFILE_RELEASE_LTO=off cargo build --frozen --release
|
||||
```
|
||||
|
||||
报告者 @rwarmstr 验证成功的是:在 **AUR PKGBUILD 的 `_srcenv()` 函数内部**导出
|
||||
`CARGO_PROFILE_RELEASE_LTO=off` 后重新构建上述两个版本,无需更改 `RUSTFLAGS`。
|
||||
报告者测试的是 AUR 配方,并未单独验证上面的直接 Cargo 命令。仅在 `makepkg` 前添加变量,
|
||||
可能会被 PKGBUILD 自己的 export 覆盖;打包配方必须保留或设置该覆盖值。
|
||||
AUR 的 `!lto` 选项行为由 [#2813](https://github.com/storytold/lightcraft/issues/2813) 单独跟踪。
|
||||
|
||||
[Cargo 的 LTO 文档](https://doc.rust-lang.org/cargo/reference/profiles.html#lto) 区分了 `false`
|
||||
和 `off`:`false` 仍可能在代码生成单元之间执行 thin local LTO(除非 `codegen-units=1`
|
||||
或 `opt-level=0`);`off` 则明确禁用 LTO。上文的低内存配方保持不变。
|
||||
此绕过方法仅针对受影响的构建,不是新的默认建议;禁用 LTO 可能改变性能、二进制大小和构建行为。
|
||||
|
||||
**中文和日文文字**需要共享字体仓库,这是一个可选的构建输入(所有正式发布版都会包含它):
|
||||
|
||||
```sh
|
||||
|
||||
@@ -402,6 +402,34 @@ CARGO_BUILD_JOBS=1 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_PROFILE_RELEASE_LTO=false
|
||||
PKGBUILD 可能會覆寫繼承的環境變數;請在本機調整打包腳本或聯絡維護者,而不要假設替
|
||||
`makepkg` 加上環境變數前綴就能優先生效。
|
||||
|
||||
**Linux/Arch 建置故障:rustc 在 ThinLTO 階段發生 SIGILL。**
|
||||
在 [#2811](https://github.com/storytold/lightcraft/issues/2811) 中,回報者的 Arch Linux x86_64
|
||||
機器配備 AMD Ryzen 9 7900X3D 與 64 GB RAM,使用 rustc 1.99.0 / LLVM 23.1.1、AUR
|
||||
`lightcraft` `0.7.1-1` 與 `0.13.0-1`、`RUSTFLAGS="-C opt-level=3 -C target-cpu=native"`,
|
||||
以及 release 設定 `opt-level=3`、`lto=thin`、`codegen-units=1`。
|
||||
`cargo build --frozen --release` 在編譯到可執行 crate 時,rustc 因 signal 4
|
||||
(`SIGILL` / `ILL_ILLOPN`)當機。core dump 指向 LLVM 的 SelectionDAG / vector-combine
|
||||
最佳化路徑;編譯器的具體根因尚未確定。這不表示所有使用這些硬體或工具鏈的機器都會失敗。
|
||||
此故障與上文 [#1196](https://github.com/storytold/lightcraft/issues/1196) 中 16 GB 機器上的
|
||||
OOM 終止表現不同,沒有證據顯示兩者根因相同。
|
||||
|
||||
直接使用 Cargo 從原始碼建置時,等價的單條指令 LTO 覆寫方式是:
|
||||
|
||||
```sh
|
||||
CARGO_PROFILE_RELEASE_LTO=off cargo build --frozen --release
|
||||
```
|
||||
|
||||
回報者 @rwarmstr 驗證成功的是:在 **AUR PKGBUILD 的 `_srcenv()` 函式內部**匯出
|
||||
`CARGO_PROFILE_RELEASE_LTO=off` 後重新建置上述兩個版本,無須變更 `RUSTFLAGS`。
|
||||
回報者測試的是 AUR 腳本,並未單獨驗證上面的直接 Cargo 指令。僅在 `makepkg` 前加上變數,
|
||||
可能會被 PKGBUILD 自己的 export 覆寫;打包腳本必須保留或設定該覆寫值。
|
||||
AUR 的 `!lto` 選項行為由 [#2813](https://github.com/storytold/lightcraft/issues/2813) 另行追蹤。
|
||||
|
||||
[Cargo 的 LTO 文件](https://doc.rust-lang.org/cargo/reference/profiles.html#lto) 區分了 `false`
|
||||
與 `off`:`false` 仍可能在程式碼產生單元之間執行 thin local LTO(除非 `codegen-units=1`
|
||||
或 `opt-level=0`);`off` 則明確停用 LTO。上文的低記憶體建置配方維持不變。
|
||||
此繞過方法僅適用於受影響的建置,不是新的預設建議;停用 LTO 可能改變效能、二進位檔大小與建置行為。
|
||||
|
||||
測試在缺少 GPU、語料庫、craft-fonts 字型或模型時會直接通過而不檢查任何內容(libtest 沒有「已跳過」
|
||||
這個狀態),所以 `cargo xtask ci` 會把它們列出來:測試會用 `lightcraft_testkit::skip!` 記錄跳過原因,
|
||||
執行結束時會列出機器提供的環境(作業系統、CPU、記憶體、每個後端的 GPU 配接器、語料庫、字型)以及每一項
|
||||
|
||||
@@ -77,3 +77,7 @@ sync_github_name = true
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Peaperfish]
|
||||
real_name = "Yule Wang"
|
||||
display_name = "Brian Wang"
|
||||
|
||||
@@ -63,6 +63,7 @@ mod tests {
|
||||
assert_eq!(resolve("Photos/2024:05/a.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Invalid)));
|
||||
#[cfg(not(windows))]
|
||||
assert_eq!(resolve(r"C:\Photos\a.jpg", Some(&doc)), Err(NotFollowed::NotHere));
|
||||
#[cfg(not(windows))]
|
||||
let gvfs = Path::new("/run/user/1000/gvfs/smb-share:server=tokyo,share=photos/catalog.lrcat");
|
||||
#[cfg(not(windows))]
|
||||
assert_eq!(resolve("a.jpg", Some(gvfs)), Ok(gvfs.with_file_name("a.jpg")));
|
||||
|
||||
@@ -227,9 +227,22 @@ impl PreparedExport {
|
||||
if remove_location {
|
||||
let metadata = lightcraft_meta::try_extract(&bytes)
|
||||
.map_err(|e| format!("{label}: can't verify location metadata in this original file: {e}"))?;
|
||||
if metadata.gps.is_some() {
|
||||
if !bytes.starts_with(b"8BPS") {
|
||||
let embedded = lightcraft_meta::embedded(&bytes);
|
||||
if let Some(xmp) = embedded.xmp.as_deref() {
|
||||
lightcraft_meta::parse_xmp(xmp).map_err(|e| {
|
||||
format!("{label}: can't verify location metadata in this original file: malformed embedded XMP: {e}")
|
||||
})?;
|
||||
}
|
||||
}
|
||||
let embeds_place_name =
|
||||
[metadata.sublocation.as_deref(), metadata.city.as_deref(), metadata.state.as_deref(), metadata.country.as_deref()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|value| !value.trim().is_empty());
|
||||
if metadata.gps.is_some() || embeds_place_name {
|
||||
return Err(format!(
|
||||
"{label}: can't remove the location from an original file that embeds GPS; export as JPEG, TIFF or DNG instead"
|
||||
"{label}: can't remove the location from an original file that embeds GPS or place names; export as JPEG or TIFF, or as DNG for a raw photo"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -916,6 +916,51 @@ fn distinct_levels(bytes: &[u8]) -> (usize, lightcraft_codecs::Decoded) {
|
||||
(v.len(), d)
|
||||
}
|
||||
|
||||
/// A 16-bit render of a photo-like scene: smooth colour gradients plus sensor-like noise of
|
||||
/// `noise` (linear, about 0.002 to 0.03 for clean to high-ISO raws), the case where the low
|
||||
/// byte of every 16-bit sample is mostly noise.
|
||||
fn noisy_render(noise: f32) -> lightcraft_pipeline::Rendered {
|
||||
let (w, h) = (512, 384);
|
||||
// a hash of the position and channel: the same noise every run
|
||||
let hash = |x: usize, y: usize, c: usize| {
|
||||
let mut z = (x as u64) << 32 ^ (y as u64) << 2 ^ c as u64 ^ 0x2882;
|
||||
z = (z ^ (z >> 33)).wrapping_mul(0xff51_afd7_ed55_8ccd);
|
||||
z = (z ^ (z >> 33)).wrapping_mul(0xc4ce_b9fe_1a85_ec53);
|
||||
(z ^ (z >> 33)) as f32 / u64::MAX as f32 - 0.5
|
||||
};
|
||||
let src = lightcraft_raster::Rgb32f::from_fn(w, h, |x, y| {
|
||||
let (u, v) = (x as f32 / w as f32, y as f32 / h as f32);
|
||||
let base = [0.05 + 0.4 * u, 0.1 + 0.3 * v, 0.2 + 0.2 * (u * v)];
|
||||
[0, 1, 2].map(|c| (base[c] + noise * hash(x, y, c)).max(0.0))
|
||||
});
|
||||
let req = lightcraft_pipeline::RenderRequest { depth: OutputDepth::U16, ..lightcraft_pipeline::RenderRequest::fit(w, h) };
|
||||
lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &req)
|
||||
}
|
||||
|
||||
/// Issue #2882: a 16-bit TIFF with ZIP (the default) is never larger than the same render
|
||||
/// uncompressed, from smooth to noisy. (LZW can be: it is printed, not checked, and the export
|
||||
/// dialog says so when it is picked.)
|
||||
#[test]
|
||||
fn sixteen_bit_zip_tiff_is_never_larger_than_uncompressed() {
|
||||
assert_eq!(ExportOptions::default().tiff_compression, TiffCompression::Deflate, "ZIP is the default");
|
||||
for noise in [0.0, 0.002, 0.01, 0.03, 0.1] {
|
||||
let r = noisy_render(noise);
|
||||
assert!(matches!(r.deep.as_ref().unwrap().samples, DeepSamples::U16(_)));
|
||||
let size = |tiff_compression| {
|
||||
let o = ExportOptions { format: ExportFormat::Tiff, bit_depth: Some(16), tiff_compression, ..Default::default() };
|
||||
assert_eq!(o.effective_depth(), OutputDepth::U16);
|
||||
encode_rendered(&r, &o, None).unwrap().len()
|
||||
};
|
||||
let (none, zip, lzw) = (size(TiffCompression::None), size(TiffCompression::Deflate), size(TiffCompression::Lzw));
|
||||
eprintln!(
|
||||
"noise {noise}: none {none} B, ZIP {zip} B ({:+.1} %), LZW {lzw} B ({:+.1} %)",
|
||||
100.0 * (zip as f64 / none as f64 - 1.0),
|
||||
100.0 * (lzw as f64 / none as f64 - 1.0)
|
||||
);
|
||||
assert!(zip <= none, "noise {noise}: 16-bit ZIP {zip} B is larger than uncompressed {none} B");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixteen_bit_tiff_and_png_have_no_banding() {
|
||||
let tiff = |bit_depth| ExportOptions { format: ExportFormat::Tiff, bit_depth, ..Default::default() };
|
||||
|
||||
@@ -404,6 +404,7 @@ impl crate::Session {
|
||||
|
||||
/// Synchronous render (CLI, MCP, tests).
|
||||
pub fn render_now(&mut self, id: PhotoId, max_w: usize, max_h: usize) -> Result<Rendered, String> {
|
||||
lightcraft_pipeline::check_output_size(max_w, max_h)?;
|
||||
let job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?;
|
||||
let r = job.run();
|
||||
self.accept(&r);
|
||||
@@ -434,7 +435,17 @@ impl crate::Session {
|
||||
space: lightcraft_pipeline::OutputSpace,
|
||||
depth: lightcraft_pipeline::OutputDepth,
|
||||
) -> Result<RenderJob, String> {
|
||||
lightcraft_pipeline::check_output_size(max_w, max_h)?;
|
||||
let mut job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?;
|
||||
// An export develops from a source at least twice its size, so it is resized down with a good filter
|
||||
// rather than made from a preview just covering it (for a raw, a binned and box-reduced image)
|
||||
let p = self.catalog.photo(id).ok_or("no such photo")?.clone();
|
||||
let level = SourceLevel::for_size(source_edge_needed(&p, &job.settings, true, max_w.max(max_h)).saturating_mul(2));
|
||||
if level.max_edge() > job.level.max_edge() {
|
||||
job.source = self.media.source_ref(&p, level);
|
||||
job.key ^= ((job.level as u64) << 60) ^ ((level as u64) << 60);
|
||||
job.level = level;
|
||||
}
|
||||
// An export renders the full image: never a thumbnail's source developed from a smaller copy of the raw
|
||||
// image that the file keeps (Lightroom's merged DNGs)
|
||||
let reduced = match &job.source {
|
||||
|
||||
@@ -517,3 +517,22 @@ fn variant_jobs_have_their_own_keys_and_hit_the_cache() {
|
||||
assert_ne!(pv.key, s.render_job(id, 800, 600, false, true).unwrap().key);
|
||||
assert_eq!(*pv.settings, a);
|
||||
}
|
||||
|
||||
/// An export develops from a source at least twice its long edge (a 2000 px export of a 6000 px
|
||||
/// photo from the original, not the 2560 px preview, which for a raw is binned and box-reduced),
|
||||
/// while on-screen views keep the cheap preview.
|
||||
#[test]
|
||||
fn an_export_develops_from_a_source_twice_its_size() {
|
||||
use lightcraft_pipeline::{OutputDepth, OutputSpace};
|
||||
let mut s = crate::Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
let p = s.catalog.photo(id).unwrap().clone();
|
||||
assert!(p.width.max(p.height) >= 6000, "a camera-sized demo: {}×{}", p.width, p.height);
|
||||
let job = s.export_job(id, 2000, 2000, OutputSpace::Srgb, OutputDepth::U8).unwrap();
|
||||
assert_eq!(job.level, SourceLevel::Full, "2000 px export");
|
||||
for edge in [200, 600, 1280, 1281, 2560] {
|
||||
let job = s.export_job(id, edge, edge, OutputSpace::Srgb, OutputDepth::U8).unwrap();
|
||||
assert!(job.level.max_edge() >= 2 * edge, "{edge} px export from {:?}", job.level);
|
||||
}
|
||||
assert_eq!(s.render_job(id, 1600, 1600, false, true).unwrap().level, SourceLevel::Preview, "a view keeps the preview");
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@ use crate::Session;
|
||||
use crate::export::{ExportFormat, ExportOptions, export_photo};
|
||||
use crate::tests_xmp::{synthetic_dng_with, synthetic_psd_with_metadata, temp_dir};
|
||||
|
||||
fn jpeg_with_xmp(metadata: &lightcraft_meta::Metadata) -> Vec<u8> {
|
||||
let pixels = [96u8; 12];
|
||||
let image = lightcraft_codecs::EncodeImage::new(2, 2, 3, lightcraft_codecs::Samples::U8(&pixels));
|
||||
let xmp = lightcraft_meta::write_xmp(metadata, None);
|
||||
let encoded = lightcraft_codecs::EncodeMeta { xmp: Some(&xmp), ..Default::default() };
|
||||
lightcraft_codecs::encode_jpeg(&image, 90, lightcraft_codecs::ChromaSubsampling::S444, &encoded).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_size_export_of_a_cropped_photo_is_not_upscaled() {
|
||||
let mut s = Session::with_demo();
|
||||
@@ -640,6 +648,168 @@ fn remove_location_holds_for_original_and_dng_exports() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_location_refuses_original_with_embedded_place_names() {
|
||||
let dir = temp_dir("export-noloc-place-fields");
|
||||
let value = "Embedded Place";
|
||||
let cases = [
|
||||
("sublocation", lightcraft_meta::Metadata { sublocation: Some(value.into()), ..Default::default() }),
|
||||
("city", lightcraft_meta::Metadata { city: Some(value.into()), ..Default::default() }),
|
||||
("state", lightcraft_meta::Metadata { state: Some(value.into()), ..Default::default() }),
|
||||
("country", lightcraft_meta::Metadata { country: Some(value.into()), ..Default::default() }),
|
||||
];
|
||||
let mut paths = Vec::new();
|
||||
for (name, metadata) in &cases {
|
||||
let path = dir.join(format!("{name}.jpg"));
|
||||
std::fs::write(&path, jpeg_with_xmp(metadata)).unwrap();
|
||||
paths.push(path.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
let mut session = Session::new().with_fs();
|
||||
session.execute("library.import", &json!({"paths": paths})).unwrap();
|
||||
let options = ExportOptions { format: ExportFormat::Original, remove_location: true, ..Default::default() };
|
||||
for (name, _) in cases {
|
||||
let photo_id = session.catalog.photos().find(|p| p.file_name == format!("{name}.jpg")).unwrap().id;
|
||||
let original = std::fs::read(dir.join(format!("{name}.jpg"))).unwrap();
|
||||
let embedded = lightcraft_meta::try_extract(&original).unwrap();
|
||||
assert!(embedded.gps.is_none(), "{name} fixture should isolate place names");
|
||||
let place_name = match name {
|
||||
"sublocation" => embedded.sublocation.as_deref(),
|
||||
"city" => embedded.city.as_deref(),
|
||||
"state" => embedded.state.as_deref(),
|
||||
"country" => embedded.country.as_deref(),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(place_name, Some(value), "{name} fixture should be parsed");
|
||||
let error = export_photo(&mut session, photo_id, &options, 1).err().expect("embedded place name should refuse Original export");
|
||||
assert!(error.contains("place names") && error.contains("export as JPEG or TIFF"), "{name}: {error}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_location_refuses_original_with_unreadable_non_psd_xmp() {
|
||||
let dir = temp_dir("export-noloc-malformed-xmp");
|
||||
let path = dir.join("Unreadable.jpg");
|
||||
let pixels = [96u8; 12];
|
||||
let image = lightcraft_codecs::EncodeImage::new(2, 2, 3, lightcraft_codecs::Samples::U8(&pixels));
|
||||
let xmp = "<x:xmpmeta><broken";
|
||||
let encoded = lightcraft_codecs::EncodeMeta { xmp: Some(xmp), ..Default::default() };
|
||||
let bytes = lightcraft_codecs::encode_jpeg(&image, 90, lightcraft_codecs::ChromaSubsampling::S444, &encoded).unwrap();
|
||||
assert!(lightcraft_meta::parse_xmp(xmp).is_err(), "fixture XMP should be malformed");
|
||||
let metadata = lightcraft_meta::try_extract(&bytes).unwrap();
|
||||
assert!(metadata.gps.is_none() && metadata.city.is_none(), "best-effort extraction omits malformed XMP");
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
|
||||
let mut session = Session::new().with_fs();
|
||||
session.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap();
|
||||
let id = session.catalog.photos().next().unwrap().id;
|
||||
let options = ExportOptions { format: ExportFormat::Original, remove_location: true, ..Default::default() };
|
||||
let error = export_photo(&mut session, id, &options, 1).err().expect("unreadable XMP must not pass location removal");
|
||||
assert!(error.contains("can't verify location metadata") && error.contains("malformed embedded XMP"), "{error}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_location_original_place_refusal_writes_no_files() {
|
||||
use crate::export::{Destination, prepare_batch, run_batch};
|
||||
|
||||
let dir = temp_dir("export-noloc-place-no-write");
|
||||
let source = dir.join("City.jpg");
|
||||
let original = jpeg_with_xmp(&lightcraft_meta::Metadata { city: Some("Embedded Place".into()), ..Default::default() });
|
||||
std::fs::write(&source, &original).unwrap();
|
||||
let output = dir.join("out");
|
||||
std::fs::create_dir_all(&output).unwrap();
|
||||
|
||||
let mut session = Session::new().with_fs();
|
||||
session.execute("library.import", &json!({"paths": [source.to_string_lossy()]})).unwrap();
|
||||
let id = session.catalog.photos().next().unwrap().id;
|
||||
let options = ExportOptions { format: ExportFormat::Original, remove_location: true, ..Default::default() };
|
||||
let items = prepare_batch(&mut session, &[id], &options).unwrap();
|
||||
let destination = Destination { dir: output.to_string_lossy().to_string(), exact: None };
|
||||
let mut writes = Vec::new();
|
||||
let mut write = |path: &str, bytes: &[u8]| -> Result<(), String> {
|
||||
writes.push(path.to_string());
|
||||
std::fs::write(path, bytes).map_err(|error| error.to_string())
|
||||
};
|
||||
let exists = |path: &str| std::path::Path::new(path).exists();
|
||||
let result = run_batch(items, &options, &destination, &mut write, &exists, true, &mut |_, _| true);
|
||||
|
||||
let error = result.unwrap_err();
|
||||
assert!(error.contains("place names"), "{error}");
|
||||
assert!(writes.is_empty(), "refused export called the writer: {writes:?}");
|
||||
assert!(!output.join("City.jpg").exists());
|
||||
assert!(!output.join("City.xmp").exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_location_checks_embedded_places_after_catalog_clear() {
|
||||
let dir = temp_dir("export-noloc-place-catalog-clear");
|
||||
let source = dir.join("City.jpg");
|
||||
let original = jpeg_with_xmp(&lightcraft_meta::Metadata { city: Some("Embedded Place".into()), ..Default::default() });
|
||||
std::fs::write(&source, &original).unwrap();
|
||||
|
||||
let mut session = Session::new().with_fs();
|
||||
session.execute("library.import", &json!({"paths": [source.to_string_lossy()]})).unwrap();
|
||||
let id = session.catalog.photos().next().unwrap().id;
|
||||
assert_eq!(session.catalog.photo(id).unwrap().meta.city, "Embedded Place");
|
||||
session.execute("photo.setMeta", &json!({"ids": [id.0], "city": ""})).unwrap();
|
||||
assert!(session.catalog.photo(id).unwrap().meta.city.is_empty());
|
||||
assert_eq!(lightcraft_meta::try_extract(&std::fs::read(&source).unwrap()).unwrap().city.as_deref(), Some("Embedded Place"));
|
||||
|
||||
let options = ExportOptions { format: ExportFormat::Original, remove_location: true, ..Default::default() };
|
||||
let error = export_photo(&mut session, id, &options, 1).err().expect("embedded place name should refuse Original export");
|
||||
assert!(error.contains("place names"), "{error}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_location_allows_original_without_embedded_location() {
|
||||
let dir = temp_dir("export-noloc-place-controls");
|
||||
let embedded_path = dir.join("Embedded.jpg");
|
||||
let no_location_path = dir.join("NoLocation.jpg");
|
||||
let blank_path = dir.join("Blank.jpg");
|
||||
let embedded = jpeg_with_xmp(&lightcraft_meta::Metadata { city: Some("Embedded Place".into()), ..Default::default() });
|
||||
let no_location = jpeg_with_xmp(&Default::default());
|
||||
let blank = jpeg_with_xmp(&lightcraft_meta::Metadata { city: Some(" \t".into()), ..Default::default() });
|
||||
std::fs::write(&embedded_path, &embedded).unwrap();
|
||||
std::fs::write(&no_location_path, &no_location).unwrap();
|
||||
std::fs::write(&blank_path, &blank).unwrap();
|
||||
assert!(lightcraft_meta::try_extract(&blank).unwrap().city.is_none(), "whitespace-only XMP place names are blank");
|
||||
|
||||
let mut session = Session::new().with_fs();
|
||||
session
|
||||
.execute(
|
||||
"library.import",
|
||||
&json!({"paths": [embedded_path.to_string_lossy(), no_location_path.to_string_lossy(), blank_path.to_string_lossy()]}),
|
||||
)
|
||||
.unwrap();
|
||||
let photo_id = |name: &str| session.catalog.photos().find(|p| p.file_name == name).unwrap().id;
|
||||
let embedded_id = photo_id("Embedded.jpg");
|
||||
let no_location_id = photo_id("NoLocation.jpg");
|
||||
let blank_id = photo_id("Blank.jpg");
|
||||
|
||||
let keep = ExportOptions { format: ExportFormat::Original, ..Default::default() };
|
||||
let kept = export_photo(&mut session, embedded_id, &keep, 1).unwrap();
|
||||
assert_eq!(kept.bytes, embedded, "removeLocation=false still copies the source byte for byte");
|
||||
assert_eq!(lightcraft_meta::try_extract(&kept.bytes).unwrap().city.as_deref(), Some("Embedded Place"));
|
||||
|
||||
let remove = ExportOptions { format: ExportFormat::Original, remove_location: true, ..Default::default() };
|
||||
for (id, source) in [(no_location_id, &no_location), (blank_id, &blank)] {
|
||||
let exported = export_photo(&mut session, id, &remove, 1).unwrap();
|
||||
assert_eq!(&exported.bytes, source);
|
||||
}
|
||||
|
||||
session.execute("photo.setMeta", &json!({"ids": [no_location_id.0], "city": "Catalog-only Place"})).unwrap();
|
||||
let catalog_only = export_photo(&mut session, no_location_id, &remove, 1).unwrap();
|
||||
let sidecar = catalog_only.sidecars.first().unwrap().1.as_slice();
|
||||
let packet = std::str::from_utf8(sidecar).unwrap();
|
||||
assert!(lightcraft_meta::parse_xmp(packet).unwrap().metadata.city.is_none(), "catalog-only place name is removed from the sidecar");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `metadata: "NONE"` as `app.export` sends it: the JPEG carries no location or camera data
|
||||
/// (it used to be read as `all` and carry both).
|
||||
#[test]
|
||||
@@ -1203,3 +1373,39 @@ fn same_folder_as_original_refuses_photos_without_a_folder() {
|
||||
assert!(e.contains("folder is missing") && e.contains(&gone), "{e}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #2882: the demo photo's 16-bit TIFF export (2000 px long edge) with ZIP (the default) is
|
||||
/// never larger than the same export uncompressed.
|
||||
#[test]
|
||||
fn sixteen_bit_zip_tiff_export_is_never_larger_than_uncompressed() {
|
||||
use crate::export::TiffCompression;
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
let size = |s: &mut Session, tiff_compression| {
|
||||
let o = ExportOptions {
|
||||
format: ExportFormat::Tiff,
|
||||
bit_depth: Some(16),
|
||||
tiff_compression,
|
||||
..ExportOptions::from_json(&json!({"longEdge": 2000}))
|
||||
};
|
||||
export_photo(s, id, &o, 1).unwrap().bytes.len()
|
||||
};
|
||||
let none = size(&mut s, TiffCompression::None);
|
||||
let zip = size(&mut s, TiffCompression::Deflate);
|
||||
let lzw = size(&mut s, TiffCompression::Lzw);
|
||||
eprintln!("demo photo, 16-bit TIFF: none {none} B, ZIP {zip} B, LZW {lzw} B");
|
||||
assert!(zip <= none, "16-bit ZIP {zip} B is larger than uncompressed {none} B");
|
||||
}
|
||||
|
||||
/// An absurd export size is refused, not a multiply overflow (it panicked before the fix).
|
||||
#[test]
|
||||
fn huge_export_size_is_an_error() {
|
||||
use lightcraft_pipeline::{OutputDepth, OutputSpace};
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
let huge = usize::MAX >> 8;
|
||||
assert!(s.render_export(id, huge, huge, OutputSpace::Srgb, OutputDepth::U8).is_err());
|
||||
assert!(s.render_export(id, 70_000, 10, OutputSpace::Srgb, OutputDepth::U8).is_err());
|
||||
assert!(s.render_now(id, huge, huge).is_err());
|
||||
assert!(s.render_now(id, 10, 70_000).is_err());
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ 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);
|
||||
// Tests that take a folder "offline" rename it to this sibling; a left-over one (from a run
|
||||
// whose process id is reused) is non-empty and would make that rename fail.
|
||||
let _ = std::fs::remove_dir_all(d.with_extension("offline"));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
@@ -39,7 +42,9 @@ fn recursive_discovery_includes_sony_sr2_and_srf_case_insensitively() {
|
||||
let src = temp_dir("sony-discovery");
|
||||
std::fs::create_dir_all(src.join("trip/day2")).unwrap();
|
||||
let names = ["r1.SR2", "f828.srf", "trip/r1.sr2", "trip/f828.SRF", "trip/day2/r1.Sr2", "trip/day2/f828.SrF", "control.JPG"];
|
||||
let mut expected: Vec<String> = names.iter().map(|name| src.join(name).to_string_lossy().into_owned()).collect();
|
||||
// one component at a time: `join("trip/r1.sr2")` keeps the `/`, discovery yields the platform's separator
|
||||
let mut expected: Vec<String> =
|
||||
names.iter().map(|name| name.split('/').fold(src.clone(), |p, c| p.join(c)).to_string_lossy().into_owned()).collect();
|
||||
for path in &expected {
|
||||
std::fs::write(path, b"discovery only, not a decoded photo").unwrap();
|
||||
}
|
||||
@@ -1433,6 +1438,11 @@ fn smart_previews_folder_is_chosen_per_library() {
|
||||
let r = again.execute("library.smartPreviewsLocation", &json!({"reset": true})).unwrap();
|
||||
assert_eq!(r["custom"], false, "{r}");
|
||||
assert_eq!(r["path"], lib.join("Smart Previews").to_string_lossy().as_ref());
|
||||
// (the open library's files are removed below: on Windows an open file cannot be)
|
||||
drop(again);
|
||||
for d in [&lib, &away, &src.with_extension("offline")] {
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
}
|
||||
|
||||
/// Missing Photos covers library photos only: a small library next to many Local browse
|
||||
|
||||
@@ -137,7 +137,8 @@ impl Url {
|
||||
}
|
||||
};
|
||||
let port = match port {
|
||||
Some(p) => p.parse::<u16>().ok().filter(|p| *p != 0).ok_or_else(|| bad("bad port"))?,
|
||||
Some(p) if p.bytes().all(|b| b.is_ascii_digit()) => p.parse::<u16>().ok().filter(|p| *p != 0).ok_or_else(|| bad("bad port"))?,
|
||||
Some(_) => return Err(bad("bad port")),
|
||||
None if tls => 443,
|
||||
None => 80,
|
||||
};
|
||||
@@ -519,7 +520,8 @@ impl Response {
|
||||
if *left == 0 {
|
||||
let line = self.conn.line(1024, limits)?;
|
||||
let hex = line.split(';').next().unwrap_or_default().trim();
|
||||
let size = u64::from_str_radix(hex, 16).map_err(|_| HttpError::Protocol(format!("bad chunk size `{hex}`")))?;
|
||||
let size = if hex.bytes().all(|b| b.is_ascii_hexdigit()) { u64::from_str_radix(hex, 16).ok() } else { None }
|
||||
.ok_or_else(|| HttpError::Protocol(format!("bad chunk size `{hex}`")))?;
|
||||
if size == 0 {
|
||||
// trailers, up to the empty line
|
||||
let mut n = 0;
|
||||
@@ -595,7 +597,10 @@ pub fn get(url: &Url, headers: &[(&str, String)], limits: &Limits) -> Result<Res
|
||||
} else if find("transfer-encoding").is_some_and(|v| v.to_ascii_lowercase().contains("chunked")) {
|
||||
BodyMode::Chunked { left: 0, done: false }
|
||||
} else if let Some(n) = find("content-length") {
|
||||
BodyMode::Length(n.trim().parse::<u64>().map_err(|_| HttpError::Protocol(format!("bad Content-Length `{n}`")))?)
|
||||
// RFC 9110: `1*DIGIT`; `u64::from_str` alone would also take a leading '+'
|
||||
let v = n.trim();
|
||||
let len = if !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit()) { v.parse::<u64>().ok() } else { None };
|
||||
BodyMode::Length(len.ok_or_else(|| HttpError::Protocol(format!("bad Content-Length `{n}`")))?)
|
||||
} else {
|
||||
BodyMode::Close
|
||||
};
|
||||
@@ -652,6 +657,29 @@ mod tests_connect {
|
||||
assert!(r.is_err());
|
||||
assert!(t.elapsed() < Duration::from_millis(1500), "{:?}", t.elapsed());
|
||||
}
|
||||
|
||||
/// Content-Length is `1*DIGIT`: a sign, hex prefix, trailing junk or empty value is refused, not read as a length.
|
||||
#[test]
|
||||
fn signed_or_malformed_content_length_is_refused() {
|
||||
for value in ["+3", "-3", "3x", "", "0x10", "+0"] {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut s, _) = listener.accept().unwrap();
|
||||
let mut buf = [0u8; 1024];
|
||||
let _ = s.read(&mut buf);
|
||||
let _ = s.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {value}\r\n\r\nabc").as_bytes());
|
||||
});
|
||||
let cancel = AtomicBool::new(false);
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{port}/f")).unwrap();
|
||||
match get(&url, &[], &limits(&cancel)) {
|
||||
Err(HttpError::Protocol(m)) => assert!(m.contains("bad Content-Length"), "{value:?}: {m}"),
|
||||
Err(e) => panic!("{value:?}: unexpected error {e:?}"),
|
||||
Ok(r) => panic!("{value:?}: malformed Content-Length accepted (status {})", r.status),
|
||||
}
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -713,6 +741,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_signed_ports() {
|
||||
for bad in ["http://example.com:+80/x", "http://example.com:-80/x", "http://[::1]:+80/x"] {
|
||||
assert!(Url::parse(bad).is_err(), "{bad}");
|
||||
}
|
||||
assert!(Url::parse("http://a.test/").unwrap().join("http://example.com:+80/x").is_err());
|
||||
assert_eq!(Url::parse("http://example.com:80/x").unwrap().port, 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_text_after_a_bracketed_ipv6_host() {
|
||||
for bad in ["http://[::1]junk/x", "https://[::1]junk/x", "http://[::1]8080/x", "http://[::1]8080"] {
|
||||
|
||||
+11
-2
@@ -466,16 +466,25 @@ fn parse_content_range(v: &str) -> Option<(u64, Option<u64>)> {
|
||||
let v = v.trim().strip_prefix("bytes")?.trim();
|
||||
let (range, total) = v.split_once('/')?;
|
||||
let (a, b) = range.split_once('-')?;
|
||||
let (a, b) = (a.trim().parse::<u64>().ok()?, b.trim().parse::<u64>().ok()?);
|
||||
let (a, b) = (digits(a)?, digits(b)?);
|
||||
if b < a {
|
||||
return None;
|
||||
}
|
||||
let total = match total.trim() {
|
||||
"*" => None,
|
||||
t => Some(t.parse::<u64>().ok()?),
|
||||
t => Some(digits(t)?),
|
||||
};
|
||||
Some((a, total))
|
||||
}
|
||||
|
||||
/// A non-empty run of ASCII digits as a `u64` (no sign, unlike `str::parse`).
|
||||
fn digits(s: &str) -> Option<u64> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
s.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -86,6 +86,9 @@ fn handle(mut s: TcpStream, hits: &AtomicUsize) {
|
||||
b.extend_from_slice(&[7u8; 5000]);
|
||||
send(&mut s, "200 OK", "", &b)
|
||||
}
|
||||
"signedchunk" => {
|
||||
let _ = s.write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n+5\r\nhello\r\n0\r\n\r\n");
|
||||
}
|
||||
"chunked" => {
|
||||
let _ = s.write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n");
|
||||
for c in body.chunks(70_001) {
|
||||
@@ -249,6 +252,16 @@ fn a_416_or_a_bad_complete_part_starts_over() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_chunk_size_is_rejected() {
|
||||
let srv = server();
|
||||
let dir = tmp("signedchunk");
|
||||
let f = FileSpec { name: "vocab.json", size: None, sha256: None, max: 1 << 20 };
|
||||
let r = run(&[f], &[format!("{}/signedchunk", srv.base)], &dir, &AtomicBool::new(false)).0;
|
||||
assert!(r.is_err(), "a chunk size of `+5` was accepted");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunked_bodies_and_unpinned_files() {
|
||||
let srv = server();
|
||||
@@ -335,6 +348,10 @@ fn content_ranges() {
|
||||
assert_eq!(parse_content_range("bytes 0-0/*"), Some((0, None)));
|
||||
assert_eq!(parse_content_range("bytes 9-1/300"), None);
|
||||
assert_eq!(parse_content_range("items 1-2/3"), None);
|
||||
assert_eq!(parse_content_range("bytes +100-+299/+300"), None);
|
||||
assert_eq!(parse_content_range("bytes 100-+299/300"), None);
|
||||
assert_eq!(parse_content_range("bytes 100-299/+300"), None);
|
||||
assert_eq!(parse_content_range("bytes 1-/3"), None);
|
||||
}
|
||||
|
||||
/// A bearer token goes only to https, only to the host the download began at, and only when there is one.
|
||||
|
||||
@@ -599,7 +599,10 @@ pub fn render(
|
||||
};
|
||||
let plan = lightcraft_pipeline::plan(src, info, s, req);
|
||||
let (w, h) = (plan.w, plan.h);
|
||||
let n = w * h;
|
||||
let Some(n) = w.checked_mul(h).filter(|n| n.checked_mul(12).is_some()) else {
|
||||
fail(FailKind::Limit, format!("{w}×{h} output is too large"));
|
||||
return None;
|
||||
};
|
||||
let _limit = match fault {
|
||||
Some(crate::Fault::Limit(bytes)) => Some(crate::ctx::LimitOverride::new(bytes)),
|
||||
_ => None,
|
||||
|
||||
@@ -132,7 +132,7 @@ impl Backend for Headless {
|
||||
"engine.commands" => {
|
||||
let mut v: Vec<Value> = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
|
||||
v.push(json!({"id": "app.export", "label": "Export Now", "menu": [], "shortcut": null,
|
||||
"params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.jxl/.psd/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|jxl|psd|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), fit?: dimensions|widthHeight (with width + height: dimensions fits either orientation, widthHeight limits the width to `width` and the height to `height`), dontEnlarge?, ppi?, quality?: 1..100, jxlLossless?, jxlEffort?: fast|normal, jxlProgressive?, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, PQ AVIF / PNG / JPEG XL, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, destinationMode?: specificFolder|sameAsOriginal (sameAsOriginal = each photo into its original's folder; `dir` unused), subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, addToPhotos?: bool (macOS: then add the files written to Apple Photos; outcome in `applePhotos`), photosAlbum?: top-level album name (made when missing), watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, insetX? (margin from the left/right edge, 0..0.4 of the short edge; 0.025), insetY? (from the top/bottom edge; 0.025), inset? (sets both), rotation?: 0|90|180|270 (degrees clockwise; 0), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default",
|
||||
"params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.jxl/.psd/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|jxl|psd|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), fit?: dimensions|widthHeight (with width + height: dimensions fits either orientation, widthHeight limits the width to `width` and the height to `height`), dontEnlarge?, ppi?, quality?: 1..100, jxlLossless?, jxlEffort?: fast|normal, jxlProgressive?, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, PQ AVIF / PNG / JPEG XL, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, destinationMode?: specificFolder|sameAsOriginal (sameAsOriginal = each photo into its original's folder; `dir` unused), subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip (default zip; lzw is usually larger and slower than zip, at 16 bit often larger than none: only for software that needs LZW), dngCompression?: lossless|deflate|uncompressed, addToPhotos?: bool (macOS: then add the files written to Apple Photos; outcome in `applePhotos`), photosAlbum?: top-level album name (made when missing), watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, insetX? (margin from the left/right edge, 0..0.4 of the short edge; 0.025), insetY? (from the top/bottom edge; 0.025), inset? (sets both), rotation?: 0|90|180|270 (degrees clockwise; 0), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default",
|
||||
"enabled": self.session.active().is_some()}));
|
||||
Ok(Value::Array(v))
|
||||
}
|
||||
@@ -207,7 +207,7 @@ mod tests {
|
||||
fn folder_discovery_matches_engine_extensions() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-mcp-discovery-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let nested = dir.join("trip/day2");
|
||||
let nested = dir.join("trip").join("day2"); // not "trip/day2": discovery yields the platform's separator
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
let mut expected = Vec::new();
|
||||
for ext in lightcraft_engine::import::EXTENSIONS {
|
||||
|
||||
@@ -238,7 +238,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
||||
"destinationMode": {"type": "string", "enum": ["specificFolder", "sameAsOriginal"], "description": "specificFolder (default) = everything into `dir`; sameAsOriginal = each photo into the folder of its original (+ `subfolder`); a photo with no folder on disk (demo, browser) or whose folder is gone gets an error"},
|
||||
"subfolder": {"type": "string", "description": "Created inside the destination folder"},
|
||||
"conflict": {"type": "string", "enum": ["unique", "overwrite", "skip"]},
|
||||
"tiffCompression": {"type": "string", "enum": ["none", "lzw", "zip"]},
|
||||
"tiffCompression": {"type": "string", "enum": ["none", "lzw", "zip"], "description": "zip (default); lzw is usually larger and slower than zip, and at 16 bit often larger than none: use it only when other software needs LZW"},
|
||||
"dngCompression": {"type": "string", "enum": ["lossless", "deflate", "uncompressed", "lossy"]},
|
||||
"format": {"type": "string", "enum": ["jpeg", "png", "tiff", "psd", "webp", "avif", "jxl", "original", "dng", "rendereddng"], "description": "psd = a flat PSD (no layers); rendereddng = Render to DNG, a linear 16-bit float DNG of the rendered photo with the edit baked in; original = the file as is + an XMP sidecar with the edits; dng = raw photos as DNG with the edits embedded"},
|
||||
"longEdge": {"type": "integer", "description": "Output long edge in px. When no size param is given: 3000; 0 = full size (cropped, native resolution)"},
|
||||
|
||||
@@ -173,6 +173,21 @@ impl PixelWindow {
|
||||
}
|
||||
}
|
||||
|
||||
/// The longest output side a render may be asked for (TIFF/PNG/JPEG all fit within it).
|
||||
pub const MAX_OUTPUT_SIDE: usize = 65_535;
|
||||
/// The most output pixels a render may be asked for (the box it fits into, ≈ 1.07 Gpx).
|
||||
pub const MAX_OUTPUT_PIXELS: usize = 1 << 30;
|
||||
|
||||
/// Rejects an output box past [`MAX_OUTPUT_SIDE`] or [`MAX_OUTPUT_PIXELS`] before anything is
|
||||
/// sized from it.
|
||||
pub fn check_output_size(max_w: usize, max_h: usize) -> Result<(), String> {
|
||||
let pixels = max_w.checked_mul(max_h);
|
||||
if max_w > MAX_OUTPUT_SIDE || max_h > MAX_OUTPUT_SIDE || pixels.is_none_or(|n| n > MAX_OUTPUT_PIXELS) {
|
||||
return Err(format!("output size {max_w}×{max_h} is too large (at most {MAX_OUTPUT_SIDE} px per side and {MAX_OUTPUT_PIXELS} pixels)"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl RenderRequest {
|
||||
pub fn fit(max_w: usize, max_h: usize) -> Self {
|
||||
Self {
|
||||
|
||||
@@ -18,9 +18,10 @@ mod sam_model;
|
||||
pub(crate) use confirm::album_entries;
|
||||
pub use confirm::confirm_dialog;
|
||||
pub use cull::{MAX_BURST_GAP, clamp_cull_burst, cull_params, fmt_gap, parse_gap};
|
||||
pub(crate) use export::preset_lzw_note;
|
||||
use export::{
|
||||
LIMIT_KB, QUALITY, START_NUMBER, WM_IMAGE_WIDTH, WM_INSET_X, WM_INSET_Y, WM_OPACITY, WM_POSITIONS, WM_SIZE, export_dialog_params, export_size,
|
||||
group_checklist, watermark_preview,
|
||||
group_checklist, tiff_lzw_note, watermark_preview,
|
||||
};
|
||||
pub use frame::{ConfirmStyle, confirm_style, list_height, prompt};
|
||||
use frame::{
|
||||
@@ -1119,7 +1120,12 @@ fn export_preset_row(
|
||||
ui.separator();
|
||||
}
|
||||
builtin = b;
|
||||
if ui.selectable_label(false, crate::i18n::builtin_label(&p.name, b)).clicked() {
|
||||
let r = ui.selectable_label(false, crate::i18n::builtin_label(&p.name, b));
|
||||
let r = match preset_lzw_note(&p.params) {
|
||||
Some(note) => r.on_hover_text(crate::i18n::tr(note)),
|
||||
None => r,
|
||||
};
|
||||
if r.clicked() {
|
||||
chosen = Some(p.name);
|
||||
}
|
||||
}
|
||||
@@ -1386,6 +1392,12 @@ fn export_naming_rows(ui: &mut egui::Ui, opts: &mut ExportOptions) {
|
||||
if opts.format == F::Tiff {
|
||||
use lightcraft_engine::export::TiffCompression as Z;
|
||||
choices(ui, "Compression", "exportTiffCompression", &[(Z::None, "None"), (Z::Lzw, "LZW"), (Z::Deflate, "ZIP")], &mut opts.tiff_compression);
|
||||
// issue #2882: LZW stays as picked, with a note on what it costs
|
||||
if let Some(note) = tiff_lzw_note(opts) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let r = ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim));
|
||||
crate::widgets::register(ui, "note:exportTiffLzw", r.rect);
|
||||
}
|
||||
}
|
||||
if opts.format == F::Dng {
|
||||
use lightcraft_engine::export::DngCompression as Z;
|
||||
|
||||
@@ -119,6 +119,29 @@ pub(super) fn watermark_preview(
|
||||
crate::widgets::register(ui, "image:exportWatermarkPreview", r.rect);
|
||||
}
|
||||
|
||||
/// The 16-bit note: LZW's byte dictionary does worse on 16-bit samples than storing them, so
|
||||
/// 16-bit LZW TIFFs came out 10-33 % larger than uncompressed ones (issue #2882).
|
||||
pub(crate) const TIFF_LZW_NOTE_16: &str =
|
||||
"At 16 bit, LZW is often larger than no compression, and larger and slower than ZIP. Use it only if other software needs LZW.";
|
||||
/// The note at 8 bit and 32-bit float: one 8-bit file in #2882 was larger with LZW too.
|
||||
pub(crate) const TIFF_LZW_NOTE: &str =
|
||||
"LZW is usually larger and slower than ZIP, and can be larger than no compression. Use it only if other software needs LZW.";
|
||||
|
||||
/// The note shown under Compression when a TIFF export uses LZW (none otherwise). The export
|
||||
/// keeps LZW: some software reads only LZW TIFFs.
|
||||
pub(crate) fn tiff_lzw_note(o: &lightcraft_engine::export::ExportOptions) -> Option<&'static str> {
|
||||
use lightcraft_engine::export::{ExportFormat, OutputDepth, TiffCompression};
|
||||
if o.format != ExportFormat::Tiff || o.tiff_compression != TiffCompression::Lzw {
|
||||
return None;
|
||||
}
|
||||
Some(if o.effective_depth() == OutputDepth::U16 { TIFF_LZW_NOTE_16 } else { TIFF_LZW_NOTE })
|
||||
}
|
||||
|
||||
/// [`tiff_lzw_note`] for an export preset's params: the hint on presets that write LZW TIFFs.
|
||||
pub(crate) fn preset_lzw_note(params: &serde_json::Value) -> Option<&'static str> {
|
||||
tiff_lzw_note(&lightcraft_engine::export::ExportOptions::from_json(params))
|
||||
}
|
||||
|
||||
/// The Export dialog's choices as `app.export` params (without the folder).
|
||||
pub(super) fn export_dialog_params(
|
||||
opts: &lightcraft_engine::export::ExportOptions,
|
||||
@@ -179,11 +202,12 @@ pub(super) const QUALITY: ControlSpec = spec("export.quality", "Quality", 1.0, 1
|
||||
|
||||
pub(super) const LIMIT_KB: ControlSpec = spec("export.limitKb", "Limit file size (KB, 0 = off)", 0.0, 20_000.0, 0.0, 10.0);
|
||||
|
||||
const SIZE_PX: ControlSpec = spec("export.sizePx", "Pixels", 16.0, 20_000.0, 2048.0, 16.0);
|
||||
// whole pixels: a typed size is snapped to the step, and 1080 or 1350 must stay what was typed
|
||||
const SIZE_PX: ControlSpec = spec("export.sizePx", "Pixels", 16.0, 20_000.0, 2048.0, 1.0);
|
||||
|
||||
const SIZE_W: ControlSpec = spec("export.sizeW", "Width (px)", 16.0, 20_000.0, 2048.0, 16.0);
|
||||
const SIZE_W: ControlSpec = spec("export.sizeW", "Width (px)", 16.0, 20_000.0, 2048.0, 1.0);
|
||||
|
||||
const SIZE_H: ControlSpec = spec("export.sizeH", "Height (px)", 16.0, 20_000.0, 2048.0, 16.0);
|
||||
const SIZE_H: ControlSpec = spec("export.sizeH", "Height (px)", 16.0, 20_000.0, 2048.0, 1.0);
|
||||
|
||||
const SIZE_MP: ControlSpec = ControlSpec { decimals: 1, ..spec("export.sizeMp", "Megapixels", 0.1, 100.0, 12.0, 0.1) };
|
||||
|
||||
@@ -300,3 +324,109 @@ mod watermark_position_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tiff_lzw_note_tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use lightcraft_engine::export::{ExportFormat, ExportOptions, TiffCompression};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{TIFF_LZW_NOTE, TIFF_LZW_NOTE_16, export_dialog_params, preset_lzw_note, tiff_lzw_note};
|
||||
use crate::headless::Headless;
|
||||
use crate::state::Dialog;
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
|
||||
fn tiff(compression: TiffCompression, bit_depth: Option<u8>) -> ExportOptions {
|
||||
ExportOptions { format: ExportFormat::Tiff, tiff_compression: compression, bit_depth, ..Default::default() }
|
||||
}
|
||||
|
||||
// Issue #2882: the note shows for LZW only, worded more strongly at 16 bit (TIFF's default depth)
|
||||
#[test]
|
||||
fn lzw_gets_a_note_stronger_at_16_bit() {
|
||||
assert_eq!(tiff_lzw_note(&tiff(TiffCompression::Lzw, None)), Some(TIFF_LZW_NOTE_16), "TIFF defaults to 16-bit");
|
||||
assert_eq!(tiff_lzw_note(&tiff(TiffCompression::Lzw, Some(16))), Some(TIFF_LZW_NOTE_16));
|
||||
assert_eq!(tiff_lzw_note(&tiff(TiffCompression::Lzw, Some(8))), Some(TIFF_LZW_NOTE));
|
||||
assert_eq!(tiff_lzw_note(&tiff(TiffCompression::Lzw, Some(32))), Some(TIFF_LZW_NOTE));
|
||||
for compression in [TiffCompression::None, TiffCompression::Deflate, TiffCompression::PackBits] {
|
||||
for depth in [None, Some(8), Some(16), Some(32)] {
|
||||
assert_eq!(tiff_lzw_note(&tiff(compression, depth)), None, "{compression:?} {depth:?}");
|
||||
}
|
||||
}
|
||||
// the setting only matters for TIFF: a JPEG preset that carries it gets no note
|
||||
let jpeg = ExportOptions { format: ExportFormat::Jpeg, tiff_compression: TiffCompression::Lzw, ..Default::default() };
|
||||
assert_eq!(tiff_lzw_note(&jpeg), None);
|
||||
assert!(TIFF_LZW_NOTE_16.contains("16 bit") && TIFF_LZW_NOTE.contains("other software needs LZW"));
|
||||
}
|
||||
|
||||
// Presets that write LZW TIFFs carry the same hint; the built-in ones don't write LZW
|
||||
#[test]
|
||||
fn presets_with_lzw_get_the_same_hint() {
|
||||
assert_eq!(preset_lzw_note(&json!({"format": "tiff", "tiffCompression": "lzw"})), Some(TIFF_LZW_NOTE_16));
|
||||
assert_eq!(preset_lzw_note(&json!({"format": "tiff", "tiffCompression": "LZW", "bitDepth": 8})), Some(TIFF_LZW_NOTE));
|
||||
assert_eq!(preset_lzw_note(&json!({"format": "tiff", "tiffCompression": "zip"})), None);
|
||||
assert_eq!(preset_lzw_note(&json!({"format": "tiff"})), None, "ZIP is the default");
|
||||
for p in lightcraft_engine::export::builtin_presets() {
|
||||
assert_eq!(preset_lzw_note(&p.params), None, "{}", p.name);
|
||||
}
|
||||
}
|
||||
|
||||
// The note never changes the choice: the dialog exports exactly the compression picked
|
||||
#[test]
|
||||
fn the_dialog_keeps_lzw_as_picked() {
|
||||
for depth in [None, Some(8), Some(16), Some(32)] {
|
||||
let o = tiff(TiffCompression::Lzw, depth);
|
||||
let params = export_dialog_params(&o, true, &Default::default(), 0);
|
||||
assert_eq!(ExportOptions::from_json(¶ms).tiff_compression, TiffCompression::Lzw, "{depth:?}: {params}");
|
||||
}
|
||||
}
|
||||
|
||||
fn set(h: &mut Headless, compression: TiffCompression, bit_depth: Option<u8>) {
|
||||
if let Some(Dialog::Export { opts, .. }) = &mut h.app.ui.dialog {
|
||||
opts.format = ExportFormat::Tiff;
|
||||
opts.tiff_compression = compression;
|
||||
opts.bit_depth = bit_depth;
|
||||
} else {
|
||||
panic!("no Export dialog");
|
||||
}
|
||||
h.settle(T);
|
||||
}
|
||||
|
||||
fn note_rect(h: &Headless) -> Option<egui::Rect> {
|
||||
h.app.widgets.iter().find(|(w, _)| w == "note:exportTiffLzw").map(|(_, r)| *r)
|
||||
}
|
||||
|
||||
fn widget(h: &Headless, id: &str) -> egui::Rect {
|
||||
h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}"))
|
||||
}
|
||||
|
||||
// Scenario: in the Export dialog, picking LZW for a TIFF shows the note under Compression; ZIP
|
||||
// and None show none, and the choice stays LZW
|
||||
#[test]
|
||||
fn export_dialog_shows_the_lzw_note_under_compression() {
|
||||
let services = Services { png: None, write_shared: Some(std::sync::Arc::new(|_: &str, _: &[u8]| Ok(()))), ..Default::default() };
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
|
||||
let mut h = Headless::new(app, [1600.0, 1000.0], 1.0);
|
||||
h.settle(T);
|
||||
let r = h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(T);
|
||||
for compression in [TiffCompression::Deflate, TiffCompression::None] {
|
||||
set(&mut h, compression, None);
|
||||
assert!(note_rect(&h).is_none(), "{compression:?} has no note");
|
||||
}
|
||||
for depth in [None, Some(8), Some(32)] {
|
||||
set(&mut h, TiffCompression::Lzw, depth);
|
||||
let note = note_rect(&h).unwrap_or_else(|| panic!("LZW at {depth:?} shows the note"));
|
||||
// under the Compression choice, inside the dialog
|
||||
assert!(note.top() >= widget(&h, "button:exportTiffCompression-1").bottom() - 1.0, "{note:?}");
|
||||
assert!(widget(&h, "dialog:window").contains_rect(note), "{note:?}");
|
||||
let Some(Dialog::Export { opts, .. }) = &h.app.ui.dialog else { panic!("dialog closed") };
|
||||
assert_eq!(opts.tiff_compression, TiffCompression::Lzw, "the note leaves the choice alone");
|
||||
}
|
||||
set(&mut h, TiffCompression::Deflate, None);
|
||||
assert!(note_rect(&h).is_none(), "back to ZIP: the note goes");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,8 @@ pub struct MergeTask {
|
||||
pub job: MergeJob,
|
||||
/// The dialog options it was started with.
|
||||
pub options: MergeDialog,
|
||||
/// The photos it merges.
|
||||
pub ids: Vec<PhotoId>,
|
||||
pub progress: Arc<Mutex<(f32, String)>>,
|
||||
pub cancel: Arc<AtomicBool>,
|
||||
rx: Receiver<Result<MergeOutput, String>>,
|
||||
@@ -102,8 +104,9 @@ pub struct MergeState {
|
||||
pub preview: Option<(egui::TextureHandle, [usize; 2], MergeDialog, Value)>,
|
||||
pub preview_pixels: Option<Arc<egui::ColorImage>>,
|
||||
pub error: Option<String>,
|
||||
/// Options whose preview failed (not retried until they change).
|
||||
pub failed_options: Option<MergeDialog>,
|
||||
/// The photos and options whose preview failed (not retried until either changes or the dialog
|
||||
/// opens again).
|
||||
pub failed_options: Option<(Vec<PhotoId>, MergeDialog)>,
|
||||
/// The preview zoomed in by a click: the point of it (0..1 across and down) at the middle of
|
||||
/// the box. `None` fits it in the box. Kept while the options change, to compare them there.
|
||||
pub zoom: Option<(f32, f32)>,
|
||||
@@ -146,7 +149,7 @@ fn spawn(app: &LightcraftApp, options: &MergeDialog, ids: &[PhotoId], preview: b
|
||||
std::thread::Builder::new().name("photo-merge".into()).spawn(work).map_err(|e| e.to_string())?;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
work();
|
||||
Ok(MergeTask { preview, job, options: options.clone(), progress, cancel, rx, guard: None })
|
||||
Ok(MergeTask { preview, job, options: options.clone(), ids: ids.to_vec(), progress, cancel, rx, guard: None })
|
||||
}
|
||||
|
||||
/// The options of the last merge of kind `command` started (kept in the library preferences), or
|
||||
@@ -165,6 +168,8 @@ pub fn open(app: &mut LightcraftApp, command: &str) -> Result<Value, String> {
|
||||
}
|
||||
app.merge.ids = ids;
|
||||
app.merge.error = None;
|
||||
// a preview that failed before is tried again, or the dialog would show neither a preview nor its error (#2708)
|
||||
app.merge.failed_options = None;
|
||||
app.merge.preview = None;
|
||||
app.merge.preview_pixels = None;
|
||||
app.merge.zoom = None;
|
||||
@@ -257,7 +262,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
Err(e) => {
|
||||
app.merge.error = Some(e);
|
||||
app.merge.failed_options = Some(opts.clone());
|
||||
app.merge.failed_options = Some((ids, opts.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,7 +294,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(t) = app.merge.preview_task.take() else { return };
|
||||
if e != "cancelled" {
|
||||
app.merge.error = Some(e);
|
||||
app.merge.failed_options = Some(t.options);
|
||||
app.merge.failed_options = Some((t.ids, t.options));
|
||||
}
|
||||
}
|
||||
Err(_) => ctx.request_repaint_after(std::time::Duration::from_millis(50)),
|
||||
@@ -366,7 +371,7 @@ fn added_message(what: &str, info: &Value) -> String {
|
||||
}
|
||||
|
||||
fn last_failed_matches(app: &LightcraftApp, opts: &MergeDialog) -> bool {
|
||||
app.merge.failed_options.as_ref() == Some(opts)
|
||||
app.merge.failed_options.as_ref().is_some_and(|(ids, o)| ids == &app.merge.ids && o == opts)
|
||||
}
|
||||
|
||||
/// Where a preview of `size` pixels is drawn in `area`: fitted, or zoomed in on the point `zoom`
|
||||
|
||||
@@ -4355,13 +4355,26 @@ fn settings_dialog_by_keyboard_and_clicks() {
|
||||
assert_eq!(h.app.session.cache_mb, 512);
|
||||
h.request("ui.clickWidget", json!({"id": "button:settingsPreview-3"}), t);
|
||||
assert_eq!(h.app.ui.settings.preview_limit, 3840);
|
||||
h.request("ui.clickWidget", json!({"id": "button:settingsMemory-2"}), t);
|
||||
assert_eq!(h.app.ui.settings.memory_mb, 1024);
|
||||
h.step();
|
||||
assert_eq!(lightcraft_engine::memory::budget(), 1024 << 20, "applied through app.memoryBudget");
|
||||
h.request("ui.clickWidget", json!({"id": "button:settingsMemory-0"}), t);
|
||||
h.step();
|
||||
assert_eq!(lightcraft_engine::memory::budget(), lightcraft_engine::memory::default_budget(), "back to automatic");
|
||||
{
|
||||
// the memory budget is process-wide: this test's turn to change it, and it goes back
|
||||
// to what it was (not the default) when the guard drops, also on a failed assertion
|
||||
// (issue #3065). The change lasts these few frames.
|
||||
use lightcraft_engine::memory::{budget, cache_share, default_budget};
|
||||
let _budget = crate::test_budget::BudgetGuard::hold();
|
||||
// the session's own share, set apart from the 1024 MB choice's: only the command
|
||||
// changes it, whatever another test does to the process-wide budget
|
||||
h.app.session.media.set_budget(cache_share(1024 << 20) + (1 << 20));
|
||||
h.request("ui.clickWidget", json!({"id": "button:settingsMemory-2"}), t);
|
||||
assert_eq!(h.app.ui.settings.memory_mb, 1024);
|
||||
h.step();
|
||||
assert_eq!(h.app.session.media.budget(), cache_share(1024 << 20), "the session's caches follow");
|
||||
assert_eq!(budget(), 1024 << 20, "applied through app.memoryBudget");
|
||||
h.request("ui.clickWidget", json!({"id": "button:settingsMemory-0"}), t);
|
||||
h.step();
|
||||
let automatic = (default_budget() >> 20) << 20;
|
||||
assert_eq!(h.app.session.media.budget(), cache_share(automatic), "back to automatic");
|
||||
assert_eq!(budget(), automatic, "back to automatic");
|
||||
}
|
||||
// Import: per-camera defaults
|
||||
h.request("ui.clickWidget", json!({"id": "button:settingsTab-import"}), t);
|
||||
h.request("ui.clickWidget", json!({"id": "check:settings.perCamera"}), t);
|
||||
@@ -4989,3 +5002,39 @@ fn screenshot_request_is_answered_without_a_window() {
|
||||
let r = h.request("ui.screenshot", json!({"headless": true}), SETTLE);
|
||||
assert_eq!(r["result"]["width"], 640, "{r}");
|
||||
}
|
||||
|
||||
/// A size typed in the Export dialog is the size exported: the pixel fields kept only multiples
|
||||
/// of 16, so 1080 became 1088 and 3000 became 3008 (issue #3300).
|
||||
#[test]
|
||||
fn export_sizes_keep_the_pixels_typed() {
|
||||
let mut h = demo([1400.0, 900.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t);
|
||||
h.settle(SETTLE);
|
||||
let type_value = |h: &mut Headless, id: &str, text: &str| {
|
||||
let r = h.request("ui.clickWidget", json!({"id": id}), t);
|
||||
assert_eq!(r["ok"], true, "{id}: {r}");
|
||||
h.request("ui.text", json!({"text": text}), t);
|
||||
h.request("ui.key", json!({"key": "Enter"}), t);
|
||||
h.settle(SETTLE);
|
||||
};
|
||||
let resize = |h: &Headless| match &h.app.ui.dialog {
|
||||
Some(crate::state::Dialog::Export { resize, .. }) => *resize,
|
||||
_ => panic!("the dialog closed"),
|
||||
};
|
||||
if let Some(crate::state::Dialog::Export { full_size, resize, .. }) = &mut h.app.ui.dialog {
|
||||
*full_size = false;
|
||||
*resize = lightcraft_engine::export::Resize::long_edge(2048);
|
||||
}
|
||||
h.settle(SETTLE);
|
||||
type_value(&mut h, "sliderValue:export.sizePx", "3000");
|
||||
assert_eq!(resize(&h).value, 3000.0);
|
||||
if let Some(crate::state::Dialog::Export { resize, .. }) = &mut h.app.ui.dialog {
|
||||
resize.mode = lightcraft_engine::export::ResizeMode::WidthHeight;
|
||||
resize.height = 2048;
|
||||
}
|
||||
h.settle(SETTLE);
|
||||
type_value(&mut h, "sliderValue:export.sizeW", "1080");
|
||||
type_value(&mut h, "sliderValue:export.sizeH", "1350");
|
||||
assert_eq!((resize(&h).value, resize(&h).height), (1080.0, 1350));
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ pub mod theme;
|
||||
pub mod widgets;
|
||||
pub mod window_place;
|
||||
|
||||
#[cfg(test)]
|
||||
mod test_budget;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1521,7 +1521,12 @@ fn context_menu_locate_and_export(app: &mut LightcraftApp, ui: &mut egui::Ui, id
|
||||
}
|
||||
crate::widgets::menu_button(ui, crate::i18n::tr("Export with Preset"), |ui| {
|
||||
for (p, _) in app.session.all_export_presets() {
|
||||
if ui.button(&p.name).clicked() {
|
||||
let r = ui.button(&p.name);
|
||||
let r = match crate::dialogs::preset_lzw_note(&p.params) {
|
||||
Some(note) => r.on_hover_text(crate::i18n::tr(note)),
|
||||
None => r,
|
||||
};
|
||||
if r.clicked() {
|
||||
match app.run("app.export", json!({"preset": p.name, "background": true})) {
|
||||
Ok(r) if r.get("background").is_some() => {}
|
||||
Ok(r) => {
|
||||
|
||||
@@ -290,10 +290,28 @@ impl Slot {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render workers of an app built by this crate's own tests. Most of them build an app, several
|
||||
/// run at once, and a test needs a render or two at a time, not the machine's six (issue #3067:
|
||||
/// sixteen tests at once ran about 96 render threads on 16 cores). A test that needs more asks
|
||||
/// for them with [`Renderer::with_worker_threads`].
|
||||
#[cfg(test)]
|
||||
pub(crate) const TEST_WORKERS: usize = 2;
|
||||
|
||||
/// Render workers an app starts with: the machine's job-pool size, at most six (none on wasm).
|
||||
#[cfg(not(test))]
|
||||
fn default_workers() -> usize {
|
||||
if cfg!(target_arch = "wasm32") { 0 } else { JobPool::<Slot, Finished>::default_threads().min(6) }
|
||||
}
|
||||
|
||||
/// Render workers an app starts with in this crate's tests: [`TEST_WORKERS`].
|
||||
#[cfg(test)]
|
||||
fn default_workers() -> usize {
|
||||
TEST_WORKERS
|
||||
}
|
||||
|
||||
impl Default for Renderer {
|
||||
fn default() -> Self {
|
||||
let threads = if cfg!(target_arch = "wasm32") { 0 } else { JobPool::<Slot, Finished>::default_threads().min(6) };
|
||||
Self::with_worker_threads(threads)
|
||||
Self::with_worker_threads(default_workers())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -542,6 +542,25 @@ fn a_panicking_render_is_a_failure_not_a_dead_worker() {
|
||||
assert_eq!(app.renderer.live_workers(), workers);
|
||||
}
|
||||
|
||||
/// An app built by this crate's tests renders on two workers, not the machine's six: several
|
||||
/// tests run at once, and each of them used to start six (issue #3067).
|
||||
#[test]
|
||||
fn a_test_app_renders_on_two_workers() {
|
||||
let (mut app, _, ctx) = fixture_with(Session::new(), (*photo()).clone());
|
||||
app.renderer.offload = None; // the real job pool
|
||||
let slot = Slot::Thumb(PhotoId(1));
|
||||
let job = app.session.thumb_job(PhotoId(1), 256).unwrap();
|
||||
app.renderer.request(slot, job, 30);
|
||||
assert_eq!(app.renderer.live_workers(), TEST_WORKERS, "the pool starts all its workers with its first job");
|
||||
let t0 = std::time::Instant::now();
|
||||
while app.renderer.is_pending(slot) {
|
||||
assert!(t0.elapsed() < std::time::Duration::from_secs(20), "the render never answered");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
app.renderer.poll(&ctx, &mut app.session);
|
||||
}
|
||||
assert!(app.renderer.textures.contains_key(&slot), "{:?}", app.renderer.failure(slot));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_quick_path_frames_build_no_jobs() {
|
||||
// an unedited raw: the grid shows its embedded preview (held here until released) and stops
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! For tests that change the process-wide memory budget ([`lightcraft_engine::memory::budget`]).
|
||||
//!
|
||||
//! The budget is one number for the whole test process: the decode, export and GPU gates, every
|
||||
//! new session's caches and the demo scene cache size from it, and other tests run at the same
|
||||
//! time. A test that changes it (through `app.memoryBudget` or Settings ▸ Performance) holds a
|
||||
//! [`BudgetGuard`] for as long as the change lasts. Tests that change the budget then run one at
|
||||
//! a time, so one can't change it under another's assertion. The budget goes back to exactly what
|
||||
//! it was before, also when the test fails: putting back the value found, not `default_budget()`,
|
||||
//! keeps a budget set from outside (`LIGHTCRAFT_MEMORY_MB`, a test harness) (issue #3065).
|
||||
//!
|
||||
//! Tests that only read the budget don't take the lock; keep the change short.
|
||||
//! The lock is per process, so per test binary, which is all the budget is shared by.
|
||||
|
||||
use std::sync::{Mutex, MutexGuard, PoisonError};
|
||||
|
||||
use lightcraft_engine::memory;
|
||||
|
||||
/// Taken by every test that changes the process-wide budget.
|
||||
static CHANGING_BUDGET: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Held while a test changes the process-wide memory budget: the other tests that change it wait,
|
||||
/// and the budget is put back as it was found when the guard drops.
|
||||
pub(crate) struct BudgetGuard {
|
||||
// fields drop in order: the budget is put back before the next test may take its turn
|
||||
_restore: Restore,
|
||||
_turn: MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
impl BudgetGuard {
|
||||
/// Wait for the other tests that change the budget, then remember the budget as it is now.
|
||||
pub(crate) fn hold() -> BudgetGuard {
|
||||
// poisoned: a test failed holding it, and its guard put the budget back on unwind
|
||||
let turn = CHANGING_BUDGET.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
BudgetGuard { _restore: Restore::new(), _turn: turn }
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts the process-wide budget back to what it was when made.
|
||||
struct Restore(usize);
|
||||
|
||||
impl Restore {
|
||||
fn new() -> Restore {
|
||||
Restore(memory::budget())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
// doesn't panic; the value came from `budget()`, so it's within what `set_budget` keeps
|
||||
memory::set_budget(self.0);
|
||||
}
|
||||
}
|
||||
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// The budget is put back as it was found, not to the default, also when the test fails
|
||||
// (issue #3065)
|
||||
#[test]
|
||||
fn the_budget_is_put_back_as_it_was_found() {
|
||||
let _turn = BudgetGuard::hold();
|
||||
// a budget set from outside the test, unlike the default
|
||||
let outside = if memory::budget() == 700 << 20 { 600 << 20 } else { 700 << 20 };
|
||||
memory::set_budget(outside);
|
||||
|
||||
{
|
||||
let _r = Restore::new();
|
||||
memory::set_budget(1024 << 20);
|
||||
assert_eq!(memory::budget(), 1024 << 20);
|
||||
}
|
||||
assert_eq!(memory::budget(), outside, "the budget found, not the default");
|
||||
|
||||
let failed = std::panic::catch_unwind(|| {
|
||||
let _r = Restore::new();
|
||||
memory::set_budget(96 << 20);
|
||||
panic!("an assertion fails while the budget is changed");
|
||||
});
|
||||
assert!(failed.is_err());
|
||||
assert_eq!(memory::budget(), outside, "put back on unwind too");
|
||||
}
|
||||
|
||||
// A test that fails holding the lock doesn't make the next one fail
|
||||
#[test]
|
||||
fn a_failed_test_does_not_block_the_next() {
|
||||
let failed = std::thread::spawn(|| {
|
||||
let _g = BudgetGuard::hold();
|
||||
panic!("an assertion fails while holding the turn");
|
||||
})
|
||||
.join();
|
||||
assert!(failed.is_err());
|
||||
let _g = BudgetGuard::hold();
|
||||
}
|
||||
}
|
||||
@@ -443,6 +443,34 @@ fn merge_dialog_remembers_the_last_settings_per_kind() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Issue #2708: a preview that failed (a photo's file missing) is tried again when the dialog opens again; it used to
|
||||
/// show neither a preview nor an error until an option changed.
|
||||
#[test]
|
||||
fn merge_dialog_retries_a_failed_preview_when_it_opens_again() {
|
||||
let (mut h, dir) = bracket("merge-retry");
|
||||
let (file, away) = (dir.join("IMG_1.dng"), dir.join("IMG_1.away"));
|
||||
std::fs::rename(&file, &away).unwrap();
|
||||
crate::merge::open(&mut h.app, "merge.panorama").unwrap();
|
||||
h.step();
|
||||
assert!(h.step_until(T, |h| h.app.merge.preview_task.is_none()));
|
||||
assert!(h.app.merge.error.is_some(), "the preview failed");
|
||||
h.app.ui.dialog = None;
|
||||
h.step();
|
||||
// the file is back (a drive reconnected); the same photos and options again
|
||||
std::fs::rename(&away, &file).unwrap();
|
||||
crate::merge::open(&mut h.app, "merge.panorama").unwrap();
|
||||
h.step();
|
||||
assert!(
|
||||
h.app.merge.preview.is_some() || h.app.merge.preview_task.is_some() || h.app.merge.error.is_some(),
|
||||
"the dialog shows a preview, one being made, or why not"
|
||||
);
|
||||
assert!(h.step_until(T, |h| h.app.merge.preview_task.is_none()));
|
||||
assert!(h.app.merge.preview.is_some(), "{:?}", h.app.merge.error);
|
||||
h.app.ui.dialog = None;
|
||||
h.step();
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// HDR for Each Bracket Set runs the merges one after the other, each with its activity row; cancelling one stops the
|
||||
/// rest.
|
||||
#[test]
|
||||
|
||||
@@ -131,7 +131,10 @@ fn app_with_library(dir: &Scratch) -> Headless {
|
||||
}
|
||||
|
||||
/// Draw the launch list (`over_open_library`: Alt-at-launch over the library that opened, else the
|
||||
/// list with no library behind it).
|
||||
/// list with no library behind it), then wait until it holds still. Each recent library's row says
|
||||
/// "Checking…" until a worker answers; the answer drops that line, so the centred window shrinks and
|
||||
/// its buttons move. A click aimed before then can find its button gone from under the pointer by
|
||||
/// the release, and `ui.clickWidget` refuses it.
|
||||
fn draw_launch_list(h: &mut Headless, over_open_library: bool) {
|
||||
let mut problem = crate::panels::library_problem::LibraryProblem::choose_later(&h.app.ui.settings);
|
||||
if over_open_library && let Some(c) = problem.chooser.as_mut() {
|
||||
@@ -139,6 +142,26 @@ fn draw_launch_list(h: &mut Headless, over_open_library: bool) {
|
||||
}
|
||||
h.app.library_problem = Some(problem);
|
||||
assert!(h.step_until(T, |h| h.app.widgets.iter().any(|(w, _)| w == "button:libraryOpen")), "the launch list is drawn");
|
||||
let answered =
|
||||
|h: &Headless| h.app.library_problem.as_ref().and_then(|p| p.chooser.as_ref()).is_some_and(|c| c.libraries.iter().all(|l| !l.checking));
|
||||
assert!(h.step_until(T, answered), "every recent library is checked");
|
||||
wait_until_still(h, "button:libraryOpen");
|
||||
}
|
||||
|
||||
/// Run frames until widget `id` keeps its rect from one frame to the next (an anchored window
|
||||
/// takes a frame to move to its new size).
|
||||
fn wait_until_still(h: &mut Headless, id: &str) {
|
||||
let rect = |h: &Headless| h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r);
|
||||
let mut last = rect(h);
|
||||
for _ in 0..60 {
|
||||
h.step();
|
||||
let now = rect(h);
|
||||
if now.is_some() && now == last {
|
||||
return;
|
||||
}
|
||||
last = now;
|
||||
}
|
||||
panic!("{id} never held still");
|
||||
}
|
||||
|
||||
/// The launch list as Alt-at-launch shows it over the library that opened.
|
||||
|
||||
@@ -14,7 +14,13 @@ fn harness(view: ViewMode, right: RightPanel) -> Harness<'static, LightcraftApp>
|
||||
app.ui.left_panel = true;
|
||||
app.ui.view = view;
|
||||
app.ui.right = right;
|
||||
let mut h = Harness::builder().with_size(vec2(1500.0, 950.0)).build_ui_state(
|
||||
let mut builder = Harness::builder().with_size(vec2(1500.0, 950.0));
|
||||
// text such as "▸" in the Reference view's hint has its glyph only in the craft-fonts faces (releases
|
||||
// always have them): without them it is drawn as a box, which this audit of names doesn't need
|
||||
if lightcraft_engine::CRAFT_FONTS.is_empty() {
|
||||
builder = builder.allow_missing_glyphs();
|
||||
}
|
||||
let mut h = builder.build_ui_state(
|
||||
|ui, app: &mut LightcraftApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
@@ -80,6 +86,10 @@ fn audit(h: &Harness<'_, LightcraftApp>, place: &str) -> Vec<String> {
|
||||
|
||||
#[test]
|
||||
fn every_interactive_node_has_a_name_in_every_view_and_tool() {
|
||||
// said once per audit, not once per harness
|
||||
if lightcraft_engine::CRAFT_FONTS.is_empty() {
|
||||
lightcraft_testkit::skip!(Fonts, "built without CRAFT_FONTS_DIR: glyphs only craft-fonts has are drawn as boxes, not checked");
|
||||
}
|
||||
let mut problems = Vec::new();
|
||||
for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid, ViewMode::Detail, ViewMode::Compare, ViewMode::Survey, ViewMode::Reference] {
|
||||
let h = harness(view, RightPanel::None);
|
||||
|
||||
@@ -96,6 +96,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
|
||||
| 2026-10-24 | minor | Gap 28: ⌘⌥⇧C / ⌘⌥⇧V (Ctrl+Alt+Shift+C / V on Windows and Linux) copy and paste metadata; no key leaves the entry; hours unchanged |
|
||||
| 2026-10-24 | minor | Gap 30 shrinks: the Info panel's Show Metadata For (Target Photo / Selected Photos, `view.infoSelectedPhotos`) shows the selected photos' shared values and marks fields that differ as Mixed; the All Metadata dialog remains; 1–3 h (was 4–8) |
|
||||
| 2026-10-24 | patch | Typed GPS input now validates the complete coordinate expression, rejecting unknown text, wrong-axis or conflicting hemispheres, and invalid DMS minutes/seconds before changing metadata; ranked gaps, estimates and readiness counts unchanged |
|
||||
| 2026-10-24 | patch | LR-EXP-METADATA: Original export refuses embedded GPS or nonblank place names when Remove Location Info is on; ranked gaps, estimates and readiness unchanged |
|
||||
| 2026-10-23 | minor | Gap 13: before a Lightroom catalog import the app asks whether it merges into the open library or goes into a new one (opt-out in Settings, #2672); hours unchanged |
|
||||
| 2026-10-22 | patch | Gap 28: View shows the current grid-cycle key beside Photo Grid and Square Grid (#2500); no new binding and no change to the remaining shortcut gap or hours |
|
||||
| 2026-10-22 | minor | Gap 15: the Print page setup and PDF gain the Japanese photo and postcard paper sizes (L判 89×127, 2L判 127×178, KG判 102×152, はがき 100×148, 往復はがき 148×200, 六切 203×254, 四切 254×305, 名刺 55×91 mm), by their customary names; gap not closed |
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,11 +16,12 @@ Select the photos, then choose **File** > **Export…**. Pick a format, a size a
|
||||
5. Under the size options, tick **Full size**, or clear it and choose **Resize to** one of **Long Edge**, **Short Edge**, **Width**, **Height**, **Width × Height**, **Megapixels** or **Percentage**, then enter the value.
|
||||
6. Set **Color space** (**sRGB**, **P3**, **Adobe RGB**, **ProPhoto** or **Rec.2020**) if you need something other than the default. Lossy **JPEG XL** writes **Adobe RGB** and **ProPhoto** as **Rec.2020**. Tick **Lossless** to keep them.
|
||||
7. Optional: set **Sharpen** for **Screen**, **Matte** or **Glossy** output, set **Metadata** to **All**, **No camera**, **Copyright** or **None**, and tick **Remove location info** or **Watermark**.
|
||||
8. Next to **Folder**, click **Choose…** and pick where the files go. **Subfolder** adds a folder inside it.
|
||||
9. Set **File name** to a template such as `{name}-{seq}`. Click **Tags** to see the tags you can insert.
|
||||
10. Set **If file exists** to **Add number**, **Overwrite** or **Skip**.
|
||||
11. On macOS, tick **Add to Apple Photos** to add the exported files to Photos as well. Type an **Album** name to put them in that album. The first time, macOS asks whether LightCraft may control Photos.
|
||||
12. Click **OK**. The export shows its progress in the activity stack at the top left.
|
||||
8. For **TIFF**, set **Compression** to **ZIP** (the default), **LZW** or **None**. **LZW** is usually larger and slower than **ZIP**, and at 16 bit it can be larger than **None**. Use it only if other software needs LZW.
|
||||
9. Next to **Folder**, click **Choose…** and pick where the files go. **Subfolder** adds a folder inside it.
|
||||
10. Set **File name** to a template such as `{name}-{seq}`. Click **Tags** to see the tags you can insert.
|
||||
11. Set **If file exists** to **Add number**, **Overwrite** or **Skip**.
|
||||
12. On macOS, tick **Add to Apple Photos** to add the exported files to Photos as well. Type an **Album** name to put them in that album. The first time, macOS asks whether LightCraft may control Photos.
|
||||
13. Click **OK**. The export shows its progress in the activity stack at the top left.
|
||||
|
||||
If you did not choose a folder, the window stays open and shows an error, so your choices are not lost.
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
or a Synchronize Folder is still running then, they now wait for it to end instead of being dropped, and a message
|
||||
says so (issue #2779).
|
||||
|
||||
### Export sizes keep the pixels typed
|
||||
- Export ▸ Image Sizing kept pixel sizes only in steps of 16, so a typed 1080 became 1088 and 3000 became 3008. The
|
||||
long edge, short edge, width and height fields now keep any whole number of pixels.
|
||||
|
||||
## 2026-10-29
|
||||
|
||||
### A person's page opens in seconds in a large library
|
||||
@@ -135,6 +139,9 @@
|
||||
- Looking for each photo's `.xmp` sidecar, which asks the disk several names for every photo, also goes up to 16
|
||||
photos at a time.
|
||||
|
||||
### Original export checks embedded place names when removing location
|
||||
- Original copies are refused when “Remove Location Info” is enabled and the source contains GPS or a nonblank sublocation, city, state or country. Use JPEG or TIFF for rendered output; DNG is available for raw photos.
|
||||
|
||||
|
||||
### Relative-date filters and smart albums follow the clock
|
||||
- A current view or source count using “in the last” rules now refreshes when the session clock moves into a new
|
||||
@@ -194,6 +201,11 @@
|
||||
early, or a GIF whose first frame is smaller than the picture) still opens up to 4 megapixels; a larger one is
|
||||
refused, because opening it would still set aside memory for the whole picture.
|
||||
|
||||
### Merge previews after a failed one
|
||||
- The HDR, Panorama and HDR Panorama dialogs make a new preview each time they open (issue #2708). After a preview
|
||||
had failed once, for example because a photo's file was missing, opening the dialog again with the same options
|
||||
showed neither a preview nor an error until an option was changed.
|
||||
|
||||
|
||||
### Tab leaves the name field in New Album, New Smart Album and the name prompts
|
||||
- In New Album, New Smart Album, Save Metadata Preset and the other dialogs with one name field, the field has the
|
||||
|
||||
Reference in New Issue
Block a user