mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 05:57:09 -06:00
Merge branch 'main' into mac-glyph-order
This commit is contained in:
@@ -37,3 +37,27 @@ jobs:
|
||||
run: |
|
||||
./packaging/windows/test-install-folder.ps1 -Arch x64
|
||||
./packaging/windows/test-install-folder.ps1 -Arch x86
|
||||
|
||||
windows-install-policy:
|
||||
timeout-minutes: 10
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install the pinned packaging toolchain
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
|
||||
dotnet tool install --tool-path $tools wix --version 5.0.2
|
||||
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
|
||||
$tools >> $env:GITHUB_PATH
|
||||
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
|
||||
foreach ($extension in @($policy.extensions)) {
|
||||
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
|
||||
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
|
||||
}
|
||||
- name: Build and check non-executable installer fixtures
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
|
||||
|
||||
@@ -27,8 +27,10 @@ permissions:
|
||||
|
||||
jobs:
|
||||
windows-arm64:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
# Immutable supporting workflow revision until the new install contract lands upstream.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
|
||||
with:
|
||||
app-name: lightcraft
|
||||
app-dir: LightCraft
|
||||
install-relative-path: Programs/ArtCraft/LightCraft
|
||||
install-scope: perUser
|
||||
|
||||
@@ -51,7 +51,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
- **Last-resort guard:** a panic hook plus `catch_unwind` around command dispatch and import/export turns an escaped
|
||||
panic into an error dialog and keeps the document. It's a safety net, not a licence; keep `panic = "unwind"` on native.
|
||||
- **Prove it:** every crash fix lands with a small synthetic regression test that panicked before the fix.
|
||||
- Enforced by clippy: root `clippy.toml` allows unwrap/expect/panic/indexing in tests only, and every production crate
|
||||
- Enforced by clippy: root `clippy.toml` allows unwrap/expect/panic in tests only; indexing is not denied. Every production crate
|
||||
root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic,
|
||||
clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Not `[workspace.lints]`: those would also hit
|
||||
integration tests, examples and benches. New crates start with the attribute.
|
||||
|
||||
@@ -52,10 +52,13 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
- **Last-resort guard:** a panic hook plus `catch_unwind` around command dispatch and import/export turns an escaped
|
||||
panic into an error dialog and keeps the document. It's a safety net, not a licence; keep `panic = "unwind"` on native.
|
||||
- **Prove it:** every crash fix lands with a small synthetic regression test that panicked before the fix.
|
||||
- Enforced by clippy: root `clippy.toml` allows unwrap/expect/panic/indexing in tests only, and every production crate
|
||||
root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic,
|
||||
clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Not `[workspace.lints]`: those would also hit
|
||||
integration tests, examples and benches. New crates start with the attribute.
|
||||
- Enforced by clippy: root `clippy.toml` keeps unwrap/expect/panic allowances scoped to tests; indexing is not denied. The workspace
|
||||
denies `cognitive_complexity`, `too_many_arguments` and `wildcard_imports`; members inherit them with
|
||||
`[lints] workspace = true`, and `sysmem` repeats those denies in its local lint table. Every production crate root
|
||||
(`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic,
|
||||
clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Every new workspace crate must include
|
||||
`[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`, and new production crate roots include
|
||||
the attribute before modules or other crate items; crate-level `//!` documentation may precede it.
|
||||
|
||||
## Non-negotiables
|
||||
- **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, profiles (DCP), lens profiles (LCP), camera matrices, fonts. Observation of the installed Lightroom is read-only (it syncs the user's personal library: never import/edit/rate/delete there). Never copy GPL/LGPL/AGPL code (darktable, RawTherapee, ART, LibRaw, rawspeed, rawloader, rawler, lensfun, dcraw-derived GPL code…).
|
||||
|
||||
Generated
+33
-33
@@ -3535,7 +3535,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-update",
|
||||
@@ -3562,7 +3562,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-catalog"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"crc32fast",
|
||||
@@ -3582,7 +3582,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-cli"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"dhat",
|
||||
"lightcraft-codecs",
|
||||
@@ -3598,7 +3598,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-codecs"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"avif-serialize",
|
||||
"bytemuck",
|
||||
@@ -3629,7 +3629,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-color"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"proptest",
|
||||
@@ -3638,7 +3638,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-denoise"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"faer-core",
|
||||
"lightcraft-denoise-core",
|
||||
@@ -3655,7 +3655,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-denoise-core"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"dhat",
|
||||
"lightcraft-raster",
|
||||
@@ -3671,7 +3671,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-develop"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
@@ -3683,7 +3683,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-engine"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"chrono",
|
||||
@@ -3727,7 +3727,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-faces"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"lightcraft-testkit",
|
||||
"matrixmultiply",
|
||||
@@ -3742,7 +3742,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-fetch"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rustls",
|
||||
@@ -3753,7 +3753,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-geocode"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.9.1",
|
||||
"serde",
|
||||
@@ -3761,7 +3761,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-geom"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3769,7 +3769,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-gpu"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"lightcraft-color",
|
||||
@@ -3789,7 +3789,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-heif"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
"rayon",
|
||||
@@ -3797,14 +3797,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-inpaint"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-lensdb"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"lightcraft-develop",
|
||||
"lightcraft-geom",
|
||||
@@ -3813,7 +3813,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-mcp"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-engine",
|
||||
@@ -3824,7 +3824,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-merge"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-color",
|
||||
@@ -3840,7 +3840,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-meta"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"lightcraft-geom",
|
||||
@@ -3853,7 +3853,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-pipeline"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"dhat",
|
||||
"image",
|
||||
@@ -3872,7 +3872,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-preview"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"lightcraft-raster",
|
||||
@@ -3884,7 +3884,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-raster"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"lightcraft-color",
|
||||
@@ -3896,7 +3896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-raw"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"dhat",
|
||||
"jpeg-encoder",
|
||||
@@ -3920,7 +3920,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-scenes"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"lightcraft-color",
|
||||
@@ -3931,7 +3931,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-segment"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
@@ -3945,7 +3945,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-slideshow"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"lightcraft-raster",
|
||||
"serde",
|
||||
@@ -3954,15 +3954,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-sysmem"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-testkit"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-tiff"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3971,7 +3971,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-ui-egui"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -4002,7 +4002,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-web"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"eframe",
|
||||
@@ -7819,7 +7819,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"flate2",
|
||||
|
||||
+4
-2
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -122,9 +122,11 @@ web-sys = "0.3"
|
||||
unsafe_code = "deny"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
cognitive_complexity = "deny"
|
||||
too_many_arguments = "deny"
|
||||
wildcard_imports = "deny"
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
too_many_arguments = "allow"
|
||||
needless_range_loop = "allow"
|
||||
type_complexity = "allow"
|
||||
excessive_precision = "allow"
|
||||
|
||||
@@ -563,9 +563,12 @@ Questions, ideas or a bug you'd like to talk through first? Bring them to [Disco
|
||||
| arm64 (Snapdragon and other ARM PCs) | `lightcraft-<ver>-windows-arm64.msi` | `lightcraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `lightcraft-<ver>-windows-x86.msi` | `lightcraft-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
Installers and executables are code-signed. The installer asks where to install (`C:\Program Files\LightCraft` by
|
||||
default; upgrades stay in the folder you chose) and ends on a page confirming LightCraft was installed, with an
|
||||
Installers and executables are code-signed. The installer asks who to install for and where (for your account only, in
|
||||
`%LOCALAPPDATA%\Programs\ArtCraft\LightCraft`, by default; "Everyone on this computer" uses
|
||||
`C:\Program Files\ArtCraft\LightCraft` (`Program Files (x86)` for the 32-bit build); upgrades stay in the folder you chose) and ends on a page confirming LightCraft was installed, with an
|
||||
option to start it. For unattended installs: `msiexec /i lightcraft-<ver>-windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`.
|
||||
A silent install is per-user by default. `INSTALLFOLDER` chooses the folder, not who it is installed for;
|
||||
add `ALLUSERS=1`, from an elevated terminal or deployment tool, for an all-users install.
|
||||
|
||||
### macOS
|
||||
|
||||
|
||||
+4
-2
@@ -484,9 +484,11 @@ egui 前端是一个可替换的 crate;它下面没有任何东西知道 UI
|
||||
| arm64(骁龙及其他 ARM PC) | `lightcraft-<ver>-windows-arm64.msi` | `lightcraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86(32 位) | `lightcraft-<ver>-windows-x86.msi` | `lightcraft-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
安装程序和可执行文件都有代码签名。安装程序会询问安装位置(默认
|
||||
`C:\Program Files\LightCraft`;升级会留在你选择的文件夹),结束时有一页确认 LightCraft 已安装,
|
||||
安装程序和可执行文件都有代码签名。安装程序会询问为谁安装以及安装位置(默认仅为当前账户,安装到
|
||||
`%LOCALAPPDATA%\Programs\ArtCraft\LightCraft`;选择“这台计算机上的所有人”则安装到
|
||||
`C:\Program Files\ArtCraft\LightCraft`(32 位版本为 `Program Files (x86)`);升级会留在你选择的文件夹),结束时有一页确认 LightCraft 已安装,
|
||||
并可选择启动它。无人值守安装:`msiexec /i lightcraft-<ver>-windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`。
|
||||
静默安装默认仅为当前用户安装。`INSTALLFOLDER` 只决定安装文件夹,不决定为谁安装;若要为所有用户安装,请在提升权限的终端或部署工具中加上 `ALLUSERS=1`。
|
||||
|
||||
### macOS
|
||||
|
||||
|
||||
+4
-2
@@ -514,9 +514,11 @@ egui 前端是一個可替換的 crate;它之下沒有任何東西知道 UI
|
||||
| arm64(Snapdragon 及其他 ARM PC) | `lightcraft-<ver>-windows-arm64.msi` | `lightcraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86(32 位元) | `lightcraft-<ver>-windows-x86.msi` | `lightcraft-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
安裝程式與執行檔都有程式碼簽章。安裝程式會詢問安裝位置(預設為
|
||||
`C:\Program Files\LightCraft`;升級時會留在你選擇的資料夾),結束時會有一頁確認 LightCraft 已安裝,
|
||||
安裝程式與執行檔都有程式碼簽章。安裝程式會詢問為誰安裝以及安裝位置(預設僅為目前帳戶,安裝到
|
||||
`%LOCALAPPDATA%\Programs\ArtCraft\LightCraft`;選擇「這台電腦上的所有人」則安裝到
|
||||
`C:\Program Files\ArtCraft\LightCraft`(32 位元版本為 `Program Files (x86)`);升級時會留在你選擇的資料夾),結束時會有一頁確認 LightCraft 已安裝,
|
||||
並可選擇立即啟動。無訊息安裝:`msiexec /i lightcraft-<ver>-windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`。
|
||||
無訊息安裝預設僅為目前使用者安裝。`INSTALLFOLDER` 只決定安裝資料夾,不決定為誰安裝;若要為所有使用者安裝,請在提升權限的終端機或部署工具中加上 `ALLUSERS=1`。
|
||||
|
||||
### macOS
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::prefs::config_dir;
|
||||
use super::*;
|
||||
use super::window_gpu;
|
||||
|
||||
/// 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:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{LibraryProblem, LightcraftApp, UiState};
|
||||
|
||||
/// Where the system keeps monitor profiles, for the Choose Profile dialog to start in: the user's
|
||||
/// own profiles first (Linux: colord / DisplayCAL; macOS: ColorSync), then the system's.
|
||||
|
||||
@@ -5,3 +5,4 @@ allow-indexing-slicing-in-tests = true
|
||||
disallowed-types = [
|
||||
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
|
||||
]
|
||||
cognitive-complexity-threshold = 30
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use lightcraft_catalog::*;
|
||||
use lightcraft_catalog::{Catalog, DEFAULT_FORGET_DAYS, FsStore, HistoryStep, Journal, MediaKind, Op, Photo, PhotoId, Source, folder_of};
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
|
||||
fn ms(t: Instant) -> f64 {
|
||||
|
||||
+16
-27
@@ -904,17 +904,9 @@ fn search_text_finds_people_and_quoted_values() {
|
||||
/// What the catalog remembers of its latest ops: which photos each changed and what of them, as long as that can be
|
||||
/// said photo by photo.
|
||||
#[test]
|
||||
fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them() {
|
||||
fn photo_ops_record_touched_ids_and_changed_aspects() {
|
||||
let mut c = Catalog::default();
|
||||
let photo = |c: &mut Catalog, name: &str| {
|
||||
let id = c.alloc_photo_id();
|
||||
c.apply(Op::AddPhoto {
|
||||
photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{name}") }, name, "JPEG", 60, 40, "2026-01-01T10:00:00")),
|
||||
})
|
||||
.unwrap();
|
||||
id
|
||||
};
|
||||
let (a, b) = (photo(&mut c, "a.jpg"), photo(&mut c, "b.jpg"));
|
||||
let (a, b) = (photo(&mut c, "a.jpg", "2026-01-01T10:00:00"), photo(&mut c, "b.jpg", "2026-01-01T10:00:00"));
|
||||
let start = c.revision;
|
||||
assert_eq!(c.touched_since(start), Some(vec![]));
|
||||
assert_eq!(c.aspects_since(start), Some(Aspects::NONE));
|
||||
@@ -940,28 +932,25 @@ fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them()
|
||||
assert_eq!(c.touched_since(before), None);
|
||||
assert_eq!(c.aspects_since(start), None);
|
||||
assert_eq!(c.touched_since(c.revision), Some(vec![]), "from after it, it is known again");
|
||||
// a revision that is not this catalog's past
|
||||
assert_eq!(c.touched_since(c.revision + 5), None);
|
||||
// a revision set from outside (a library just loaded): nothing is known of how the catalog got there, and after
|
||||
// the next op nothing of before it
|
||||
let old = c.revision;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_handles_loaded_revisions_failed_batches_and_retention() {
|
||||
let mut c = Catalog::default();
|
||||
let a = photo(&mut c, "a.jpg", "2026-01-01T10:00:00");
|
||||
let album = c.alloc_album_id();
|
||||
c.apply(Op::AddAlbum { album: Album::new(album, "Trip") }).unwrap();
|
||||
// a batch that fails midway is undone as a whole; a batch in a batch is remembered photo by photo; a batch
|
||||
// with an op that changes more than photos is not
|
||||
let before = c.revision;
|
||||
c.revision = 7 << 32;
|
||||
assert_eq!(c.touched_since(old), None);
|
||||
assert_eq!(c.touched_since(before), None);
|
||||
assert_eq!(c.aspects_since(c.revision), None);
|
||||
let loaded = c.revision;
|
||||
c.apply(Op::SetRating { id: a, rating: 1 }).unwrap();
|
||||
assert_eq!(c.touched_since(loaded), Some(vec![a]));
|
||||
assert_eq!(c.touched_since(old), None);
|
||||
// an op that fails changes nothing and is not remembered
|
||||
let before = c.revision;
|
||||
assert!(c.apply(Op::SetRating { id: PhotoId(9_999), rating: 1 }).is_err());
|
||||
assert_eq!(c.revision, before);
|
||||
assert_eq!(c.touched_since(before), Some(vec![]));
|
||||
c.apply(Op::SetFlag { id: a, flag: Flag::Pick }).unwrap();
|
||||
assert_eq!(c.touched_since(before), Some(vec![a]));
|
||||
assert!(c.aspects_since(before).is_some_and(|x| x.any_of(Aspects::FLAG)));
|
||||
// a batch that fails midway is undone as a whole; a batch in a batch is remembered photo by photo; a batch
|
||||
// with an op that changes more than photos is not
|
||||
assert_eq!(c.touched_since(before), None);
|
||||
|
||||
let before = c.revision;
|
||||
assert!(c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 5 }, Op::SetRating { id: PhotoId(9_999), rating: 1 }] }).is_err());
|
||||
assert_eq!((c.revision, c.photo(a).map(|p| p.rating)), (before, Some(1)));
|
||||
|
||||
+82
-44
@@ -227,7 +227,7 @@ pub enum TiffCompression {
|
||||
/// Encode TIFF: 8/16-bit integer or 32-bit float, gray/RGB with optional (unassociated) alpha.
|
||||
/// ICC and XMP are embedded; EXIF is not (write it through the TIFF writer in `lightcraft-tiff`).
|
||||
pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &EncodeMeta) -> Result<Vec<u8>> {
|
||||
use tiff::encoder::{Compression, DeflateLevel, TiffEncoder, colortype};
|
||||
use tiff::encoder::{Compression, DeflateLevel, TiffEncoder};
|
||||
use tiff::tags::Predictor;
|
||||
img.validate()?;
|
||||
let e = |e: tiff::TiffError| Error::Encode(e.to_string());
|
||||
@@ -243,54 +243,92 @@ pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &Encod
|
||||
if !float && matches!(compression, TiffCompression::Lzw | TiffCompression::Deflate) { Predictor::Horizontal } else { Predictor::None };
|
||||
let mut enc = TiffEncoder::new(&mut cur).map_err(e)?.with_compression(comp).with_predictor(predictor);
|
||||
let (w, h) = (img.width, img.height);
|
||||
let n = w as usize * h as usize;
|
||||
// Gray + alpha is stored as RGBA (the encoder has no GrayA colour type).
|
||||
let ch = img.channels as usize;
|
||||
|
||||
macro_rules! write {
|
||||
($ct:ty, $data:expr, $alpha:expr) => {{
|
||||
let mut im = enc.new_image::<$ct>(w, h).map_err(e)?;
|
||||
if let Some(ppi) = meta.ppi.filter(|p| *p > 0) {
|
||||
im.resolution(tiff::tags::ResolutionUnit::Inch, tiff::encoder::Rational { n: ppi as u32, d: 1 });
|
||||
}
|
||||
if let Some(icc) = meta.icc {
|
||||
im.encoder().write_tag(tiff::tags::Tag::IccProfile, icc).map_err(e)?;
|
||||
}
|
||||
if let Some(xmp) = meta.xmp {
|
||||
im.encoder().write_tag(tiff::tags::Tag::from_u16_exhaustive(700), xmp.as_bytes()).map_err(e)?;
|
||||
}
|
||||
if $alpha {
|
||||
im.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[2u16][..]).map_err(e)?;
|
||||
}
|
||||
im.write_data($data).map_err(e)?;
|
||||
}};
|
||||
}
|
||||
fn expand_ga<T: Copy>(s: &[T], n: usize) -> Vec<T> {
|
||||
s.as_chunks::<2>().0.iter().take(n).flat_map(|c| [c[0], c[0], c[0], c[1]]).collect()
|
||||
}
|
||||
let pixels = w as usize * h as usize;
|
||||
match img.samples {
|
||||
Samples::U8(s) => match ch {
|
||||
1 => write!(colortype::Gray8, &s[..n], false),
|
||||
2 => write!(colortype::RGBA8, &expand_ga(s, n), true),
|
||||
3 => write!(colortype::RGB8, &s[..n * 3], false),
|
||||
_ => write!(colortype::RGBA8, &s[..n * 4], true),
|
||||
},
|
||||
Samples::U16(s) => match ch {
|
||||
1 => write!(colortype::Gray16, &s[..n], false),
|
||||
2 => write!(colortype::RGBA16, &expand_ga(s, n), true),
|
||||
3 => write!(colortype::RGB16, &s[..n * 3], false),
|
||||
_ => write!(colortype::RGBA16, &s[..n * 4], true),
|
||||
},
|
||||
Samples::F32(s) => match ch {
|
||||
1 => write!(colortype::Gray32Float, &s[..n], false),
|
||||
2 => write!(colortype::RGBA32Float, &expand_ga(s, n), true),
|
||||
3 => write!(colortype::RGB32Float, &s[..n * 3], false),
|
||||
_ => write!(colortype::RGBA32Float, &s[..n * 4], true),
|
||||
},
|
||||
Samples::U8(samples) => write_tiff_u8(&mut enc, samples, pixels, img.channels as usize, img, meta)?,
|
||||
Samples::U16(samples) => write_tiff_u16(&mut enc, samples, pixels, img.channels as usize, img, meta)?,
|
||||
Samples::F32(samples) => write_tiff_f32(&mut enc, samples, pixels, img.channels as usize, img, meta)?,
|
||||
}
|
||||
Ok(cur.into_inner())
|
||||
}
|
||||
|
||||
macro_rules! write_tiff_image {
|
||||
($encoder:expr, $image:expr, $meta:expr, $color:ty, $data:expr, $alpha:expr) => {{
|
||||
let mut image = $encoder.new_image::<$color>($image.width, $image.height).map_err(|e| Error::Encode(e.to_string()))?;
|
||||
if let Some(ppi) = $meta.ppi.filter(|ppi| *ppi > 0) {
|
||||
image.resolution(tiff::tags::ResolutionUnit::Inch, tiff::encoder::Rational { n: ppi as u32, d: 1 });
|
||||
}
|
||||
if let Some(icc) = $meta.icc {
|
||||
image.encoder().write_tag(tiff::tags::Tag::IccProfile, icc).map_err(|e| Error::Encode(e.to_string()))?;
|
||||
}
|
||||
if let Some(xmp) = $meta.xmp {
|
||||
image.encoder().write_tag(tiff::tags::Tag::from_u16_exhaustive(700), xmp.as_bytes()).map_err(|e| Error::Encode(e.to_string()))?;
|
||||
}
|
||||
if $alpha {
|
||||
image.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[2u16][..]).map_err(|e| Error::Encode(e.to_string()))?;
|
||||
}
|
||||
image.write_data($data).map_err(|e| Error::Encode(e.to_string()))?;
|
||||
}};
|
||||
}
|
||||
|
||||
fn expanded_gray_alpha<T: Copy>(samples: &[T], pixels: usize) -> Vec<T> {
|
||||
samples.as_chunks::<2>().0.iter().take(pixels).flat_map(|channel| [channel[0], channel[0], channel[0], channel[1]]).collect()
|
||||
}
|
||||
|
||||
fn write_tiff_u8<W: std::io::Write + std::io::Seek>(
|
||||
encoder: &mut tiff::encoder::TiffEncoder<W>,
|
||||
samples: &[u8],
|
||||
pixels: usize,
|
||||
channels: usize,
|
||||
image: &EncodeImage,
|
||||
meta: &EncodeMeta,
|
||||
) -> Result<()> {
|
||||
use tiff::encoder::colortype;
|
||||
match channels {
|
||||
1 => write_tiff_image!(encoder, image, meta, colortype::Gray8, &samples[..pixels], false),
|
||||
2 => write_tiff_image!(encoder, image, meta, colortype::RGBA8, &expanded_gray_alpha(samples, pixels), true),
|
||||
3 => write_tiff_image!(encoder, image, meta, colortype::RGB8, &samples[..pixels * 3], false),
|
||||
_ => write_tiff_image!(encoder, image, meta, colortype::RGBA8, &samples[..pixels * 4], true),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_tiff_u16<W: std::io::Write + std::io::Seek>(
|
||||
encoder: &mut tiff::encoder::TiffEncoder<W>,
|
||||
samples: &[u16],
|
||||
pixels: usize,
|
||||
channels: usize,
|
||||
image: &EncodeImage,
|
||||
meta: &EncodeMeta,
|
||||
) -> Result<()> {
|
||||
use tiff::encoder::colortype;
|
||||
match channels {
|
||||
1 => write_tiff_image!(encoder, image, meta, colortype::Gray16, &samples[..pixels], false),
|
||||
2 => write_tiff_image!(encoder, image, meta, colortype::RGBA16, &expanded_gray_alpha(samples, pixels), true),
|
||||
3 => write_tiff_image!(encoder, image, meta, colortype::RGB16, &samples[..pixels * 3], false),
|
||||
_ => write_tiff_image!(encoder, image, meta, colortype::RGBA16, &samples[..pixels * 4], true),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_tiff_f32<W: std::io::Write + std::io::Seek>(
|
||||
encoder: &mut tiff::encoder::TiffEncoder<W>,
|
||||
samples: &[f32],
|
||||
pixels: usize,
|
||||
channels: usize,
|
||||
image: &EncodeImage,
|
||||
meta: &EncodeMeta,
|
||||
) -> Result<()> {
|
||||
use tiff::encoder::colortype;
|
||||
match channels {
|
||||
1 => write_tiff_image!(encoder, image, meta, colortype::Gray32Float, &samples[..pixels], false),
|
||||
2 => write_tiff_image!(encoder, image, meta, colortype::RGBA32Float, &expanded_gray_alpha(samples, pixels), true),
|
||||
3 => write_tiff_image!(encoder, image, meta, colortype::RGB32Float, &samples[..pixels * 3], false),
|
||||
_ => write_tiff_image!(encoder, image, meta, colortype::RGBA32Float, &samples[..pixels * 4], true),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Encode a flat PSD (8/16-bit gray or RGB, no layers; alpha dropped): the hand-off an image
|
||||
/// editor such as PhotoCraft saves back to in place. ICC, EXIF, XMP and resolution are embedded.
|
||||
pub fn encode_psd(img: &EncodeImage, meta: &EncodeMeta) -> Result<Vec<u8>> {
|
||||
|
||||
@@ -14,6 +14,14 @@ pub struct Layout {
|
||||
pub dy: usize,
|
||||
}
|
||||
|
||||
/// The mosaic dimensions and cell origin for one square denoiser input tile.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct TileRegion {
|
||||
pub mosaic_size: (usize, usize),
|
||||
pub cell_origin: (isize, isize),
|
||||
pub tile_size: usize,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
/// The layout of a 2 × 2 colour pattern, row-major, colours 0 = red, 1 = green, 2 = blue; `None` when
|
||||
/// it is not a Bayer cell (one red, one blue, the greens on the other diagonal).
|
||||
@@ -65,7 +73,10 @@ pub fn reflect(i: isize, n: usize) -> usize {
|
||||
/// Pack the `tile × tile` cells whose top-left cell is `(cx0, cy0)` into `out` as four planes `[R, G1, G2, B]`
|
||||
/// (`4 · tile²` values, NCHW). G1 is the green beside the red sample, G2 the green under it. Samples outside
|
||||
/// the picture are mirrored. Returns `false` (and writes nothing) when the sizes do not fit.
|
||||
pub fn pack_tile(mosaic: &[f32], width: usize, height: usize, layout: Layout, cx0: isize, cy0: isize, tile: usize, out: &mut [f32]) -> bool {
|
||||
pub fn pack_tile(mosaic: &[f32], layout: Layout, region: TileRegion, out: &mut [f32]) -> bool {
|
||||
let (width, height) = region.mosaic_size;
|
||||
let (cx0, cy0) = region.cell_origin;
|
||||
let tile = region.tile_size;
|
||||
let Some(plane) = tile.checked_mul(tile) else { return false };
|
||||
let Some(values) = plane.checked_mul(4) else { return false };
|
||||
if width == 0
|
||||
@@ -113,7 +124,7 @@ mod tests {
|
||||
let layout = Layout::from_cell([0, 1, 1, 2]).unwrap();
|
||||
let mut out = [7.0; 4];
|
||||
for (w, h, tile, x) in [(usize::MAX, 2, 1, 0), (1, 1, usize::MAX, 0), (1, 1, 0, 0), (1, 1, 1, isize::MAX), (1, 1, 1, isize::MIN)] {
|
||||
assert!(!pack_tile(&[0.5], w, h, layout, x, 0, tile, &mut out));
|
||||
assert!(!pack_tile(&[0.5], layout, TileRegion { mosaic_size: (w, h), cell_origin: (x, 0), tile_size: tile }, &mut out));
|
||||
assert_eq!(out, [7.0; 4]);
|
||||
}
|
||||
for n in [1, 2, isize::MAX as usize, usize::MAX] {
|
||||
@@ -160,7 +171,7 @@ mod tests {
|
||||
let (nx, ny) = layout.cells(w, h);
|
||||
let tile = nx.max(ny);
|
||||
let mut out = vec![0.0; 4 * tile * tile];
|
||||
assert!(pack_tile(&mosaic, w, h, layout, 0, 0, tile, &mut out));
|
||||
assert!(pack_tile(&mosaic, layout, TileRegion { mosaic_size: (w, h), cell_origin: (0, 0), tile_size: tile }, &mut out));
|
||||
let plane = tile * tile;
|
||||
for (p, want) in [10.0, 20.0, 20.0, 30.0].into_iter().enumerate() {
|
||||
assert!(out[p * plane..(p + 1) * plane].iter().all(|&v| v == want), "layout {cell:?} plane {p}");
|
||||
@@ -174,7 +185,7 @@ mod tests {
|
||||
let (w, h) = (4, 4);
|
||||
let mosaic: Vec<f32> = (0..16).map(|i| i as f32).collect();
|
||||
let mut out = vec![0.0; 4 * 4];
|
||||
assert!(pack_tile(&mosaic, w, h, Layout { dx: 0, dy: 0 }, 0, 0, 2, &mut out));
|
||||
assert!(pack_tile(&mosaic, Layout { dx: 0, dy: 0 }, TileRegion { mosaic_size: (w, h), cell_origin: (0, 0), tile_size: 2 }, &mut out));
|
||||
// cell (0,0): R = site (0,0)=0, G1 = site (1,0)=1, G2 = site (0,1)=4, B = site (1,1)=5
|
||||
assert_eq!([out[0], out[4], out[8], out[12]], [0.0, 1.0, 4.0, 5.0]);
|
||||
// cell (1,0): sites (2,0)=2, (3,0)=3, (2,1)=6, (3,1)=7
|
||||
@@ -184,8 +195,8 @@ mod tests {
|
||||
#[test]
|
||||
fn packing_rejects_wrong_sizes() {
|
||||
let mut out = vec![0.0; 16];
|
||||
assert!(!pack_tile(&[0.0; 15], 4, 4, Layout { dx: 0, dy: 0 }, 0, 0, 2, &mut out));
|
||||
assert!(!pack_tile(&[0.0; 16], 4, 4, Layout { dx: 0, dy: 0 }, 0, 0, 3, &mut out));
|
||||
assert!(!pack_tile(&[], 0, 0, Layout { dx: 0, dy: 0 }, 0, 0, 2, &mut out));
|
||||
assert!(!pack_tile(&[0.0; 15], Layout { dx: 0, dy: 0 }, TileRegion { mosaic_size: (4, 4), cell_origin: (0, 0), tile_size: 2 }, &mut out));
|
||||
assert!(!pack_tile(&[0.0; 16], Layout { dx: 0, dy: 0 }, TileRegion { mosaic_size: (4, 4), cell_origin: (0, 0), tile_size: 3 }, &mut out));
|
||||
assert!(!pack_tile(&[], Layout { dx: 0, dy: 0 }, TileRegion { mosaic_size: (0, 0), cell_origin: (0, 0), tile_size: 2 }, &mut out));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use lightcraft_raster::Rgb32f;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::bayer::{Layout, pack_tile};
|
||||
use crate::bayer::{Layout, TileRegion, pack_tile};
|
||||
use crate::manifest::Gain;
|
||||
use crate::tiles::{starts, weight};
|
||||
|
||||
@@ -139,7 +139,12 @@ pub fn denoise_bayer(
|
||||
.par_iter()
|
||||
.map(|&(x0, y0, bx, ax, by, ay)| {
|
||||
let mut input = allocate(4 * p.tile * p.tile, 0f32)?;
|
||||
if !pack_tile(mosaic, width, height, layout, x0 as isize, y0 as isize, p.tile, &mut input) {
|
||||
if !pack_tile(
|
||||
mosaic,
|
||||
layout,
|
||||
TileRegion { mosaic_size: (width, height), cell_origin: (x0 as isize, y0 as isize), tile_size: p.tile },
|
||||
&mut input,
|
||||
) {
|
||||
return Err(Error::Input("a tile could not be packed".into()));
|
||||
}
|
||||
let mut out = runner.run(&input)?;
|
||||
|
||||
@@ -91,107 +91,119 @@ fn diff(a: &lightcraft_raster::Rgba8, b: &lightcraft_raster::Rgba8) -> (u8, f64)
|
||||
(max, sum as f64 / (a.data.len() * 3).max(1) as f64)
|
||||
}
|
||||
|
||||
fn benchmark_jpeg(args: &[String], n: usize) {
|
||||
// a JPEG (e.g. an embedded preview pulled out with exiftool) decoded to fit a few boxes
|
||||
for path in args {
|
||||
let bytes = std::fs::read(path).expect("read");
|
||||
for edge in [256u32, 512, 1600, 2560] {
|
||||
let o = lightcraft_codecs::DecodeOptions::fit(edge, edge);
|
||||
let d = lightcraft_codecs::decode(&bytes, o).expect("decode");
|
||||
println!(
|
||||
" {path} fit {edge}: {}×{} {}",
|
||||
d.image.width,
|
||||
d.image.height,
|
||||
best(n, || drop(lightcraft_codecs::decode(&bytes, o).expect("decode")))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn benchmark_load(args: &[String], n: usize, only: &str) {
|
||||
// opening a photo in the loupe, step by step: what the app's loaders cost (per file)
|
||||
for path in args {
|
||||
let bytes = std::fs::read(path).expect("read");
|
||||
println!("{path}");
|
||||
if std::env::var("LOADONLY").is_ok() {
|
||||
// only the preview load (for a profiler)
|
||||
println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load"))));
|
||||
continue;
|
||||
}
|
||||
println!(" {:<36} {}", "file read:", best(n, || drop(std::fs::read(path).expect("read"))));
|
||||
println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode"))));
|
||||
let mut raw = lightcraft_raw::decode(&bytes).expect("decode");
|
||||
if let lightcraft_raw::RawData::U16(d) = &raw.data {
|
||||
let sum = d.iter().enumerate().fold(0u64, |a, (i, &v)| a.wrapping_mul(31).wrapping_add(v as u64 ^ i as u64));
|
||||
println!(" decode checksum: {sum:016x}");
|
||||
}
|
||||
if only == "load1" {
|
||||
println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load"))));
|
||||
continue;
|
||||
}
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) {
|
||||
println!(" {:<36} {}", format!("binned ×{k} masked:"), best(n, || drop(raw.develop_binned_masked(k, 0.99))));
|
||||
}
|
||||
for edge in [2560usize, 1600] {
|
||||
println!(
|
||||
" {:<36} {}",
|
||||
format!("embedded srgb, whole file ({edge}):"),
|
||||
best(n, || drop(lightcraft_engine::files::embedded_preview_srgb(&bytes, edge)))
|
||||
);
|
||||
let quick = lightcraft_engine::files::fs_preview_loader();
|
||||
println!(" {:<36} {}", format!("fs_preview_loader ({edge}):"), best(n, || drop(quick(path, edge))));
|
||||
}
|
||||
println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load"))));
|
||||
let (loader, _) = lightcraft_engine::files::fs_hooks();
|
||||
println!(" {:<36} {}", "fs loader (2560, read+load):", best(n, || drop(loader(path, 2560).expect("load"))));
|
||||
}
|
||||
}
|
||||
|
||||
fn benchmark_source(args: &[String], n: usize) {
|
||||
// opening a photo: where the time goes before the first sharp loupe (per file)
|
||||
for path in args {
|
||||
let bytes = std::fs::read(path).expect("read");
|
||||
println!("{path}");
|
||||
let mut raw = lightcraft_raw::decode(&bytes).expect("decode");
|
||||
// as the loader does: lens corrections are applied by the pipeline, not in the raw develop
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
println!(" {}×{} cpp {} cfa {:?}", raw.width, raw.height, raw.cpp, raw.cfa.as_ref().map(|c| c.name()));
|
||||
println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode"))));
|
||||
println!(" {:<36} {}", "normalize:", best(n, || drop(raw.normalized().expect("norm"))));
|
||||
let norm = raw.normalized().expect("norm");
|
||||
println!(" {:<36} {}", "demosaic AHD:", best(n, || drop(lightcraft_raw::demosaic(&norm, lightcraft_raw::Method::Ahd))));
|
||||
if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) {
|
||||
println!(" {:<36} {}", format!("binned ×{k} (2560 preview):"), best(n, || drop(raw.develop_binned(k, 0.99))));
|
||||
let img = raw.develop_binned(k, 0.99).expect("bin").expect("binnable");
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
println!(
|
||||
" {:<36} {}",
|
||||
"highlight reconstruct (binned):",
|
||||
best(n, || {
|
||||
let mut i = img.clone();
|
||||
lightcraft_raw::highlight::reconstruct(&mut i, t.wb, 0.99);
|
||||
})
|
||||
);
|
||||
println!(" {:<36} {}", "clone (binned):", best(n, || drop(img.clone())));
|
||||
println!(" {:<36} {}", "fit 2560 (binned):", best(n, || drop(fit(&img, 2560, 2560, Filter::Box))));
|
||||
}
|
||||
println!(" {:<36} {}", "embedded preview (2560):", best(n, || drop(lightcraft_engine::files::load_embedded_preview(&bytes, 2560))));
|
||||
for edge in [512usize, 2560, usize::MAX] {
|
||||
println!(
|
||||
" {:<36} {}",
|
||||
format!("load_bytes({edge}):"),
|
||||
best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, edge).expect("load")))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let n: usize = std::env::var("N").ok().and_then(|v| v.parse().ok()).unwrap_or(5);
|
||||
let only = std::env::var("ONLY").unwrap_or_default();
|
||||
let run = |name: &str| only.is_empty() || only.split(',').any(|o| name.contains(o));
|
||||
if only == "jpeg" {
|
||||
// a JPEG (e.g. an embedded preview pulled out with exiftool) decoded to fit a few boxes
|
||||
for path in &args {
|
||||
let bytes = std::fs::read(path).expect("read");
|
||||
for edge in [256u32, 512, 1600, 2560] {
|
||||
let o = lightcraft_codecs::DecodeOptions::fit(edge, edge);
|
||||
let d = lightcraft_codecs::decode(&bytes, o).expect("decode");
|
||||
println!(
|
||||
" {path} fit {edge}: {}×{} {}",
|
||||
d.image.width,
|
||||
d.image.height,
|
||||
best(n, || drop(lightcraft_codecs::decode(&bytes, o).expect("decode")))
|
||||
);
|
||||
}
|
||||
}
|
||||
benchmark_jpeg(&args, n);
|
||||
return;
|
||||
}
|
||||
if only == "load" || only == "load1" {
|
||||
// opening a photo in the loupe, step by step: what the app's loaders cost (per file)
|
||||
for path in &args {
|
||||
let bytes = std::fs::read(path).expect("read");
|
||||
println!("{path}");
|
||||
if std::env::var("LOADONLY").is_ok() {
|
||||
// only the preview load (for a profiler)
|
||||
println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load"))));
|
||||
continue;
|
||||
}
|
||||
println!(" {:<36} {}", "file read:", best(n, || drop(std::fs::read(path).expect("read"))));
|
||||
println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode"))));
|
||||
let mut raw = lightcraft_raw::decode(&bytes).expect("decode");
|
||||
if let lightcraft_raw::RawData::U16(d) = &raw.data {
|
||||
let sum = d.iter().enumerate().fold(0u64, |a, (i, &v)| a.wrapping_mul(31).wrapping_add(v as u64 ^ i as u64));
|
||||
println!(" decode checksum: {sum:016x}");
|
||||
}
|
||||
if only == "load1" {
|
||||
println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load"))));
|
||||
continue;
|
||||
}
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) {
|
||||
println!(" {:<36} {}", format!("binned ×{k} masked:"), best(n, || drop(raw.develop_binned_masked(k, 0.99))));
|
||||
}
|
||||
for edge in [2560usize, 1600] {
|
||||
println!(
|
||||
" {:<36} {}",
|
||||
format!("embedded srgb, whole file ({edge}):"),
|
||||
best(n, || drop(lightcraft_engine::files::embedded_preview_srgb(&bytes, edge)))
|
||||
);
|
||||
let quick = lightcraft_engine::files::fs_preview_loader();
|
||||
println!(" {:<36} {}", format!("fs_preview_loader ({edge}):"), best(n, || drop(quick(path, edge))));
|
||||
}
|
||||
println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load"))));
|
||||
let (loader, _) = lightcraft_engine::files::fs_hooks();
|
||||
println!(" {:<36} {}", "fs loader (2560, read+load):", best(n, || drop(loader(path, 2560).expect("load"))));
|
||||
}
|
||||
benchmark_load(&args, n, &only);
|
||||
return;
|
||||
}
|
||||
if only == "source" {
|
||||
// opening a photo: where the time goes before the first sharp loupe (per file)
|
||||
for path in &args {
|
||||
let bytes = std::fs::read(path).expect("read");
|
||||
println!("{path}");
|
||||
let mut raw = lightcraft_raw::decode(&bytes).expect("decode");
|
||||
// as the loader does: lens corrections are applied by the pipeline, not in the raw develop
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
println!(" {}×{} cpp {} cfa {:?}", raw.width, raw.height, raw.cpp, raw.cfa.as_ref().map(|c| c.name()));
|
||||
println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode"))));
|
||||
println!(" {:<36} {}", "normalize:", best(n, || drop(raw.normalized().expect("norm"))));
|
||||
let norm = raw.normalized().expect("norm");
|
||||
println!(" {:<36} {}", "demosaic AHD:", best(n, || drop(lightcraft_raw::demosaic(&norm, lightcraft_raw::Method::Ahd))));
|
||||
if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) {
|
||||
println!(" {:<36} {}", format!("binned ×{k} (2560 preview):"), best(n, || drop(raw.develop_binned(k, 0.99))));
|
||||
let img = raw.develop_binned(k, 0.99).expect("bin").expect("binnable");
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
println!(
|
||||
" {:<36} {}",
|
||||
"highlight reconstruct (binned):",
|
||||
best(n, || {
|
||||
let mut i = img.clone();
|
||||
lightcraft_raw::highlight::reconstruct(&mut i, t.wb, 0.99);
|
||||
})
|
||||
);
|
||||
println!(" {:<36} {}", "clone (binned):", best(n, || drop(img.clone())));
|
||||
println!(" {:<36} {}", "fit 2560 (binned):", best(n, || drop(fit(&img, 2560, 2560, Filter::Box))));
|
||||
}
|
||||
println!(" {:<36} {}", "embedded preview (2560):", best(n, || drop(lightcraft_engine::files::load_embedded_preview(&bytes, 2560))));
|
||||
for edge in [512usize, 2560, usize::MAX] {
|
||||
println!(
|
||||
" {:<36} {}",
|
||||
format!("load_bytes({edge}):"),
|
||||
best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, edge).expect("load")))
|
||||
);
|
||||
}
|
||||
}
|
||||
benchmark_source(&args, n);
|
||||
return;
|
||||
}
|
||||
let run = |name: &str| only.is_empty() || only.split(',').any(|o| name.contains(o));
|
||||
let (full, info) = match args.first() {
|
||||
Some(path) => {
|
||||
let bytes = std::fs::read(path).expect("read");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::look_fit::{fit_dng_tone, fit_preview};
|
||||
use super::mono::{fit_dark_tone, monochrome_look};
|
||||
use super::proxy::{LensGeometry, proxies};
|
||||
use super::*;
|
||||
use super::{CameraLook, CameraTone, CameraTransform, ColorData, D50, D65, Mat3, ProfileLook, REC2020, RawFormat, RawImage, ToneCurve, Xy, bradford};
|
||||
|
||||
/// Long edge of the sensor/JPEG proxy a single photo's look is fitted on (the acceptance gates
|
||||
/// below were set at this size).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::mono::to_prophoto;
|
||||
use super::pairs::{displayed, luma};
|
||||
use super::*;
|
||||
use super::{CameraLook, CameraTone, HsvTable, Mat3, ToneMap, luminance_2020};
|
||||
|
||||
/// [`HueSat::apply`] restores the luminance in full only if the table kept at least this share of it. A colour
|
||||
/// the table darkens to [`NO_LUMINANCE_KEPT`] or less (only near-black noise, whose channels go negative) keeps
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::mono::{fit_tone_only_with, monochrome};
|
||||
use super::pairs::{Attempt, TONE_FITS, fit_attempt, fit_partial, search_ordered};
|
||||
use super::proxy::{CLIP_TOLERANCE, LensGeometry, MIN_PREVIEW_FRACTION, proxies, structure_agrees};
|
||||
use super::tone::{HighlightCheck, highlights};
|
||||
use super::*;
|
||||
use super::{CameraLook, CameraTone, CameraTransform, HsvTable, Mat3, RawImage, Rgb32f, ToneMap, luminance_2020};
|
||||
|
||||
/// The look fitted to the file's camera JPEG, and whether that JPEG is black and white ([`monochrome`]).
|
||||
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> (Option<CameraLook>, bool) {
|
||||
|
||||
@@ -7,7 +7,7 @@ use super::pairs::{
|
||||
};
|
||||
use super::proxy::{LensGeometry, proxies};
|
||||
use super::tone::{HighlightCheck, ToneFit, fit_tone, highlights, highlights_hold};
|
||||
use super::*;
|
||||
use super::{CameraLook, CameraTransform, D50, D65, HsvTable, Mat3, PROPHOTO, REC2020, RawImage, Rgb32f, ToneMap, bradford, luminance_2020};
|
||||
|
||||
/// A camera JPEG pixel's channels may differ by this much (linear) and still count as grey.
|
||||
const GREY_SPREAD: f32 = 0.002;
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::proxy::DARK_FLOOR;
|
||||
#[cfg(test)]
|
||||
use super::tone::highlights;
|
||||
use super::tone::{HighlightCheck, ToneFit, highlights_hold};
|
||||
use super::*;
|
||||
use super::{CameraLook, CameraTone, HsvTable, Mat3, Rgb32f, ToneMap, luminance_2020};
|
||||
|
||||
/// A fit must cut the held-out squared error to below this share of the fallback's.
|
||||
pub(super) const MIN_IMPROVEMENT: f64 = 0.7;
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::colour::{PROFILE_PROXY, PROXY, file_local_look};
|
||||
use super::hue_sat::fit_hue_sat;
|
||||
use super::look_fit::{FIT_PREVIEW_MAX_PIXELS, SENSOR_CLIP, clip_mask, without_clipped};
|
||||
use super::pairs::{collect_pairs, fit_matrix};
|
||||
use super::*;
|
||||
use super::{CameraTransform, Filter, HsvTable, Mat3, RawFormat, RawImage, Rgb32f, fit, luminance_2020, resize};
|
||||
|
||||
/// The embedded preview's long edge must be at least this share of the raw's (and 384 px): a DNG whose preview is a
|
||||
/// small thumbnail is not fitted. The fit itself runs on a proxy of at most 384 px, so the 1024 px preview
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::look_fit::dro_off;
|
||||
use super::pairs::{Pairs, displayed, luma};
|
||||
use super::*;
|
||||
use super::{CameraTone, Rgb32f, ToneMap, luminance_2020};
|
||||
|
||||
/// How the per-photo tone curve is fitted ([`fit_pairs_ordered`] tries them in order).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
|
||||
@@ -129,16 +129,18 @@ fn identify(path: &Path, c: &str, sha_known: Option<&str>) -> Result<Source> {
|
||||
|
||||
/// Install the model in `src`. A model that passes its self-test becomes the one in use (`activate`, the default): installing
|
||||
/// a model is how someone says they want it. `verified` is for a download whose hash was already checked.
|
||||
fn install_file(
|
||||
dir: &Path,
|
||||
loader: &crate::denoise::Loader,
|
||||
struct InstallFile<'a> {
|
||||
dir: &'a Path,
|
||||
run_on: RunOn,
|
||||
accepted_at: String,
|
||||
c: &str,
|
||||
src: &Path,
|
||||
command: &'a str,
|
||||
src: &'a Path,
|
||||
acknowledged: bool,
|
||||
verified: bool,
|
||||
) -> Result<(Value, Arc<dyn crate::denoise::Model>)> {
|
||||
}
|
||||
|
||||
fn install_file(loader: &crate::denoise::Loader, request: InstallFile<'_>) -> Result<(Value, Arc<dyn crate::denoise::Model>)> {
|
||||
let InstallFile { dir, run_on, accepted_at, command: c, src, acknowledged, verified } = request;
|
||||
let ident = identify(src, c, None)?;
|
||||
let m = ident.manifest.clone();
|
||||
manifest::validate(&m).map_err(|e| bad(c, e.to_string()))?;
|
||||
@@ -262,7 +264,8 @@ fn start_install(s: &mut Session, src: PathBuf, id: String, activate: bool, veri
|
||||
.spawn(move || {
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _held = crate::memory::work_gate().acquire(512 * 1024 * 1024);
|
||||
install_file(&dir, &loader, run_on, at, C, &src, true, verified).map_err(|e| plain(&e))
|
||||
install_file(&loader, InstallFile { dir: &dir, run_on, accepted_at: at, command: C, src: &src, acknowledged: true, verified })
|
||||
.map_err(|e| plain(&e))
|
||||
}))
|
||||
.unwrap_or_else(|_| Err("the model installation stopped unexpectedly".into()));
|
||||
if verified {
|
||||
@@ -290,8 +293,18 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.denoise.install.is_some() {
|
||||
return Err(bad(C, "another model is being installed"));
|
||||
}
|
||||
let (result, model) =
|
||||
install_file(&models_dir(s, C)?, &s.denoise.loader, s.denoise.settings.run_on(), (s.clock)(), C, Path::new(path), acknowledged, false)?;
|
||||
let (result, model) = install_file(
|
||||
&s.denoise.loader,
|
||||
InstallFile {
|
||||
dir: &models_dir(s, C)?,
|
||||
run_on: s.denoise.settings.run_on(),
|
||||
accepted_at: (s.clock)(),
|
||||
command: C,
|
||||
src: Path::new(path),
|
||||
acknowledged,
|
||||
verified: false,
|
||||
},
|
||||
)?;
|
||||
activate_install(s, &result, model, activate)
|
||||
}
|
||||
|
||||
|
||||
@@ -188,7 +188,10 @@ mod download {
|
||||
|
||||
use lightcraft_geocode::{GEONAMES_BASE, GEONAMES_ZIP};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
Cancel, DownloadOutcome, GEONAMES_ATTRIBUTION, GEONAMES_FILES, GeoNames, PoisonError, Result, Session, Value, bad, dataset_dir, json,
|
||||
str_param,
|
||||
};
|
||||
use crate::activity::Unit;
|
||||
|
||||
const MAX_ZIP: u64 = 64 * 1024 * 1024;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{AlbumId, LibrarySource, PhotoId, Result, Session, Value, bad};
|
||||
|
||||
/// `base` with a partial Filter (JSON) merged on top.
|
||||
/// `patch` merged onto `base`, its rules brought up to date (`RuleSet::upgrade`), unchecked (see
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::selection::strs;
|
||||
use super::*;
|
||||
use super::{AlbumId, PhotoId, Result, Selection, Session, Value, bad, bool_or, json, str_param};
|
||||
|
||||
/// The auto-import folder's visible files with their sizes (the file-system half of
|
||||
/// `library.autoImportScan`; the app lists on a worker thread and passes `listing`).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{AlbumId, Flag, Op, PhotoId, Result, Selection, Session, Value, bad, bool_or, json, ok};
|
||||
|
||||
/// The selection after photos left the view (deleted, or rated, flagged or labelled out of it).
|
||||
/// Its callers end an open edit (a slider drag) before their own change, as moving to the next
|
||||
|
||||
@@ -5,7 +5,10 @@ use super::selection::{
|
||||
album_param, album_targets, album_targets_or_err, change_flag, change_rating, cull, flip, for_targets, has_library, mark_deleted,
|
||||
opt_album_param, rotate, row_step, select_after_deletion, select_by, step, strs,
|
||||
};
|
||||
use super::*;
|
||||
use super::{
|
||||
Album, AlbumId, ColorLabel, CommandSpec, CopyrightStatus, Filter, Flag, GroupBy, LibrarySource, Op, PhotoId, Result, Selection, Sort, SortKey,
|
||||
Value, always, bad, bool_or, cmd, has_active, has_library_selection, has_selection, json, ok, str_param,
|
||||
};
|
||||
|
||||
/// Seeds stay exactly representable as a JSON double, so web and agent clients read back what they wrote.
|
||||
const MAX_SEED: u64 = 1 << 53;
|
||||
|
||||
@@ -37,9 +37,16 @@ fn props(x: &str) -> Props {
|
||||
#[test]
|
||||
fn maps_common_fields() {
|
||||
let p = props(SIDECAR);
|
||||
assert!(has_adjustments(&p));
|
||||
let partial = to_partial(&p, Target::RawAbsolute);
|
||||
let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0);
|
||||
assert!(has_adjustments(&p));
|
||||
assert_common_tone_fields(&s);
|
||||
assert_common_color_fields(&s);
|
||||
assert_common_detail_fields(&s);
|
||||
assert_common_geometry_fields(&s);
|
||||
}
|
||||
|
||||
fn assert_common_tone_fields(s: &DevelopSettings) {
|
||||
assert_eq!(s.wb.mode, WbMode::Custom);
|
||||
assert_eq!((s.wb.temp, s.wb.tint), (5150.0, 12.0));
|
||||
assert_eq!(s.light.exposure, 0.65);
|
||||
@@ -54,15 +61,24 @@ fn maps_common_fields() {
|
||||
assert_eq!(s.curve.refine_saturation, 40.0);
|
||||
assert!((s.curve.master[1].x - 64.0 / 255.0).abs() < 1e-9 && (s.curve.master[1].y - 52.0 / 255.0).abs() < 1e-9);
|
||||
assert!(s.curve.red.is_empty(), "identity channel curve maps to no curve");
|
||||
}
|
||||
|
||||
fn assert_common_color_fields(s: &DevelopSettings) {
|
||||
assert_eq!((s.grading.shadows.hue, s.grading.shadows.sat), (210.0, 14.0));
|
||||
assert_eq!((s.grading.highlights.hue, s.grading.highlights.sat), (40.0, 9.0));
|
||||
assert_eq!((s.grading.midtones.hue, s.grading.midtones.sat), (120.0, 6.0));
|
||||
assert_eq!((s.grading.balance, s.grading.blending), (20.0, 70.0));
|
||||
}
|
||||
|
||||
fn assert_common_detail_fields(s: &DevelopSettings) {
|
||||
assert_eq!((s.detail.sharpen_amount, s.detail.sharpen_radius, s.detail.sharpen_detail, s.detail.sharpen_masking), (55.0, 1.2, 30.0, 12.0));
|
||||
assert_eq!((s.detail.nr_luminance, s.detail.nr_color), (18.0, 25.0));
|
||||
assert_eq!(s.vignette.amount, -21.0);
|
||||
assert_eq!(s.vignette.style, VignetteStyle::ColorPriority);
|
||||
assert_eq!((s.grain.amount, s.grain.size, s.grain.roughness), (15.0, 30.0, 60.0));
|
||||
}
|
||||
|
||||
fn assert_common_geometry_fields(s: &DevelopSettings) {
|
||||
assert_eq!(s.geometry.upright, Upright::Level);
|
||||
assert_eq!(s.geometry.vertical, -10.0);
|
||||
assert!(s.optics.remove_ca);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#[cfg(feature = "denoise")]
|
||||
use super::model::{GpuSide, STACK};
|
||||
#[cfg(feature = "denoise")]
|
||||
use super::*;
|
||||
use super::{Arc, Duration, Mutex, Path, PathBuf, PoisonError, SystemTime, TileRunner};
|
||||
|
||||
/// Tiles run at once on a graphics card: enough to keep it fed while others are packed and blended.
|
||||
#[cfg(feature = "denoise")]
|
||||
|
||||
@@ -4,8 +4,9 @@ use super::gpu::{GPU_CRASHED, GPU_MAX_FALLBACKS, GPU_PARALLEL, GPU_SLOW, GPU_STU
|
||||
use super::settings::{installed_models, read_settings};
|
||||
#[cfg(feature = "denoise")]
|
||||
use super::state::Pace;
|
||||
use super::*;
|
||||
|
||||
use super::{Arc, DenoiserManifest, Mutex, Path, TileRunner, Value, json};
|
||||
#[cfg(feature = "denoise")]
|
||||
use super::{AtomicBool, AtomicUsize, OnceLock, Ordering, PathBuf, PoisonError, RunError};
|
||||
/// Stack of the threads that run the model (the runtime recurses through its graph).
|
||||
pub(super) const STACK: usize = 16 << 20;
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::model::{Loader, Model, ModelSlot, RunOn, STACK};
|
||||
use super::settings::EXT;
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, AtomicBool, AtomicUsize, ByteReader, Condvar, Control, DenoiserManifest, HashSet, Hasher128, Layout, Mutex, OnceLock, Ordering, Params,
|
||||
Path, PathBuf, Photo, PoisonError, RunError, Source, SystemTime, denoise_bayer, product,
|
||||
};
|
||||
|
||||
/// Samples at or above this (white = 1) are clipped: the model leaves them as the camera recorded them.
|
||||
const CLIP: f32 = 0.99;
|
||||
|
||||
@@ -4,7 +4,10 @@ use super::model::STACK;
|
||||
use super::products::{Active, JobSpec, MakeError, Progress, eligible, make_product, product_key, product_path, products_in};
|
||||
use super::settings::{DIR, SETTINGS_TTL, installed_models, read_settings};
|
||||
use super::state::{AUTO_LOOK, Failure, MAX_QUEUE, Outcome, Pace, PhotoState, Running};
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, DenoiseSpec, DevelopSettings, HashMap, HashSet, MakeProduct, Mutex, Ordering, PairLoader, Path, PathBuf, Photo, PhotoId, PoisonError,
|
||||
Selection, Session, Source, Value, json, product,
|
||||
};
|
||||
|
||||
impl Session {
|
||||
/// Where the active model's tiles run (see [`Model::device`]); `{kind: "none"}` while no model is loaded.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::gpu::GPU_SETUP_MARKER;
|
||||
use super::model::RunOn;
|
||||
use super::*;
|
||||
use super::{DenoiserManifest, Deserialize, Duration, Path, PathBuf, Serialize, Value};
|
||||
|
||||
/// Extension of a product file.
|
||||
pub const EXT: &str = "lcdn";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::model::{Loader, default_loader};
|
||||
use super::products::{Active, Progress};
|
||||
use super::settings::Settings;
|
||||
use super::*;
|
||||
use super::{Arc, Downloads, HashMap, Mutex, PathBuf, PhotoId, Serialize, VecDeque};
|
||||
|
||||
/// Most photos waiting in the queue.
|
||||
pub(super) const MAX_QUEUE: usize = 5000;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::ExportOptions;
|
||||
use super::place::Placer;
|
||||
use super::prepare::{Destination, Exported, PreparedExport};
|
||||
use super::*;
|
||||
|
||||
/// Run prepared exports in order: pick each one's path, and hand the bytes (and sidecars) to
|
||||
/// `write`. `exists` tells whether a path is taken. The conflict policy applies to a file and its
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::options::{ExportFormat, MetadataPolicy, SharpenAmount, SharpenFor};
|
||||
use super::watermark::{Watermark, draw_watermark, draw_watermark_deep};
|
||||
use super::*;
|
||||
use super::{
|
||||
ChromaSubsampling, DateTime, DeepImage, DeepSamples, EncodeImage, EncodeMeta, ExportOptions, Gps, Metadata, NamedSpace, OutputDepth, OutputSpace,
|
||||
Rgba8, Samples, encode, icc,
|
||||
};
|
||||
use std::borrow::Cow;
|
||||
|
||||
/// Unsharp mask on an 8-bit image (separable box-blur approximation of a small Gaussian).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::options::{Conflict, DestinationMode, ExportFormat, HdrCurve, MetadataPolicy, Resize, ResizeMode, SharpenAmount, SharpenFor};
|
||||
use super::prepare::DngCompression;
|
||||
use super::watermark::{Anchor, Watermark};
|
||||
use super::*;
|
||||
use super::{ExportOptions, JxlColour, JxlEffort, JxlOptions, OutputDepth, OutputSpace, TiffCompression};
|
||||
|
||||
impl Default for ExportOptions {
|
||||
fn default() -> Self {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::ExportOptions;
|
||||
use super::options::{Conflict, DestinationMode};
|
||||
use super::prepare::{Destination, Exported};
|
||||
use super::*;
|
||||
|
||||
/// Write an exported or rendered file on disk: its folder is created if needed, and the file is
|
||||
/// replaced atomically ([`lightcraft_catalog::safe_file::write_atomic_nosync`]: a temp file
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::batch::run_batch;
|
||||
use super::encoding::{encode_gain_map_jpeg, encode_rendered, export_metadata, removes_location};
|
||||
use super::options::ExportFormat;
|
||||
use super::params::apple_photos_unsupported;
|
||||
use super::*;
|
||||
use super::{ExportOptions, Metadata, OutputDepth};
|
||||
|
||||
/// One exported file.
|
||||
pub struct Exported {
|
||||
|
||||
@@ -1067,6 +1067,11 @@ fn avif_ten_bit() {
|
||||
/// unknown keys (naming the nearest known one) and values of the wrong kind or range.
|
||||
#[test]
|
||||
fn export_params_are_validated() {
|
||||
assert_invalid_export_params();
|
||||
assert_export_options_round_trip();
|
||||
}
|
||||
|
||||
fn assert_invalid_export_params() {
|
||||
use serde_json::json;
|
||||
let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
let e = err(json!({"longEdgee": 400}));
|
||||
@@ -1092,6 +1097,11 @@ fn export_params_are_validated() {
|
||||
.to_string()
|
||||
.starts_with("invalid parameters for `export.savePreset`")
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_export_options_round_trip() {
|
||||
use serde_json::json;
|
||||
let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
// everything the presets, the dialog, Export with Previous and `render --opt` send passes
|
||||
for p in builtin_presets() {
|
||||
ExportOptions::from_params(&p.params).unwrap_or_else(|e| panic!("{}: {e}", p.name));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::encoding::srgb8_in;
|
||||
use super::*;
|
||||
use super::{DeepImage, DeepSamples, Deserialize, OutputSpace, Rgba8, Serialize};
|
||||
|
||||
/// Where a watermark sits.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -13,7 +13,7 @@ use std::sync::{Arc, Condvar, Mutex, PoisonError};
|
||||
use std::time::Duration;
|
||||
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use lightcraft_faces::align::{Rgb, align_to_template, crop_box};
|
||||
use lightcraft_faces::align::{FaceCrop, Rgb, align_to_template, crop_box};
|
||||
use lightcraft_faces::runtime::Embedder;
|
||||
use lightcraft_faces::yunet::{Detector, Face, Options};
|
||||
use lightcraft_geom::Rect;
|
||||
@@ -221,13 +221,11 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done {
|
||||
Some((_, f)) => align_to_template(&img, &scaled(f), iw, ih),
|
||||
None => crop_box(
|
||||
&img,
|
||||
rect.x0 as f32 * w as f32,
|
||||
rect.y0 as f32 * h as f32,
|
||||
rect.x1 as f32 * w as f32,
|
||||
rect.y1 as f32 * h as f32,
|
||||
BOX_PAD,
|
||||
iw,
|
||||
ih,
|
||||
FaceCrop {
|
||||
bounds: (rect.x0 as f32 * w as f32, rect.y0 as f32 * h as f32, rect.x1 as f32 * w as f32, rect.y1 as f32 * h as f32),
|
||||
padding: BOX_PAD,
|
||||
output_size: (iw, ih),
|
||||
},
|
||||
),
|
||||
};
|
||||
// shown by the detector's box where it found this face: every face then has the same tightness
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::load::{load_vec, load_vec_pair, load_vec_reduced};
|
||||
use super::preview::{fit_box, fs_preview_loader, preview_orientation};
|
||||
use super::probe::probe_bytes;
|
||||
use super::*;
|
||||
use super::{Arc, DenoiseSpec, FileLoader, FileProbe, Filter, PairLoader, ReducedLoader, fit};
|
||||
|
||||
/// The file's first [`lightcraft_raw::PREVIEW_PREFIX`] bytes (or the whole file when it is shorter),
|
||||
/// which is what every format [`lightcraft_raw::bounded_preview`] covers keeps its pointer in.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::colour::bin_factor;
|
||||
use super::preview::{fit_box, load_embedded_preview};
|
||||
use super::probe::preview_reason;
|
||||
use super::*;
|
||||
use super::{Arc, CameraColourModel, DenoiseSpec, Filter, HIGHLIGHT_CLIP, Orientation, Rgb32f, SourceInfo, fit, xy_to_temp_tint};
|
||||
|
||||
/// Decode a file into a linear Rec.2020 image no larger than `max_edge`, oriented.
|
||||
///
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::fs::{quick_image_srgb, raf_preview_extent, read_prefix, read_prefix_at};
|
||||
use super::*;
|
||||
use super::{Arc, Filter, Orientation, PreviewLoader, Rgb32f, SourceInfo, fit};
|
||||
|
||||
/// Orientation for an embedded preview: its own EXIF orientation when it has one, else the raw file's.
|
||||
pub(super) fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::load::WbScaleOf;
|
||||
use super::preview::embedded_preview_size;
|
||||
use super::*;
|
||||
use super::{MediaKind, Meta, Orientation, ProbeInfo};
|
||||
|
||||
pub(super) fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
|
||||
let shutter = m.exposure_time.map(|t| if t >= 1.0 { format!("{t:.0}") } else { format!("1/{:.0}", 1.0 / t) }).unwrap_or_default();
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::defaults::apply_import_defaults;
|
||||
use super::files::copy_into;
|
||||
use super::job::{Prepared, PreparedItem, ReadyFile};
|
||||
use super::types::{Duplicate, ImportOptions, ImportReport, Kept, Moved, OnDeleted};
|
||||
use super::*;
|
||||
use super::{HashMap, Op, Path, PathBuf, Photo, PhotoId, ProbeInfo, Session, Source};
|
||||
|
||||
/// A batch's items in file order while its transfers are pending ([`ImportJob::run_plan`]).
|
||||
#[derive(Default)]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Deserialize, Photo, Serialize, Session};
|
||||
|
||||
/// The develop settings a photo gets on import: raws start from their as-shot white balance with
|
||||
/// default sharpening / colour noise reduction, and file-embedded lens corrections on (as the
|
||||
|
||||
@@ -5,7 +5,7 @@ use super::job::{ImportJob, Prepared, PreparedItem, ReadyFile};
|
||||
use super::scan::ScanProgress;
|
||||
#[cfg(test)]
|
||||
use super::types::{ImportMode, ImportOptions};
|
||||
use super::*;
|
||||
use super::{Path, PathBuf, ProbeInfo, Session};
|
||||
|
||||
/// File extensions offered by import discovery and file pickers (lower case).
|
||||
/// This includes preview-only raws; the file probe decides whether sensor decoding is available.
|
||||
@@ -253,6 +253,7 @@ pub(super) fn copy_into(dir: &Path, src: &str, name: Option<&str>, probe_hash: O
|
||||
#[cfg(test)]
|
||||
mod prepared_tests {
|
||||
use super::*;
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
|
||||
|
||||
/// Extensions the picker may offer: every one in [`EXTENSIONS`], minus the
|
||||
/// files this build cannot import (see [`accept_list`]).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::commit::{How, Plan, Slot, Transfer, commit_prepared, name_family, run_transfers, system_clock};
|
||||
use super::files::{expand, probe_all};
|
||||
use super::types::{ImportCandidate, ImportMode, ImportOptions, ImportReport, Kept, OnDeleted};
|
||||
use super::*;
|
||||
use super::{HashMap, MediaKind, Path, PathBuf, Photo, PhotoId, ProbeInfo, Session, Source};
|
||||
|
||||
/// Import files/folders. See the module docs.
|
||||
pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Result<ImportReport> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::files::{expand, probe_all};
|
||||
use super::types::ImportCandidate;
|
||||
use super::*;
|
||||
use super::{HashMap, Path, PathBuf, PhotoId, ProbeInfo, Session, Source};
|
||||
|
||||
/// What a [`scan`] needs from the session, so it can run on another thread (a folder on a network
|
||||
/// share can take minutes to read).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Deserialize, MediaKind, Photo, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum ImportMode {
|
||||
|
||||
@@ -858,6 +858,83 @@ pub(crate) struct ApplyPrepared<'a> {
|
||||
pub(crate) undo0: usize,
|
||||
}
|
||||
|
||||
fn stage_collections(
|
||||
s: &mut crate::Session,
|
||||
data: &CatalogImport,
|
||||
index: &mut ImportIndex,
|
||||
ids: &HashMap<i64, PhotoId>,
|
||||
ops: &mut Vec<Op>,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> usize {
|
||||
let mut albums = HashMap::new();
|
||||
for collection in &data.collections {
|
||||
if number(collection, "systemOnly") != 0 {
|
||||
continue;
|
||||
}
|
||||
let collection_key = format!("{}:{}", key(&data.source), number(collection, "id_local"));
|
||||
let id = index
|
||||
.collections
|
||||
.get(&collection_key)
|
||||
.copied()
|
||||
.map(lightcraft_catalog::AlbumId)
|
||||
.filter(|id| s.catalog.album(*id).is_some())
|
||||
.unwrap_or_else(|| s.catalog.alloc_album_id());
|
||||
index.collections.insert(collection_key, id.0);
|
||||
albums.insert(number(collection, "id_local"), id);
|
||||
}
|
||||
let parents: HashMap<_, _> = data.collections.iter().map(|r| (number(r, "id_local"), number(r, "parent"))).collect();
|
||||
let mut grouped_members: HashMap<i64, Vec<&Row>> = HashMap::new();
|
||||
for member in &data.members {
|
||||
grouped_members.entry(number(member, "collection")).or_default().push(member);
|
||||
}
|
||||
for members in grouped_members.values_mut() {
|
||||
members.sort_by(|a, b| text(a, "positionInCollection").cmp(text(b, "positionInCollection")));
|
||||
}
|
||||
let mut ordered: Vec<_> = data.collections.iter().collect();
|
||||
ordered.sort_by_key(|r| {
|
||||
let mut depth = 0;
|
||||
let mut at = number(r, "parent");
|
||||
while at != 0 && depth < 64 {
|
||||
depth += 1;
|
||||
at = parents.get(&at).copied().unwrap_or(0);
|
||||
}
|
||||
depth
|
||||
});
|
||||
for collection in ordered {
|
||||
let Some(id) = albums.get(&number(collection, "id_local")).copied() else { continue };
|
||||
let mut album = Album::new(id, text(collection, "name"));
|
||||
album.parent = albums.get(&number(collection, "parent")).copied();
|
||||
album.folder = text(collection, "creationId").contains("group");
|
||||
if text(collection, "creationId").contains("smart") {
|
||||
warnings.push(format!("{}: smart rules preserved in archive; imported current membership", album.name));
|
||||
}
|
||||
if !album.folder {
|
||||
album.photos = grouped_members
|
||||
.get(&number(collection, "id_local"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|r| ids.get(&number(r, "image")).copied())
|
||||
.collect();
|
||||
}
|
||||
if s.catalog.album(id).is_some() {
|
||||
// Reimport preserves the user's album name/hierarchy, merges only new membership.
|
||||
if let Some(old) = s.catalog.album(id).filter(|a| !a.folder && !a.is_smart()) {
|
||||
let mut photos = old.photos.clone();
|
||||
let mut seen: HashSet<_> = photos.iter().copied().collect();
|
||||
for photo in album.photos {
|
||||
if seen.insert(photo) {
|
||||
photos.push(photo);
|
||||
}
|
||||
}
|
||||
ops.push(Op::SetAlbumPhotos { id, photos });
|
||||
}
|
||||
} else {
|
||||
ops.push(Op::AddAlbum { album });
|
||||
}
|
||||
}
|
||||
albums.len()
|
||||
}
|
||||
|
||||
/// Turn the read catalog into library records in one undo step and return the import report
|
||||
/// (added, missing, failed, warnings and skipped images).
|
||||
pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result<AppliedImport> {
|
||||
@@ -1022,78 +1099,13 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co
|
||||
}
|
||||
by_path.entry(key(&src.path)).or_insert(photo_id);
|
||||
}
|
||||
let mut albums = HashMap::new();
|
||||
for collection in &data.collections {
|
||||
if number(collection, "systemOnly") != 0 {
|
||||
continue;
|
||||
}
|
||||
let collection_key = format!("{}:{}", key(&data.source), number(collection, "id_local"));
|
||||
let id = index
|
||||
.collections
|
||||
.get(&collection_key)
|
||||
.copied()
|
||||
.map(lightcraft_catalog::AlbumId)
|
||||
.filter(|id| s.catalog.album(*id).is_some())
|
||||
.unwrap_or_else(|| s.catalog.alloc_album_id());
|
||||
index.collections.insert(collection_key, id.0);
|
||||
albums.insert(number(collection, "id_local"), id);
|
||||
}
|
||||
let parents: HashMap<_, _> = data.collections.iter().map(|r| (number(r, "id_local"), number(r, "parent"))).collect();
|
||||
let mut grouped_members: HashMap<i64, Vec<&Row>> = HashMap::new();
|
||||
for member in &data.members {
|
||||
grouped_members.entry(number(member, "collection")).or_default().push(member);
|
||||
}
|
||||
for members in grouped_members.values_mut() {
|
||||
members.sort_by(|a, b| text(a, "positionInCollection").cmp(text(b, "positionInCollection")));
|
||||
}
|
||||
let mut ordered: Vec<_> = data.collections.iter().collect();
|
||||
ordered.sort_by_key(|r| {
|
||||
let mut depth = 0;
|
||||
let mut at = number(r, "parent");
|
||||
while at != 0 && depth < 64 {
|
||||
depth += 1;
|
||||
at = parents.get(&at).copied().unwrap_or(0);
|
||||
}
|
||||
depth
|
||||
});
|
||||
for collection in ordered {
|
||||
let Some(id) = albums.get(&number(collection, "id_local")).copied() else { continue };
|
||||
let mut album = Album::new(id, text(collection, "name"));
|
||||
album.parent = albums.get(&number(collection, "parent")).copied();
|
||||
album.folder = text(collection, "creationId").contains("group");
|
||||
if text(collection, "creationId").contains("smart") {
|
||||
warnings.push(format!("{}: smart rules preserved in archive; imported current membership", album.name));
|
||||
}
|
||||
if !album.folder {
|
||||
album.photos = grouped_members
|
||||
.get(&number(collection, "id_local"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|r| ids.get(&number(r, "image")).copied())
|
||||
.collect();
|
||||
}
|
||||
if s.catalog.album(id).is_some() {
|
||||
// Reimport preserves the user's album name/hierarchy, merges only new membership.
|
||||
if let Some(old) = s.catalog.album(id).filter(|a| !a.folder && !a.is_smart()) {
|
||||
let mut photos = old.photos.clone();
|
||||
let mut seen: HashSet<_> = photos.iter().copied().collect();
|
||||
for photo in album.photos {
|
||||
if seen.insert(photo) {
|
||||
photos.push(photo);
|
||||
}
|
||||
}
|
||||
ops.push(Op::SetAlbumPhotos { id, photos });
|
||||
}
|
||||
} else {
|
||||
ops.push(Op::AddAlbum { album });
|
||||
}
|
||||
}
|
||||
let album_count = stage_collections(s, &data, index, &ids, &mut ops, &mut warnings);
|
||||
if !ops.is_empty() {
|
||||
s.commit("Import Lightroom Catalog", Op::Batch { ops })?;
|
||||
}
|
||||
s.merge_undo(s.undo.len().saturating_sub(undo0), "Import Lightroom Catalog");
|
||||
Ok(AppliedImport {
|
||||
report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"skipped":data.skipped,"unmapped":unmapped,"archive":archive_path,"mapping":ids}),
|
||||
report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":album_count,"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"skipped":data.skipped,"unmapped":unmapped,"archive":archive_path,"mapping":ids}),
|
||||
index: index.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::settings_hash::SettingsHashes;
|
||||
use super::source::{DecodedSource, DenoiseSpec, PairLoader, RENDERED_MEM_BYTES, SourceLevel, SourceRef, THUMB_SOURCE_BYTES};
|
||||
use super::*;
|
||||
use super::{Arc, DevelopSettings, Hasher128, Lru, MediaCache, Photo, PhotoId, PreviewCache, Rgb32f, Source, SourceInfo};
|
||||
|
||||
/// The photos that have a cached denoised picture for the denoise model in use. Only memory: nothing here touches the
|
||||
/// file system, so it can be asked on the UI thread.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::render::{RenderJob, RenderResult};
|
||||
use super::source::{PreviewLoader, SourceLevel};
|
||||
use super::*;
|
||||
use super::{Arc, Deserialize, Hash128, Histogram, Photo, PhotoId, PreviewCache, Rendered, Rgba8, Serialize, Source, SourceInfo};
|
||||
|
||||
/// Where a [`QuickJob`]'s stand-in image came from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use super::quick::QuickSource;
|
||||
use super::source::{DecodedSource, SourceLevel, SourceRef};
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, DevelopSettings, Hash128, Histogram, PhotoId, PreviewCache, Quality, RenderRequest, Rendered, Rgb32f, Rgba8, Source, SourceInfo, StageCache,
|
||||
};
|
||||
|
||||
/// Everything needed to render one photo, detached from the session.
|
||||
#[derive(Clone)]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::quick::{QuickJob, content_key, source_info};
|
||||
use super::render::{RenderJob, RenderResult};
|
||||
use super::source::{PreviewLoader, RENDER_CACHE_VERSION, SourceLevel, SourceRef, THUMB_SIZES, face_edge, source_edge_needed, thumb_bucket};
|
||||
use super::*;
|
||||
use super::{Arc, DevelopSettings, Hash128, Hasher128, MediaKind, Op, Photo, PhotoId, RenderRequest, Rendered, Rgb32f, Source, SourceInfo};
|
||||
|
||||
impl crate::Session {
|
||||
/// Build a render job for `id` fitting `max_w × max_h`. `before` renders the unedited look.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Arc, DevelopSettings, HashMap, PhotoId, VecDeque, Weak};
|
||||
|
||||
pub(super) const SETTINGS_HASH_ENTRIES: usize = 1024;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Arc, Deserialize, DevelopSettings, MediaKind, Photo, Rgb32f, Rgba8, Serialize, SourceInfo};
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 70;
|
||||
|
||||
@@ -255,6 +255,15 @@ pub struct Image16 {
|
||||
pub rgb: Vec<u16>,
|
||||
}
|
||||
|
||||
/// A rectangular image region used as a source or destination for `Image16::blit`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct BlitRect {
|
||||
pub x: usize,
|
||||
pub y: usize,
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
}
|
||||
|
||||
impl Image16 {
|
||||
pub fn filled(w: usize, h: usize, c: [u16; 3]) -> Result<Self, String> {
|
||||
let n = w.checked_mul(h).and_then(|n| n.checked_mul(3)).ok_or("image too large")?;
|
||||
@@ -284,9 +293,11 @@ impl Image16 {
|
||||
}
|
||||
|
||||
/// Copies the `w` x `h` block of `src` at (`sx`, `sy`) to (`dx`, `dy`), clipped to both images.
|
||||
pub fn blit(&mut self, src: &Image16, sx: usize, sy: usize, dx: usize, dy: usize, w: usize, h: usize) {
|
||||
let w = w.min(src.w.saturating_sub(sx)).min(self.w.saturating_sub(dx));
|
||||
let h = h.min(src.h.saturating_sub(sy)).min(self.h.saturating_sub(dy));
|
||||
pub fn blit(&mut self, src: &Image16, source: BlitRect, destination: BlitRect) {
|
||||
let BlitRect { x: sx, y: sy, .. } = source;
|
||||
let BlitRect { x: dx, y: dy, .. } = destination;
|
||||
let w = source.w.min(destination.w).min(src.w.saturating_sub(sx)).min(self.w.saturating_sub(dx));
|
||||
let h = source.h.min(destination.h).min(src.h.saturating_sub(sy)).min(self.h.saturating_sub(dy));
|
||||
for row in 0..h {
|
||||
let from = src.rgb.get(((sy + row) * src.w + sx) * 3..((sy + row) * src.w + sx + w) * 3);
|
||||
let to = self.rgb.get_mut(((dy + row) * self.w + dx) * 3..((dy + row) * self.w + dx + w) * 3);
|
||||
@@ -424,7 +435,7 @@ pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &Cell
|
||||
if border > 0 {
|
||||
page.fill_rect(dx - border, dy - border, w + 2 * border, h + 2 * border, style.border);
|
||||
}
|
||||
page.blit(&img, sx, sy, dx, dy, w, h);
|
||||
page.blit(&img, BlitRect { x: sx, y: sy, w, h }, BlitRect { x: dx, y: dy, w, h });
|
||||
let s = style.stroke_px.min(w.min(h) / 2);
|
||||
if s > 0 {
|
||||
page.fill_rect(dx, dy, w, s, style.stroke);
|
||||
|
||||
@@ -182,9 +182,9 @@ fn rotate_and_blit_are_clipped() {
|
||||
let r = img.rotated_cw().unwrap();
|
||||
assert_eq!((r.w, r.h), (2, 3));
|
||||
let mut p = page();
|
||||
p.blit(&img, 0, 0, 99, 99, 10, 10);
|
||||
p.blit(&img, BlitRect { x: 0, y: 0, w: 10, h: 10 }, BlitRect { x: 99, y: 99, w: 10, h: 10 });
|
||||
assert_eq!(at(&p, 99, 99), at(&img, 0, 0));
|
||||
p.blit(&img, 5, 5, 0, 0, 3, 3);
|
||||
p.blit(&img, BlitRect { x: 5, y: 5, w: 3, h: 3 }, BlitRect { x: 0, y: 0, w: 3, h: 3 });
|
||||
p.fill_rect(95, 95, 50, 50, RED);
|
||||
assert_eq!(at(&p, 99, 99), RED);
|
||||
p.fill_rect(usize::MAX, usize::MAX, usize::MAX, usize::MAX, RED);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::sidecar::PacketVersion;
|
||||
use super::*;
|
||||
use super::{Path, PathBuf, SidecarNaming};
|
||||
|
||||
/// Every sidecar that may belong to `path` (both naming conventions, `.xmp` and `.XMP`).
|
||||
pub(super) fn sidecars(path: &str) -> Vec<(PathBuf, SidecarNaming, bool)> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::fs::{MoveFs, RealFs, folded_path, is_respelling, move_one, sidecars};
|
||||
use super::sidecar::{RetainedPacket, SidecarMove, advance_packets, note_companions, packet_aliases};
|
||||
use super::*;
|
||||
use super::{HashMap, HashSet, Op, Path, PathBuf, SidecarNaming, Source};
|
||||
|
||||
/// Why [`move_file`] failed, and what it left behind.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::fs::{MoveFs, RealFs, folded_path};
|
||||
use super::moves::{BatchMoveError, move_all, set_file_ops_to};
|
||||
use super::sidecar::{PacketVersion, retry_sidecars, retry_sidecars_for_paths};
|
||||
use super::tokens::{expand, split_ext};
|
||||
use super::*;
|
||||
use super::{EngineError, HashMap, HashSet, Op, Path, PathBuf, Photo, PhotoId, RenamePlan, Result, Session, Source};
|
||||
|
||||
/// The photos among `ids` a rename handles, in order: photos sharing one file (virtual copies)
|
||||
/// rename once. No file-system calls.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::fs::{MoveFs, is_respelling, move_one};
|
||||
use super::*;
|
||||
use super::{Path, PathBuf};
|
||||
|
||||
/// An unfinished sidecar operation. Keep its exact paths and any recorded copy requirement: the photo may
|
||||
/// already have followed its new path, so a retry cannot infer this work from the catalog.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{MONTHS, Path, Photo, PhotoId, Serialize, Source};
|
||||
|
||||
pub(super) fn split_ext(name: &str) -> (&str, &str) {
|
||||
match name.rsplit_once('.') {
|
||||
|
||||
@@ -73,85 +73,85 @@ fn partial_move_companions_allow_save_and_autowrite_without_weakening_collisions
|
||||
for upper_full in [false, true] {
|
||||
for naming in [SidecarNaming::Stem, SidecarNaming::Full] {
|
||||
for auto in [false, true] {
|
||||
let dir = crate::tests_xmp::temp_dir(&format!("r3-companions-{upper_stem}-{upper_full}-{naming:?}-{auto}"));
|
||||
let a = dir.join("a.jpg");
|
||||
let b = dir.join("Trip.jpg");
|
||||
std::fs::write(&a, b"physical fixture; Session rename does not decode").unwrap();
|
||||
let path = |photo: &Path, style, upper| {
|
||||
let path = sidecar_path(&photo.to_string_lossy(), style);
|
||||
if upper { path.with_extension("XMP") } else { path }
|
||||
};
|
||||
let stem_a = path(&a, SidecarNaming::Stem, upper_stem);
|
||||
let full_a = path(&a, SidecarNaming::Full, upper_full);
|
||||
let stem_b = path(&b, SidecarNaming::Stem, upper_stem);
|
||||
let full_b = path(&b, SidecarNaming::Full, upper_full);
|
||||
let stem_bytes = packet("stem-keep");
|
||||
let full_bytes = packet("full-keep");
|
||||
std::fs::write(&stem_a, &stem_bytes).unwrap();
|
||||
std::fs::write(&full_a, &full_bytes).unwrap();
|
||||
let mut s = session(&a);
|
||||
s.xmp.naming = naming;
|
||||
s.xmp.auto_write = auto;
|
||||
let fs = Faults(vec![full_a.clone(), b.clone()]);
|
||||
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1);
|
||||
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err());
|
||||
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() });
|
||||
let pending = queues(&s);
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes);
|
||||
|
||||
// Missing packet and an unrelated packet at either case spelling remain protected.
|
||||
let held = dir.join("held.xmp");
|
||||
std::fs::rename(&full_a, &held).unwrap();
|
||||
s.execute("photo.setMeta", &json!({"title": "Fresh", "addKeywords": ["café"]})).unwrap();
|
||||
assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(queues(&s), pending);
|
||||
assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes);
|
||||
std::fs::rename(&held, &full_a).unwrap();
|
||||
for collision in [full_b.clone(), full_b.with_extension(if upper_full { "xmp" } else { "XMP" })] {
|
||||
std::fs::write(&collision, packet("unrelated-packet")).unwrap();
|
||||
let occupied = std::fs::read(&collision).unwrap();
|
||||
assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(queues(&s), pending);
|
||||
assert_eq!(std::fs::read(&collision).unwrap(), occupied);
|
||||
assert_eq!(std::fs::read_to_string(&full_a).unwrap(), full_bytes);
|
||||
assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes);
|
||||
std::fs::remove_file(collision).unwrap();
|
||||
}
|
||||
// AutoWrite is the first successful repair when enabled; explicit save otherwise.
|
||||
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
|
||||
if !auto {
|
||||
assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
assert!(queues(&s).is_empty());
|
||||
assert!(!full_a.exists());
|
||||
let (written, untouched, expected) =
|
||||
if naming == SidecarNaming::Stem { (&stem_b, &full_b, &full_bytes) } else { (&full_b, &stem_b, &stem_bytes) };
|
||||
assert_fresh(written);
|
||||
assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected);
|
||||
assert_eq!(parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(4));
|
||||
for (command, rating) in [("edit.undo", 0), ("edit.redo", 4)] {
|
||||
s.execute(command, &json!({})).unwrap();
|
||||
if !auto {
|
||||
save(&mut s);
|
||||
}
|
||||
assert_fresh(written);
|
||||
assert_eq!(
|
||||
parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating,
|
||||
Some(rating)
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected);
|
||||
}
|
||||
assert_eq!(s.xmp.naming, naming);
|
||||
assert_eq!(s.xmp.auto_write, auto);
|
||||
drop(s);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
check_companion_scenario(upper_stem, upper_full, naming, auto);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_companion_scenario(upper_stem: bool, upper_full: bool, naming: SidecarNaming, auto: bool) {
|
||||
let dir = crate::tests_xmp::temp_dir(&format!("r3-companions-{upper_stem}-{upper_full}-{naming:?}-{auto}"));
|
||||
let a = dir.join("a.jpg");
|
||||
let b = dir.join("Trip.jpg");
|
||||
std::fs::write(&a, b"physical fixture; Session rename does not decode").unwrap();
|
||||
let path = |photo: &Path, style, upper| {
|
||||
let path = sidecar_path(&photo.to_string_lossy(), style);
|
||||
if upper { path.with_extension("XMP") } else { path }
|
||||
};
|
||||
let stem_a = path(&a, SidecarNaming::Stem, upper_stem);
|
||||
let full_a = path(&a, SidecarNaming::Full, upper_full);
|
||||
let stem_b = path(&b, SidecarNaming::Stem, upper_stem);
|
||||
let full_b = path(&b, SidecarNaming::Full, upper_full);
|
||||
let stem_bytes = packet("stem-keep");
|
||||
let full_bytes = packet("full-keep");
|
||||
std::fs::write(&stem_a, &stem_bytes).unwrap();
|
||||
std::fs::write(&full_a, &full_bytes).unwrap();
|
||||
let mut s = session(&a);
|
||||
s.xmp.naming = naming;
|
||||
s.xmp.auto_write = auto;
|
||||
let fs = Faults(vec![full_a.clone(), b.clone()]);
|
||||
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1);
|
||||
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err());
|
||||
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() });
|
||||
let pending = queues(&s);
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes);
|
||||
|
||||
// Missing packet and an unrelated packet at either case spelling remain protected.
|
||||
let held = dir.join("held.xmp");
|
||||
std::fs::rename(&full_a, &held).unwrap();
|
||||
s.execute("photo.setMeta", &json!({"title": "Fresh", "addKeywords": ["café"]})).unwrap();
|
||||
assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(queues(&s), pending);
|
||||
assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes);
|
||||
std::fs::rename(&held, &full_a).unwrap();
|
||||
for collision in [full_b.clone(), full_b.with_extension(if upper_full { "xmp" } else { "XMP" })] {
|
||||
std::fs::write(&collision, packet("unrelated-packet")).unwrap();
|
||||
let occupied = std::fs::read(&collision).unwrap();
|
||||
assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(queues(&s), pending);
|
||||
assert_eq!(std::fs::read(&collision).unwrap(), occupied);
|
||||
assert_eq!(std::fs::read_to_string(&full_a).unwrap(), full_bytes);
|
||||
assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes);
|
||||
std::fs::remove_file(collision).unwrap();
|
||||
}
|
||||
// AutoWrite is the first successful repair when enabled; explicit save otherwise.
|
||||
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
|
||||
if !auto {
|
||||
assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
assert!(queues(&s).is_empty());
|
||||
assert!(!full_a.exists());
|
||||
let (written, untouched, expected) =
|
||||
if naming == SidecarNaming::Stem { (&stem_b, &full_b, &full_bytes) } else { (&full_b, &stem_b, &stem_bytes) };
|
||||
assert_fresh(written);
|
||||
assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected);
|
||||
assert_eq!(parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(4));
|
||||
for (command, rating) in [("edit.undo", 0), ("edit.redo", 4)] {
|
||||
s.execute(command, &json!({})).unwrap();
|
||||
if !auto {
|
||||
save(&mut s);
|
||||
}
|
||||
assert_fresh(written);
|
||||
assert_eq!(parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(rating));
|
||||
assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected);
|
||||
}
|
||||
assert_eq!(s.xmp.naming, naming);
|
||||
assert_eq!(s.xmp.auto_write, auto);
|
||||
drop(s);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn rollback_repair_and_shared_copy_keep_known_companions() {
|
||||
for rollback in [false, true] {
|
||||
|
||||
@@ -348,64 +348,12 @@ fn detached_history_entry_is_checked_before_unrelated_global_retirement() {
|
||||
// A private-method contract, not a UI reproduction: application reachability of two
|
||||
// simultaneous repair owners is unproven. Native move failures and follow_stuck seed
|
||||
// both records; the second owner's normal apply_rename preflight is deliberately bypassed.
|
||||
struct NativePartial {
|
||||
id: PhotoId,
|
||||
original: PathBuf,
|
||||
moved: PathBuf,
|
||||
stem: PathBuf,
|
||||
full: PathBuf,
|
||||
companion: PathBuf,
|
||||
target: PathBuf,
|
||||
}
|
||||
let root = crate::tests_xmp::temp_dir("detached-history-preflight-contract");
|
||||
let image = lightcraft_raster::Rgba8::from_fn(4, 3, |_, _| [40, 80, 120, 255]);
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&image), &Default::default()).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.xmp.naming = SidecarNaming::Stem;
|
||||
let mut native = Vec::new();
|
||||
for (i, name) in ["first", "second"].into_iter().enumerate() {
|
||||
let dir = root.join(name);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let original = dir.join("a.png");
|
||||
let moved = dir.join("Trip.png");
|
||||
std::fs::write(&original, &png).unwrap();
|
||||
let stem = path(&original, SidecarNaming::Stem, false);
|
||||
let full = path(&original, SidecarNaming::Full, true);
|
||||
for (p, marker) in [(&stem, format!("{name}-stem")), (&full, format!("{name}-full"))] {
|
||||
std::fs::write(p, packet(&marker)).unwrap();
|
||||
assert_eq!(parse_sidecar(&std::fs::read_to_string(p).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(5));
|
||||
}
|
||||
let id = PhotoId(i as u64 + 1);
|
||||
let photo = Photo::new(id, Source::File { path: original.to_string_lossy().into_owned() }, "a.png", "PNG", 4, 3, "");
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap();
|
||||
let fs = Faults(vec![full.clone(), moved.clone()]);
|
||||
if i == 0 {
|
||||
let plans = s.plan_rename_with(&fs, &[id], "Trip", 1);
|
||||
assert!(s.apply_rename_with(&fs, &plans).is_err());
|
||||
} else {
|
||||
// Register the genuine second partial move without draining the first repair.
|
||||
let moves = vec![(original.to_string_lossy().into_owned(), moved.to_string_lossy().into_owned())];
|
||||
let err = crate::rename::move_all(&fs, &moves).unwrap_err();
|
||||
assert_eq!(err.stuck, moves);
|
||||
assert_eq!(err.pending_sidecars.len(), 1);
|
||||
let op = Op::SetFile { id, source: Source::File { path: moved.to_string_lossy().into_owned() }, file_name: "Trip.png".into() };
|
||||
s.follow_stuck(&op, err, true);
|
||||
}
|
||||
assert_eq!(s.undo.len(), i + 1);
|
||||
assert_eq!(queued(&s).len(), i + 1);
|
||||
assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: moved.to_string_lossy().into_owned() });
|
||||
let decoded = lightcraft_codecs::decode(&std::fs::read(&moved).unwrap(), Default::default()).unwrap();
|
||||
assert_eq!((decoded.width, decoded.height), (4, 3));
|
||||
native.push(NativePartial {
|
||||
id,
|
||||
original,
|
||||
companion: path(&moved, SidecarNaming::Stem, false),
|
||||
target: path(&moved, SidecarNaming::Full, true),
|
||||
moved,
|
||||
stem,
|
||||
full,
|
||||
});
|
||||
}
|
||||
let native = create_native_partials(&root, &png, &mut s);
|
||||
let first = &native[0];
|
||||
let second = &native[1];
|
||||
let first_stem = std::fs::read(&first.companion).unwrap();
|
||||
@@ -463,3 +411,61 @@ fn detached_history_entry_is_checked_before_unrelated_global_retirement() {
|
||||
drop(s);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
struct NativePartial {
|
||||
id: PhotoId,
|
||||
original: PathBuf,
|
||||
moved: PathBuf,
|
||||
stem: PathBuf,
|
||||
full: PathBuf,
|
||||
companion: PathBuf,
|
||||
target: PathBuf,
|
||||
}
|
||||
|
||||
fn create_native_partials(root: &Path, png: &[u8], s: &mut Session) -> Vec<NativePartial> {
|
||||
let mut native = Vec::new();
|
||||
for (i, name) in ["first", "second"].into_iter().enumerate() {
|
||||
let dir = root.join(name);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let original = dir.join("a.png");
|
||||
let moved = dir.join("Trip.png");
|
||||
std::fs::write(&original, png).unwrap();
|
||||
let stem = path(&original, SidecarNaming::Stem, false);
|
||||
let full = path(&original, SidecarNaming::Full, true);
|
||||
for (p, marker) in [(&stem, format!("{name}-stem")), (&full, format!("{name}-full"))] {
|
||||
std::fs::write(p, packet(&marker)).unwrap();
|
||||
assert_eq!(parse_sidecar(&std::fs::read_to_string(p).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(5));
|
||||
}
|
||||
let id = PhotoId(i as u64 + 1);
|
||||
let photo = Photo::new(id, Source::File { path: original.to_string_lossy().into_owned() }, "a.png", "PNG", 4, 3, "");
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap();
|
||||
let fs = Faults(vec![full.clone(), moved.clone()]);
|
||||
if i == 0 {
|
||||
let plans = s.plan_rename_with(&fs, &[id], "Trip", 1);
|
||||
assert!(s.apply_rename_with(&fs, &plans).is_err());
|
||||
} else {
|
||||
// Register the genuine second partial move without draining the first repair.
|
||||
let moves = vec![(original.to_string_lossy().into_owned(), moved.to_string_lossy().into_owned())];
|
||||
let err = crate::rename::move_all(&fs, &moves).unwrap_err();
|
||||
assert_eq!(err.stuck, moves);
|
||||
assert_eq!(err.pending_sidecars.len(), 1);
|
||||
let op = Op::SetFile { id, source: Source::File { path: moved.to_string_lossy().into_owned() }, file_name: "Trip.png".into() };
|
||||
s.follow_stuck(&op, err, true);
|
||||
}
|
||||
assert_eq!(s.undo.len(), i + 1);
|
||||
assert_eq!(queued(s).len(), i + 1);
|
||||
assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: moved.to_string_lossy().into_owned() });
|
||||
let decoded = lightcraft_codecs::decode(&std::fs::read(&moved).unwrap(), Default::default()).unwrap();
|
||||
assert_eq!((decoded.width, decoded.height), (4, 3));
|
||||
native.push(NativePartial {
|
||||
id,
|
||||
original,
|
||||
companion: path(&moved, SidecarNaming::Stem, false),
|
||||
target: path(&moved, SidecarNaming::Full, true),
|
||||
moved,
|
||||
stem,
|
||||
full,
|
||||
});
|
||||
}
|
||||
native
|
||||
}
|
||||
|
||||
@@ -58,118 +58,122 @@ fn autowrite_repairs_partial_rename_before_merging_foreign_xmp_across_library_sw
|
||||
for save_naming in [SidecarNaming::Stem, SidecarNaming::Full] {
|
||||
for upper in [false, true] {
|
||||
for switch in [false, true] {
|
||||
let root = crate::tests_xmp::temp_dir(&format!("repair-save-{naming:?}-{save_naming:?}-{upper}-{switch}"));
|
||||
let a = root.join("a.png");
|
||||
let b = root.join("Trip.png");
|
||||
png(&a);
|
||||
let mut old = sidecar_path(&a.to_string_lossy(), naming);
|
||||
if upper {
|
||||
old = old.with_extension("XMP");
|
||||
}
|
||||
std::fs::write(&old, FOREIGN).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(root.join("library-a"), false).unwrap();
|
||||
s.execute("library.import", &json!({"paths": [a]})).unwrap();
|
||||
let mut id = s.catalog.photos().next().unwrap().id;
|
||||
s.selection = crate::Selection::single(id);
|
||||
s.undo.clear();
|
||||
let fs = PartialRename { sidecar: old.clone(), photo: b.clone() };
|
||||
let plans = s.plan_rename_with(&fs, &[id], "Trip", 1);
|
||||
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err());
|
||||
assert_eq!(queued(&s).len(), 1);
|
||||
assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() });
|
||||
let pending = queued(&s);
|
||||
let held = root.join("temporarily-unavailable.xmp");
|
||||
std::fs::rename(&old, &held).unwrap();
|
||||
if switch {
|
||||
s.close_library().unwrap();
|
||||
s.open_library(root.join("library-b"), false).unwrap();
|
||||
assert_eq!(s.pending_sidecar_repairs, pending);
|
||||
s.execute("library.import", &json!({"paths": [b]})).unwrap();
|
||||
id = s.catalog.photos().next().unwrap().id;
|
||||
s.selection = crate::Selection::single(id);
|
||||
}
|
||||
s.xmp.naming = save_naming;
|
||||
s.xmp.auto_write = true;
|
||||
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
|
||||
let preferred = sidecar_path(&b.to_string_lossy(), save_naming);
|
||||
assert!(!preferred.exists() && !preferred.with_extension("XMP").exists());
|
||||
assert!(!pending[0].to.exists());
|
||||
assert_eq!(queued(&s), pending, "unavailable repair stays queued after AutoWrite");
|
||||
let saved = s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
|
||||
assert!(saved["written"].as_array().unwrap().is_empty());
|
||||
assert!(saved["failed"][0]["error"].as_str().unwrap().contains("unfinished XMP repair"));
|
||||
assert_eq!(std::fs::read(&held).unwrap(), FOREIGN);
|
||||
assert_eq!(queued(&s), pending);
|
||||
|
||||
// A missing sidecar for another photo does not prevent this healthy save.
|
||||
let healthy = root.join("healthy.png");
|
||||
png(&healthy);
|
||||
let healthy_id = PhotoId(999);
|
||||
s.commit(
|
||||
"Healthy Photo",
|
||||
Op::AddPhoto {
|
||||
photo: Box::new(Photo::new(
|
||||
healthy_id,
|
||||
Source::File { path: healthy.to_string_lossy().into_owned() },
|
||||
"healthy.png",
|
||||
"PNG",
|
||||
4,
|
||||
3,
|
||||
"",
|
||||
)),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("photo.setMeta", &json!({"ids": [healthy_id.0], "title": "Healthy save"})).unwrap();
|
||||
let packet = std::fs::read_to_string(sidecar_path(&healthy.to_string_lossy(), save_naming)).unwrap();
|
||||
assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some("Healthy save"));
|
||||
assert_eq!(queued(&s), pending);
|
||||
|
||||
// An existing destination is never overwritten and the original packet remains.
|
||||
std::fs::rename(&held, &old).unwrap();
|
||||
let destination = pending[0].to.clone();
|
||||
let collision = String::from_utf8(FOREIGN.to_vec()).unwrap().replace("Rating=\"2\"", "Rating=\"5\"").into_bytes();
|
||||
let collision_path = if upper { preferred.clone() } else { destination.clone() };
|
||||
std::fs::write(&collision_path, &collision).unwrap();
|
||||
s.execute("photo.setMeta", &json!({"ids": [id.0], "title": "Fresh title", "addKeywords": ["café"]})).unwrap();
|
||||
assert_eq!(std::fs::read(&old).unwrap(), FOREIGN);
|
||||
assert_eq!(std::fs::read(&collision_path).unwrap(), collision);
|
||||
assert_eq!(queued(&s), pending);
|
||||
s.selection = crate::Selection::single(id);
|
||||
assert_eq!(s.execute("photo.saveMetadataToFile", &json!({})).unwrap()["failed"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(std::fs::read(&collision_path).unwrap(), collision);
|
||||
std::fs::remove_file(&collision_path).unwrap(); // remove only this test's collision
|
||||
|
||||
// The next successful AutoWrite runs the real repair and production XMP merge.
|
||||
s.execute("photo.setMeta", &json!({"title": "Final title"})).unwrap();
|
||||
assert!(queued(&s).is_empty());
|
||||
assert!(!old.exists());
|
||||
let packet = std::fs::read_to_string(&destination).unwrap();
|
||||
assert!(packet.contains("fixture:Marker=\"keep-me\""));
|
||||
let parsed = parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap();
|
||||
assert_eq!(parsed.rating, Some(4));
|
||||
assert_eq!(parsed.title.as_deref(), Some("Final title"));
|
||||
assert!(parsed.keywords.as_ref().unwrap().contains(&"café".to_string()));
|
||||
if upper {
|
||||
// by listing: on a case-insensitive volume `preferred` answers for the upper-case packet
|
||||
let listed = std::fs::read_dir(&root).unwrap().any(|e| Some(e.unwrap().file_name().as_os_str()) == preferred.file_name());
|
||||
assert!(!listed, "merge uses the repaired uppercase packet");
|
||||
}
|
||||
for (step, title) in [("edit.undo", "Fresh title"), ("edit.redo", "Final title")] {
|
||||
s.execute(step, &json!({})).unwrap();
|
||||
let packet = std::fs::read_to_string(&destination).unwrap();
|
||||
assert!(packet.contains("fixture:Marker=\"keep-me\""));
|
||||
assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some(title));
|
||||
}
|
||||
drop(s);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
check_autowrite_repair_scenario(naming, save_naming, upper, switch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_autowrite_repair_scenario(naming: SidecarNaming, save_naming: SidecarNaming, upper: bool, switch: bool) {
|
||||
let root = crate::tests_xmp::temp_dir(&format!("repair-save-{naming:?}-{save_naming:?}-{upper}-{switch}"));
|
||||
let a = root.join("a.png");
|
||||
let b = root.join("Trip.png");
|
||||
png(&a);
|
||||
let mut old = sidecar_path(&a.to_string_lossy(), naming);
|
||||
if upper {
|
||||
old = old.with_extension("XMP");
|
||||
}
|
||||
std::fs::write(&old, FOREIGN).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(root.join("library-a"), false).unwrap();
|
||||
s.execute("library.import", &json!({"paths": [a]})).unwrap();
|
||||
let mut id = s.catalog.photos().next().unwrap().id;
|
||||
s.selection = crate::Selection::single(id);
|
||||
s.undo.clear();
|
||||
let fs = PartialRename { sidecar: old.clone(), photo: b.clone() };
|
||||
let plans = s.plan_rename_with(&fs, &[id], "Trip", 1);
|
||||
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err());
|
||||
assert_eq!(queued(&s).len(), 1);
|
||||
assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() });
|
||||
let pending = queued(&s);
|
||||
let held = root.join("temporarily-unavailable.xmp");
|
||||
std::fs::rename(&old, &held).unwrap();
|
||||
if switch {
|
||||
s.close_library().unwrap();
|
||||
s.open_library(root.join("library-b"), false).unwrap();
|
||||
assert_eq!(s.pending_sidecar_repairs, pending);
|
||||
s.execute("library.import", &json!({"paths": [b]})).unwrap();
|
||||
id = s.catalog.photos().next().unwrap().id;
|
||||
s.selection = crate::Selection::single(id);
|
||||
}
|
||||
s.xmp.naming = save_naming;
|
||||
s.xmp.auto_write = true;
|
||||
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
|
||||
let preferred = sidecar_path(&b.to_string_lossy(), save_naming);
|
||||
assert!(!preferred.exists() && !preferred.with_extension("XMP").exists());
|
||||
assert!(!pending[0].to.exists());
|
||||
assert_eq!(queued(&s), pending, "unavailable repair stays queued after AutoWrite");
|
||||
let saved = s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
|
||||
assert!(saved["written"].as_array().unwrap().is_empty());
|
||||
assert!(saved["failed"][0]["error"].as_str().unwrap().contains("unfinished XMP repair"));
|
||||
assert_eq!(std::fs::read(&held).unwrap(), FOREIGN);
|
||||
assert_eq!(queued(&s), pending);
|
||||
|
||||
// A missing sidecar for another photo does not prevent this healthy save.
|
||||
let healthy = root.join("healthy.png");
|
||||
png(&healthy);
|
||||
let healthy_id = PhotoId(999);
|
||||
s.commit(
|
||||
"Healthy Photo",
|
||||
Op::AddPhoto {
|
||||
photo: Box::new(Photo::new(healthy_id, Source::File { path: healthy.to_string_lossy().into_owned() }, "healthy.png", "PNG", 4, 3, "")),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("photo.setMeta", &json!({"ids": [healthy_id.0], "title": "Healthy save"})).unwrap();
|
||||
let packet = std::fs::read_to_string(sidecar_path(&healthy.to_string_lossy(), save_naming)).unwrap();
|
||||
assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some("Healthy save"));
|
||||
assert_eq!(queued(&s), pending);
|
||||
|
||||
// An existing destination is never overwritten and the original packet remains.
|
||||
std::fs::rename(&held, &old).unwrap();
|
||||
let destination = pending[0].to.clone();
|
||||
let collision = String::from_utf8(FOREIGN.to_vec()).unwrap().replace("Rating=\"2\"", "Rating=\"5\"").into_bytes();
|
||||
let collision_path = if upper { preferred.clone() } else { destination.clone() };
|
||||
std::fs::write(&collision_path, &collision).unwrap();
|
||||
s.execute("photo.setMeta", &json!({"ids": [id.0], "title": "Fresh title", "addKeywords": ["café"]})).unwrap();
|
||||
assert_eq!(std::fs::read(&old).unwrap(), FOREIGN);
|
||||
assert_eq!(std::fs::read(&collision_path).unwrap(), collision);
|
||||
assert_eq!(queued(&s), pending);
|
||||
s.selection = crate::Selection::single(id);
|
||||
assert_eq!(s.execute("photo.saveMetadataToFile", &json!({})).unwrap()["failed"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(std::fs::read(&collision_path).unwrap(), collision);
|
||||
std::fs::remove_file(&collision_path).unwrap(); // remove only this test's collision
|
||||
|
||||
// The next successful AutoWrite runs the real repair and production XMP merge.
|
||||
s.execute("photo.setMeta", &json!({"title": "Final title"})).unwrap();
|
||||
assert!(queued(&s).is_empty());
|
||||
assert!(!old.exists());
|
||||
let packet = std::fs::read_to_string(&destination).unwrap();
|
||||
assert!(packet.contains("fixture:Marker=\"keep-me\""));
|
||||
let parsed = parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap();
|
||||
assert_eq!(parsed.rating, Some(4));
|
||||
assert_eq!(parsed.title.as_deref(), Some("Final title"));
|
||||
assert!(parsed.keywords.as_ref().unwrap().contains(&"café".to_string()));
|
||||
assert_repaired_case_spelling(&root, &preferred, upper);
|
||||
assert_undo_redo_preserve_foreign_packet(&mut s, &destination);
|
||||
drop(s);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
fn assert_repaired_case_spelling(root: &Path, preferred: &Path, upper: bool) {
|
||||
if !upper {
|
||||
return;
|
||||
}
|
||||
// By listing, a case-insensitive volume answers for the uppercase packet.
|
||||
let listed = std::fs::read_dir(root).unwrap().any(|e| Some(e.unwrap().file_name().as_os_str()) == preferred.file_name());
|
||||
assert!(!listed, "merge uses the repaired uppercase packet");
|
||||
}
|
||||
|
||||
fn assert_undo_redo_preserve_foreign_packet(s: &mut Session, destination: &Path) {
|
||||
for (step, title) in [("edit.undo", "Fresh title"), ("edit.redo", "Final title")] {
|
||||
s.execute(step, &json!({})).unwrap();
|
||||
let packet = std::fs::read_to_string(destination).unwrap();
|
||||
assert!(packet.contains("fixture:Marker=\"keep-me\""));
|
||||
assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some(title));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn unowned_shared_stem_repair_does_not_block_healthy_jpeg_full_sidecar_save() {
|
||||
let root = crate::tests_xmp::temp_dir("repair-save-shared-stem");
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! Import: recursive folders, duplicate detection by content, add vs copy into the library.
|
||||
|
||||
use std::path::Path;
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::atomic::AtomicUsize,
|
||||
};
|
||||
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
@@ -1711,109 +1715,139 @@ fn smart_previews_from_an_older_look_fit_are_refreshed() {
|
||||
}
|
||||
|
||||
fn smart_look_refresh_for(format: &str) {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
let src = temp_dir(&format!("smartlook-src-{format}"));
|
||||
let lib = temp_dir(&format!("smartlook-lib-{format}"));
|
||||
write_png(&src.join("a.png"), 3);
|
||||
write_png(&src.join("b.png"), 9);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy(), src.join("b.png").to_string_lossy()]})).unwrap();
|
||||
let find = |s: &Session, name: &str| s.catalog.photos().find(|p| p.file_name == name).unwrap().id;
|
||||
let (id, png) = (find(&s, "a.png"), find(&s, "b.png"));
|
||||
// `a` stands for a raw that takes the per-file camera look (the test loader decodes its pixels)
|
||||
let mut raw = (**s.catalog.photo(id).unwrap()).clone();
|
||||
raw.format = format.into();
|
||||
raw.kind = lightcraft_catalog::MediaKind::Raw;
|
||||
s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap();
|
||||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().relative_wb(), format == "ARW");
|
||||
assert!(!s.catalog.photo(png).unwrap().relative_wb());
|
||||
s.execute("library.select", &json!({"ids": [id.0, png.0], "active": id.0})).unwrap();
|
||||
let dir = s.media.smart_dir.clone().unwrap();
|
||||
let file = crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap());
|
||||
let png_file = crate::smart::sharded_file_path(&dir, s.catalog.photo(png).unwrap());
|
||||
let (old, new) = (test_tone(1.5), test_tone(3.5));
|
||||
let calls = std::sync::Arc::new(AtomicUsize::new(0));
|
||||
let plain = s.media.file_loader.clone().unwrap();
|
||||
s.media.file_loader = Some(look_loader(plain.clone(), old, calls.clone()));
|
||||
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
let mut case = SmartLookCase::new(format);
|
||||
assert_current_smart_look_is_not_rebuilt(&mut case);
|
||||
let (old_bytes, png_bytes) = keep_offline_previews(&mut case);
|
||||
refresh_online_previews(&mut case, &old_bytes, &png_bytes);
|
||||
let _ = std::fs::remove_dir_all(&case.src);
|
||||
let _ = std::fs::remove_dir_all(&case.lib);
|
||||
}
|
||||
|
||||
struct SmartLookCase {
|
||||
src: PathBuf,
|
||||
lib: PathBuf,
|
||||
session: Session,
|
||||
id: PhotoId,
|
||||
dir: PathBuf,
|
||||
file: PathBuf,
|
||||
png_file: PathBuf,
|
||||
old: lightcraft_pipeline::tone::CameraTone,
|
||||
new: lightcraft_pipeline::tone::CameraTone,
|
||||
calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
plain_loader: crate::media::FileLoader,
|
||||
}
|
||||
|
||||
impl SmartLookCase {
|
||||
fn new(format: &str) -> Self {
|
||||
let src = temp_dir(&format!("smartlook-src-{format}"));
|
||||
let lib = temp_dir(&format!("smartlook-lib-{format}"));
|
||||
write_png(&src.join("a.png"), 3);
|
||||
write_png(&src.join("b.png"), 9);
|
||||
let mut session = Session::new().with_fs();
|
||||
session.open_library(&lib, false).unwrap();
|
||||
session.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy(), src.join("b.png").to_string_lossy()]})).unwrap();
|
||||
let find = |s: &Session, name: &str| s.catalog.photos().find(|p| p.file_name == name).unwrap().id;
|
||||
let (id, png_id) = (find(&session, "a.png"), find(&session, "b.png"));
|
||||
// `a` stands for a raw that takes the per-file camera look (the test loader decodes its pixels)
|
||||
let mut raw = (**session.catalog.photo(id).unwrap()).clone();
|
||||
raw.format = format.into();
|
||||
raw.kind = lightcraft_catalog::MediaKind::Raw;
|
||||
session.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap();
|
||||
session.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap();
|
||||
assert_eq!(session.catalog.photo(id).unwrap().relative_wb(), format == "ARW");
|
||||
assert!(!session.catalog.photo(png_id).unwrap().relative_wb());
|
||||
session.execute("library.select", &json!({"ids": [id.0, png_id.0], "active": id.0})).unwrap();
|
||||
let dir = session.media.smart_dir.clone().unwrap();
|
||||
let file = crate::smart::sharded_file_path(&dir, session.catalog.photo(id).unwrap());
|
||||
let png_file = crate::smart::sharded_file_path(&dir, session.catalog.photo(png_id).unwrap());
|
||||
let (old, new) = (test_tone(1.5), test_tone(3.5));
|
||||
let calls = std::sync::Arc::new(AtomicUsize::new(0));
|
||||
let plain_loader = session.media.file_loader.clone().unwrap();
|
||||
session.media.file_loader = Some(look_loader(plain_loader.clone(), old, calls.clone()));
|
||||
Self { src, lib, session, id, dir, file, png_file, old, new, calls, plain_loader }
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_current_smart_look_is_not_rebuilt(case: &mut SmartLookCase) {
|
||||
let r = case.session.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64()), (Some(2), Some(0)), "{r}");
|
||||
assert_eq!(smart_header(&file)["look_version"], json!(crate::camera_preview::LOOK_VERSION), "the writer stamps the fit version");
|
||||
assert!(!crate::smart::is_stale(&file));
|
||||
assert_eq!(smart_header(&case.file)["look_version"], json!(crate::camera_preview::LOOK_VERSION), "the writer stamps the fit version");
|
||||
assert!(!crate::smart::is_stale(&case.file));
|
||||
|
||||
// current header: kept as it is, the original is not even read
|
||||
let before = std::fs::read(&file).unwrap();
|
||||
calls.store(0, Ordering::SeqCst);
|
||||
s.media.file_loader = Some(look_loader(plain.clone(), new, calls.clone()));
|
||||
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
let before = std::fs::read(&case.file).unwrap();
|
||||
case.calls.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
case.session.media.file_loader = Some(look_loader(case.plain_loader.clone(), case.new, case.calls.clone()));
|
||||
let r = case.session.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64()), (Some(2), Some(0)), "{r}");
|
||||
assert!(std::fs::read(&file).unwrap() == before, "a current proxy is not rebuilt");
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(std::fs::read(&case.file).unwrap(), before, "a current proxy is not rebuilt");
|
||||
assert_eq!(case.calls.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
fn keep_offline_previews(case: &mut SmartLookCase) -> (Vec<u8>, Vec<u8>) {
|
||||
// old headers (no field), originals offline: kept unchanged, and still marked stale
|
||||
for f in [&file, &png_file] {
|
||||
for f in [&case.file, &case.png_file] {
|
||||
edit_smart_header(f, |h| {
|
||||
h.as_object_mut().unwrap().remove("look_version");
|
||||
});
|
||||
}
|
||||
assert!(crate::smart::is_stale(&file), "no field = version 0");
|
||||
let old_bytes = std::fs::read(&file).unwrap();
|
||||
let png_bytes = std::fs::read(&png_file).unwrap();
|
||||
std::fs::rename(&src, src.with_extension("offline")).unwrap();
|
||||
s.media.forget(id);
|
||||
s.media.forget(png);
|
||||
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert!(crate::smart::is_stale(&case.file), "no field = version 0");
|
||||
let old_bytes = std::fs::read(&case.file).unwrap();
|
||||
let png_bytes = std::fs::read(&case.png_file).unwrap();
|
||||
std::fs::rename(&case.src, case.src.with_extension("offline")).unwrap();
|
||||
case.session.media.forget(case.id);
|
||||
let r = case.session.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64(), r["staleKept"].as_u64()), (Some(2), Some(0), Some(1)), "{r}");
|
||||
assert!(r["failed"].as_array().unwrap().is_empty(), "{r}");
|
||||
assert!(std::fs::read(&file).unwrap() == old_bytes, "offline: the stored preview and curve are untouched");
|
||||
assert!(crate::smart::is_stale(&file), "and it stays marked for the next time");
|
||||
assert_eq!(crate::smart::decode(&old_bytes).unwrap().1, Some(old), "the old look is still there");
|
||||
assert_eq!(std::fs::read(&case.file).unwrap(), old_bytes, "offline: the stored preview and curve are untouched");
|
||||
assert!(crate::smart::is_stale(&case.file), "and it stays marked for the next time");
|
||||
assert_eq!(crate::smart::decode(&old_bytes).unwrap().1, Some(case.old), "the old look is still there");
|
||||
// ... and the opening refresh keeps it too, then marks the folder checked
|
||||
assert!(!crate::smart::look_checked(&dir));
|
||||
crate::cmd::previews::refresh_stale_smart_previews(&mut s).unwrap().join().unwrap();
|
||||
assert!(std::fs::read(&file).unwrap() == old_bytes);
|
||||
assert!(crate::smart::look_checked(&dir));
|
||||
assert!(!crate::smart::look_checked(&case.dir));
|
||||
crate::cmd::previews::refresh_stale_smart_previews(&mut case.session).unwrap().join().unwrap();
|
||||
assert_eq!(std::fs::read(&case.file).unwrap(), old_bytes);
|
||||
assert!(crate::smart::look_checked(&case.dir));
|
||||
(old_bytes, png_bytes)
|
||||
}
|
||||
|
||||
fn refresh_online_previews(case: &mut SmartLookCase, old_bytes: &[u8], png_bytes: &[u8]) {
|
||||
// the original comes back: the scan has run at this version, so the next opening leaves it;
|
||||
// Build Smart Previews rebuilds it with the current look
|
||||
std::fs::rename(src.with_extension("offline"), &src).unwrap();
|
||||
calls.store(0, Ordering::SeqCst);
|
||||
assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut s).is_none(), "checked once per version");
|
||||
assert!(std::fs::read(&file).unwrap() == old_bytes);
|
||||
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!((r["refreshed"].as_u64(), calls.load(Ordering::SeqCst)), (Some(1), 1), "{r}");
|
||||
assert_eq!(smart_header(&file)["look_version"], json!(crate::camera_preview::LOOK_VERSION));
|
||||
assert_eq!(crate::smart::decode(&std::fs::read(&file).unwrap()).unwrap().1, Some(new), "rebuilt with the current look");
|
||||
assert!(!crate::smart::is_stale(&file));
|
||||
assert!(std::fs::read(&png_file).unwrap() == png_bytes, "a PNG's proxy is never rebuilt for the look");
|
||||
std::fs::rename(case.src.with_extension("offline"), &case.src).unwrap();
|
||||
case.calls.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut case.session).is_none(), "checked once per version");
|
||||
assert_eq!(std::fs::read(&case.file).unwrap(), old_bytes);
|
||||
let r = case.session.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!((r["refreshed"].as_u64(), case.calls.load(std::sync::atomic::Ordering::SeqCst)), (Some(1), 1), "{r}");
|
||||
assert_eq!(smart_header(&case.file)["look_version"], json!(crate::camera_preview::LOOK_VERSION));
|
||||
assert_eq!(crate::smart::decode(&std::fs::read(&case.file).unwrap()).unwrap().1, Some(case.new), "rebuilt with the current look");
|
||||
assert!(!crate::smart::is_stale(&case.file));
|
||||
assert_eq!(std::fs::read(&case.png_file).unwrap(), png_bytes, "a PNG's proxy is never rebuilt for the look");
|
||||
|
||||
// a folder checked at an older version (or not at all) is scanned once at the next opening
|
||||
edit_smart_header(&file, |h| h["look_version"] = json!(0));
|
||||
std::fs::write(dir.join("look-version"), b"0\n").unwrap();
|
||||
assert!(!crate::smart::look_checked(&dir));
|
||||
s.media.forget(id);
|
||||
calls.store(0, Ordering::SeqCst);
|
||||
crate::cmd::previews::refresh_stale_smart_previews(&mut s).unwrap().join().unwrap();
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1, "only the raw's original is read");
|
||||
assert!(!crate::smart::is_stale(&file));
|
||||
assert!(std::fs::read(&png_file).unwrap() == png_bytes);
|
||||
assert!(crate::smart::look_checked(&dir));
|
||||
edit_smart_header(&case.file, |h| h["look_version"] = json!(0));
|
||||
std::fs::write(case.dir.join("look-version"), b"0\n").unwrap();
|
||||
assert!(!crate::smart::look_checked(&case.dir));
|
||||
case.session.media.forget(case.id);
|
||||
case.calls.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
crate::cmd::previews::refresh_stale_smart_previews(&mut case.session).unwrap().join().unwrap();
|
||||
assert_eq!(case.calls.load(std::sync::atomic::Ordering::SeqCst), 1, "only the raw's original is read");
|
||||
assert!(!crate::smart::is_stale(&case.file));
|
||||
assert_eq!(std::fs::read(&case.png_file).unwrap(), png_bytes);
|
||||
assert!(crate::smart::look_checked(&case.dir));
|
||||
// once only
|
||||
let rebuilt = std::fs::read(&file).unwrap();
|
||||
assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut s).is_none());
|
||||
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!((r["refreshed"].as_u64(), calls.load(Ordering::SeqCst)), (Some(0), 1), "{r}");
|
||||
assert!(std::fs::read(&file).unwrap() == rebuilt);
|
||||
let rebuilt = std::fs::read(&case.file).unwrap();
|
||||
assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut case.session).is_none());
|
||||
let r = case.session.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!((r["refreshed"].as_u64(), case.calls.load(std::sync::atomic::Ordering::SeqCst)), (Some(0), 1), "{r}");
|
||||
assert_eq!(std::fs::read(&case.file).unwrap(), rebuilt);
|
||||
|
||||
// Build Smart Previews refreshes a stale one the same way
|
||||
edit_smart_header(&file, |h| h["look_version"] = json!(0));
|
||||
s.media.file_loader = Some(plain);
|
||||
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
edit_smart_header(&case.file, |h| h["look_version"] = json!(0));
|
||||
case.session.media.file_loader = Some(case.plain_loader.clone());
|
||||
let r = case.session.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!(r["refreshed"], 1, "{r}");
|
||||
assert_eq!(crate::smart::decode(&std::fs::read(&file).unwrap()).unwrap().1, None, "this fit gives no curve: none is kept");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
assert_eq!(crate::smart::decode(&std::fs::read(&case.file).unwrap()).unwrap().1, None, "this fit gives no curve: none is kept");
|
||||
}
|
||||
|
||||
/// Issue #1534 (review): a proxy's encoding version is migrated independently of the per-file
|
||||
|
||||
@@ -19,6 +19,14 @@ pub struct Rgb<'a> {
|
||||
pub height: usize,
|
||||
}
|
||||
|
||||
/// Face bounds, padding factor, and output dimensions for box-based alignment.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FaceCrop {
|
||||
pub bounds: (f32, f32, f32, f32),
|
||||
pub padding: f32,
|
||||
pub output_size: (usize, usize),
|
||||
}
|
||||
|
||||
impl Rgb<'_> {
|
||||
fn valid(&self) -> bool {
|
||||
self.width > 0 && self.height > 0 && self.width.checked_mul(self.height).and_then(|n| n.checked_mul(3)) == Some(self.data.len())
|
||||
@@ -145,7 +153,10 @@ pub fn align_to_template(img: &Rgb, landmarks: &[(f32, f32); 5], out_w: usize, o
|
||||
}
|
||||
|
||||
/// A square around a face box (pixels of `img`), `pad` times its larger side, as `out_w` × `out_h`.
|
||||
pub fn crop_box(img: &Rgb, x0: f32, y0: f32, x1: f32, y1: f32, pad: f32, out_w: usize, out_h: usize) -> Option<Vec<u8>> {
|
||||
pub fn crop_box(img: &Rgb, crop: FaceCrop) -> Option<Vec<u8>> {
|
||||
let (x0, y0, x1, y1) = crop.bounds;
|
||||
let pad = crop.padding;
|
||||
let (out_w, out_h) = crop.output_size;
|
||||
if ![x0, y0, x1, y1, pad].iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
@@ -247,16 +258,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
let img = Rgb { data: &data, width: w, height: h };
|
||||
let out = crop_box(&img, 90.0, 40.0, 110.0, 60.0, 2.0, 64, 64).unwrap();
|
||||
let out = crop_box(&img, FaceCrop { bounds: (90.0, 40.0, 110.0, 60.0), padding: 2.0, output_size: (64, 64) }).unwrap();
|
||||
// the 20 px block fills the middle half of the 64 px output
|
||||
let at = |x: usize, y: usize| out[(y * 64 + x) * 3 + 1];
|
||||
assert!(at(32, 32) > 250 && at(20, 20) > 250 && at(44, 44) > 250 && at(5, 5) < 5 && at(60, 32) < 5);
|
||||
assert!(crop_box(&img, 5.0, 5.0, 5.0, 5.0, 2.0, 64, 64).is_none(), "an empty box");
|
||||
assert!(crop_box(&img, f32::NAN, 0.0, 10.0, 10.0, 2.0, 64, 64).is_none());
|
||||
assert!(crop_box(&img, 0.0, 0.0, 10.0, 10.0, 2.0, 0, 64).is_none());
|
||||
assert!(crop_box(&img, 0.0, 0.0, 10.0, 10.0, 2.0, 100_000, 64).is_none());
|
||||
assert!(crop_box(&img, FaceCrop { bounds: (5.0, 5.0, 5.0, 5.0), padding: 2.0, output_size: (64, 64) }).is_none(), "an empty box");
|
||||
assert!(crop_box(&img, FaceCrop { bounds: (f32::NAN, 0.0, 10.0, 10.0), padding: 2.0, output_size: (64, 64) }).is_none());
|
||||
assert!(crop_box(&img, FaceCrop { bounds: (0.0, 0.0, 10.0, 10.0), padding: 2.0, output_size: (0, 64) }).is_none());
|
||||
assert!(crop_box(&img, FaceCrop { bounds: (0.0, 0.0, 10.0, 10.0), padding: 2.0, output_size: (100_000, 64) }).is_none());
|
||||
let bad = Rgb { data: &data[..10], width: w, height: h };
|
||||
assert!(crop_box(&bad, 0.0, 0.0, 10.0, 10.0, 2.0, 64, 64).is_none(), "wrong data length");
|
||||
assert!(crop_box(&bad, FaceCrop { bounds: (0.0, 0.0, 10.0, 10.0), padding: 2.0, output_size: (64, 64) }).is_none(), "wrong data length");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -264,7 +275,7 @@ mod tests {
|
||||
let data = vec![80u8; 50 * 50 * 3];
|
||||
let img = Rgb { data: &data, width: 50, height: 50 };
|
||||
// a box mostly outside the picture is padded by clamping, not an error
|
||||
assert!(crop_box(&img, -30.0, -30.0, 20.0, 20.0, 1.5, 32, 32).is_some());
|
||||
assert!(crop_box(&img, FaceCrop { bounds: (-30.0, -30.0, 20.0, 20.0), padding: 1.5, output_size: (32, 32) }).is_some());
|
||||
let far: [(f32, f32); 5] = [(1e6, 1e6), (1e6 + 5.0, 1e6), (1e6 + 2.0, 1e6 + 3.0), (1e6, 1e6 + 6.0), (1e6 + 5.0, 1e6 + 6.0)];
|
||||
let _ = align_to_template(&img, &far, 112, 112);
|
||||
let tiny: [(f32, f32); 5] = [(1.0, 1.0), (1.01, 1.0), (1.0, 1.01), (1.01, 1.01), (1.005, 1.005)];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Safe CPU kernels shared by detection and recognition. No inner thread pool.
|
||||
use super::*;
|
||||
use super::{ConvJob, Graph, MAX_TENSOR, NetError, Node, Result, Tensor, chw, conv_direct, pads, pair, program, shape, shape_err};
|
||||
use ndarray::{ArrayView2, ArrayViewMut2, linalg::general_mat_mul};
|
||||
const BLOCK: usize = 256;
|
||||
#[derive(Default)]
|
||||
@@ -30,7 +30,16 @@ fn multiply(w: &[f32], x: &[f32], y: &mut [f32], m: usize, k: usize, n: usize) -
|
||||
}
|
||||
/// For equal-width, unit-stride maps a shifted plane is one contiguous copy. Only horizontal
|
||||
/// border stripes need masking; clipping the flat source range also removes vertical padding.
|
||||
fn shifted_plane(plane: &[f32], width: usize, ky: usize, kx: usize, pt: usize, pl: usize, start: usize, row: &mut [f32]) -> Result<()> {
|
||||
#[derive(Clone, Copy)]
|
||||
struct PlaneShift {
|
||||
width: usize,
|
||||
kernel: (usize, usize),
|
||||
padding: (usize, usize),
|
||||
start: usize,
|
||||
}
|
||||
|
||||
fn shifted_plane(plane: &[f32], row: &mut [f32], shift: PlaneShift) -> Result<()> {
|
||||
let PlaneShift { width, kernel: (ky, kx), padding: (pt, pl), start } = shift;
|
||||
let end = start + row.len();
|
||||
let delta = (ky as i128 - pt as i128) * width as i128 + kx as i128 - pl as i128;
|
||||
let lo = (-delta).clamp(start as i128, end as i128);
|
||||
@@ -145,13 +154,8 @@ pub(super) fn conv(n: &Node, x: &Tensor, g: &Graph, scratch: &mut Scratch) -> Re
|
||||
if sh == 1 && sw == 1 && ow == w {
|
||||
shifted_plane(
|
||||
plane,
|
||||
w,
|
||||
ky,
|
||||
kx,
|
||||
pt,
|
||||
pl,
|
||||
start,
|
||||
row.get_mut(..len).ok_or_else(|| NetError::Shape("short lower panel".into()))?,
|
||||
PlaneShift { width: w, kernel: (ky, kx), padding: (pt, pl), start },
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use super::*;
|
||||
use super::{ConvJob, Data, HashMap, Net, NetError, Result, kernels, pads, pair, shape, shape_err, unsupported};
|
||||
|
||||
/// Value 0 is the network's input.
|
||||
pub const INPUT: usize = 0;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Bounded, allocation-free (apart from small shape lists) graph checking.
|
||||
use super::*;
|
||||
use super::{Data, Graph, HashMap, MAX_TENSOR, NetError, Node, Result, pads, pair, shape_err, unsupported};
|
||||
use crate::graph::{Attr, Weight};
|
||||
|
||||
pub(super) fn elements(s: &[usize]) -> Result<usize> {
|
||||
|
||||
+17
-14
@@ -235,32 +235,35 @@ fn round_up(v: usize, to: usize) -> usize {
|
||||
v.div_ceil(to) * to
|
||||
}
|
||||
|
||||
/// Work out the steps, the buffers and the arranged weights for `net` on `tile × tile` input cells.
|
||||
fn plan(net: &Net, tile: usize, max_binding: u64) -> Result<(Layout, Vec<Layer>), String> {
|
||||
fn validate_plan(net: &Net, tile: usize, ops: &[Op]) -> Result<(), String> {
|
||||
if !net.fits_tile(tile) {
|
||||
return unsupported("the tile does not fit the network");
|
||||
}
|
||||
if tile > MAX_TILE {
|
||||
return unsupported("the tile is larger than the kernels' dispatches reach");
|
||||
}
|
||||
if !net.in_channels.is_multiple_of(4) {
|
||||
return unsupported("the input channel count is not a multiple of 4");
|
||||
}
|
||||
let count = ops.len();
|
||||
let ends_right = matches!(
|
||||
(count.checked_sub(2).and_then(|i| ops.get(i)), ops.last()),
|
||||
(Some(Op::Conv(conv)), Some(Op::DepthToSpace2 { src }))
|
||||
if conv.k == 1 && conv.src2.is_none() && *src == count - 1 && conv.cout == 12 && conv.leaky.is_none()
|
||||
);
|
||||
if ends_right { Ok(()) } else { unsupported("the network does not end in a 1 × 1 convolution to 12 channels and depth-to-space") }
|
||||
}
|
||||
|
||||
/// Work out the steps, the buffers and the arranged weights for `net` on `tile × tile` input cells.
|
||||
fn plan(net: &Net, tile: usize, max_binding: u64) -> Result<(Layout, Vec<Layer>), String> {
|
||||
let ops = net.ops();
|
||||
validate_plan(net, tile, ops)?;
|
||||
let side = |t: usize| -> usize {
|
||||
let level = net.shape(t).map_or(0, |s| s.level);
|
||||
if level >= 0 { tile >> level } else { tile << (-level) }
|
||||
};
|
||||
let chan = |t: usize| net.shape(t).map_or(0, |s| s.channels);
|
||||
if !net.in_channels.is_multiple_of(4) {
|
||||
return unsupported("the input channel count is not a multiple of 4");
|
||||
}
|
||||
let ops = net.ops();
|
||||
// the network must end in a 1 × 1 convolution and a depth-to-space (they run as one kernel)
|
||||
let n_ops = ops.len();
|
||||
let ends_right = matches!(
|
||||
(n_ops.checked_sub(2).and_then(|i| ops.get(i)), ops.last()),
|
||||
(Some(Op::Conv(c)), Some(Op::DepthToSpace2 { src })) if c.k == 1 && c.src2.is_none() && *src == n_ops - 1 && c.cout == 12 && c.leaky.is_none()
|
||||
);
|
||||
if !ends_right {
|
||||
return unsupported("the network does not end in a 1 × 1 convolution to 12 channels and depth-to-space");
|
||||
}
|
||||
// the last layer that reads each tensor
|
||||
let mut last_use = vec![0usize; n_ops + 1];
|
||||
for (i, op) in ops.iter().enumerate() {
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
use lightcraft_geom::Orientation;
|
||||
use lightcraft_pipeline::finish::{FinishParams, MASK_TERMS, mask_terms};
|
||||
use lightcraft_pipeline::finish::{FinishParams, FinishParamsInput, MASK_TERMS, mask_terms};
|
||||
use lightcraft_pipeline::geometry::SampleMode;
|
||||
use lightcraft_pipeline::local::plane_at;
|
||||
use lightcraft_pipeline::{Plan, RenderRequest, Rendered, SourceInfo, local};
|
||||
@@ -668,7 +668,15 @@ pub fn render(
|
||||
lap("masks", &mut t, &mut cx);
|
||||
|
||||
// 5. per-pixel stage
|
||||
let mut fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air, req.space);
|
||||
let mut fp = FinishParams::new(FinishParamsInput {
|
||||
settings: s,
|
||||
frame: &plan.frame,
|
||||
info,
|
||||
image_size: (w, h),
|
||||
px_per_long: plan.px_per_long,
|
||||
air_pre: prep.air,
|
||||
space: req.space,
|
||||
});
|
||||
// soft proofing as the CPU's `finish` sets it up (an output-device table never gets here: see `render`)
|
||||
fp.proof = req.proof.map(|pr| pr.params(req.space));
|
||||
if let Some(d) = &req.display {
|
||||
|
||||
@@ -201,6 +201,11 @@ fn st_ref_pic_set(r: &mut Bits, idx: usize, num_delta_pocs: &[u32]) -> Option<u3
|
||||
fn parse_sps(rbsp: &[u8]) -> Option<Signal> {
|
||||
let r = &mut Bits { data: rbsp, pos: 0 };
|
||||
let (_, _, max_sub_layers_minus1) = sps_size(r)?;
|
||||
parse_sps_parameters(r, max_sub_layers_minus1)?;
|
||||
parse_vui(r)
|
||||
}
|
||||
|
||||
fn parse_sps_parameters(r: &mut Bits<'_>, max_sub_layers_minus1: u32) -> Option<()> {
|
||||
if r.flag()? {
|
||||
for _ in 0..4 {
|
||||
r.ue()?; // conf_win_*_offset
|
||||
@@ -253,6 +258,10 @@ fn parse_sps(rbsp: &[u8]) -> Option<Signal> {
|
||||
}
|
||||
}
|
||||
r.skip(2)?; // sps_temporal_mvp_enabled_flag, strong_intra_smoothing_enabled_flag
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn parse_vui(r: &mut Bits<'_>) -> Option<Signal> {
|
||||
let mut signal = Signal::default();
|
||||
if !r.flag()? {
|
||||
return Some(signal); // no VUI
|
||||
|
||||
+62
-37
@@ -246,33 +246,13 @@ impl Backend for HoldAfterFirst {
|
||||
}
|
||||
}
|
||||
|
||||
/// Real stdio framing and real atomic exports: progress, ping during a batch, cancellation
|
||||
/// between complete photos, unrelated output preservation, and a usable session afterwards.
|
||||
/// Both exports wait after their first photo ([`HoldAfterFirst`]) until the ping is answered or
|
||||
/// the cancel is read, so neither can finish first on a fast machine.
|
||||
#[test]
|
||||
fn export_progress_and_cancel() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-mcp-progress-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (input, mut send) = std::io::pipe().unwrap();
|
||||
let (output, write) = std::io::pipe().unwrap();
|
||||
let hold = Arc::new(AtomicBool::new(true));
|
||||
let backend = HoldAfterFirst { inner: file_backed_photos(&dir), hold: hold.clone() };
|
||||
let server = std::thread::spawn(move || Server::new(Box::new(backend)).serve(BufReader::new(input), write).unwrap());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let reader = std::thread::spawn(move || {
|
||||
for line in BufReader::new(output).lines() {
|
||||
let value: Value = serde_json::from_str(&line.unwrap()).unwrap();
|
||||
if tx.send(value).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let next = || rx.recv_timeout(Duration::from_secs(30)).expect("MCP reply/progress");
|
||||
writeln!(send, "{}", call(1, "query_photos", json!({"limit":8}))).unwrap();
|
||||
let query = next();
|
||||
let photos = query["result"]["structuredContent"]["photos"].as_array().unwrap();
|
||||
type Rx = std::sync::mpsc::Receiver<Value>;
|
||||
|
||||
fn next_msg(rx: &Rx) -> Value {
|
||||
rx.recv_timeout(Duration::from_secs(30)).expect("MCP reply/progress")
|
||||
}
|
||||
|
||||
fn assert_query_photos(photos: &[Value]) {
|
||||
assert_eq!(photos.len(), 8);
|
||||
// Real files, real metadata: eight distinct 1200×800 PNGs probed from disk, not synthesized.
|
||||
let names: std::collections::HashSet<&str> = photos.iter().map(|p| p["fileName"].as_str().unwrap()).collect();
|
||||
@@ -281,13 +261,15 @@ fn export_progress_and_cancel() {
|
||||
assert_eq!((p["width"].as_u64(), p["height"].as_u64()), (Some(1200), Some(800)), "{p}");
|
||||
assert_eq!(p["format"], "PNG", "{p}");
|
||||
}
|
||||
let ids: Vec<_> = photos.iter().map(|p| p["id"].clone()).collect();
|
||||
}
|
||||
|
||||
fn run_complete_export(send: &mut impl Write, rx: &Rx, hold: &AtomicBool, ids: &[Value], dir: &std::path::Path) {
|
||||
let mut request = call(2, "export", json!({"ids":ids,"dir":dir.join("complete"),"longEdge":1200,"format":"png"}));
|
||||
request["params"]["_meta"] = json!({"progressToken":"photos","io.modelcontextprotocol/protocolVersion":"2026-07-28"});
|
||||
writeln!(send, "{request}").unwrap();
|
||||
let (mut last, mut notes, mut asked, mut answered) = (-1.0, 0, false, false);
|
||||
loop {
|
||||
let value = next();
|
||||
let value = next_msg(rx);
|
||||
if value["method"] == "notifications/progress" {
|
||||
assert_eq!(value["params"]["progressToken"], "photos");
|
||||
let progress = value["params"]["progress"].as_f64().unwrap();
|
||||
@@ -312,17 +294,16 @@ fn export_progress_and_cancel() {
|
||||
}
|
||||
assert!(notes >= 2 && answered, "{notes} progress messages; ping answered {answered}");
|
||||
assert_eq!(last, 8.0);
|
||||
let out = dir.join("cancelled");
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
let decoy = out.join("photo999.png");
|
||||
std::fs::write(&decoy, b"unrelated existing output").unwrap();
|
||||
}
|
||||
|
||||
fn run_cancelled_export(send: &mut impl Write, rx: &Rx, hold: &AtomicBool, ids: &[Value], out: &std::path::Path) {
|
||||
// this export waits after its first photo until the cancel below is read
|
||||
hold.store(true, Ordering::SeqCst);
|
||||
let mut request = call(4, "command_run", json!({"id":"app.export","params":{"ids":ids,"dir":out,"longEdge":1200,"format":"png"}}));
|
||||
request["params"]["_meta"] = json!({"progressToken":44});
|
||||
writeln!(send, "{request}").unwrap();
|
||||
loop {
|
||||
let value = next();
|
||||
let value = next_msg(rx);
|
||||
assert_ne!(value["id"], 4, "must be cancellable before completion: {value}");
|
||||
assert_eq!(value["params"]["progressToken"], 44, "{value}");
|
||||
if value["params"]["progress"].as_f64().unwrap() > 0.0 {
|
||||
@@ -332,7 +313,7 @@ fn export_progress_and_cancel() {
|
||||
writeln!(send, "{}", json!({"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":4}})).unwrap();
|
||||
writeln!(send, "{}", call(5, "doc_inspect", json!({}))).unwrap();
|
||||
loop {
|
||||
let value = next();
|
||||
let value = next_msg(rx);
|
||||
if value["method"] == "notifications/progress" {
|
||||
continue;
|
||||
}
|
||||
@@ -340,13 +321,19 @@ fn export_progress_and_cancel() {
|
||||
assert_eq!(value["result"]["isError"], false);
|
||||
break;
|
||||
}
|
||||
assert_eq!(std::fs::read(&decoy).unwrap(), b"unrelated existing output");
|
||||
let photos: Vec<_> = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().path()).filter(|p| *p != decoy).collect();
|
||||
}
|
||||
|
||||
fn assert_cancelled_output(out: &std::path::Path, decoy: &std::path::Path) {
|
||||
assert_eq!(std::fs::read(decoy).unwrap(), b"unrelated existing output");
|
||||
let photos: Vec<_> = std::fs::read_dir(out).unwrap().map(|e| e.unwrap().path()).filter(|p| p != decoy).collect();
|
||||
assert_eq!(photos.len(), 1, "only the photo completed before the cancel remains: {photos:?}");
|
||||
for path in &photos {
|
||||
let d = lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).expect("complete image, no partial/temp file");
|
||||
assert_eq!((d.width, d.height), (1200, 800), "{}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_complete_output(dir: &std::path::Path) {
|
||||
// The finished export's eight outputs are real 1200×800 PNGs, one per distinct original.
|
||||
let complete: Vec<_> = std::fs::read_dir(dir.join("complete")).unwrap().map(|e| e.unwrap().path()).collect();
|
||||
assert_eq!(complete.len(), 8, "earlier export retained: {complete:?}");
|
||||
@@ -359,6 +346,44 @@ fn export_progress_and_cancel() {
|
||||
exported.insert(bytes);
|
||||
}
|
||||
assert_eq!(exported.len(), 8, "eight distinct outputs, not copies of one export");
|
||||
}
|
||||
|
||||
/// Real stdio framing and real atomic exports: progress, ping during a batch, cancellation
|
||||
/// between complete photos, unrelated output preservation, and a usable session afterwards.
|
||||
/// Both exports wait after their first photo ([`HoldAfterFirst`]) until the ping is answered or
|
||||
/// the cancel is read, so neither can finish first on a fast machine.
|
||||
#[test]
|
||||
fn export_progress_and_cancel() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-mcp-progress-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (input, mut send) = std::io::pipe().unwrap();
|
||||
let (output, write) = std::io::pipe().unwrap();
|
||||
let hold = Arc::new(AtomicBool::new(true));
|
||||
let backend = HoldAfterFirst { inner: file_backed_photos(&dir), hold: hold.clone() };
|
||||
let server = std::thread::spawn(move || Server::new(Box::new(backend)).serve(BufReader::new(input), write).unwrap());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let reader = std::thread::spawn(move || {
|
||||
for line in BufReader::new(output).lines() {
|
||||
let value: Value = serde_json::from_str(&line.unwrap()).unwrap();
|
||||
if tx.send(value).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
writeln!(send, "{}", call(1, "query_photos", json!({"limit":8}))).unwrap();
|
||||
let query = next_msg(&rx);
|
||||
let photos = query["result"]["structuredContent"]["photos"].as_array().unwrap();
|
||||
assert_query_photos(photos);
|
||||
let ids: Vec<_> = photos.iter().map(|p| p["id"].clone()).collect();
|
||||
run_complete_export(&mut send, &rx, &hold, &ids, &dir);
|
||||
let out = dir.join("cancelled");
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
let decoy = out.join("photo999.png");
|
||||
std::fs::write(&decoy, b"unrelated existing output").unwrap();
|
||||
run_cancelled_export(&mut send, &rx, &hold, &ids, &out);
|
||||
assert_cancelled_output(&out, &decoy);
|
||||
assert_complete_output(&dir);
|
||||
drop(send);
|
||||
// The fixture's files stay in place until the server (which owns the session) and the reader
|
||||
// have finished: the session reads the originals; the output is what the checks above read.
|
||||
|
||||
+23
-3
@@ -187,7 +187,16 @@ impl Metadata {
|
||||
orientation,
|
||||
gps,
|
||||
width,
|
||||
height,
|
||||
height
|
||||
);
|
||||
self.fill_missing_descriptive(other);
|
||||
}
|
||||
|
||||
/// Fill descriptive metadata separately from the camera and image properties.
|
||||
fn fill_missing_descriptive(&mut self, other: &Metadata) {
|
||||
fill!(
|
||||
self,
|
||||
other,
|
||||
artist,
|
||||
copyright,
|
||||
copyright_marked,
|
||||
@@ -405,11 +414,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn merge_rules() {
|
||||
let mut a = Metadata { make: Some("A".into()), rating: Some(1), ..Default::default() };
|
||||
let b = Metadata { make: Some("B".into()), model: Some("M".into()), rating: Some(4), keywords: vec!["k".into()], ..Default::default() };
|
||||
let mut a = Metadata { make: Some("A".into()), title: Some("Original".into()), rating: Some(1), ..Default::default() };
|
||||
let b = Metadata {
|
||||
make: Some("B".into()),
|
||||
model: Some("M".into()),
|
||||
title: Some("Replacement".into()),
|
||||
caption: Some("Caption".into()),
|
||||
rating: Some(4),
|
||||
keywords: vec!["k".into()],
|
||||
..Default::default()
|
||||
};
|
||||
a.fill_missing(&b);
|
||||
assert_eq!(a.make.as_deref(), Some("A"));
|
||||
assert_eq!(a.model.as_deref(), Some("M"));
|
||||
assert_eq!(a.title.as_deref(), Some("Original"));
|
||||
assert_eq!(a.caption.as_deref(), Some("Caption"));
|
||||
assert_eq!(a.keywords, vec!["k".to_string()]);
|
||||
assert_eq!(a.rating, Some(1));
|
||||
a.overlay_user_fields(&b);
|
||||
assert_eq!(a.rating, Some(4));
|
||||
|
||||
+156
-159
@@ -186,19 +186,20 @@ pub struct FinishParams {
|
||||
pub peak: f32,
|
||||
}
|
||||
|
||||
pub struct FinishParamsInput<'a> {
|
||||
pub settings: &'a DevelopSettings,
|
||||
pub frame: &'a Frame,
|
||||
pub info: &'a SourceInfo,
|
||||
pub image_size: (usize, usize),
|
||||
pub px_per_long: f64,
|
||||
pub air_pre: f32,
|
||||
pub space: OutputSpace,
|
||||
}
|
||||
|
||||
impl FinishParams {
|
||||
/// Parameters for a `w × h` render into `space`; `ev`/`air_pre` as in [`crate::Prepared`].
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
s: &DevelopSettings,
|
||||
frame: &Frame,
|
||||
info: &SourceInfo,
|
||||
w: usize,
|
||||
h: usize,
|
||||
px_per_long: f64,
|
||||
air_pre: f32,
|
||||
space: OutputSpace,
|
||||
) -> FinishParams {
|
||||
pub fn new(input: FinishParamsInput<'_>) -> FinishParams {
|
||||
let FinishParamsInput { settings: s, frame, info, image_size: (w, h), px_per_long, air_pre, space } = input;
|
||||
let effects = s.section_enabled("effects");
|
||||
let (clar, tex, dehaze) = if effects {
|
||||
((s.effects.clarity / 100.0) as f32, (s.effects.texture / 100.0) as f32, (s.effects.dehaze / 100.0) as f32)
|
||||
@@ -288,7 +289,8 @@ pub(crate) fn finish(
|
||||
proof: Option<crate::Proof>,
|
||||
) -> Rgba8 {
|
||||
let (w, h) = (p.img.width, p.img.height);
|
||||
let mut fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space);
|
||||
let mut fp =
|
||||
FinishParams::new(FinishParamsInput { settings: s, frame, info, image_size: (w, h), px_per_long: p.px_per_long, air_pre: p.air, space });
|
||||
fp.proof = proof.map(|pr| pr.params(space));
|
||||
if let Some(d) = display {
|
||||
fp.for_display(d, proof);
|
||||
@@ -305,7 +307,6 @@ pub(crate) fn finish(
|
||||
}
|
||||
|
||||
/// [`finish`] into 16-bit display-encoded or 32-bit float linear samples (output primaries).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn finish_deep(
|
||||
p: &Prepared,
|
||||
s: &DevelopSettings,
|
||||
@@ -317,7 +318,8 @@ pub(crate) fn finish_deep(
|
||||
) -> DeepImage {
|
||||
use lightcraft_color::transfer::srgb_to_linear;
|
||||
let (w, h) = (p.img.width, p.img.height);
|
||||
let mut fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space);
|
||||
let mut fp =
|
||||
FinishParams::new(FinishParamsInput { settings: s, frame, info, image_size: (w, h), px_per_long: p.px_per_long, air_pre: p.air, space });
|
||||
fp.proof = proof.map(|pr| pr.params(space));
|
||||
let trc = fp.out_trc;
|
||||
let samples = match depth {
|
||||
@@ -354,29 +356,7 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
|
||||
) -> Vec<T> {
|
||||
let (w, h) = (p.img.width, p.img.height);
|
||||
let p_lut = fp.lut.clone();
|
||||
let FinishParams {
|
||||
tone,
|
||||
ops,
|
||||
curves,
|
||||
vig,
|
||||
to_out,
|
||||
out_luma,
|
||||
hl,
|
||||
sh,
|
||||
clar,
|
||||
tex,
|
||||
dehaze,
|
||||
sharpen,
|
||||
sharpen_mask,
|
||||
air,
|
||||
air_pre,
|
||||
gain,
|
||||
ev,
|
||||
grain,
|
||||
..
|
||||
} = fp;
|
||||
let (hl, sh, clar, tex, dehaze, sharpen, sharpen_mask, air, air_pre, gain, ev) =
|
||||
(*hl, *sh, *clar, *tex, *dehaze, *sharpen, *sharpen_mask, *air, *air_pre, *gain, *ev);
|
||||
let FinishParams { curves, vig, to_out, out_luma, grain, .. } = fp;
|
||||
let terms: Vec<[f32; MASK_TERMS]> = p.masks.iter().map(|m| mask_terms(&m.adjust)).collect();
|
||||
let out_to_norm = fp.out_to_norm;
|
||||
let long = fp.ow.max(fp.oh);
|
||||
@@ -392,128 +372,17 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let i = y * w + x;
|
||||
let raw = p.img.data[i];
|
||||
let mut c = if gain == 1.0 { raw } else { raw.map(|v| v * gain) };
|
||||
let local = pixel_local_terms(p, &terms, i);
|
||||
let l_sat = local.values[11];
|
||||
let l_hue = local.values[12];
|
||||
let l_pre = p.log_l.data[i];
|
||||
let l0 = l_pre + ev;
|
||||
|
||||
// --- local (mask) contributions: alpha-weighted sums of the masks' terms
|
||||
let mut l = [0.0f32; MASK_SUMS];
|
||||
let mut tint_col: Option<([f32; 3], f32)> = None;
|
||||
for (m, t) in p.masks.iter().zip(&terms) {
|
||||
let a = m.alpha.data[i];
|
||||
if a <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
for k in 0..MASK_SUMS {
|
||||
l[k] += a * t[k];
|
||||
}
|
||||
if t[MASK_SUMS] > 0.0 {
|
||||
tint_col = Some(([t[MASK_SUMS + 1], t[MASK_SUMS + 2], 0.0], a * t[MASK_SUMS + 3]));
|
||||
}
|
||||
}
|
||||
let [l_exp, l_temp, l_tint, l_con, l_hl, l_sh, l_wh, l_bl, l_tex, l_clar, l_dehaze, l_sat, l_hue, l_sharp, l_noise, l_moire, l_defringe] =
|
||||
l;
|
||||
|
||||
// --- local Moiré (and the colour part of Noise): chromaticity towards its blur
|
||||
let mt = (l_moire + 0.5 * l_noise.max(0.0)).clamp(-1.0, 1.0);
|
||||
if mt != 0.0
|
||||
&& let Some(cb) = &p.chroma_blur
|
||||
{
|
||||
let (y, ch0, cb) = (luminance_2020(c), crate::masks::chromaticity(raw), cb.data[i]);
|
||||
c = std::array::from_fn(|k| ((ch0[k] + (cb[k] - ch0[k]) * mt) * y).max(0.0));
|
||||
}
|
||||
// --- local Defringe: desaturate purple / green fringes along edges
|
||||
let df = l_defringe.clamp(0.0, 1.0);
|
||||
if df > 0.0
|
||||
&& let Some(b) = &p.texture_blur
|
||||
{
|
||||
let k = df * defringe_weight(c, p.log_l.data[i] - b.data[i]);
|
||||
if k > 0.0 {
|
||||
let y = luminance_2020(c);
|
||||
c = c.map(|v| v + (y - v) * k);
|
||||
}
|
||||
}
|
||||
|
||||
// --- dehaze (scene linear)
|
||||
let dz = dehaze + l_dehaze;
|
||||
if dz != 0.0
|
||||
&& let Some(dark) = &p.dark
|
||||
{
|
||||
// The dark channel is a blur, not a patch minimum: near bright neighbours it credits
|
||||
// dark pixels with veil they don't hold (and a windowed render borrows the whole
|
||||
// frame's airlight), so the subtraction went past zero and crushed them to black
|
||||
// (#2504). Never attribute more veil than the pixel's own minimum channel.
|
||||
let c_min = c[0].min(c[1]).min(c[2]);
|
||||
let d = (if air_pre > 0.0 { dark.data[i] / air_pre } else { 0.0 }).min(if air > 0.0 { c_min / air } else { 0.0 }).clamp(0.0, 1.0);
|
||||
if dz > 0.0 {
|
||||
let t = (1.0 - 0.95 * dz.min(1.0) * d).max(0.12);
|
||||
c = c.map(|v| ((v - air * (1.0 - t)) / t).max(0.0));
|
||||
} else {
|
||||
let k = (-dz).min(1.0) * 0.7 * (0.35 + 0.65 * d);
|
||||
c = c.map(|v| v + (air * 0.9 - v) * k);
|
||||
}
|
||||
}
|
||||
|
||||
// --- local exposure / temp / tint
|
||||
if l_exp != 0.0 {
|
||||
let g = l_exp.exp2();
|
||||
c = c.map(|v| v * g);
|
||||
}
|
||||
if l_temp != 0.0 || l_tint != 0.0 {
|
||||
let y0 = luminance_2020(c);
|
||||
c = [c[0] * (1.0 + 0.3 * l_temp), c[1] * (1.0 - 0.22 * l_tint), c[2] * (1.0 - 0.3 * l_temp).max(0.0)];
|
||||
let y1 = luminance_2020(c).max(1e-9);
|
||||
c = c.map(|v| v * y0 / y1);
|
||||
}
|
||||
let l0 = l_pre + fp.ev;
|
||||
let (mut c, l1) = adjust_scene_pixel(p, fp, i, raw, &local);
|
||||
|
||||
// --- local tone in log luminance
|
||||
let l1 = if dz != 0.0 || l_exp != 0.0 { log_lum(c) } else { l0 };
|
||||
let shift = l1 - l0;
|
||||
let base = p.base.data[i] + ev + shift;
|
||||
let mut delta = 0.0f32;
|
||||
let (hh, ss) = (hl + l_hl, sh + l_sh);
|
||||
if hh != 0.0 || ss != 0.0 {
|
||||
let ws = 1.0 - smooth(-4.8, 0.3, base);
|
||||
let wh = smooth(-1.0, 2.8, base);
|
||||
delta += ss * 1.7 * ws * ws.sqrt() + hh * 1.7 * wh;
|
||||
}
|
||||
if l_wh != 0.0 {
|
||||
delta += l_wh * 0.8 * smooth(0.5, 3.0, l1);
|
||||
}
|
||||
if l_bl != 0.0 {
|
||||
delta += l_bl * 0.8 * (1.0 - smooth(-6.0, -1.5, l1));
|
||||
}
|
||||
if l_con != 0.0 {
|
||||
delta += l_con * 0.14 * l1.clamp(-6.0, 4.0);
|
||||
}
|
||||
let cl = clar + l_clar;
|
||||
if cl != 0.0
|
||||
&& let Some(b) = &p.clarity_blur
|
||||
{
|
||||
let det = (l_pre - b.data[i]).clamp(-2.5, 2.5);
|
||||
let mid = (-(base / 3.2).powi(2)).exp();
|
||||
delta += cl * 0.85 * det * (0.35 + 0.65 * mid);
|
||||
}
|
||||
let tx = tex + l_tex;
|
||||
let sp = l_sharp * 0.6 + sharpen;
|
||||
if (tx != 0.0 || sp != 0.0)
|
||||
&& let Some(b) = &p.texture_blur
|
||||
{
|
||||
let det = l_pre - b.data[i];
|
||||
let tame = 1.0 - 0.6 * smooth(0.4, 1.6, det.abs());
|
||||
delta += tx * 1.1 * det.clamp(-1.0, 1.0) * tame;
|
||||
if sp != 0.0 {
|
||||
let m = if sharpen_mask > 0.0 { smooth(sharpen_mask * 0.25, sharpen_mask * 0.25 + 0.15, det.abs()) } else { 1.0 };
|
||||
delta += sp * 1.3 * det.clamp(-0.8, 0.8) * m;
|
||||
}
|
||||
}
|
||||
// local Noise: smooth (or, negative, boost) small-amplitude detail, keep edges
|
||||
if l_noise != 0.0
|
||||
&& let Some(b) = &p.texture_blur
|
||||
{
|
||||
let det = l_pre - b.data[i];
|
||||
delta -= l_noise.clamp(-1.0, 1.0) * 0.9 * det * (1.0 - smooth(0.1, 0.5, det.abs()));
|
||||
}
|
||||
let base = p.base.data[i] + fp.ev + shift;
|
||||
let delta = local_tone_delta(p, fp, i, base, l1, &local);
|
||||
if delta != 0.0 {
|
||||
let g = delta.exp2();
|
||||
c = c.map(|v| v * g);
|
||||
@@ -526,9 +395,9 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
|
||||
|
||||
// --- tone map on luminance, highlight desaturation
|
||||
let yl = luminance_2020(c);
|
||||
let o = tone.apply(yl);
|
||||
let o = fp.tone.apply(yl);
|
||||
let mut d = if yl > 1e-9 { c.map(|v| v * o / yl) } else { [0.0; 3] };
|
||||
let k = tone.chroma_scale(o);
|
||||
let k = fp.tone.chroma_scale(o);
|
||||
if k != 1.0 {
|
||||
d = d.map(|v| o + (v - o) * k);
|
||||
}
|
||||
@@ -539,8 +408,8 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
|
||||
}
|
||||
|
||||
// --- colour
|
||||
d = ops.apply(d, l_sat, l_hue);
|
||||
if let Some((dir, amt)) = tint_col {
|
||||
d = fp.ops.apply(d, l_sat, l_hue);
|
||||
if let Some((dir, amt)) = local.tint {
|
||||
let lab = lightcraft_color::perceptual::oklab_from_2020(d);
|
||||
d = lightcraft_color::perceptual::oklab_to_2020([lab[0], lab[1] + dir[0] * 0.08 * amt, lab[2] + dir[1] * 0.08 * amt]);
|
||||
}
|
||||
@@ -663,6 +532,134 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
|
||||
out
|
||||
}
|
||||
|
||||
struct PixelLocalTerms {
|
||||
values: [f32; MASK_SUMS],
|
||||
tint: Option<([f32; 3], f32)>,
|
||||
}
|
||||
|
||||
fn pixel_local_terms(p: &Prepared, terms: &[[f32; MASK_TERMS]], index: usize) -> PixelLocalTerms {
|
||||
let mut values = [0.0; MASK_SUMS];
|
||||
let mut tint = None;
|
||||
for (mask, terms) in p.masks.iter().zip(terms) {
|
||||
let alpha = mask.alpha.data[index];
|
||||
if alpha <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
for (sum, term) in values.iter_mut().zip(terms.iter().take(MASK_SUMS)) {
|
||||
*sum += alpha * term;
|
||||
}
|
||||
if terms[MASK_SUMS] > 0.0 {
|
||||
tint = Some(([terms[MASK_SUMS + 1], terms[MASK_SUMS + 2], 0.0], alpha * terms[MASK_SUMS + 3]));
|
||||
}
|
||||
}
|
||||
PixelLocalTerms { values, tint }
|
||||
}
|
||||
|
||||
fn adjust_scene_pixel(p: &Prepared, fp: &FinishParams, index: usize, raw: [f32; 3], local: &PixelLocalTerms) -> ([f32; 3], f32) {
|
||||
let [l_exp, l_temp, l_tint, _, _, _, _, _, _, _, l_dehaze, _, _, _, l_noise, l_moire, l_defringe] = local.values;
|
||||
let mut color = if fp.gain == 1.0 { raw } else { raw.map(|value| value * fp.gain) };
|
||||
|
||||
let moire = (l_moire + 0.5 * l_noise.max(0.0)).clamp(-1.0, 1.0);
|
||||
if moire != 0.0
|
||||
&& let Some(blur) = &p.chroma_blur
|
||||
{
|
||||
let (luminance, original, blurred) = (luminance_2020(color), crate::masks::chromaticity(raw), blur.data[index]);
|
||||
color = std::array::from_fn(|channel| ((original[channel] + (blurred[channel] - original[channel]) * moire) * luminance).max(0.0));
|
||||
}
|
||||
let defringe = l_defringe.clamp(0.0, 1.0);
|
||||
if defringe > 0.0
|
||||
&& let Some(blur) = &p.texture_blur
|
||||
{
|
||||
let amount = defringe * defringe_weight(color, p.log_l.data[index] - blur.data[index]);
|
||||
if amount > 0.0 {
|
||||
let luminance = luminance_2020(color);
|
||||
color = color.map(|value| value + (luminance - value) * amount);
|
||||
}
|
||||
}
|
||||
|
||||
let dehaze = fp.dehaze + l_dehaze;
|
||||
if dehaze != 0.0
|
||||
&& let Some(dark) = &p.dark
|
||||
{
|
||||
// Limit the estimated veil to the pixel's own minimum channel. This avoids crushing bright
|
||||
// neighbours when the dark-channel blur spans the edge of a local window.
|
||||
let min_channel = color[0].min(color[1]).min(color[2]);
|
||||
let veil = (if fp.air_pre > 0.0 { dark.data[index] / fp.air_pre } else { 0.0 })
|
||||
.min(if fp.air > 0.0 { min_channel / fp.air } else { 0.0 })
|
||||
.clamp(0.0, 1.0);
|
||||
if dehaze > 0.0 {
|
||||
let transmission = (1.0 - 0.95 * dehaze.min(1.0) * veil).max(0.12);
|
||||
color = color.map(|value| ((value - fp.air * (1.0 - transmission)) / transmission).max(0.0));
|
||||
} else {
|
||||
let amount = (-dehaze).min(1.0) * 0.7 * (0.35 + 0.65 * veil);
|
||||
color = color.map(|value| value + (fp.air * 0.9 - value) * amount);
|
||||
}
|
||||
}
|
||||
|
||||
if l_exp != 0.0 {
|
||||
let gain = l_exp.exp2();
|
||||
color = color.map(|value| value * gain);
|
||||
}
|
||||
if l_temp != 0.0 || l_tint != 0.0 {
|
||||
let luminance_before = luminance_2020(color);
|
||||
color = [color[0] * (1.0 + 0.3 * l_temp), color[1] * (1.0 - 0.22 * l_tint), color[2] * (1.0 - 0.3 * l_temp).max(0.0)];
|
||||
let luminance_after = luminance_2020(color).max(1e-9);
|
||||
color = color.map(|value| value * luminance_before / luminance_after);
|
||||
}
|
||||
|
||||
let l0 = p.log_l.data[index] + fp.ev;
|
||||
let log_luminance = if dehaze != 0.0 || l_exp != 0.0 { log_lum(color) } else { l0 };
|
||||
(color, log_luminance)
|
||||
}
|
||||
|
||||
fn local_tone_delta(p: &Prepared, fp: &FinishParams, index: usize, base: f32, l1: f32, local: &PixelLocalTerms) -> f32 {
|
||||
let [_, _, _, l_con, l_hl, l_sh, l_wh, l_bl, l_tex, l_clar, _, _, _, l_sharp, l_noise, _, _] = local.values;
|
||||
let mut delta = 0.0;
|
||||
let (highlights, shadows) = (fp.hl + l_hl, fp.sh + l_sh);
|
||||
if highlights != 0.0 || shadows != 0.0 {
|
||||
let shadow_weight = 1.0 - smooth(-4.8, 0.3, base);
|
||||
let highlight_weight = smooth(-1.0, 2.8, base);
|
||||
delta += shadows * 1.7 * shadow_weight * shadow_weight.sqrt() + highlights * 1.7 * highlight_weight;
|
||||
}
|
||||
if l_wh != 0.0 {
|
||||
delta += l_wh * 0.8 * smooth(0.5, 3.0, l1);
|
||||
}
|
||||
if l_bl != 0.0 {
|
||||
delta += l_bl * 0.8 * (1.0 - smooth(-6.0, -1.5, l1));
|
||||
}
|
||||
if l_con != 0.0 {
|
||||
delta += l_con * 0.14 * l1.clamp(-6.0, 4.0);
|
||||
}
|
||||
let clarity = fp.clar + l_clar;
|
||||
if clarity != 0.0
|
||||
&& let Some(blur) = &p.clarity_blur
|
||||
{
|
||||
let detail = (p.log_l.data[index] - blur.data[index]).clamp(-2.5, 2.5);
|
||||
let midtone = (-(base / 3.2).powi(2)).exp();
|
||||
delta += clarity * 0.85 * detail * (0.35 + 0.65 * midtone);
|
||||
}
|
||||
let texture = fp.tex + l_tex;
|
||||
let sharpen = l_sharp * 0.6 + fp.sharpen;
|
||||
if (texture != 0.0 || sharpen != 0.0)
|
||||
&& let Some(blur) = &p.texture_blur
|
||||
{
|
||||
let detail = p.log_l.data[index] - blur.data[index];
|
||||
let tame = 1.0 - 0.6 * smooth(0.4, 1.6, detail.abs());
|
||||
delta += texture * 1.1 * detail.clamp(-1.0, 1.0) * tame;
|
||||
if sharpen != 0.0 {
|
||||
let mask = if fp.sharpen_mask > 0.0 { smooth(fp.sharpen_mask * 0.25, fp.sharpen_mask * 0.25 + 0.15, detail.abs()) } else { 1.0 };
|
||||
delta += sharpen * 1.3 * detail.clamp(-0.8, 0.8) * mask;
|
||||
}
|
||||
}
|
||||
if l_noise != 0.0
|
||||
&& let Some(blur) = &p.texture_blur
|
||||
{
|
||||
let detail = p.log_l.data[index] - blur.data[index];
|
||||
delta -= l_noise.clamp(-1.0, 1.0) * 0.9 * detail * (1.0 - smooth(0.1, 0.5, detail.abs()));
|
||||
}
|
||||
delta
|
||||
}
|
||||
|
||||
/// A colour needing more desaturation than this (scale < `GAMUT_WARN`) to fit is out of gamut for
|
||||
/// the gamut warnings (tolerates rounding at the gamut boundary).
|
||||
const GAMUT_WARN: f32 = 0.995;
|
||||
|
||||
+50
-42
@@ -284,48 +284,8 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {
|
||||
if let Some(v) = c.baseline_sharpness {
|
||||
ifd.set(t::BASELINE_SHARPNESS, rat_vec(&[v]));
|
||||
}
|
||||
// the source's own profile look travels with its data (conversions, smart previews, merges)
|
||||
let p = &c.profile;
|
||||
let table_tags = |ifd: &mut IfdBuilder, table: &crate::profile::HsvTable, dims: u16, enc: u16| {
|
||||
ifd.set(dims, Value::Long(vec![table.hue_divisions as u32, table.sat_divisions as u32, table.val_divisions as u32]));
|
||||
if table.srgb_value {
|
||||
ifd.set(enc, Value::Long(vec![1]));
|
||||
}
|
||||
};
|
||||
let flat = |table: &crate::profile::HsvTable| Value::Float(table.data.iter().flatten().copied().collect());
|
||||
match &p.hue_sat_map {
|
||||
[Some(a), b] => {
|
||||
table_tags(&mut ifd, a, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING);
|
||||
ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(a));
|
||||
if let Some(b) =
|
||||
b.as_ref().filter(|b| (b.hue_divisions, b.sat_divisions, b.val_divisions) == (a.hue_divisions, a.sat_divisions, a.val_divisions))
|
||||
{
|
||||
ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_2, flat(b));
|
||||
}
|
||||
}
|
||||
[None, Some(b)] => {
|
||||
table_tags(&mut ifd, b, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING);
|
||||
ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(b));
|
||||
}
|
||||
[None, None] => {}
|
||||
}
|
||||
if let Some(l) = &p.look_table {
|
||||
table_tags(&mut ifd, l, t::PROFILE_LOOK_TABLE_DIMS, t::PROFILE_LOOK_TABLE_ENCODING);
|
||||
ifd.set(t::PROFILE_LOOK_TABLE_DATA, flat(l));
|
||||
}
|
||||
if let Some(curve) = &p.tone_curve {
|
||||
ifd.set(t::PROFILE_TONE_CURVE, Value::Float(curve.points.iter().flatten().copied().collect()));
|
||||
}
|
||||
if let Some(map) = &p.gain_table_map {
|
||||
// the raw IFD is IFD 0 here, where both tag versions are valid
|
||||
let (version2, bytes) = map.to_bytes(opts.order);
|
||||
ifd.set(if version2 { t::PROFILE_GAIN_TABLE_MAP_2 } else { t::PROFILE_GAIN_TABLE_MAP }, Value::Undefined(bytes));
|
||||
}
|
||||
for (list, tag) in [(&raw.opcodes.list1, t::OPCODE_LIST_1), (&raw.opcodes.list2, t::OPCODE_LIST_2), (&raw.opcodes.list3, t::OPCODE_LIST_3)] {
|
||||
if !list.is_empty() {
|
||||
ifd.set(tag, Value::Undefined(opcodes::write_list(list)));
|
||||
}
|
||||
}
|
||||
write_profile_tags(&mut ifd, raw, opts.order);
|
||||
write_opcode_tags(&mut ifd, raw);
|
||||
|
||||
match (&raw.data, opts.compression) {
|
||||
(RawData::F32(v), DngCompression::Deflate { tile, half }) => {
|
||||
@@ -495,6 +455,54 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {
|
||||
Ok(TiffWriter::new(opts.order, false).write(&[ifd])?)
|
||||
}
|
||||
|
||||
fn write_profile_tags(ifd: &mut IfdBuilder, raw: &RawImage, order: ByteOrder) {
|
||||
// The source's own profile look travels with its data (conversions, smart previews, merges).
|
||||
let profile = &raw.color.profile;
|
||||
let table_tags = |ifd: &mut IfdBuilder, table: &crate::profile::HsvTable, dims: u16, enc: u16| {
|
||||
ifd.set(dims, Value::Long(vec![table.hue_divisions as u32, table.sat_divisions as u32, table.val_divisions as u32]));
|
||||
if table.srgb_value {
|
||||
ifd.set(enc, Value::Long(vec![1]));
|
||||
}
|
||||
};
|
||||
let flat = |table: &crate::profile::HsvTable| Value::Float(table.data.iter().flatten().copied().collect());
|
||||
match &profile.hue_sat_map {
|
||||
[Some(first), second] => {
|
||||
table_tags(ifd, first, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING);
|
||||
ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(first));
|
||||
if let Some(second) = second.as_ref().filter(|second| {
|
||||
(second.hue_divisions, second.sat_divisions, second.val_divisions) == (first.hue_divisions, first.sat_divisions, first.val_divisions)
|
||||
}) {
|
||||
ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_2, flat(second));
|
||||
}
|
||||
}
|
||||
[None, Some(table)] => {
|
||||
table_tags(ifd, table, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING);
|
||||
ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(table));
|
||||
}
|
||||
[None, None] => {}
|
||||
}
|
||||
if let Some(table) = &profile.look_table {
|
||||
table_tags(ifd, table, t::PROFILE_LOOK_TABLE_DIMS, t::PROFILE_LOOK_TABLE_ENCODING);
|
||||
ifd.set(t::PROFILE_LOOK_TABLE_DATA, flat(table));
|
||||
}
|
||||
if let Some(curve) = &profile.tone_curve {
|
||||
ifd.set(t::PROFILE_TONE_CURVE, Value::Float(curve.points.iter().flatten().copied().collect()));
|
||||
}
|
||||
if let Some(map) = &profile.gain_table_map {
|
||||
// The raw IFD is IFD 0 here, where both tag versions are valid.
|
||||
let (version2, bytes) = map.to_bytes(order);
|
||||
ifd.set(if version2 { t::PROFILE_GAIN_TABLE_MAP_2 } else { t::PROFILE_GAIN_TABLE_MAP }, Value::Undefined(bytes));
|
||||
}
|
||||
}
|
||||
|
||||
fn write_opcode_tags(ifd: &mut IfdBuilder, raw: &RawImage) {
|
||||
for (list, tag) in [(&raw.opcodes.list1, t::OPCODE_LIST_1), (&raw.opcodes.list2, t::OPCODE_LIST_2), (&raw.opcodes.list3, t::OPCODE_LIST_3)] {
|
||||
if !list.is_empty() {
|
||||
ifd.set(tag, Value::Undefined(opcodes::write_list(list)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exif `LightSource` code of a white point the rendered DNG writer knows (D65 = 21, D50 = 23).
|
||||
fn illuminant_of(white: lightcraft_color::Xy) -> u16 {
|
||||
if (white.x - lightcraft_color::D50.x).abs() < 1e-3 && (white.y - lightcraft_color::D50.y).abs() < 1e-3 { 23 } else { 21 }
|
||||
|
||||
+140
-103
@@ -253,6 +253,9 @@ impl Rd<'_> {
|
||||
self.p += 8;
|
||||
Some(v)
|
||||
}
|
||||
fn finite_f64(&mut self) -> Option<f64> {
|
||||
self.f64().filter(|v| v.is_finite())
|
||||
}
|
||||
fn f32(&mut self) -> Option<f32> {
|
||||
let v = f32::from_be_bytes(self.d.get(self.p..self.p + 4)?.try_into().ok()?);
|
||||
self.p += 4;
|
||||
@@ -276,109 +279,126 @@ impl Rd<'_> {
|
||||
}
|
||||
|
||||
fn parse_one(id: u32, flags: u32, params: &[u8]) -> Option<Opcode> {
|
||||
let mut r = Rd { d: params, p: 0 };
|
||||
let f = |r: &mut Rd| r.f64().filter(|v| v.is_finite());
|
||||
Some(match id {
|
||||
let mut reader = Rd { d: params, p: 0 };
|
||||
let parsed = match id {
|
||||
1 | 2 | 3 | 14 => parse_warp(id, flags, &mut reader)?,
|
||||
4 => Opcode::FixBadPixelsConstant { constant: reader.u32()?, bayer_phase: reader.u32()? },
|
||||
5 => parse_bad_pixel_list(&mut reader)?,
|
||||
6 => Opcode::TrimBounds { top: reader.u32()?, left: reader.u32()?, bottom: reader.u32()?, right: reader.u32()? },
|
||||
7..=13 => parse_area_opcode(id, &mut reader)?,
|
||||
_ => Opcode::Unknown { id, flags, params: params.to_vec() },
|
||||
};
|
||||
Some(parsed)
|
||||
}
|
||||
|
||||
fn parse_warp(id: u32, flags: u32, reader: &mut Rd<'_>) -> Option<Opcode> {
|
||||
match id {
|
||||
1 => {
|
||||
let n = r.u32()? as usize;
|
||||
if n == 0 || n > 4 {
|
||||
return None;
|
||||
}
|
||||
let mut planes = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
planes.push([f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?]);
|
||||
}
|
||||
Opcode::WarpRectilinear { planes, center: [f(&mut r)?, f(&mut r)?] }
|
||||
let planes = parse_planes(reader, 4, |r| {
|
||||
Some([r.finite_f64()?, r.finite_f64()?, r.finite_f64()?, r.finite_f64()?, r.finite_f64()?, r.finite_f64()?])
|
||||
})?;
|
||||
Some(Opcode::WarpRectilinear { planes, center: [reader.finite_f64()?, reader.finite_f64()?] })
|
||||
}
|
||||
2 => {
|
||||
let n = r.u32()? as usize;
|
||||
if n == 0 || n > 4 {
|
||||
return None;
|
||||
}
|
||||
let mut planes = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
planes.push([f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?]);
|
||||
}
|
||||
Opcode::WarpFisheye { planes, center: [f(&mut r)?, f(&mut r)?] }
|
||||
let planes = parse_planes(reader, 4, |r| Some([r.finite_f64()?, r.finite_f64()?, r.finite_f64()?, r.finite_f64()?]))?;
|
||||
Some(Opcode::WarpFisheye { planes, center: [reader.finite_f64()?, reader.finite_f64()?] })
|
||||
}
|
||||
14 => {
|
||||
let n = r.u32()? as usize;
|
||||
// N sets of 19 doubles, then the centre (2 doubles) and reciprocalRadial (LONG); nothing else
|
||||
if n == 0 || n > WR2_MAX_PLANES || params.len() != 4 + n * WR2_LEN * 8 + 16 + 4 {
|
||||
return None;
|
||||
}
|
||||
let mut planes = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
let mut k = [0.0; WR2_LEN];
|
||||
for v in k.iter_mut() {
|
||||
*v = f(&mut r).filter(|v| v.abs() <= WR2_MAX_COEF)?;
|
||||
}
|
||||
// the spec requires 0 <= min_valid_radius < max_valid_radius <= 1
|
||||
if !(k[17] >= 0.0 && k[17] < k[18] && k[18] <= 1.0) {
|
||||
return None;
|
||||
}
|
||||
planes.push(k);
|
||||
}
|
||||
let center = [f(&mut r).filter(|v| v.abs() <= 4.0)?, f(&mut r).filter(|v| v.abs() <= 4.0)?];
|
||||
Opcode::WarpRectilinear2 { planes, center, reciprocal: r.u32()? != 0, optional: flags & 1 != 0 }
|
||||
3 => Some(Opcode::FixVignetteRadial {
|
||||
k: [reader.finite_f64()?, reader.finite_f64()?, reader.finite_f64()?, reader.finite_f64()?, reader.finite_f64()?],
|
||||
center: [reader.finite_f64()?, reader.finite_f64()?],
|
||||
}),
|
||||
14 => parse_warp_rectilinear2(flags, reader),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_planes<const N: usize>(reader: &mut Rd<'_>, max: usize, mut parse: impl FnMut(&mut Rd<'_>) -> Option<[f64; N]>) -> Option<Vec<[f64; N]>> {
|
||||
let count = reader.u32()? as usize;
|
||||
if count == 0 || count > max {
|
||||
return None;
|
||||
}
|
||||
(0..count).map(|_| parse(reader)).collect()
|
||||
}
|
||||
|
||||
fn parse_warp_rectilinear2(flags: u32, reader: &mut Rd<'_>) -> Option<Opcode> {
|
||||
let count = reader.u32()? as usize;
|
||||
// N sets of 19 doubles, then the centre (2 doubles) and reciprocalRadial (LONG); nothing else.
|
||||
if count == 0 || count > WR2_MAX_PLANES || reader.remaining() != count * WR2_LEN * 8 + 16 + 4 {
|
||||
return None;
|
||||
}
|
||||
let mut planes = Vec::with_capacity(count);
|
||||
for _ in 0..count {
|
||||
let mut coefficients = [0.0; WR2_LEN];
|
||||
for value in &mut coefficients {
|
||||
*value = reader.finite_f64().filter(|value| value.abs() <= WR2_MAX_COEF)?;
|
||||
}
|
||||
3 => Opcode::FixVignetteRadial { k: [f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?], center: [f(&mut r)?, f(&mut r)?] },
|
||||
4 => Opcode::FixBadPixelsConstant { constant: r.u32()?, bayer_phase: r.u32()? },
|
||||
5 => {
|
||||
let bayer_phase = r.u32()?;
|
||||
let (np, nr) = (r.u32()? as usize, r.u32()? as usize);
|
||||
if np.saturating_mul(8).saturating_add(nr.saturating_mul(16)) > r.remaining() {
|
||||
return None;
|
||||
}
|
||||
let points = (0..np).map(|_| Some((r.u32()?, r.u32()?))).collect::<Option<_>>()?;
|
||||
let rects = (0..nr).map(|_| Some([r.u32()?, r.u32()?, r.u32()?, r.u32()?])).collect::<Option<_>>()?;
|
||||
Opcode::FixBadPixelsList { bayer_phase, points, rects }
|
||||
if !(coefficients[17] >= 0.0 && coefficients[17] < coefficients[18] && coefficients[18] <= 1.0) {
|
||||
return None;
|
||||
}
|
||||
6 => Opcode::TrimBounds { top: r.u32()?, left: r.u32()?, bottom: r.u32()?, right: r.u32()? },
|
||||
planes.push(coefficients);
|
||||
}
|
||||
let center = [reader.finite_f64().filter(|value| value.abs() <= 4.0)?, reader.finite_f64().filter(|value| value.abs() <= 4.0)?];
|
||||
Some(Opcode::WarpRectilinear2 { planes, center, reciprocal: reader.u32()? != 0, optional: flags & 1 != 0 })
|
||||
}
|
||||
|
||||
fn parse_bad_pixel_list(reader: &mut Rd<'_>) -> Option<Opcode> {
|
||||
let bayer_phase = reader.u32()?;
|
||||
let (point_count, rect_count) = (reader.u32()? as usize, reader.u32()? as usize);
|
||||
if point_count.saturating_mul(8).saturating_add(rect_count.saturating_mul(16)) > reader.remaining() {
|
||||
return None;
|
||||
}
|
||||
let points = (0..point_count).map(|_| Some((reader.u32()?, reader.u32()?))).collect::<Option<_>>()?;
|
||||
let rects = (0..rect_count).map(|_| Some([reader.u32()?, reader.u32()?, reader.u32()?, reader.u32()?])).collect::<Option<_>>()?;
|
||||
Some(Opcode::FixBadPixelsList { bayer_phase, points, rects })
|
||||
}
|
||||
|
||||
fn parse_area_opcode(id: u32, reader: &mut Rd<'_>) -> Option<Opcode> {
|
||||
let area = reader.area()?;
|
||||
match id {
|
||||
7 => {
|
||||
let area = r.area()?;
|
||||
let n = r.u32()? as usize;
|
||||
if n == 0 || n > 65536 || n * 2 > r.remaining() {
|
||||
let count = reader.u32()? as usize;
|
||||
if count == 0 || count > 65536 || count * 2 > reader.remaining() {
|
||||
return None;
|
||||
}
|
||||
Opcode::MapTable { area, table: (0..n).map(|_| r.u16()).collect::<Option<_>>()? }
|
||||
Some(Opcode::MapTable { area, table: (0..count).map(|_| reader.u16()).collect::<Option<_>>()? })
|
||||
}
|
||||
8 => {
|
||||
let area = r.area()?;
|
||||
let deg = r.u32()? as usize;
|
||||
if deg > 8 {
|
||||
let degree = reader.u32()? as usize;
|
||||
if degree > 8 {
|
||||
return None;
|
||||
}
|
||||
Opcode::MapPolynomial { area, coefficients: (0..=deg).map(|_| f(&mut r)).collect::<Option<_>>()? }
|
||||
Some(Opcode::MapPolynomial { area, coefficients: (0..=degree).map(|_| reader.finite_f64()).collect::<Option<_>>()? })
|
||||
}
|
||||
9 => {
|
||||
let area = r.area()?;
|
||||
let (pv, ph) = (r.u32()?, r.u32()?);
|
||||
let spacing = [f(&mut r)?, f(&mut r)?];
|
||||
let origin = [f(&mut r)?, f(&mut r)?];
|
||||
let mp = r.u32()?;
|
||||
let n = (pv as usize).checked_mul(ph as usize)?.checked_mul(mp as usize)?;
|
||||
if n == 0 || n * 4 > r.remaining() {
|
||||
return None;
|
||||
}
|
||||
let gains = (0..n).map(|_| r.f32()).collect::<Option<_>>()?;
|
||||
Opcode::GainMap { area, points_v: pv, points_h: ph, spacing, origin, map_planes: mp, gains }
|
||||
}
|
||||
10..=13 => {
|
||||
let area = r.area()?;
|
||||
let n = r.u32()? as usize;
|
||||
if n * 4 > r.remaining() {
|
||||
return None;
|
||||
}
|
||||
let v: Vec<f32> = (0..n).map(|_| r.f32()).collect::<Option<_>>()?;
|
||||
match id {
|
||||
10 => Opcode::DeltaPerRow { area, deltas: v },
|
||||
11 => Opcode::DeltaPerColumn { area, deltas: v },
|
||||
12 => Opcode::ScalePerRow { area, scales: v },
|
||||
_ => Opcode::ScalePerColumn { area, scales: v },
|
||||
}
|
||||
}
|
||||
_ => Opcode::Unknown { id, flags, params: params.to_vec() },
|
||||
9 => parse_gain_map(area, reader),
|
||||
10..=13 => parse_delta_scale(id, area, reader),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_gain_map(area: Area, reader: &mut Rd<'_>) -> Option<Opcode> {
|
||||
let (points_v, points_h) = (reader.u32()?, reader.u32()?);
|
||||
let spacing = [reader.finite_f64()?, reader.finite_f64()?];
|
||||
let origin = [reader.finite_f64()?, reader.finite_f64()?];
|
||||
let map_planes = reader.u32()?;
|
||||
let count = (points_v as usize).checked_mul(points_h as usize)?.checked_mul(map_planes as usize)?;
|
||||
if count == 0 || count > reader.remaining() / 4 {
|
||||
return None;
|
||||
}
|
||||
let gains = (0..count).map(|_| reader.f32()).collect::<Option<_>>()?;
|
||||
Some(Opcode::GainMap { area, points_v, points_h, spacing, origin, map_planes, gains })
|
||||
}
|
||||
|
||||
fn parse_delta_scale(id: u32, area: Area, reader: &mut Rd<'_>) -> Option<Opcode> {
|
||||
let count = reader.u32()? as usize;
|
||||
if count > reader.remaining() / 4 {
|
||||
return None;
|
||||
}
|
||||
let values: Vec<f32> = (0..count).map(|_| reader.f32()).collect::<Option<_>>()?;
|
||||
Some(match id {
|
||||
10 => Opcode::DeltaPerRow { area, deltas: values },
|
||||
11 => Opcode::DeltaPerColumn { area, deltas: values },
|
||||
12 => Opcode::ScalePerRow { area, scales: values },
|
||||
_ => Opcode::ScalePerColumn { area, scales: values },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -508,19 +528,29 @@ fn area_iter(a: &Area, w: usize, h: usize, cpp: usize) -> impl Iterator<Item = (
|
||||
.flat_map(move |y| (l..r.max(l)).step_by(a.col_pitch.max(1) as usize).flat_map(move |x| (p0..p1.max(p0)).map(move |p| (x, y, p))))
|
||||
}
|
||||
|
||||
fn gain_at(points_v: u32, points_h: u32, spacing: [f64; 2], origin: [f64; 2], map_planes: u32, gains: &[f32], rv: f64, rh: f64, plane: usize) -> f32 {
|
||||
let (pv, ph) = (points_v as usize, points_h as usize);
|
||||
let mp = map_planes as usize;
|
||||
let p = plane.min(mp - 1);
|
||||
let fy = if spacing[0] > 0.0 { ((rv - origin[0]) / spacing[0]).clamp(0.0, (pv - 1) as f64) } else { 0.0 };
|
||||
let fx = if spacing[1] > 0.0 { ((rh - origin[1]) / spacing[1]).clamp(0.0, (ph - 1) as f64) } else { 0.0 };
|
||||
let (y0, x0) = (fy.floor() as usize, fx.floor() as usize);
|
||||
let (y1, x1) = ((y0 + 1).min(pv - 1), (x0 + 1).min(ph - 1));
|
||||
let (ty, tx) = ((fy - y0 as f64) as f32, (fx - x0 as f64) as f32);
|
||||
let g = |y: usize, x: usize| gains[(y * ph + x) * mp + p];
|
||||
let top = g(y0, x0) + (g(y0, x1) - g(y0, x0)) * tx;
|
||||
let bot = g(y1, x0) + (g(y1, x1) - g(y1, x0)) * tx;
|
||||
top + (bot - top) * ty
|
||||
struct GainMapGrid<'a> {
|
||||
points: [usize; 2],
|
||||
spacing: [f64; 2],
|
||||
origin: [f64; 2],
|
||||
planes: usize,
|
||||
gains: &'a [f32],
|
||||
}
|
||||
|
||||
impl GainMapGrid<'_> {
|
||||
fn sample_at(&self, position: [f64; 2], plane: usize) -> f32 {
|
||||
let [pv, ph] = self.points;
|
||||
let mp = self.planes;
|
||||
let p = plane.min(mp - 1);
|
||||
let fy = if self.spacing[0] > 0.0 { ((position[0] - self.origin[0]) / self.spacing[0]).clamp(0.0, (pv - 1) as f64) } else { 0.0 };
|
||||
let fx = if self.spacing[1] > 0.0 { ((position[1] - self.origin[1]) / self.spacing[1]).clamp(0.0, (ph - 1) as f64) } else { 0.0 };
|
||||
let (y0, x0) = (fy.floor() as usize, fx.floor() as usize);
|
||||
let (y1, x1) = ((y0 + 1).min(pv - 1), (x0 + 1).min(ph - 1));
|
||||
let (ty, tx) = ((fy - y0 as f64) as f32, (fx - x0 as f64) as f32);
|
||||
let g = |y: usize, x: usize| self.gains[(y * ph + x) * mp + p];
|
||||
let top = g(y0, x0) + (g(y0, x1) - g(y0, x0)) * tx;
|
||||
let bot = g(y1, x0) + (g(y1, x1) - g(y1, x0)) * tx;
|
||||
top + (bot - top) * ty
|
||||
}
|
||||
}
|
||||
|
||||
/// Radius normaliser: distance from the centre to the farthest image corner.
|
||||
@@ -556,10 +586,17 @@ pub fn apply_list(list: &[Opcode], buf: &mut [f32], w: usize, h: usize, cpp: usi
|
||||
}
|
||||
}
|
||||
Opcode::GainMap { area, points_v, points_h, spacing, origin, map_planes, gains } => {
|
||||
let grid = GainMapGrid {
|
||||
points: [*points_v as usize, *points_h as usize],
|
||||
spacing: *spacing,
|
||||
origin: *origin,
|
||||
planes: *map_planes as usize,
|
||||
gains,
|
||||
};
|
||||
for (x, y, p) in area_iter(area, w, h, cpp) {
|
||||
let rv = y as f64 / h as f64;
|
||||
let rh = x as f64 / w as f64;
|
||||
let g = gain_at(*points_v, *points_h, *spacing, *origin, *map_planes, gains, rv, rh, p - area.plane as usize);
|
||||
let g = grid.sample_at([rv, rh], p - area.plane as usize);
|
||||
buf[(y * w + x) * cpp + p] *= g;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reject_opcode_counts_whose_bytes_exceed_the_remaining_data() {
|
||||
let mut gain_map = Vec::new();
|
||||
for value in [u32::MAX, 1] {
|
||||
gain_map.extend_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
gain_map.extend(be(&[1.0, 1.0, 0.0, 0.0]));
|
||||
gain_map.extend_from_slice(&1u32.to_be_bytes());
|
||||
assert!(parse_gain_map(Area::default(), &mut Rd { d: &gain_map, p: 0 }).is_none());
|
||||
|
||||
let delta_count = u32::MAX.to_be_bytes();
|
||||
assert!(parse_delta_scale(10, Area::default(), &mut Rd { d: &delta_count, p: 0 }).is_none());
|
||||
}
|
||||
|
||||
pub(crate) struct ListWriter(pub Vec<u8>, pub u32);
|
||||
|
||||
impl ListWriter {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Reading the pixel data of a TIFF IFD (strips or tiles; uncompressed, lossless/lossy JPEG, Deflate, JPEG XL), in parallel.
|
||||
|
||||
use crate::unpack::*;
|
||||
use crate::unpack::{f16_to_f32, f24_to_f32, inflate, read_u16s, undo_diff_u16, undo_float_predictor, unpack_lsb, unpack_msb, unpack_words32_msb};
|
||||
use crate::{MAX_SAMPLES, RawData, RawError, Result, ljpeg};
|
||||
use lightcraft_tiff::image::{Chunk, ImageInfo, chunk_bytes};
|
||||
use lightcraft_tiff::{ByteOrder, tags::compression as comp};
|
||||
|
||||
@@ -632,20 +632,23 @@ fn lossless_file((width, height): (usize, usize), values: &[i32]) -> Fixture {
|
||||
|
||||
/// Constant-coefficient planes for one tile: LL3 flat at `ll3`, band `mid_band.0` flat at `mid_band.1`,
|
||||
/// everything else zero. Encoded at the given level count with the given quant/gain/base parameters.
|
||||
fn constant_band_planes(
|
||||
plane_w: usize,
|
||||
plane_h: usize,
|
||||
struct ConstantBandPlaneConfig<'a> {
|
||||
plane_size: (usize, usize),
|
||||
levels: u8,
|
||||
ll3: i32,
|
||||
mid_band: Option<(usize, i32)>,
|
||||
quant: u8,
|
||||
gains: [u32; 10],
|
||||
bases: [u32; 10],
|
||||
qp: Option<&[i32]>,
|
||||
qp: Option<&'a [i32]>,
|
||||
qp_padding: usize,
|
||||
before: bool,
|
||||
after: bool,
|
||||
) -> (usize, Vec<u8>) {
|
||||
boundary_bands: (bool, bool),
|
||||
}
|
||||
|
||||
fn constant_band_planes(config: ConstantBandPlaneConfig<'_>) -> (usize, Vec<u8>) {
|
||||
let ConstantBandPlaneConfig { plane_size: (plane_w, plane_h), levels, ll3, mid_band, quant, gains, bases, qp, qp_padding, boundary_bands } =
|
||||
config;
|
||||
let (before, after) = boundary_bands;
|
||||
let planes: [Vec<Vec<u8>>; 4] = if levels == 0 {
|
||||
let mut encoded: [Vec<Vec<u8>>; 4] = Default::default();
|
||||
for bands in encoded.iter_mut() {
|
||||
@@ -681,7 +684,18 @@ fn constant_band_planes(
|
||||
|
||||
/// A 16×16 three-level C-RAW file built from constant bands at the given uniform quant value.
|
||||
fn constant_bands_file(ll3: i32, mid_band: Option<(usize, i32)>, quant: u8) -> Fixture {
|
||||
let (header_size, sample) = constant_band_planes(8, 8, 3, ll3, mid_band, quant, [0; 10], [0; 10], None, 0, false, false);
|
||||
let (header_size, sample) = constant_band_planes(ConstantBandPlaneConfig {
|
||||
plane_size: (8, 8),
|
||||
levels: 3,
|
||||
ll3,
|
||||
mid_band,
|
||||
quant,
|
||||
gains: [0; 10],
|
||||
bases: [0; 10],
|
||||
qp: None,
|
||||
qp_padding: 0,
|
||||
boundary_bands: (false, false),
|
||||
});
|
||||
let coding = cmp1(0x100, 16, 16, 16, 16, 3, header_size as u32);
|
||||
Fixture { file: wrap(&coding, 16, 16, &sample, None), expected: Vec::new() }
|
||||
}
|
||||
@@ -697,7 +711,18 @@ fn v200_mapped_file(
|
||||
bases: [u32; 10],
|
||||
) -> Fixture {
|
||||
assert_eq!(map.len(), (width / 16) * (height / 4));
|
||||
let (header_size, sample) = constant_band_planes(width / 2, height / 2, 3, ll3, mid_band, 4, gains, bases, Some(map), 3, false, false);
|
||||
let (header_size, sample) = constant_band_planes(ConstantBandPlaneConfig {
|
||||
plane_size: (width / 2, height / 2),
|
||||
levels: 3,
|
||||
ll3,
|
||||
mid_band,
|
||||
quant: 4,
|
||||
gains,
|
||||
bases,
|
||||
qp: Some(map),
|
||||
qp_padding: 3,
|
||||
boundary_bands: (false, false),
|
||||
});
|
||||
let coding = cmp1(0x200, width as u32, height as u32, width as u32, height as u32, 3, header_size as u32);
|
||||
Fixture { file: wrap(&coding, width as u32, height as u32, &sample, None), expected: Vec::new() }
|
||||
}
|
||||
@@ -811,20 +836,18 @@ proptest! {
|
||||
}
|
||||
*b = if i < 4 { base + 1 } else { base };
|
||||
}
|
||||
let (header_size, sample) = constant_band_planes(
|
||||
width / 2,
|
||||
height / 2,
|
||||
3,
|
||||
let (header_size, sample) = constant_band_planes(ConstantBandPlaneConfig {
|
||||
plane_size: (width / 2, height / 2),
|
||||
levels: 3,
|
||||
ll3,
|
||||
Some((4, mid)),
|
||||
4,
|
||||
mid_band: Some((4, mid)),
|
||||
quant: 4,
|
||||
gains,
|
||||
bases,
|
||||
Some(&qp_values[..map_w * map_h]),
|
||||
3,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
qp: Some(&qp_values[..map_w * map_h]),
|
||||
qp_padding: 3,
|
||||
boundary_bands: (false, false),
|
||||
});
|
||||
let coding = cmp1(0x200, width as u32, height as u32, width as u32, height as u32, 3, header_size as u32);
|
||||
let file = wrap(&coding, width as u32, height as u32, &sample, None);
|
||||
let image = decode(&file).unwrap();
|
||||
@@ -879,7 +902,18 @@ fn lossless_sample(value: i32) -> Vec<u8> {
|
||||
|
||||
/// Raw sample bytes of a constant-band 16×16 C-RAW fixture.
|
||||
fn constant_bands_sample(ll3: i32, mid_band: Option<(usize, i32)>, quant: u8) -> Vec<u8> {
|
||||
let (_, sample) = constant_band_planes(8, 8, 3, ll3, mid_band, quant, [0; 10], [0; 10], None, 0, false, false);
|
||||
let (_, sample) = constant_band_planes(ConstantBandPlaneConfig {
|
||||
plane_size: (8, 8),
|
||||
levels: 3,
|
||||
ll3,
|
||||
mid_band,
|
||||
quant,
|
||||
gains: [0; 10],
|
||||
bases: [0; 10],
|
||||
qp: None,
|
||||
qp_padding: 0,
|
||||
boundary_bands: (false, false),
|
||||
});
|
||||
sample
|
||||
}
|
||||
|
||||
|
||||
@@ -179,7 +179,14 @@ fn pine(p: &P, x: f32, v: f32, ground: f32, height: f32, density: f32) -> Option
|
||||
best
|
||||
}
|
||||
|
||||
fn water(p: &P, x: f32, v: f32, wl: f32, calm: f32, above: &dyn Fn(f32, f32) -> C, tint: C, sun: (f32, f32), sun_col: C) -> C {
|
||||
struct WaterLight {
|
||||
tint: C,
|
||||
sun: (f32, f32),
|
||||
sun_col: C,
|
||||
}
|
||||
|
||||
fn water(p: &P, x: f32, v: f32, wl: f32, calm: f32, above: &dyn Fn(f32, f32) -> C, light: WaterLight) -> C {
|
||||
let WaterLight { tint, sun, sun_col } = light;
|
||||
let d = v - wl;
|
||||
let rip = fbm(x * 9.0, d * 60.0 / (d + 0.05), p.seed ^ 0xaa, 4) * (1.0 - calm);
|
||||
let rv = (wl - d * 1.0 + rip * (0.01 + 0.06 * (1.0 - calm)) * (d * 10.0 + 0.2)).max(0.0);
|
||||
@@ -340,7 +347,7 @@ fn alpine(p: &P, x: f32, v: f32, blue: bool) -> C {
|
||||
};
|
||||
if v > wl {
|
||||
let tint = if blue { hex(0x1a2440) } else { hex(0x20303a) };
|
||||
return water(p, x, v, wl, 0.75, &above, tint, sun, sun_col);
|
||||
return water(p, x, v, wl, 0.75, &above, WaterLight { tint, sun, sun_col });
|
||||
}
|
||||
above(x, v)
|
||||
}
|
||||
@@ -419,7 +426,7 @@ fn ocean(p: &P, x: f32, v: f32) -> C {
|
||||
if v < hz + head * 0.3 {
|
||||
return hex(0x120c14);
|
||||
}
|
||||
return water(p, x, v, hz, 0.35, &above, hex(0x1a2040), sun, sun_col);
|
||||
return water(p, x, v, hz, 0.35, &above, WaterLight { tint: hex(0x1a2040), sun, sun_col });
|
||||
}
|
||||
let _ = head;
|
||||
above(x, v)
|
||||
@@ -465,7 +472,7 @@ fn aurora(p: &P, x: f32, v: f32) -> C {
|
||||
add(sky(p, &sk, x, v), aur(x, v))
|
||||
};
|
||||
if v > wl {
|
||||
return water(p, x, v, wl, 0.9, &above, hex(0x020408), (-5.0, 5.0), [0.0; 3]);
|
||||
return water(p, x, v, wl, 0.9, &above, WaterLight { tint: hex(0x020408), sun: (-5.0, 5.0), sun_col: [0.0; 3] });
|
||||
}
|
||||
above(x, v)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ description = "LightCraft sysmem (L0): returns freed allocator memory to the OS.
|
||||
unsafe_code = "deny"
|
||||
|
||||
[lints.clippy]
|
||||
cognitive_complexity = "deny"
|
||||
too_many_arguments = "deny"
|
||||
wildcard_imports = "deny"
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
use super::clock::{AppClock, monotonic_ms};
|
||||
use super::services::Services;
|
||||
use super::state::{GestureFrame, InjectedDrop, InjectedPress};
|
||||
use super::*;
|
||||
use super::{
|
||||
AiModel, Caches, CommandError, ControlRequest, ControlResponse, ErrorKind, GESTURE_TIMEOUT_MS, LightcraftApp, MAX_PENDING_GESTURES,
|
||||
MAX_PENDING_SHOTS, MAX_SHOT_BYTES, MAX_SHOT_TOTAL_BYTES, MAX_TEXTURE_SIDE, PendingGesture, PendingShot, Perf, Receiver, SCREEN_PROFILE,
|
||||
SCREENSHOT_FALLBACK_MS, SCREENSHOT_SETTLE_MS, SHOT_FORCE_BUSY_MS, SHOT_SETTLE_FRAMES, SHOT_SETTLE_MS, Sender, Session, ShotGeometry,
|
||||
TOAST_SECONDS, UiState, Value, docking, drop_overlay, export_task, focus_scan, frame_rate, headless, i18n, import, lightroom_import, menus,
|
||||
merge, model_setup, panels, pick, recheck, render, shortcuts, slideshow_export, state, sync, tasks, theme, updates_ui, widgets,
|
||||
};
|
||||
|
||||
mod control;
|
||||
mod frame;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{ControlResponse, GESTURE_TIMEOUT_MS, GestureFrame, LightcraftApp, MAX_PENDING_GESTURES, PendingGesture, Sender, Value};
|
||||
use crate::control;
|
||||
|
||||
impl LightcraftApp {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
LightcraftApp, docking, drop_overlay, export_task, focus_scan, import, lightroom_import, merge, model_setup, monotonic_ms, panels, pick, recheck,
|
||||
shortcuts, slideshow_export, state, sync, tasks, theme, updates_ui, widgets,
|
||||
};
|
||||
|
||||
impl LightcraftApp {
|
||||
/// Install/refresh fonts once and watch the device and availability caches. Setup, not input.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{GESTURE_TIMEOUT_MS, InjectedDrop, InjectedPress, LightcraftApp, drop_overlay, import, panels};
|
||||
|
||||
impl LightcraftApp {
|
||||
/// Preserve the currently queued handles while a prompt or blocking library window owns
|
||||
@@ -262,7 +262,7 @@ mod drop_tests {
|
||||
fn photo_sidecars_are_not_presets() {
|
||||
let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
let dropped = v(&["/a/IMG_1.CR2", "/a/IMG_1.xmp", "/a/IMG_2.jpg", "/a/IMG_2.jpg.XMP", "/a/Look.xmp", "/b/IMG_1.xmp"]);
|
||||
let (presets, photos) = super::split_presets(dropped);
|
||||
let (presets, photos) = crate::split_presets(dropped);
|
||||
assert_eq!(photos, v(&["/a/IMG_1.CR2", "/a/IMG_2.jpg"]));
|
||||
assert_eq!(presets, v(&["/a/Look.xmp", "/b/IMG_1.xmp"]));
|
||||
}
|
||||
@@ -271,31 +271,31 @@ mod drop_tests {
|
||||
fn detection_results_are_summarised_in_words() {
|
||||
let r =
|
||||
|counts: &[usize]| serde_json::json!({"photos": counts.iter().map(|n| serde_json::json!({"faces": vec![0; *n]})).collect::<Vec<_>>()});
|
||||
assert_eq!(super::detect_summary(&r(&[0])), "No faces found");
|
||||
assert_eq!(super::detect_summary(&r(&[1])), "Found 1 face");
|
||||
assert_eq!(super::detect_summary(&r(&[3])), "Found 3 faces");
|
||||
assert_eq!(super::detect_summary(&r(&[0, 0])), "No faces found in 2 photos");
|
||||
assert_eq!(super::detect_summary(&r(&[2, 0, 1])), "Found 3 faces in 2 photos");
|
||||
assert_eq!(super::detect_summary(&serde_json::json!({})), "No faces found in 0 photos");
|
||||
assert_eq!(crate::app::detect_summary(&r(&[0])), "No faces found");
|
||||
assert_eq!(crate::app::detect_summary(&r(&[1])), "Found 1 face");
|
||||
assert_eq!(crate::app::detect_summary(&r(&[3])), "Found 3 faces");
|
||||
assert_eq!(crate::app::detect_summary(&r(&[0, 0])), "No faces found in 2 photos");
|
||||
assert_eq!(crate::app::detect_summary(&r(&[2, 0, 1])), "Found 3 faces in 2 photos");
|
||||
assert_eq!(crate::app::detect_summary(&serde_json::json!({})), "No faces found in 0 photos");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropped_face_models_are_told_apart() {
|
||||
for p in ["/a/model.onnx", "/a/dir/M.ONNX"] {
|
||||
assert!(super::is_model_file(p), "{p}");
|
||||
assert!(crate::is_model_file(p), "{p}");
|
||||
}
|
||||
for p in ["/a/model.onnx.jpg", "/a/onnx", "/a/b.png"] {
|
||||
assert!(!super::is_model_file(p), "{p}");
|
||||
assert!(!crate::is_model_file(p), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropped_presets_are_told_apart_from_photos() {
|
||||
for p in ["/a/Look.lrtemplate", "/a/b.XMP", "/a/pack.zip", "/a/x.lcpreset", "/a/Magic Hour.mplumpack", "/a/Pop.lmp", "/a/Bundle.LMP"] {
|
||||
assert!(super::is_preset_file(p), "{p}");
|
||||
assert!(crate::is_preset_file(p), "{p}");
|
||||
}
|
||||
for p in ["/a/IMG_1.CR2", "/a/b.dng", "/a/c.jpg", "/a/folder"] {
|
||||
assert!(!super::is_preset_file(p), "{p}");
|
||||
assert!(!crate::is_preset_file(p), "{p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{LightcraftApp, SCREEN_PROFILE, theme};
|
||||
|
||||
impl LightcraftApp {
|
||||
/// The system's appearance: the host's answer first, then egui's (none: unknown).
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
ControlResponse, LightcraftApp, MAX_PENDING_SHOTS, MAX_SHOT_BYTES, MAX_SHOT_TOTAL_BYTES, PendingShot, SCREENSHOT_FALLBACK_MS,
|
||||
SCREENSHOT_SETTLE_MS, SHOT_FORCE_BUSY_MS, SHOT_SETTLE_FRAMES, SHOT_SETTLE_MS, Sender, ShotGeometry, headless,
|
||||
};
|
||||
use crate::control;
|
||||
|
||||
impl LightcraftApp {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Caches, album_picker, i18n, panels};
|
||||
|
||||
/// The left panel's counts.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
|
||||
+1662
-1313
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user