diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a663a4a..3a43c15 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -36,6 +36,10 @@ jobs:
- uses: taiki-e/install-action@cargo-deny
- uses: taiki-e/install-action@typos
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22.12'
+
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
diff --git a/Cargo.lock b/Cargo.lock
index b7ff4cf..1716a82 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -8,6 +8,15 @@ version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
+[[package]]
+name = "addr2line"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
+dependencies = [
+ "gimli",
+]
+
[[package]]
name = "adler2"
version = "2.0.1"
@@ -133,6 +142,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+[[package]]
+name = "approx"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "arbitrary"
version = "1.4.2"
@@ -246,7 +264,7 @@ dependencies = [
"anyhow",
"arrayvec",
"log",
- "nom",
+ "nom 8.0.0",
"num-rational",
"v_frame",
]
@@ -260,6 +278,30 @@ dependencies = [
"arrayvec",
]
+[[package]]
+name = "backtrace"
+version = "0.3.76"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
+dependencies = [
+ "addr2line",
+ "cfg-if",
+ "libc",
+ "miniz_oxide",
+ "object",
+ "rustc-demangle",
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "backtrace-ext"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50"
+dependencies = [
+ "backtrace",
+]
+
[[package]]
name = "base64"
version = "0.21.7"
@@ -823,38 +865,14 @@ dependencies = [
"windows-sys 0.61.2",
]
-[[package]]
-name = "darling"
-version = "0.21.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
-dependencies = [
- "darling_core 0.21.3",
- "darling_macro 0.21.3",
-]
-
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
- "darling_core 0.23.0",
- "darling_macro 0.23.0",
-]
-
-[[package]]
-name = "darling_core"
-version = "0.21.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
-dependencies = [
- "fnv",
- "ident_case",
- "proc-macro2",
- "quote",
- "strsim",
- "syn 2.0.117",
+ "darling_core",
+ "darling_macro",
]
[[package]]
@@ -870,24 +888,13 @@ dependencies = [
"syn 2.0.117",
]
-[[package]]
-name = "darling_macro"
-version = "0.21.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
-dependencies = [
- "darling_core 0.21.3",
- "quote",
- "syn 2.0.117",
-]
-
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
- "darling_core 0.23.0",
+ "darling_core",
"quote",
"syn 2.0.117",
]
@@ -1022,6 +1029,15 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "document-features"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
+dependencies = [
+ "litrs",
+]
+
[[package]]
name = "dom_query"
version = "0.27.0"
@@ -1188,6 +1204,20 @@ dependencies = [
"hashbrown 0.13.2",
]
+[[package]]
+name = "fast_image_resize"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12dd43e5011e8d8411a3215a0d57a2ec5c68282fb90eb5d7221fab0113442174"
+dependencies = [
+ "bytemuck",
+ "cfg-if",
+ "document-features",
+ "image",
+ "num-traits",
+ "thiserror 2.0.18",
+]
+
[[package]]
name = "fastrand"
version = "2.4.1"
@@ -1520,6 +1550,17 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "geo-types"
+version = "0.7.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94776032c45f950d30a13af6113c2ad5625316c9abfbccee4dd5a6695f8fe0f5"
+dependencies = [
+ "approx",
+ "num-traits",
+ "serde",
+]
+
[[package]]
name = "getrandom"
version = "0.1.16"
@@ -1577,6 +1618,12 @@ dependencies = [
"weezl",
]
+[[package]]
+name = "gimli"
+version = "0.32.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
+
[[package]]
name = "gio"
version = "0.18.4"
@@ -1948,12 +1995,13 @@ dependencies = [
[[package]]
name = "icu_collections"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [
"displaydoc",
"potential_utf",
+ "utf8_iter",
"yoke",
"zerofrom",
"zerovec",
@@ -1961,9 +2009,9 @@ dependencies = [
[[package]]
name = "icu_locale_core"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
"displaydoc",
"litemap",
@@ -1974,9 +2022,9 @@ dependencies = [
[[package]]
name = "icu_normalizer"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
dependencies = [
"icu_collections",
"icu_normalizer_data",
@@ -1988,15 +2036,15 @@ dependencies = [
[[package]]
name = "icu_normalizer_data"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
[[package]]
name = "icu_properties"
-version = "2.1.2"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
dependencies = [
"icu_collections",
"icu_locale_core",
@@ -2008,15 +2056,15 @@ dependencies = [
[[package]]
name = "icu_properties_data"
-version = "2.1.2"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
[[package]]
name = "icu_provider"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [
"displaydoc",
"icu_locale_core",
@@ -2062,9 +2110,9 @@ dependencies = [
[[package]]
name = "image"
-version = "0.25.9"
+version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
@@ -2080,8 +2128,8 @@ dependencies = [
"rayon",
"rgb",
"tiff",
- "zune-core 0.5.1",
- "zune-jpeg 0.5.15",
+ "zune-core",
+ "zune-jpeg",
]
[[package]]
@@ -2193,12 +2241,28 @@ dependencies = [
"serde",
]
+[[package]]
+name = "is_ci"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45"
+
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+[[package]]
+name = "iso6709parse"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5090db9c6a716d1f4eeb729957e889e9c28156061c825cbccd44950cf0f3c66"
+dependencies = [
+ "geo-types",
+ "nom 7.1.3",
+]
+
[[package]]
name = "itertools"
version = "0.14.0"
@@ -2325,15 +2389,6 @@ dependencies = [
"serde_json",
]
-[[package]]
-name = "kamadak-exif"
-version = "0.5.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ef4fc70d0ab7e5b6bafa30216a6b48705ea964cdfc29c050f2412295eba58077"
-dependencies = [
- "mutate_once",
-]
-
[[package]]
name = "keyboard-types"
version = "0.7.0"
@@ -2445,6 +2500,12 @@ dependencies = [
"winapi",
]
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
[[package]]
name = "libredox"
version = "0.1.16"
@@ -2477,6 +2538,12 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+[[package]]
+name = "litrs"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -2583,6 +2650,36 @@ dependencies = [
"autocfg",
]
+[[package]]
+name = "miette"
+version = "7.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
+dependencies = [
+ "backtrace",
+ "backtrace-ext",
+ "cfg-if",
+ "miette-derive",
+ "owo-colors",
+ "supports-color",
+ "supports-hyperlinks",
+ "supports-unicode",
+ "terminal_size",
+ "textwrap",
+ "unicode-width 0.1.14",
+]
+
+[[package]]
+name = "miette-derive"
+version = "7.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "mime"
version = "0.3.17"
@@ -2599,6 +2696,12 @@ dependencies = [
"unicase",
]
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2623,9 +2726,9 @@ dependencies = [
[[package]]
name = "moxcms"
-version = "0.7.11"
+version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
@@ -2680,12 +2783,6 @@ dependencies = [
"windows-sys 0.60.2",
]
-[[package]]
-name = "mutate_once"
-version = "0.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af"
-
[[package]]
name = "ndk"
version = "0.9.0"
@@ -2749,6 +2846,16 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
[[package]]
name = "nom"
version = "8.0.0"
@@ -2758,6 +2865,21 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "nom-exif"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e78a8215f056e78a6887b4872e61bab5690dec4648b37396b6721be6f89c4ea"
+dependencies = [
+ "bytes",
+ "chrono",
+ "iso6709parse",
+ "nom 7.1.3",
+ "regex",
+ "thiserror 2.0.18",
+ "tracing",
+]
+
[[package]]
name = "noop_proc_macro"
version = "0.3.0"
@@ -2877,6 +2999,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
+ "libm",
]
[[package]]
@@ -3035,6 +3158,15 @@ dependencies = [
"objc2-foundation",
]
+[[package]]
+name = "object"
+version = "0.37.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -3053,6 +3185,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+[[package]]
+name = "owo-colors"
+version = "4.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
+
[[package]]
name = "pango"
version = "0.18.3"
@@ -3113,12 +3251,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
-[[package]]
-name = "path-slash"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e91099d4268b0e11973f036e885d652fb0b21fedcf69738c627f94db6a44f42"
-
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -3129,14 +3261,13 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
name = "perima"
version = "0.6.4"
dependencies = [
- "anyhow",
"chrono",
"clap",
"ctrlc",
"directories",
- "dunce",
"image",
"insta",
+ "miette",
"nix",
"perima-core",
"perima-db",
@@ -3162,6 +3293,7 @@ dependencies = [
"blake3",
"proptest",
"serde",
+ "tempfile",
"thiserror 2.0.18",
"unicode-normalization",
"uuid",
@@ -3189,8 +3321,8 @@ version = "0.6.4"
dependencies = [
"chrono",
"directories",
- "dunce",
"image",
+ "miette",
"perima-core",
"perima-db",
"perima-fs",
@@ -3220,7 +3352,6 @@ dependencies = [
"file-id",
"notify",
"notify-debouncer-full",
- "path-slash",
"perima-core",
"sysinfo",
"tempfile",
@@ -3249,11 +3380,13 @@ name = "perima-media"
version = "0.6.4"
dependencies = [
"blake3",
+ "chrono",
+ "fast_image_resize",
"image",
- "kamadak-exif",
"mime_guess",
"mp4",
"mp4parse",
+ "nom-exif",
"perima-core",
"tempfile",
"thiserror 2.0.18",
@@ -3350,7 +3483,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
dependencies = [
"phf_shared 0.10.0",
- "rand 0.8.5",
+ "rand 0.8.6",
]
[[package]]
@@ -3360,7 +3493,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared 0.11.3",
- "rand 0.8.5",
+ "rand 0.8.6",
]
[[package]]
@@ -3647,9 +3780,9 @@ dependencies = [
[[package]]
name = "pxfm"
-version = "0.1.28"
+version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
+checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "qoi"
@@ -3718,9 +3851,9 @@ dependencies = [
[[package]]
name = "rand"
-version = "0.8.5"
+version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha 0.3.1",
@@ -3858,9 +3991,9 @@ dependencies = [
[[package]]
name = "ravif"
-version = "0.12.0"
+version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285"
+checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45"
dependencies = [
"avif-serialize",
"imgref",
@@ -4098,6 +4231,12 @@ dependencies = [
"sqlite-wasm-rs",
]
+[[package]]
+name = "rustc-demangle"
+version = "0.1.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
+
[[package]]
name = "rustc-hash"
version = "2.1.2"
@@ -4354,9 +4493,9 @@ dependencies = [
[[package]]
name = "serde_with"
-version = "3.17.0"
+version = "3.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9"
+checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -4373,11 +4512,11 @@ dependencies = [
[[package]]
name = "serde_with_macros"
-version = "3.17.0"
+version = "3.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0"
+checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65"
dependencies = [
- "darling 0.21.3",
+ "darling",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -4618,9 +4757,9 @@ dependencies = [
[[package]]
name = "sqlite-wasm-rs"
-version = "0.5.2"
+version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b"
+checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36"
dependencies = [
"cc",
"js-sys",
@@ -4695,6 +4834,27 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+[[package]]
+name = "supports-color"
+version = "3.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6"
+dependencies = [
+ "is_ci",
+]
+
+[[package]]
+name = "supports-hyperlinks"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91"
+
+[[package]]
+name = "supports-unicode"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2"
+
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -5080,7 +5240,7 @@ version = "2.0.0-rc.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e23657d20f2b5508d5eca5ee6bb98d77e4c13127b9049e102b5db6a63bc73665"
dependencies = [
- "darling 0.23.0",
+ "darling",
"heck 0.5.0",
"proc-macro2",
"quote",
@@ -5170,6 +5330,26 @@ dependencies = [
"utf-8",
]
+[[package]]
+name = "terminal_size"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
+dependencies = [
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "textwrap"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
+dependencies = [
+ "unicode-linebreak",
+ "unicode-width 0.2.2",
+]
+
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -5221,16 +5401,16 @@ dependencies = [
[[package]]
name = "tiff"
-version = "0.10.3"
+version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f"
+checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
dependencies = [
"fax",
"flate2",
"half",
"quick-error 2.0.1",
"weezl",
- "zune-jpeg 0.4.21",
+ "zune-jpeg",
]
[[package]]
@@ -5291,9 +5471,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
-version = "1.52.0"
+version = "1.52.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776"
+checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
dependencies = [
"bytes",
"libc",
@@ -5430,7 +5610,7 @@ dependencies = [
"indexmap 2.14.0",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
- "winnow 1.0.1",
+ "winnow 1.0.2",
]
[[package]]
@@ -5439,7 +5619,7 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
- "winnow 1.0.1",
+ "winnow 1.0.2",
]
[[package]]
@@ -5609,9 +5789,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
-version = "1.19.0"
+version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
+checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "unarray"
@@ -5672,6 +5852,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+[[package]]
+name = "unicode-linebreak"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
+
[[package]]
name = "unicode-normalization"
version = "0.1.25"
@@ -5687,6 +5873,18 @@ version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
+[[package]]
+name = "unicode-width"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+
[[package]]
name = "unicode-xid"
version = "0.2.6"
@@ -5738,9 +5936,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
-version = "1.23.0"
+version = "1.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
+checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
dependencies = [
"getrandom 0.4.2",
"js-sys",
@@ -5845,11 +6043,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
-version = "1.0.1+wasi-0.2.4"
+version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
- "wit-bindgen 0.46.0",
+ "wit-bindgen 0.57.1",
]
[[package]]
@@ -5975,9 +6173,9 @@ dependencies = [
[[package]]
name = "web_atoms"
-version = "0.2.3"
+version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576"
+checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
dependencies = [
"phf 0.13.1",
"phf_codegen 0.13.1",
@@ -6567,9 +6765,9 @@ dependencies = [
[[package]]
name = "winnow"
-version = "1.0.1"
+version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
+checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0"
dependencies = [
"memchr",
]
@@ -6584,12 +6782,6 @@ dependencies = [
"windows-sys 0.59.0",
]
-[[package]]
-name = "wit-bindgen"
-version = "0.46.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
-
[[package]]
name = "wit-bindgen"
version = "0.51.0"
@@ -6599,6 +6791,12 @@ dependencies = [
"wit-bindgen-rust-macro",
]
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
@@ -6858,12 +7056,6 @@ version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
-[[package]]
-name = "zune-core"
-version = "0.4.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
-
[[package]]
name = "zune-core"
version = "0.5.1"
@@ -6879,20 +7071,11 @@ dependencies = [
"simd-adler32",
]
-[[package]]
-name = "zune-jpeg"
-version = "0.4.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
-dependencies = [
- "zune-core 0.4.12",
-]
-
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
- "zune-core 0.5.1",
+ "zune-core",
]
diff --git a/Cargo.toml b/Cargo.toml
index 6acbcd7..79c589f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -47,6 +47,7 @@ print_stderr = "warn"
# propagate correctly via the workspace graph.
perima-media = { path = "crates/media", version = "0.6.3" }
anyhow = "1"
+miette = { version = "7", features = ["fancy"] }
thiserror = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -63,16 +64,19 @@ notify = "8.2"
notify-debouncer-full = "0.7"
sysinfo = "0.38"
directories = "6"
+# unicode-normalization: KEPT only for tag NFC normalization in crates/core/src/tag.rs.
+# DO NOT use in path code — path equivalence is handled via fs::canonicalize round-trip
+# (see crates/core/tests/props_path_nfc_equivalence.rs). Strategy review tracked in
+# GH issue #112.
unicode-normalization = "0.1"
-path-slash = "0.2"
dunce = "1"
file-id = "0.2"
-tauri = { version = "2", features = ["protocol-asset"] }
+tauri = { version = "~2.10", features = ["protocol-asset"] }
tauri-specta = { version = "=2.0.0-rc.24", features = ["derive", "typescript"] }
specta = "=2.0.0-rc.24"
specta-typescript = "0.0.11"
-tauri-build = "2"
-tauri-plugin-dialog = "2"
+tauri-build = "~2.5"
+tauri-plugin-dialog = "~2.7"
clap = { version = "4", features = ["derive"] }
tempfile = "3"
insta = { version = "1", features = ["yaml", "filters"] }
@@ -81,7 +85,7 @@ rayon = "1"
ctrlc = { version = "3", features = ["termination"] }
chrono = { version = "0.4", features = ["serde"] }
image = "0.25"
-kamadak-exif = "0.5"
+nom-exif = "2.7"
mp4parse = "0.17"
mime_guess = "2"
diff --git a/apps/desktop/bun.lock b/apps/desktop/bun.lock
index 33c7138..9d7860b 100644
--- a/apps/desktop/bun.lock
+++ b/apps/desktop/bun.lock
@@ -12,16 +12,22 @@
"react-dom": "^19",
},
"devDependencies": {
+ "@babel/core": "^7",
+ "@babel/plugin-syntax-jsx": "^7",
+ "@babel/preset-typescript": "^7",
"@evilmartians/lefthook": "^2.1.6",
+ "@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/vite": "^4",
"@tauri-apps/cli": "^2",
"@testing-library/jest-dom": "^6",
"@testing-library/react": "^16",
+ "@types/babel__core": "^7",
"@types/react": "^19",
"@types/react-dom": "^19",
"@typescript-eslint/eslint-plugin": "^8.58.2",
"@typescript-eslint/parser": "^8.58.2",
- "@vitejs/plugin-react": "^4",
+ "@vitejs/plugin-react": "^6",
+ "babel-plugin-react-compiler": "=1.0.0",
"eslint": "^9",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
@@ -30,8 +36,8 @@
"jsdom": "^26",
"tailwindcss": "^4",
"typescript": "^5",
- "vite": "^6",
- "vitest": "^3",
+ "vite": "^8",
+ "vitest": "^3.2",
},
},
},
@@ -48,16 +54,28 @@
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
+ "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" } }, "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg=="],
+
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
+ "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/traverse": "^7.28.6", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow=="],
+
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
+ "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.28.5", "", { "dependencies": { "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5" } }, "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg=="],
+
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
+ "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.27.1", "", { "dependencies": { "@babel/types": "^7.27.1" } }, "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw=="],
+
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
+ "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.28.6", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg=="],
+
+ "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg=="],
+
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
@@ -68,9 +86,15 @@
"@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
- "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
+ "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="],
+
+ "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="],
+
+ "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.28.6", "", { "dependencies": { "@babel/helper-module-transforms": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA=="],
- "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
+ "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw=="],
+
+ "@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="],
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
@@ -90,6 +114,12 @@
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
+ "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
+
+ "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
+
+ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
+
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
@@ -184,13 +214,49 @@
"@microsoft/tsdoc-config": ["@microsoft/tsdoc-config@0.17.1", "", { "dependencies": { "@microsoft/tsdoc": "0.15.1", "ajv": "~8.12.0", "jju": "~1.4.0", "resolve": "~1.22.2" } }, "sha512-UtjIFe0C6oYgTnad4q1QP4qXwLhe6tIpNTRStJ2RZEPIkqQPREAwE5spzVxsdn9UaEMUqhh0AqSx3X4nWAKXWw=="],
+ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
+
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
- "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
+ "@oxc-project/types": ["@oxc-project/types@0.126.0", "", {}, "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ=="],
+
+ "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.16", "", { "os": "android", "cpu": "arm64" }, "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA=="],
+
+ "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ=="],
+
+ "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ=="],
+
+ "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.16", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g=="],
+
+ "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm" }, "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg=="],
+
+ "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg=="],
+
+ "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg=="],
+
+ "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ=="],
+
+ "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "s390x" }, "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ=="],
+
+ "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg=="],
+
+ "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w=="],
+
+ "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.16", "", { "os": "none", "cpu": "arm64" }, "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA=="],
+
+ "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.16", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ=="],
+
+ "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q=="],
+
+ "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "x64" }, "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g=="],
+
+ "@rolldown/plugin-babel": ["@rolldown/plugin-babel@0.2.3", "", { "dependencies": { "picomatch": "^4.0.4" }, "peerDependencies": { "@babel/core": "^7.29.0 || ^8.0.0-rc.1", "@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1", "@babel/runtime": "^7.27.0 || ^8.0.0-rc.1", "rolldown": "^1.0.0-rc.5", "vite": "^8.0.0" }, "optionalPeers": ["@babel/plugin-transform-runtime", "@babel/runtime", "vite"] }, "sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw=="],
+
+ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
@@ -306,6 +372,8 @@
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
+ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
+
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
@@ -348,7 +416,7 @@
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="],
- "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
+ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
@@ -402,6 +470,8 @@
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
+ "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],
+
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g=="],
@@ -834,8 +904,6 @@
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
- "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
-
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
@@ -850,6 +918,8 @@
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
+ "rolldown": ["rolldown@1.0.0-rc.16", "", { "dependencies": { "@oxc-project/types": "=0.126.0", "@rolldown/pluginutils": "1.0.0-rc.16" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-x64": "1.0.0-rc.16", "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g=="],
+
"rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
"rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="],
@@ -950,6 +1020,8 @@
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
+ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
@@ -968,7 +1040,7 @@
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
- "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
+ "vite": ["vite@8.0.9", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.16", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw=="],
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
@@ -1058,8 +1130,14 @@
"pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
+ "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.16", "", {}, "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA=="],
+
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
+ "vite-node/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
+
+ "vitest/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
+
"@microsoft/tsdoc-config/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
diff --git a/apps/desktop/eslint.config.js b/apps/desktop/eslint.config.js
index a58843d..c8a6a59 100644
--- a/apps/desktop/eslint.config.js
+++ b/apps/desktop/eslint.config.js
@@ -103,7 +103,7 @@ export default [
// WHY: react-hooks v7 adds many new experimental rules. The classic two
// (rules-of-hooks, exhaustive-deps) are the critical ones; downgrade the
// rest to warn until we've audited the codebase against v7's semantics.
- // Follow-up: https://github.com/utof/perima (file issue for each warn below)
+ // Follow-up: see GH issue #116 for the audit plan.
"react-hooks/static-components": "warn",
"react-hooks/use-memo": "warn",
"react-hooks/void-use-memo": "warn",
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index bacdd5b..039bee4 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -3,6 +3,7 @@
"private": true,
"version": "0.6.3",
"type": "module",
+ "packageManager": "bun@1.3.11",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
@@ -11,6 +12,9 @@
"preview": "vite preview",
"tauri": "tauri"
},
+ "engines": {
+ "node": ">=20.19 <21 || >=22.12"
+ },
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2",
@@ -24,11 +28,17 @@
"@tauri-apps/cli": "^2",
"@testing-library/jest-dom": "^6",
"@testing-library/react": "^16",
+ "@types/babel__core": "^7",
"@types/react": "^19",
"@types/react-dom": "^19",
"@typescript-eslint/eslint-plugin": "^8.58.2",
"@typescript-eslint/parser": "^8.58.2",
- "@vitejs/plugin-react": "^4",
+ "@babel/core": "^7",
+ "@babel/plugin-syntax-jsx": "^7",
+ "@babel/preset-typescript": "^7",
+ "@rolldown/plugin-babel": "^0.2.3",
+ "@vitejs/plugin-react": "^6",
+ "babel-plugin-react-compiler": "=1.0.0",
"eslint": "^9",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
@@ -37,7 +47,7 @@
"jsdom": "^26",
"tailwindcss": "^4",
"typescript": "^5",
- "vite": "^6",
- "vitest": "^3"
+ "vite": "^8",
+ "vitest": "^3.2"
}
}
diff --git a/apps/desktop/src/__tests__/compiler-canary.test.ts b/apps/desktop/src/__tests__/compiler-canary.test.ts
new file mode 100644
index 0000000..f5b2a32
--- /dev/null
+++ b/apps/desktop/src/__tests__/compiler-canary.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from "vitest";
+import * as babel from "@babel/core";
+// WHY: @babel/plugin-syntax-jsx is needed so preset-typescript (which handles
+// TypeScript stripping) doesn't reject JSX tokens — the Compiler itself requires
+// real JSX in the source to recognise a React component and apply memoization.
+import babelPluginSyntaxJsx from "@babel/plugin-syntax-jsx";
+
+// WHY: Spec-mandated Compiler-activation canary. Runs babel-plugin-react-compiler
+// directly in `annotation` mode on a known-good snippet, bypasses the Vite build
+// pipeline entirely, and asserts the transformed source imports from
+// `react/compiler-runtime` (React 19's built-in runtime path).
+//
+// This is stable against minifier renames (which is why the spec rejects
+// bundle-grep as the primary verification).
+//
+// WHY "use memo" inside the function body (not at file level): the React Compiler
+// treats "use memo" as a function-level directive (like "use strict") — placing
+// it at the program level is parsed but silently ignored by the Compiler's
+// annotation-mode opt-in logic. File-level opt-in requires `compilationMode: "all"`.
+const CANARY_SOURCE = `
+ import { useState } from "react";
+ export function Canary() {
+ "use memo";
+ const [n, setN] = useState(0);
+ return
{n}
;
+ }
+`;
+
+describe("react-compiler transform", () => {
+ it("emits react/compiler-runtime import for a 'use memo' annotated component", async () => {
+ const result = await babel.transformAsync(CANARY_SOURCE, {
+ filename: "canary.tsx",
+ presets: [
+ ["@babel/preset-typescript", { allExtensions: true, isTSX: true }],
+ ],
+ plugins: [
+ babelPluginSyntaxJsx,
+ [
+ "babel-plugin-react-compiler",
+ {
+ // WHY: keep in sync with vite.config.ts's reactCompilerPreset target option;
+ // a silent drift would assert the wrong runtime import path.
+ target: "19",
+ compilationMode: "annotation",
+ },
+ ],
+ ],
+ babelrc: false,
+ configFile: false,
+ });
+ expect(result?.code).toMatch(/from ["']react\/compiler-runtime["']/);
+ });
+});
diff --git a/apps/desktop/src/vendor.d.ts b/apps/desktop/src/vendor.d.ts
new file mode 100644
index 0000000..84d61de
--- /dev/null
+++ b/apps/desktop/src/vendor.d.ts
@@ -0,0 +1,10 @@
+// WHY: @babel/plugin-syntax-jsx ships no TypeScript declarations and no
+// @types/babel__plugin-syntax-jsx exists on DefinitelyTyped. Declaring the
+// module here satisfies tsc without pinning to `any` across the whole project.
+// The plugin is used only in compiler-canary.test.ts as a Babel plugin handle;
+// typed as `object` to match @types/babel__core's PluginTarget union, which
+// is what babel.transformAsync's `plugins` array ultimately accepts.
+declare module "@babel/plugin-syntax-jsx" {
+ const plugin: object;
+ export default plugin;
+}
diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts
index e463676..9f3ccc2 100644
--- a/apps/desktop/vite.config.ts
+++ b/apps/desktop/vite.config.ts
@@ -1,10 +1,29 @@
import { defineConfig } from "vite";
-import react from "@vitejs/plugin-react";
+import react, { reactCompilerPreset } from "@vitejs/plugin-react";
+import babel from "@rolldown/plugin-babel";
import tailwindcss from "@tailwindcss/vite";
// WHY: Tailwind 4 uses the Vite plugin (not PostCSS) — no tailwind.config.js needed.
+// WHY: React Compiler 1.0 runs via a separate `@rolldown/plugin-babel` pass
+// configured with `reactCompilerPreset()` exported by @vitejs/plugin-react.
+// This is the canonical wiring for plugin-react v6 (see the v6 README).
+// Compiler auto-memoizes components; DO NOT add manual useMemo/useCallback
+// for new code. See AGENTS.md "Frontend / React Compiler" pin.
+// WHY: target: '19' is pinned explicitly even though '19' is the current
+// default — relying on the implicit default is a silent-failure hazard.
+// A future react-compiler minor could flip the default, switching our
+// runtime emit from `react/compiler-runtime` (React 19 built-in) to
+// `react-compiler-runtime` (17/18 polyfill) and defeating the Step 4
+// grep canary. Explicit pin costs one line and eliminates that class.
export default defineConfig({
- plugins: [react(), tailwindcss()],
+ plugins: [
+ react(),
+ // WHY: panicThreshold defaults to `none` — Compiler silently skips bailout
+ // components rather than failing the build; acceptable for a greenfield
+ // frontend where the component surface is fully under our control.
+ babel({ presets: [reactCompilerPreset({ target: "19" })] }),
+ tailwindcss(),
+ ],
server: {
port: 5173,
// WHY: Fail fast if port is taken; Tauri dev expects exactly 5173.
@@ -18,8 +37,8 @@ export default defineConfig({
environment: "jsdom",
setupFiles: ["src/__tests__/setup.ts"],
globals: true,
- // WHY: tsc composite mode emits to dist-types; exclude it so vitest
- // doesn't double-run the compiled JS alongside the original TSX.
+ // WHY: tsc composite mode emits to dist-types; exclude it so vitest doesn't
+ // double-run the compiled JS alongside the original TSX.
exclude: ["dist-types/**", "node_modules/**"],
include: ["src/**/*.{test,spec}.{ts,tsx}"],
},
diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml
index 900b416..1c314cc 100644
--- a/crates/cli/Cargo.toml
+++ b/crates/cli/Cargo.toml
@@ -22,14 +22,13 @@ serde_json.workspace = true
serde.workspace = true
clap.workspace = true
-anyhow.workspace = true
+miette.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
directories.workspace = true
ctrlc.workspace = true
rayon.workspace = true
uuid.workspace = true
-dunce.workspace = true
tokio.workspace = true
tokio-util.workspace = true
diff --git a/crates/cli/src/cmd/metadata.rs b/crates/cli/src/cmd/metadata.rs
index 5c50850..8a1e0d4 100644
--- a/crates/cli/src/cmd/metadata.rs
+++ b/crates/cli/src/cmd/metadata.rs
@@ -54,7 +54,8 @@ pub(crate) async fn run(
args: &MetadataArgs,
) -> Result<(), CoreError> {
validate_file(&args.path)?;
- let absolute_path = dunce::canonicalize(&args.path).map_err(CoreError::Io)?;
+ let absolute_path =
+ perima_fs::platform_path::canonicalize(&args.path).map_err(CoreError::Io)?;
// Resolve volume from the containing directory. WHY parent(): volume
// detection inspects the mount point; a file path's parent is the
diff --git a/crates/cli/src/cmd/scan.rs b/crates/cli/src/cmd/scan.rs
index c6005d7..35982ab 100644
--- a/crates/cli/src/cmd/scan.rs
+++ b/crates/cli/src/cmd/scan.rs
@@ -443,6 +443,7 @@ fn validate_root(root: &Path) -> Result<(), CoreError> {
}
fn canonicalize_for_walk(root: &Path) -> Result {
- // dunce::canonicalize avoids UNC prefixes on Windows.
- dunce::canonicalize(root).map_err(CoreError::Io)
+ // WHY: routes through perima_fs::platform_path::canonicalize — the single
+ // source of truth for the #[cfg(windows)] dunce / std fallback.
+ perima_fs::platform_path::canonicalize(root).map_err(CoreError::Io)
}
diff --git a/crates/cli/src/cmd/tag.rs b/crates/cli/src/cmd/tag.rs
index f9a2956..76b7276 100644
--- a/crates/cli/src/cmd/tag.rs
+++ b/crates/cli/src/cmd/tag.rs
@@ -6,7 +6,7 @@
//! - `tag ls [--json]` — list all active tags with attachment counts
use std::io::Write;
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
use perima_core::{BlakeHash, CoreError, DeviceId, FileRepository, TagRepository, normalize_tag};
@@ -80,7 +80,7 @@ where
/// WHY separate helper: both `run_add` and `run_rm` need the same
/// canonicalization + suffix-match logic. Extracting it avoids
/// duplicating the error messages and the `list_file_locations` call.
-fn resolve_hash(file_repo: &F, path: &PathBuf) -> Result
+fn resolve_hash(file_repo: &F, path: &Path) -> Result
where
F: FileRepository + ?Sized,
{
@@ -97,7 +97,7 @@ where
)));
}
- let absolute = dunce::canonicalize(path).map_err(CoreError::Io)?;
+ let absolute = perima_fs::platform_path::canonicalize(path).map_err(CoreError::Io)?;
let absolute_str = absolute
.to_str()
.ok_or_else(|| CoreError::InvalidPath(format!("non-UTF8 path: {}", absolute.display())))?;
@@ -122,7 +122,7 @@ fn run_add(
tag_repo: &T,
file_repo: &F,
device: DeviceId,
- path: &PathBuf,
+ path: &Path,
tags: &[String],
) -> Result<(), CoreError>
where
@@ -154,7 +154,7 @@ fn run_rm(
tag_repo: &T,
file_repo: &F,
device: DeviceId,
- path: &PathBuf,
+ path: &Path,
tag_raw: &str,
) -> Result<(), CoreError>
where
diff --git a/crates/cli/src/cmd/watch.rs b/crates/cli/src/cmd/watch.rs
index b06cc6c..0406de0 100644
--- a/crates/cli/src/cmd/watch.rs
+++ b/crates/cli/src/cmd/watch.rs
@@ -163,9 +163,9 @@ fn validate_root(root: &Path) -> Result<(), CoreError> {
}
fn canonicalize(root: &Path) -> Result {
- // WHY dunce: avoids UNC prefixes on Windows while behaving identically
- // on Unix.
- dunce::canonicalize(root).map_err(CoreError::Io)
+ // WHY: routes through perima_fs::platform_path::canonicalize — the single
+ // source of truth for the #[cfg(windows)] dunce / std fallback.
+ perima_fs::platform_path::canonicalize(root).map_err(CoreError::Io)
}
// ---------------------------------------------------------------------------
diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml
index 012bca9..4384a56 100644
--- a/crates/core/Cargo.toml
+++ b/crates/core/Cargo.toml
@@ -16,6 +16,7 @@ unicode-normalization.workspace = true
[dev-dependencies]
proptest.workspace = true
blake3.workspace = true
+tempfile.workspace = true
[lints]
workspace = true
diff --git a/crates/core/src/tag.rs b/crates/core/src/tag.rs
index 5aec789..d712f3e 100644
--- a/crates/core/src/tag.rs
+++ b/crates/core/src/tag.rs
@@ -1,6 +1,11 @@
//! Tag value type + name normalization.
use serde::{Deserialize, Serialize};
+// WHY: `unicode-normalization` is KEPT in the workspace solely for
+// this tag-name NFC normalization. Path code (MediaPath) no longer
+// uses it post-L5 (see GH issue #113). See GH issue
+// #112 for the long-term tag normalization question (is NFC
+// load-bearing, or handled by SQLite collation?).
use unicode_normalization::UnicodeNormalization;
use uuid::Uuid;
diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs
index 092d2fe..fa00072 100644
--- a/crates/core/src/types.rs
+++ b/crates/core/src/types.rs
@@ -112,27 +112,37 @@ const fn parse_nibble(b: u8) -> Option {
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)]
pub struct FileSize(pub u64);
-/// Path relative to a volume root. NFC-normalized, forward-slash,
-/// no leading slash. The constructor is *idempotent* AND makes
-/// canonically-equivalent inputs compare equal (NFC = NFD).
+/// Path relative to a volume root. Forward-slash, no leading slash.
///
-/// WHY: the combination of (NFC normalization + forward-slash
-/// conversion + leading-slash strip) in one pass is what makes
-/// the constructor simultaneously idempotent AND case-canonical
-/// under Unicode equivalence. Splitting these into separate
-/// passes would preserve idempotence but break equivalence
-/// (NFC-then-slash-fix would still differ from slash-fix-then-NFC
-/// on edge cases involving combining marks inside path segments).
+/// The constructor is *idempotent* over lexical form (double-slash and
+/// leading-slash inputs produce the same bytes on re-construction).
+/// It does NOT attempt Unicode canonical-equivalence; two `MediaPaths`
+/// constructed from NFC and NFD spellings of the same visual filename
+/// will compare unequal by byte.
+///
+/// WHY the NFC guarantee was removed (see lib-audit L5): macOS NFD
+/// vs NFC equivalence is better handled at the filesystem layer by
+/// round-tripping the OS's own normalization via a canonicalize call
+/// (in perima, `dunce::canonicalize` which delegates to
+/// `std::fs::canonicalize` on non-Windows and strips `\\?\` prefixes
+/// on Windows). Production `MediaPaths` are constructed downstream
+/// of a canonicalized walk (see
+/// `crates/cli/src/cmd/scan.rs::canonicalize_for_walk` and peers),
+/// so they receive platform-consistent bytes. Callers constructing
+/// `MediaPaths` from user-typed strings (rare in production; this
+/// type is typically machine-derived from canonicalized walk output)
+/// accept the byte-exact-match contract.
#[derive(Clone, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)]
pub struct MediaPath(String);
impl MediaPath {
/// Construct a normalized `MediaPath` from a raw string.
+ ///
+ /// Converts `\\` to `/` and strips leading `/`. Does NOT perform
+ /// Unicode normalization — see struct-level doc.
#[must_use]
pub fn new(raw: &str) -> Self {
- use unicode_normalization::UnicodeNormalization;
- let nfc: String = raw.nfc().collect();
- let slashed = nfc.replace('\\', "/");
+ let slashed = raw.replace('\\', "/");
let trimmed = slashed.trim_start_matches('/').to_owned();
Self(trimmed)
}
@@ -187,7 +197,12 @@ impl Default for DeviceId {
pub struct DiscoveredFile {
/// Absolute path as observed during the walk.
pub absolute_path: PathBuf,
- /// Path relative to the volume root, NFC-normalized.
+ /// Path relative to the volume root. Lexical normalization only
+ /// (forward-slash, no leading slash). NFC normalization is a
+ /// filesystem-layer concern handled upstream by the canonicalized
+ /// walk; see `MediaPath`'s struct-level doc and
+ /// `crates/core/tests/props_path_nfc_equivalence.rs` for the
+ /// mechanism.
pub relative_path: MediaPath,
/// File size in bytes at walk time.
pub size: FileSize,
@@ -336,11 +351,16 @@ mod tests {
}
#[test]
- fn media_path_nfc_equivalence_fixed() {
- // "café" — precomposed (NFC) vs decomposed (NFD).
- let precomposed = "caf\u{00E9}";
- let decomposed = "cafe\u{0301}";
- assert_eq!(MediaPath::new(precomposed), MediaPath::new(decomposed));
+ fn media_path_no_longer_nfc_collapses() {
+ // Post-L5: MediaPath::new is lexical-only. NFC and NFD spellings
+ // of "café" produce byte-distinct MediaPaths. Equivalence across
+ // Unicode forms is a filesystem-layer concern (fs::canonicalize),
+ // not a MediaPath::new concern.
+ let precomposed = "caf\u{00E9}"; // NFC, 5 bytes
+ let decomposed = "cafe\u{0301}"; // NFD, 6 bytes
+ assert_ne!(MediaPath::new(precomposed), MediaPath::new(decomposed));
+ // But identical spellings still collapse.
+ assert_eq!(MediaPath::new(precomposed), MediaPath::new(precomposed));
}
#[test]
diff --git a/crates/core/tests/props_path_nfc_equivalence.rs b/crates/core/tests/props_path_nfc_equivalence.rs
index edcdbfe..0b482fe 100644
--- a/crates/core/tests/props_path_nfc_equivalence.rs
+++ b/crates/core/tests/props_path_nfc_equivalence.rs
@@ -1,18 +1,76 @@
-//! Property: `MediaPath::new` collapses canonically-equivalent
-//! inputs. Applying NFD decomposition before construction must
-//! yield the same `MediaPath`.
+//! macOS-only test: `fs::canonicalize` returns the stored dirent name
+//! for any NFC/NFD lookup spelling of the same file. This replaces
+//! the pre-L5 proptest that asserted
+//! `MediaPath::new(NFC) == MediaPath::new(NFD)` — see
+//! `crates/core/src/types.rs` `MediaPath` doc for the invariant shift.
+//!
+//! # Mechanism (reviewer-verified)
+//!
+//! On default macOS APFS and HFS+ volumes the VFS layer is
+//! normalization-insensitive:
+//! - HFS+ stores NFD; any NFC or NFD lookup resolves to the stored
+//! NFD entry.
+//! - APFS preserves whatever bytes the caller wrote at creation; the
+//! macOS VFS layer handles NFC/NFD equivalence at lookup time, so
+//! both NFC and NFD spellings of a given file resolve to the one
+//! stored dirent. (Case-sensitive APFS variants can change this —
+//! out of scope for perima's tooling.)
+//!
+//! `realpath(3)` — which `std::fs::canonicalize` calls — returns the
+//! stored dirent name (whichever form the file was written under). So
+//! `canonicalize(NFC_lookup)` and `canonicalize(NFD_lookup)` return
+//! the SAME string (the stored one) on both filesystems.
+//!
+//! On Linux ext4: byte-exact, no normalization; `canonicalize(NFD)`
+//! of an NFC-stored file FAILS. We don't attempt the equivalence
+//! there.
+//!
+//! On Windows NTFS: the Win32 API layer applies some normalization
+//! but the behavior is hand-wavy across NT versions + drivers. We
+//! skip Windows rather than assert a weaker claim that might mislead
+//! a debugger.
+//!
+//! Off-macOS this file contains only a trivial byte-identity test
+//! (`media_path_byte_identity_off_macos` below) — the real NFC/NFD
+//! coverage runs only under macOS CI. The off-macOS branch exists
+//! to keep the test file non-empty on Linux + Windows; it asserts
+//! properties already covered by `props_path_idempotence.rs` and
+//! the inline tests in `crates/core/src/types.rs`.
-use perima_core::MediaPath;
-use proptest::prelude::*;
-use unicode_normalization::UnicodeNormalization;
+#[cfg(target_os = "macos")]
+#[test]
+#[allow(clippy::similar_names)] // NFC/NFD are domain terms of art — intentional
+fn fs_canonicalize_resolves_nfc_and_nfd_lookups_to_same_stored_name() {
+ use std::fs;
+ let dir = tempfile::tempdir().expect("tempdir");
+ // Write the fixture under an NFC-spelled filename.
+ let nfc_name = "caf\u{00E9}.txt";
+ let nfc_path = dir.path().join(nfc_name);
+ fs::write(&nfc_path, b"hello").expect("write NFC fixture");
-proptest! {
- #![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })]
- #[test]
- fn media_path_nfc_equivalence(s in "\\PC{0,200}") {
- let nfd: String = s.nfd().collect();
- let from_original = MediaPath::new(&s);
- let from_nfd = MediaPath::new(&nfd);
- prop_assert_eq!(from_original, from_nfd);
- }
+ // Look up the same file via its NFD spelling. Both APFS + HFS+
+ // resolve this to the SAME stored dirent.
+ let nfd_name = "cafe\u{0301}.txt";
+ let nfd_path = dir.path().join(nfd_name);
+
+ let canon_nfc = fs::canonicalize(&nfc_path).expect("canonicalize NFC");
+ let canon_nfd = fs::canonicalize(&nfd_path).expect("canonicalize NFD on macOS");
+ assert_eq!(
+ canon_nfc, canon_nfd,
+ "macOS realpath should return the same stored dirent name for NFC + NFD lookups"
+ );
+}
+
+#[cfg(not(target_os = "macos"))]
+#[test]
+fn media_path_byte_identity_off_macos() {
+ // On Linux + Windows we don't exercise fs::canonicalize NFC/NFD
+ // equivalence — Linux has none (byte-exact ext4), Windows is
+ // hand-wavy across NT versions. The weaker invariant tested here:
+ // MediaPath::new is deterministic byte-identity under identical
+ // input. NFC-equivalence is no longer MediaPath's job.
+ use perima_core::MediaPath;
+ let s = "caf\u{00E9}/file.txt";
+ assert_eq!(MediaPath::new(s), MediaPath::new(s));
+ assert_eq!(MediaPath::new(s).as_str(), "caf\u{00E9}/file.txt");
}
diff --git a/crates/desktop/Cargo.toml b/crates/desktop/Cargo.toml
index 3eb26fc..4ebd785 100644
--- a/crates/desktop/Cargo.toml
+++ b/crates/desktop/Cargo.toml
@@ -20,6 +20,7 @@ perima-db = { path = "../db" }
perima-fs = { path = "../fs" }
perima-hash = { path = "../hash" }
perima-media.workspace = true
+miette.workspace = true
tauri.workspace = true
tauri-specta.workspace = true
specta.workspace = true
@@ -31,7 +32,6 @@ tracing.workspace = true
uuid.workspace = true
chrono.workspace = true
rayon.workspace = true
-dunce.workspace = true
directories.workspace = true
tokio.workspace = true
tokio-util.workspace = true
diff --git a/crates/desktop/src/commands.rs b/crates/desktop/src/commands.rs
index a75538a..b05ab2a 100644
--- a/crates/desktop/src/commands.rs
+++ b/crates/desktop/src/commands.rs
@@ -326,7 +326,10 @@ pub async fn run_scan_inner_with_metadata(
metadata_repo: Option>,
) -> Result {
validate_root(root)?;
- let canonical_root = dunce::canonicalize(root).map_err(perima_core::CoreError::Io)?;
+ // WHY: routes through perima_fs::platform_path::canonicalize — the single
+ // source of truth for the #[cfg(windows)] dunce / std fallback.
+ let canonical_root =
+ perima_fs::platform_path::canonicalize(root).map_err(perima_core::CoreError::Io)?;
let scanner = WalkdirScanner::new();
let hasher = Blake3Service::new();
@@ -535,7 +538,8 @@ pub async fn start_watch(
) -> Result<(), String> {
let root = PathBuf::from(&path);
validate_root(&root).map_err(|e| e.to_string())?;
- let canonical_root = dunce::canonicalize(&root).map_err(|e| format!("canonicalize: {e}"))?;
+ let canonical_root =
+ perima_fs::platform_path::canonicalize(&root).map_err(|e| format!("canonicalize: {e}"))?;
let detected = perima_fs::detect_volume(&canonical_root).map_err(|e| e.to_string())?;
let db_path = state.data_dir.join("perima.db");
diff --git a/crates/fs/Cargo.toml b/crates/fs/Cargo.toml
index c56fcfc..ab0f5e2 100644
--- a/crates/fs/Cargo.toml
+++ b/crates/fs/Cargo.toml
@@ -11,7 +11,6 @@ publish = false # WHY: intra-workspace only; not published to crates.io
perima-core = { path = "../core" }
thiserror.workspace = true
walkdir.workspace = true
-path-slash.workspace = true
dunce.workspace = true
tracing.workspace = true
sysinfo.workspace = true
diff --git a/crates/fs/src/lib.rs b/crates/fs/src/lib.rs
index 78e9d7f..3b4fb09 100644
--- a/crates/fs/src/lib.rs
+++ b/crates/fs/src/lib.rs
@@ -4,6 +4,7 @@
pub mod errors;
pub mod paths;
+pub mod platform_path;
pub mod volumes;
pub mod walker;
pub mod watcher;
diff --git a/crates/fs/src/paths.rs b/crates/fs/src/paths.rs
index 37e2224..84f90c5 100644
--- a/crates/fs/src/paths.rs
+++ b/crates/fs/src/paths.rs
@@ -12,20 +12,40 @@ use crate::errors::Error;
/// `absolute` must be under `volume_root`; callers should pre-
/// canonicalize both sides to avoid symlink surprises.
///
+/// Only `Path::Component::Normal` segments are preserved; `..`
+/// (`ParentDir`) and `.` (`CurDir`) are silently dropped during the
+/// components-iterator join. Callers must pre-resolve such components
+/// if semantic preservation is required — all current callers feed
+/// canonicalized OS paths where `..`/`.` cannot survive.
+///
/// # Errors
/// Returns `Error::NotUnderVolume` if `absolute` is not prefixed by
/// `volume_root`.
pub fn relativize(absolute: &Path, volume_root: &Path) -> Result {
- let abs = dunce::simplified(absolute);
- let root = dunce::simplified(volume_root);
+ use std::path::Component;
+
+ let abs = crate::platform_path::simplified(absolute);
+ let root = crate::platform_path::simplified(volume_root);
let rel = abs
.strip_prefix(root)
.map_err(|_| Error::NotUnderVolume(absolute.to_path_buf()))?;
- // WHY: convert to forward-slash explicitly before handing to
- // MediaPath so our Windows test cases normalize consistently
- // on non-Windows hosts.
- let as_str = path_slash::PathExt::to_slash_lossy(rel);
- Ok(MediaPath::new(as_str.as_ref()))
+
+ // WHY: iterate Normal components and join with '/'. This handles
+ // Windows (backslash separator), Unix (forward slash), AND Windows
+ // UNC/drive-letter prefixes correctly — Prefix/RootDir/CurDir/
+ // ParentDir components are skipped by the filter, so there's no
+ // leading `//` to fight MediaPath::new's trim_start_matches('/').
+ // Equivalent on Unix to the prior path_slash behavior
+ // (to_slash_lossy).
+ let as_str = rel
+ .components()
+ .filter_map(|c| match c {
+ Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
+ _ => None,
+ })
+ .collect::>()
+ .join("/");
+ Ok(MediaPath::new(&as_str))
}
#[cfg(test)]
@@ -53,4 +73,33 @@ mod tests {
let r = relativize(root, root).expect("relativize root");
assert_eq!(r.as_str(), "");
}
+
+ #[test]
+ fn relativize_joins_with_forward_slash_regardless_of_platform() {
+ let root = std::path::Path::new("/tmp/perima");
+ let abs = std::path::Path::new("/tmp/perima/sub/dir/a.jpg");
+ let r = relativize(abs, root).expect("relativize");
+ assert_eq!(r.as_str(), "sub/dir/a.jpg");
+ }
+
+ #[test]
+ #[cfg(windows)]
+ fn relativize_windows_unc_path_produces_forward_slash_path() {
+ // Real UNC input — would be mangled by replace('\\',"/") →
+ // //server/share/... then trim_start_matches('/') collapses
+ // leading slashes. Components-iterator approach sidesteps this.
+ let root = std::path::Path::new(r"\\server\share");
+ let abs = std::path::Path::new(r"\\server\share\photos\a.jpg");
+ let r = relativize(abs, root).expect("relativize");
+ assert_eq!(r.as_str(), "photos/a.jpg");
+ }
+
+ #[test]
+ #[cfg(windows)]
+ fn relativize_windows_drive_letter_path_produces_forward_slash_path() {
+ let root = std::path::Path::new(r"C:\data");
+ let abs = std::path::Path::new(r"C:\data\photos\a.jpg");
+ let r = relativize(abs, root).expect("relativize");
+ assert_eq!(r.as_str(), "photos/a.jpg");
+ }
}
diff --git a/crates/fs/src/platform_path.rs b/crates/fs/src/platform_path.rs
new file mode 100644
index 0000000..8770462
--- /dev/null
+++ b/crates/fs/src/platform_path.rs
@@ -0,0 +1,49 @@
+//! Windows-scoped path-canonicalization helpers.
+//!
+//! `dunce` works around two Windows bugs in `std::fs::canonicalize`:
+//! (1) `\\?\` UNC-prefix pollution of user-displayable paths and
+//! (2) rejection of valid legacy drive-letter paths. On non-Windows
+//! `dunce` is documented to pass through to `std::fs::canonicalize` /
+//! identity `simplified`, so we avoid pulling it as a direct dep
+//! outside crates/fs — the `#[cfg(windows)]` branch below is the
+//! only place dunce is reachable from.
+
+use std::io;
+use std::path::{Path, PathBuf};
+
+/// Canonicalize a path, stripping `\\?\` on Windows.
+///
+/// On non-Windows this is `std::fs::canonicalize`.
+///
+/// # Errors
+/// Returns [`std::io::Error`] if the path does not exist or cannot be
+/// resolved by the OS.
+#[inline]
+pub fn canonicalize(p: &Path) -> io::Result {
+ #[cfg(windows)]
+ {
+ dunce::canonicalize(p)
+ }
+ #[cfg(not(windows))]
+ {
+ std::fs::canonicalize(p)
+ }
+}
+
+/// Return `p` with `\\?\` prefix stripped on Windows; identity elsewhere.
+// WHY allow: clippy suggests `const fn` based on the non-Windows identity
+// branch, but the #[cfg(windows)] branch calls dunce::simplified which is
+// not const. Silencing here is cleaner than splitting into two cfg-gated fns.
+#[allow(clippy::missing_const_for_fn)]
+#[inline]
+#[must_use]
+pub fn simplified(p: &Path) -> &Path {
+ #[cfg(windows)]
+ {
+ dunce::simplified(p)
+ }
+ #[cfg(not(windows))]
+ {
+ p
+ }
+}
diff --git a/crates/fs/src/volumes.rs b/crates/fs/src/volumes.rs
index caf090c..ac0b4bb 100644
--- a/crates/fs/src/volumes.rs
+++ b/crates/fs/src/volumes.rs
@@ -21,7 +21,8 @@ pub struct DetectedVolume {
/// Detect the volume that contains `path` using a longest mount-prefix match.
///
-/// Canonicalizes `path` via `dunce::canonicalize`, then enumerates all
+/// Canonicalizes `path` via [`crate::platform_path::canonicalize`]
+/// (dunce on Windows, `std::fs::canonicalize` elsewhere), then enumerates all
/// disks with [`sysinfo::Disks::new_with_refreshed_list`] and finds the
/// disk whose mount point is the longest prefix of the canonicalized path.
///
@@ -30,7 +31,7 @@ pub struct DetectedVolume {
/// - `CoreError::Io` if `path` cannot be canonicalized (e.g. does not exist).
/// - `CoreError::Internal` if no mounted disk covers the path.
pub fn detect_volume(path: &Path) -> Result {
- let canonical = dunce::canonicalize(path)?;
+ let canonical = crate::platform_path::canonicalize(path)?;
let disks = Disks::new_with_refreshed_list();
// WHY: longest-prefix wins so that nested mounts (e.g. /mnt/data and
diff --git a/crates/fs/src/watcher.rs b/crates/fs/src/watcher.rs
index e14037e..810ca07 100644
--- a/crates/fs/src/watcher.rs
+++ b/crates/fs/src/watcher.rs
@@ -64,11 +64,13 @@ impl DebouncedWatcher {
// strip_prefix in relativize() fails for every event and nothing
// reaches the bus. Canonicalizing both the registered paths and
// volume_root ensures they match whatever the OS reports in events.
- // dunce::canonicalize avoids UNC prefixes on Windows.
- let canonical_root = dunce::canonicalize(volume_root).map_err(CoreError::Io)?;
+ // crate::platform_path::canonicalize wraps dunce on Windows (avoids
+ // UNC prefix pollution) and std::fs::canonicalize elsewhere.
+ let canonical_root =
+ crate::platform_path::canonicalize(volume_root).map_err(CoreError::Io)?;
let canonical_paths: Vec = paths
.iter()
- .map(|p| dunce::canonicalize(p).map_err(CoreError::Io))
+ .map(|p| crate::platform_path::canonicalize(p).map_err(CoreError::Io))
.collect::>()?;
// WHY std mpsc: notify-debouncer-full uses std::sync::mpsc for its
diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml
index 0cee69e..16327e5 100644
--- a/crates/media/Cargo.toml
+++ b/crates/media/Cargo.toml
@@ -9,8 +9,16 @@ publish = false # WHY: intra-workspace only; not published to crates.io
[dependencies]
perima-core = { path = "../core" }
+# WHY crate-local (not workspace): L7 will separately update workspace deps;
+# keeping this local avoids any race between the two tasks.
+fast_image_resize = { version = "6", features = ["image"] }
image.workspace = true
-kamadak-exif.workspace = true
+nom-exif.workspace = true
+# WHY chrono: nom-exif's as_time_components() returns (NaiveDateTime, Option);
+# we need NaiveDateTime::format for the naive path and DateTime::to_rfc3339 for the
+# offset-aware path. No `use chrono::…` appears in media source files because the
+# inferred types flow from nom-exif's return values — the dep is real, just implicit.
+chrono.workspace = true
mp4parse.workspace = true
mime_guess.workspace = true
tokio.workspace = true
diff --git a/crates/media/src/extractor.rs b/crates/media/src/extractor.rs
index 7c4f24b..1ea2d46 100644
--- a/crates/media/src/extractor.rs
+++ b/crates/media/src/extractor.rs
@@ -1,20 +1,19 @@
//! Metadata extractors for images and MP4/MOV video.
//!
//! `ImageExtractor` reads PNG/JPEG dimensions via the `image` crate and
-//! EXIF tags via `kamadak-exif`. `VideoExtractor` parses `moov`-box data
+//! EXIF tags via `nom-exif`. `VideoExtractor` parses `moov`-box data
//! via Mozilla's `mp4parse` reader. `CompositeExtractor` dispatches to
//! the first registered extractor whose [`MetadataExtractor::accepts`]
//! returns `true` for the requested MIME.
use std::fs::File;
-use std::io::BufReader;
use std::path::Path;
use std::sync::Arc;
use perima_core::{BlakeHash, CoreError, MediaMetadata, MetadataExtractor};
/// Image metadata extractor backed by `image` (dimensions) and
-/// `kamadak-exif` (capture timestamp + camera).
+/// `nom-exif` (capture timestamp + camera).
///
/// Handles every `image/*` MIME the `image` crate can decode.
#[derive(Clone, Copy, Debug, Default)]
@@ -75,92 +74,95 @@ impl MetadataExtractor for ImageExtractor {
/// Read EXIF `DateTimeOriginal`, `Make`, and `Model` from an image.
///
+/// Uses `nom-exif`'s typed accessors: `as_str()` returns bare strings
+/// (no display quoting). `as_time_components()` returns
+/// `(NaiveDateTime, Option)`; we format naive →
+/// `"YYYY-MM-DDTHH:MM:SS"`, offset-aware → RFC 3339 with offset suffix
+/// via `chrono::DateTime::to_rfc3339`, bypassing the `Display` impl of
+/// the `#[non_exhaustive]` `EntryValue` enum.
+///
/// Returns `(None, None, None)` if the file has no EXIF segment, the
/// segment is malformed, or the individual fields are absent. A missing
/// EXIF block is expected for PNGs and many camera-exported JPEGs —
/// treating it as an error would be noisy. Any I/O or parser error is
/// traced at `debug` level.
fn read_exif(path: &Path) -> (Option, Option, Option) {
- let Ok(file) = File::open(path) else {
- return (None, None, None);
- };
- let mut reader = BufReader::new(file);
- let exif = match exif::Reader::new().read_from_container(&mut reader) {
- Ok(e) => e,
+ let ms = match nom_exif::MediaSource::file_path(path) {
+ Ok(ms) => ms,
Err(err) => {
tracing::debug!(
path = %path.display(),
error = %err,
- "EXIF parse failed or absent",
+ "nom-exif: could not open file as MediaSource",
);
return (None, None, None);
}
};
- let captured_at = exif
- .get_field(exif::Tag::DateTimeOriginal, exif::In::PRIMARY)
- .and_then(|f| {
- let raw = f.display_value().to_string();
- // `DateTimeOriginal` is "YYYY:MM:DD HH:MM:SS" per EXIF spec.
- // WHY reshape: callers consume ISO 8601 throughout the DB
- // (`first_seen`, `last_seen`). Converting at extraction time
- // keeps storage canonical even though the timezone is
- // unknown (EXIF omits zone; we annotate as naive-UTC).
- exif_datetime_to_iso8601(&raw)
- });
+ // Early-return when the container carries no EXIF block (e.g. raw
+ // PNG, MP4 without embedded EXIF). This avoids the parse attempt
+ // and the noisy "no Exif data" error it would produce.
+ if !ms.has_exif() {
+ return (None, None, None);
+ }
+ let mut parser = nom_exif::MediaParser::new();
+ let iter: nom_exif::ExifIter = match parser.parse(ms) {
+ Ok(iter) => iter,
+ Err(err) => {
+ tracing::debug!(
+ path = %path.display(),
+ error = %err,
+ "nom-exif: EXIF parse failed",
+ );
+ return (None, None, None);
+ }
+ };
+ let exif: nom_exif::Exif = iter.into();
+
+ // WHY trim_end_matches: EXIF ASCII tags are often NUL-terminated or
+ // space-padded (e.g. "NIKON CORPORATION \0"). nom-exif's as_str()
+ // does not strip these; we normalise before storing.
let camera_make = exif
- .get_field(exif::Tag::Make, exif::In::PRIMARY)
- .map(|f| strip_quotes(&f.display_value().to_string()));
+ .get(nom_exif::ExifTag::Make)
+ .and_then(|v| v.as_str())
+ .map(|s| s.trim_end_matches(['\0', ' ']).to_owned());
let camera_model = exif
- .get_field(exif::Tag::Model, exif::In::PRIMARY)
- .map(|f| strip_quotes(&f.display_value().to_string()));
+ .get(nom_exif::ExifTag::Model)
+ .and_then(|v| v.as_str())
+ .map(|s| s.trim_end_matches(['\0', ' ']).to_owned());
+
+ // WHY reshape: callers consume ISO 8601 throughout the DB
+ // (`first_seen`, `last_seen`). Converting at extraction time keeps
+ // storage canonical.
+ // WHY as_time_components: the typed accessor is stable across any future
+ // Display-impl changes in nom-exif's #[non_exhaustive] EntryValue enum
+ // and carries the FixedOffset when present, letting us emit a proper
+ // RFC 3339 string with offset suffix for timezone-aware EXIF values.
+ // Using byte-index-10 into a to_string() output would be fragile and
+ // discards the offset information entirely.
+ let captured_at = exif
+ .get(nom_exif::ExifTag::DateTimeOriginal)
+ .and_then(nom_exif::EntryValue::as_time_components)
+ .map(|(naive, offset)| {
+ offset.map_or_else(
+ || naive.format("%Y-%m-%dT%H:%M:%S").to_string(),
+ |tz| {
+ // WHY .single(): defensive — FixedOffset cannot produce
+ // LocalResult::None/Ambiguous, but the typed shape is
+ // forward-compatible if nom-exif ever exposes Tz-aware values.
+ naive.and_local_timezone(tz).single().map_or_else(
+ || naive.format("%Y-%m-%dT%H:%M:%S").to_string(),
+ |dt| dt.to_rfc3339(),
+ )
+ },
+ )
+ });
(captured_at, camera_make, camera_model)
}
-/// `kamadak-exif`'s `display_value()` wraps ASCII strings in double
-/// quotes (e.g. `"Canon"`). Strip those so downstream consumers see
-/// bare values.
-fn strip_quotes(s: &str) -> String {
- s.trim_matches('"').to_owned()
-}
-
-/// Convert an EXIF `DateTimeOriginal` rendering to ISO 8601.
-///
-/// EXIF on the wire uses `"YYYY:MM:DD HH:MM:SS"` (all colons); however
-/// `kamadak-exif::Field::display_value()` normalises the date-part
-/// separators to hyphens, yielding `"YYYY-MM-DD HH:MM:SS"`. We accept
-/// either form by reading the first 19 chars positionally: only the
-/// space at index 10 is inspected to decide the input shape is
-/// plausible. Output is always `"YYYY-MM-DDTHH:MM:SS"`.
-///
-/// WHY accept both: reusing one function for future raw-tag reads (that
-/// keep the EXIF-colon form) without forcing the caller to choose.
-///
-/// Returns `None` if the input is shorter than 19 chars or missing the
-/// date/time separator at position 10.
-fn exif_datetime_to_iso8601(raw: &str) -> Option {
- let raw = raw.trim().trim_matches('"').trim();
- if raw.len() < 19 {
- return None;
- }
- let bytes = raw.as_bytes();
- if bytes[10] != b' ' {
- return None;
- }
- let mut out = String::with_capacity(19);
- for (i, c) in raw.chars().take(19).enumerate() {
- match i {
- 4 | 7 => out.push('-'),
- 10 => out.push('T'),
- _ => out.push(c),
- }
- }
- Some(out)
-}
-
/// Video metadata extractor backed by `mp4parse`.
///
/// Handles `video/mp4` and `video/quicktime` (the two MIMEs the
@@ -385,33 +387,3 @@ impl MetadataExtractor for CompositeExtractor {
})
}
}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn exif_datetime_converts_colon_form_to_iso8601() {
- let got = exif_datetime_to_iso8601("2024:06:01 12:34:56").expect("parse");
- assert_eq!(got, "2024-06-01T12:34:56");
- }
-
- #[test]
- fn exif_datetime_converts_hyphen_form_to_iso8601() {
- // `kamadak-exif::Field::display_value()` normalises the date
- // portion to hyphens; the output must still be ISO 8601.
- let got = exif_datetime_to_iso8601("2024-06-01 12:34:56").expect("parse");
- assert_eq!(got, "2024-06-01T12:34:56");
- }
-
- #[test]
- fn exif_datetime_rejects_short_input() {
- assert!(exif_datetime_to_iso8601("2024").is_none());
- }
-
- #[test]
- fn strip_quotes_handles_bare_string() {
- assert_eq!(strip_quotes("Canon"), "Canon");
- assert_eq!(strip_quotes("\"Canon\""), "Canon");
- }
-}
diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs
index 4c4d51c..85d97dc 100644
--- a/crates/media/src/lib.rs
+++ b/crates/media/src/lib.rs
@@ -6,7 +6,7 @@
//! scanner.
//!
//! The extractors are confined here so the rest of the workspace stays
-//! free of `image`, `kamadak-exif`, `mp4parse`, and `mime_guess`
+//! free of `image`, `nom-exif`, `mp4parse`, and `mime_guess`
//! dependencies.
#![forbid(unsafe_code)]
diff --git a/crates/media/src/thumbnail.rs b/crates/media/src/thumbnail.rs
index e693512..34599b2 100644
--- a/crates/media/src/thumbnail.rs
+++ b/crates/media/src/thumbnail.rs
@@ -18,8 +18,9 @@
use std::path::{Path, PathBuf};
+use fast_image_resize::images::Image;
+use fast_image_resize::{IntoImageView, Resizer};
use image::ImageReader;
-use image::imageops::FilterType;
use perima_core::{BlakeHash, CoreError};
/// Generate WebP thumbnails for image sources.
@@ -66,6 +67,42 @@ impl std::fmt::Debug for ThumbnailGenerator {
/// images × ~20 KB each ≈ 2 GB, acceptable per plan risks section).
pub const DEFAULT_MAX_SIZE: u32 = 256;
+/// Compute fit-in-box dimensions preserving aspect ratio.
+///
+/// WHY: `image::DynamicImage::resize` does this internally; `fast_image_resize`
+/// expects explicit target dimensions.
+#[allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_sign_loss,
+ // WHY: ratio is in (0.0, 1.0] (box ≤ src), and src_w / src_h are u32, so
+ // src * ratio is ≥ 0 and ≤ src_w/src_h < u32::MAX. Truncation after
+ // .round() is deliberate and safe here.
+)]
+fn compute_fit(src_w: u32, src_h: u32, box_w: u32, box_h: u32) -> (u32, u32) {
+ let ratio = (f64::from(box_w) / f64::from(src_w)).min(f64::from(box_h) / f64::from(src_h));
+ let dst_w = ((f64::from(src_w) * ratio).round() as u32).max(1);
+ let dst_h = ((f64::from(src_h) * ratio).round() as u32).max(1);
+ (dst_w, dst_h)
+}
+
+/// Normalize input to a pixel type our resize match can handle.
+///
+/// WHY: `fast_image_resize` + image-feature exposes `pixel_type()` which returns
+/// None for some `DynamicImage` variants (16-bit PNG, HDR f32, grayscale+alpha).
+/// Rather than regressing thumbnail support for exotic inputs, coerce them to
+/// 8-bit of the closest channel-count match. Common cases (Rgb8/Rgba8/Luma8)
+/// pass through unchanged — the "RGB→RGBA promotion" regression is guarded.
+fn coerce_for_resize(img: image::DynamicImage) -> image::DynamicImage {
+ use image::DynamicImage::{ImageLuma8, ImageRgb8, ImageRgba8};
+ match &img {
+ ImageLuma8(_) | ImageRgb8(_) | ImageRgba8(_) => img,
+ // LumaA8, 16-bit, HDR, and any future variant: coerce to 8-bit RGBA.
+ // LumaA8 has no 1:1 fast_image_resize PixelType — promote to RGBA to
+ // preserve alpha. All other exotic variants similarly downgrade to RGBA.
+ _ => ImageRgba8(img.to_rgba8()),
+ }
+}
+
impl ThumbnailGenerator {
/// Construct a generator rooted at `data_dir` with
/// [`DEFAULT_MAX_SIZE`] as the pixel ceiling.
@@ -134,6 +171,75 @@ impl ThumbnailGenerator {
.join(format!("{hex}.webp"))
}
+ /// Resize `img` to fit within `max_size × max_size` using
+ /// SIMD-accelerated Lanczos3 via `fast_image_resize`.
+ ///
+ /// # Errors
+ ///
+ /// Returns `CoreError::Internal` if `fast_image_resize` cannot handle
+ /// the pixel type after coercion (should be unreachable in practice).
+ fn resize_image(&self, img: image::DynamicImage) -> Result {
+ // WHY fast_image_resize: 3-10× faster than image::imageops::resize
+ // (SIMD Lanczos3: SSE4.1/AVX2/NEON/WASM). Audit §Q5. Default algorithm
+ // for Resizer::new() is Lanczos3 for convolution-capable pixel types,
+ // so no ResizeOptions needed.
+
+ // Normalize exotic variants (16-bit, HDR, LumaA) to 8-bit before resize.
+ // Common RGB/RGBA/Luma8 pass through unchanged.
+ let img = coerce_for_resize(img);
+
+ let (dst_w, dst_h) = compute_fit(img.width(), img.height(), self.max_size, self.max_size);
+
+ // `img` is DynamicImage; with `features = ["image"]` it implements IntoImageView
+ // so we read pixels directly, no buffer copy. After coerce_for_resize, pixel_type()
+ // is guaranteed Some(U8 / U8x3 / U8x4).
+ let pixel_type = img.pixel_type().ok_or_else(|| {
+ CoreError::Internal(format!(
+ "fast_image_resize: coerce_for_resize returned unsupported variant ({:?})",
+ img.color()
+ ))
+ })?;
+
+ let mut dst = Image::new(dst_w, dst_h, pixel_type);
+
+ Resizer::new()
+ .resize(&img, &mut dst, None)
+ .map_err(|e| CoreError::Internal(format!("fast_image_resize: {e}")))?;
+
+ // Wrap dst back into DynamicImage matching the source pixel type.
+ // WHY into_vec(): consumes `dst` and returns the already-allocated Vec
+ // without copying (BufferContainer::Owned path in fast_image_resize 6.x).
+ let buf = dst.into_vec();
+ let resized = match pixel_type {
+ fast_image_resize::PixelType::U8x3 => image::DynamicImage::ImageRgb8(
+ image::RgbImage::from_raw(dst_w, dst_h, buf).ok_or_else(|| {
+ CoreError::Internal("RgbImage::from_raw returned None".into())
+ })?,
+ ),
+ fast_image_resize::PixelType::U8x4 => image::DynamicImage::ImageRgba8(
+ image::RgbaImage::from_raw(dst_w, dst_h, buf).ok_or_else(|| {
+ CoreError::Internal("RgbaImage::from_raw returned None".into())
+ })?,
+ ),
+ fast_image_resize::PixelType::U8 => image::DynamicImage::ImageLuma8(
+ image::GrayImage::from_raw(dst_w, dst_h, buf).ok_or_else(|| {
+ CoreError::Internal("GrayImage::from_raw returned None".into())
+ })?,
+ ),
+ // WHY defense-in-depth: coerce_for_resize guarantees U8/U8x3/U8x4
+ // above, so this arm is statically unreachable. The Err path exists
+ // to avoid a panic if a future fast_image_resize version adds new
+ // PixelType variants that bypass the coerce guarantee.
+ other => {
+ return Err(CoreError::Internal(format!(
+ "fast_image_resize: thumbnail path saw unexpected pixel type {other:?}"
+ )));
+ }
+ };
+
+ Ok(resized)
+ }
+
/// Decode `source`, resize to fit `max_size` while preserving
/// aspect, encode as WebP, and atomically write to the target
/// computed by [`path_for`](Self::path_for).
@@ -178,10 +284,7 @@ impl ThumbnailGenerator {
.decode()
.map_err(|e| CoreError::Internal(format!("decode {}: {e}", source.display())))?;
- // WHY `resize` (not `resize_exact`): preserves aspect ratio,
- // clamping the longer side to `max_size`. A 1000×500 input
- // becomes 256×128 with `max_size = 256`.
- let resized = img.resize(self.max_size, self.max_size, FilterType::Lanczos3);
+ let resized = self.resize_image(img)?;
// WHY atomic write: without `.tmp` + `rename` a crash mid-
// encode leaves a half-written `.webp` that passes the
@@ -210,10 +313,11 @@ impl ThumbnailGenerator {
}
#[cfg(test)]
+#[allow(clippy::unwrap_used)]
mod tests {
use std::path::Path;
- use image::{ImageBuffer, Rgb};
+ use image::{DynamicImage, ImageBuffer, Rgb};
use tempfile::TempDir;
use super::*;
@@ -370,4 +474,165 @@ mod tests {
// No directories created under an ephemeral `PathBuf::new()`
// root (the disabled generator never touches the filesystem).
}
+
+ #[test]
+ #[ignore = "perf benchmark; run: cargo test -p perima-media --release resize_only_bench -- --ignored --nocapture"]
+ #[allow(clippy::unwrap_used, clippy::print_stderr)]
+ fn resize_only_bench() {
+ use std::time::Instant;
+ let tmp = tempfile::tempdir().unwrap();
+ let tgen = ThumbnailGenerator::with_max_size(tmp.path().to_path_buf(), 512);
+
+ // 4928×3279 (24MP) matches fast_image_resize's upstream benchmark.
+ // Smaller sources hide the SIMD win behind loop overhead.
+ let rgb = image::RgbImage::from_pixel(4928, 3279, image::Rgb([128, 64, 32]));
+ let src_dyn = image::DynamicImage::ImageRgb8(rgb);
+
+ let start = Instant::now();
+ for _ in 0..50 {
+ let _ = tgen.resize_image(src_dyn.clone()).expect("resize_image");
+ }
+ let elapsed = start.elapsed();
+ eprintln!(
+ "resize_only_bench: 50 iters 4928x3279 → 512x(aspect) in {elapsed:?} ({:?} / iter)",
+ elapsed / 50
+ );
+ }
+
+ // --- pixel-type preservation tests (merged from former mod pixel_type_tests) ---
+
+ #[test]
+ fn resize_preserves_rgb_source_as_rgb() {
+ let tmp = tempfile::tempdir().unwrap();
+ let tgen = ThumbnailGenerator::with_max_size(tmp.path().to_path_buf(), 64);
+
+ let rgb = image::RgbImage::from_pixel(200, 100, image::Rgb([200, 100, 50]));
+ let src = DynamicImage::ImageRgb8(rgb);
+
+ let resized = tgen.resize_image(src).expect("resize_image");
+
+ assert!(
+ matches!(resized, DynamicImage::ImageRgb8(_)),
+ "RGB source must stay RGB after resize; got {:?}",
+ resized.color()
+ );
+ }
+
+ #[test]
+ fn resize_preserves_rgba_source_as_rgba() {
+ let tmp = tempfile::tempdir().unwrap();
+ let tgen = ThumbnailGenerator::with_max_size(tmp.path().to_path_buf(), 64);
+
+ let rgba = image::RgbaImage::from_pixel(200, 100, image::Rgba([200, 100, 50, 180]));
+ let src = DynamicImage::ImageRgba8(rgba);
+
+ let resized = tgen.resize_image(src).expect("resize_image");
+
+ assert!(
+ matches!(resized, DynamicImage::ImageRgba8(_)),
+ "RGBA source must stay RGBA after resize; got {:?}",
+ resized.color()
+ );
+ }
+
+ #[test]
+ fn resize_coerces_16bit_rgb_to_8bit_rgba() {
+ let tmp = tempfile::tempdir().unwrap();
+ let tgen = ThumbnailGenerator::with_max_size(tmp.path().to_path_buf(), 64);
+
+ let rgb16 = image::ImageBuffer::, _>::from_pixel(
+ 200,
+ 100,
+ image::Rgb([30000_u16, 20000, 10000]),
+ );
+ let src = DynamicImage::ImageRgb16(rgb16);
+
+ let resized = tgen.resize_image(src).expect("resize_image");
+
+ assert!(
+ matches!(
+ resized,
+ DynamicImage::ImageRgba8(_) | DynamicImage::ImageRgb8(_)
+ ),
+ "16-bit source must coerce to 8-bit variant; got {:?}",
+ resized.color()
+ );
+ }
+
+ // --- new regression tests (fixes 4, 5, 6) ---
+
+ #[test]
+ fn resize_preserves_luma8_source_as_luma8() {
+ let tmp = tempfile::tempdir().unwrap();
+ let tgen = ThumbnailGenerator::with_max_size(tmp.path().to_path_buf(), 64);
+
+ let luma = image::GrayImage::from_pixel(200, 100, image::Luma([128_u8]));
+ let src = DynamicImage::ImageLuma8(luma);
+
+ let resized = tgen.resize_image(src).expect("resize_image");
+
+ assert!(
+ matches!(resized, DynamicImage::ImageLuma8(_)),
+ "Luma8 source must stay Luma8 after resize; got {:?}",
+ resized.color()
+ );
+ }
+
+ #[test]
+ fn resize_coerces_lumaa8_to_rgba8_preserving_alpha() {
+ // coerce_for_resize promotes LumaA8 (grayscale + alpha) to RGBA8
+ // because fast_image_resize has no PixelType::U8x2 in our match.
+ // This test verifies alpha is preserved through the promotion.
+ let tmp = tempfile::tempdir().unwrap();
+ let tgen = ThumbnailGenerator::with_max_size(tmp.path().to_path_buf(), 64);
+
+ // Use a LumaA8 with non-opaque alpha so the test meaningfully
+ // verifies alpha survives.
+ let lumaa = image::ImageBuffer::, _>::from_pixel(
+ 200,
+ 100,
+ image::LumaA([128, 180]),
+ );
+ let src = DynamicImage::ImageLumaA8(lumaa);
+
+ let resized = tgen.resize_image(src).expect("resize_image");
+
+ assert!(
+ matches!(resized, DynamicImage::ImageRgba8(_)),
+ "LumaA8 source must coerce to RGBA8 after resize; got {:?}",
+ resized.color()
+ );
+ }
+
+ #[test]
+ fn generate_handles_16bit_png_via_full_pipeline() {
+ // Regression: 16-bit PNG decodes to DynamicImage::ImageRgb16 (or Rgba16),
+ // which coerce_for_resize normalizes to RGBA8. Without the coerce path
+ // the pixel_type() call would fail. Verify the full generate() pipeline
+ // produces a thumbnail file for this exotic input.
+ let tmp = tempfile::tempdir().unwrap();
+ let src_path = tmp.path().join("16bit.png");
+
+ // Build a 16-bit RGB PNG and save it.
+ let rgb16 = image::ImageBuffer::, _>::from_pixel(
+ 300,
+ 200,
+ image::Rgb([30000_u16, 20000, 10000]),
+ );
+ image::DynamicImage::ImageRgb16(rgb16)
+ .save(&src_path)
+ .expect("save 16-bit PNG");
+
+ let tgen = ThumbnailGenerator::with_max_size(tmp.path().to_path_buf(), 64);
+
+ // Fabricate a hash for the content-addressed thumbnail path.
+ let hash_bytes = *blake3::hash(b"16bit-png-test-fixture").as_bytes();
+ let hash = perima_core::BlakeHash::from_bytes(hash_bytes);
+
+ let out = tgen
+ .generate(&hash, &src_path)
+ .expect("generate")
+ .expect("Some path");
+ assert!(out.exists(), "Thumbnail file should exist at {out:?}");
+ }
}
diff --git a/crates/media/tests/integration.rs b/crates/media/tests/integration.rs
index 3dffb38..7a98cbb 100644
--- a/crates/media/tests/integration.rs
+++ b/crates/media/tests/integration.rs
@@ -7,7 +7,7 @@
#![allow(clippy::missing_errors_doc)]
use std::fs::{self, File};
-use std::io::{BufWriter, Cursor, Write};
+use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -33,72 +33,305 @@ fn make_test_png(width: u32, height: u32, path: &Path) -> PathBuf {
path.to_path_buf()
}
-/// Build a minimal JPEG containing only SOI + APP1(EXIF) + EOI.
+/// Build a minimal JPEG containing SOI + APP0(JFIF) + APP1(EXIF) + EOI.
///
/// WHY this shape (not a real encoded image):
-/// - `kamadak-exif`'s `read_from_container` only needs SOI + APP1;
-/// it does not parse the scan data.
+/// - `nom-exif`'s `MediaParser` requires a preceding APP0 (JFIF) marker
+/// before the APP1 (EXIF) segment for its streaming JPEG parser to
+/// recognise the file as a valid JPEG container.
/// - `image::image_dimensions` is NOT called against this fixture —
/// the EXIF test focuses on the text fields, and the PNG-dimensions
/// test uses a real PNG.
/// - Avoiding a fully-encoded JPEG keeps the helper small and removes
/// any dependency on the `image` JPEG encoder.
///
-/// WHY generated at runtime (not a byte-vendored const):
-/// the plan's preferred option is a const `&[u8]` produced externally
-/// via `exiftool`. This environment does not have `exiftool`; building
-/// the TIFF/EXIF segment at runtime via `exif::experimental::Writer` is
-/// documented as the acceptable fallback (plan §Task 3, Option 2) and
-/// has the same correctness property (deterministic bytes, no binary
-/// blob in git) without the external tool dependency.
+/// WHY hand-crafted TIFF bytes (not a third-party writer):
+/// We no longer depend on `kamadak-exif`, which previously provided
+/// `exif::experimental::Writer`. Instead we build a minimal little-endian
+/// TIFF block with a proper `ExifIFDPointer` chain (IFD0 → `ExifSubIFD`)
+/// that nom-exif can follow. The byte layout follows EXIF 2.3 §4.5.
fn make_jpeg_with_exif(datetime_original: &str, make: &str, model: &str, path: &Path) -> PathBuf {
- use exif::experimental::Writer;
- use exif::{Field, In, Tag, Value};
-
- // Build the TIFF/EXIF payload.
- let dt = Field {
- tag: Tag::DateTimeOriginal,
- ifd_num: In::PRIMARY,
- value: Value::Ascii(vec![datetime_original.as_bytes().to_vec()]),
+ let tiff_bytes = build_tiff_exif(datetime_original, make, model);
+
+ // Assemble JPEG: SOI + APP0(JFIF) + APP1(Exif) + EOI.
+ // APP1 length covers [len_hi, len_lo, "Exif\0\0", TIFF] — i.e.
+ // 2 (len itself) + 6 (identifier) + tiff.len().
+ let app1_payload_len = 2 + 6 + tiff_bytes.len();
+ let app1_len =
+ u16::try_from(app1_payload_len).expect("APP1 payload too large for a single segment");
+
+ let file = File::create(path).expect("create jpeg");
+ let mut w = BufWriter::new(file);
+ w.write_all(&[0xFF, 0xD8]).expect("SOI");
+ // APP0 (JFIF) — 16-byte minimal header required by nom-exif's JPEG parser.
+ w.write_all(&[0xFF, 0xE0, 0x00, 0x10])
+ .expect("APP0 marker+len");
+ w.write_all(b"JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00")
+ .expect("JFIF payload");
+ // APP1 (EXIF)
+ w.write_all(&[0xFF, 0xE1]).expect("APP1 marker");
+ w.write_all(&app1_len.to_be_bytes()).expect("APP1 length");
+ w.write_all(b"Exif\0\0").expect("Exif identifier");
+ w.write_all(&tiff_bytes).expect("TIFF body");
+ w.write_all(&[0xFF, 0xD9]).expect("EOI");
+ w.flush().expect("flush jpeg");
+ path.to_path_buf()
+}
+
+/// Build a minimal little-endian TIFF block with the proper EXIF IFD
+/// chain for three tags: `Make` and `Model` in IFD0, and
+/// `DateTimeOriginal` in the EXIF `SubIFD` (pointed to from IFD0 via tag
+/// `ExifIFDPointer` 0x8769).
+///
+/// Layout (offsets relative to start of TIFF block):
+/// ```text
+/// 0x00 TIFF header (8 bytes): "II" + 0x002A + IFD0_offset=8
+/// 0x08 IFD0 (42 bytes): count=3, [Make, Model, ExifIFDPointer], next=0
+/// 0x32 ExifSubIFD (18 bytes): count=1, [DateTimeOriginal], next=0
+/// 0x44 String area: Make\0, Model\0, DateTimeOriginal\0
+/// ```
+///
+/// Each IFD entry is 12 bytes: tag(2) + type(2) + count(4) + offset(4).
+fn build_tiff_exif(datetime_original: &str, make: &str, model: &str) -> Vec {
+ // Tag / type constants (EXIF 2.3).
+ const TAG_MAKE: u16 = 0x010F;
+ const TAG_MODEL: u16 = 0x0110;
+ const TAG_EXIF_IFD_POINTER: u16 = 0x8769;
+ const TAG_DATETIME_ORIGINAL: u16 = 0x9003;
+ const TYPE_ASCII: u16 = 2;
+ const TYPE_LONG: u16 = 4;
+
+ // Offset layout (all LE, relative to TIFF block start).
+ // TIFF header: 8. IFD0: 2 + 3*12 + 4 = 42 → ends at 50.
+ // ExifSubIFD: 2 + 1*12 + 4 = 18 → ends at 68. String area from 68.
+ const IFD0_OFFSET: u32 = 8;
+ const IFD0_ENTRY_COUNT: u16 = 3;
+ const EXIF_SUBIFD_OFFSET: u32 = IFD0_OFFSET + 2 + (IFD0_ENTRY_COUNT as u32 * 12) + 4; // 50
+ const EXIF_SUBIFD_ENTRY_COUNT: u16 = 1;
+ const STRING_AREA: u32 = EXIF_SUBIFD_OFFSET + 2 + (EXIF_SUBIFD_ENTRY_COUNT as u32 * 12) + 4; // 68
+
+ // ASCII values are NUL-terminated per TIFF/EXIF spec.
+ let make_bytes: Vec = {
+ let mut v = make.as_bytes().to_vec();
+ v.push(0);
+ v
};
- let mk = Field {
- tag: Tag::Make,
- ifd_num: In::PRIMARY,
- value: Value::Ascii(vec![make.as_bytes().to_vec()]),
+ let model_bytes: Vec = {
+ let mut v = model.as_bytes().to_vec();
+ v.push(0);
+ v
};
- let mdl = Field {
- tag: Tag::Model,
- ifd_num: In::PRIMARY,
- value: Value::Ascii(vec![model.as_bytes().to_vec()]),
+ let dt_bytes: Vec = {
+ let mut v = datetime_original.as_bytes().to_vec();
+ v.push(0);
+ v
};
- let mut writer = Writer::new();
- writer.push_field(&dt);
- writer.push_field(&mk);
- writer.push_field(&mdl);
-
- let mut tiff_buf = Cursor::new(Vec::::new());
- writer.write(&mut tiff_buf, false).expect("write tiff exif");
- let tiff_bytes = tiff_buf.into_inner();
+ let make_len = u32::try_from(make_bytes.len()).expect("make too long for TIFF");
+ let model_len = u32::try_from(model_bytes.len()).expect("model too long for TIFF");
+ let dt_len = u32::try_from(dt_bytes.len()).expect("datetime too long for TIFF");
+
+ let make_offset = STRING_AREA;
+ let model_offset = make_offset + make_len;
+ let dt_offset = model_offset + model_len;
+
+ let mut buf = Vec::new();
+
+ // TIFF header.
+ buf.extend_from_slice(b"II"); // little-endian byte order
+ buf.extend_from_slice(&42_u16.to_le_bytes()); // TIFF magic
+ buf.extend_from_slice(&IFD0_OFFSET.to_le_bytes());
+
+ // IFD0 (entries must be sorted ascending by tag).
+ buf.extend_from_slice(&IFD0_ENTRY_COUNT.to_le_bytes());
+ // Make (0x010F)
+ buf.extend_from_slice(&TAG_MAKE.to_le_bytes());
+ buf.extend_from_slice(&TYPE_ASCII.to_le_bytes());
+ buf.extend_from_slice(&make_len.to_le_bytes());
+ buf.extend_from_slice(&make_offset.to_le_bytes());
+ // Model (0x0110)
+ buf.extend_from_slice(&TAG_MODEL.to_le_bytes());
+ buf.extend_from_slice(&TYPE_ASCII.to_le_bytes());
+ buf.extend_from_slice(&model_len.to_le_bytes());
+ buf.extend_from_slice(&model_offset.to_le_bytes());
+ // ExifIFDPointer (0x8769) — inline LONG offset to ExifSubIFD.
+ buf.extend_from_slice(&TAG_EXIF_IFD_POINTER.to_le_bytes());
+ buf.extend_from_slice(&TYPE_LONG.to_le_bytes());
+ buf.extend_from_slice(&1_u32.to_le_bytes()); // count = 1
+ buf.extend_from_slice(&EXIF_SUBIFD_OFFSET.to_le_bytes());
+ // IFD0 next-IFD pointer.
+ buf.extend_from_slice(&0_u32.to_le_bytes());
+
+ // ExifSubIFD.
+ buf.extend_from_slice(&EXIF_SUBIFD_ENTRY_COUNT.to_le_bytes());
+ // DateTimeOriginal (0x9003)
+ buf.extend_from_slice(&TAG_DATETIME_ORIGINAL.to_le_bytes());
+ buf.extend_from_slice(&TYPE_ASCII.to_le_bytes());
+ buf.extend_from_slice(&dt_len.to_le_bytes());
+ buf.extend_from_slice(&dt_offset.to_le_bytes());
+ // ExifSubIFD next-IFD pointer.
+ buf.extend_from_slice(&0_u32.to_le_bytes());
+
+ // String area.
+ buf.extend_from_slice(&make_bytes);
+ buf.extend_from_slice(&model_bytes);
+ buf.extend_from_slice(&dt_bytes);
+
+ buf
+}
- // Assemble JPEG: SOI + APP1 + (Exif\0\0 + TIFF) + EOI.
- // APP1 length bytes cover [len_hi, len_lo, Exif\0\0, TIFF] — i.e.
- // 2 (len itself) + 6 (identifier) + tiff.len().
- let payload_len = 2 + 6 + tiff_bytes.len();
- let len_u16 = u16::try_from(payload_len).expect("APP1 payload too large for a single segment");
+/// Build a minimal JPEG containing SOI + APP0(JFIF) + APP1(EXIF) + EOI with
+/// both `DateTimeOriginal` (0x9003) and `OffsetTimeOriginal` (0x9011) set in
+/// the `ExifSubIFD`.
+///
+/// WHY separate helper (not a flag on `make_jpeg_with_exif`): keeping the
+/// two helpers independent avoids a boolean parameter that would change the
+/// byte layout mid-function and make the offset arithmetic harder to follow.
+fn make_jpeg_with_exif_offset(
+ datetime_original: &str,
+ offset: &str,
+ make: &str,
+ model: &str,
+ path: &Path,
+) -> PathBuf {
+ let tiff_bytes = build_tiff_exif_with_offset(datetime_original, offset, make, model);
+
+ let app1_payload_len = 2 + 6 + tiff_bytes.len();
+ let app1_len =
+ u16::try_from(app1_payload_len).expect("APP1 payload too large for a single segment");
let file = File::create(path).expect("create jpeg");
let mut w = BufWriter::new(file);
- w.write_all(&[0xFF, 0xD8]).expect("SOI"); // SOI
- w.write_all(&[0xFF, 0xE1]).expect("APP1 marker"); // APP1
- w.write_all(&len_u16.to_be_bytes()).expect("APP1 length");
+ w.write_all(&[0xFF, 0xD8]).expect("SOI");
+ w.write_all(&[0xFF, 0xE0, 0x00, 0x10])
+ .expect("APP0 marker+len");
+ w.write_all(b"JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00")
+ .expect("JFIF payload");
+ w.write_all(&[0xFF, 0xE1]).expect("APP1 marker");
+ w.write_all(&app1_len.to_be_bytes()).expect("APP1 length");
w.write_all(b"Exif\0\0").expect("Exif identifier");
w.write_all(&tiff_bytes).expect("TIFF body");
- w.write_all(&[0xFF, 0xD9]).expect("EOI"); // EOI
+ w.write_all(&[0xFF, 0xD9]).expect("EOI");
w.flush().expect("flush jpeg");
path.to_path_buf()
}
+/// Build a minimal little-endian TIFF block with two `ExifSubIFD` entries:
+/// `DateTimeOriginal` (0x9003) and `OffsetTimeOriginal` (0x9011).
+///
+/// Layout (offsets relative to start of TIFF block):
+/// ```text
+/// 0x00 TIFF header (8 bytes): "II" + 0x002A + IFD0_offset=8
+/// 0x08 IFD0 (42 bytes): count=3, [Make, Model, ExifIFDPointer], next=0
+/// 0x32 ExifSubIFD (30 bytes): count=2, [DateTimeOriginal, OffsetTimeOriginal], next=0
+/// 0x50 String area: Make\0, Model\0, DateTimeOriginal\0, OffsetTimeOriginal\0
+/// ```
+fn build_tiff_exif_with_offset(
+ datetime_original: &str,
+ offset: &str,
+ make: &str,
+ model: &str,
+) -> Vec {
+ const TAG_MAKE: u16 = 0x010F;
+ const TAG_MODEL: u16 = 0x0110;
+ const TAG_EXIF_IFD_POINTER: u16 = 0x8769;
+ const TAG_DATETIME_ORIGINAL: u16 = 0x9003;
+ const TAG_OFFSET_TIME_ORIGINAL: u16 = 0x9011;
+ const TYPE_ASCII: u16 = 2;
+ const TYPE_LONG: u16 = 4;
+
+ // IFD0: 2 + 3*12 + 4 = 42 → [8, 50)
+ // ExifSubIFD: 2 + 2*12 + 4 = 30 → [50, 80)
+ // String area from 80.
+ const IFD0_OFFSET: u32 = 8;
+ const IFD0_ENTRY_COUNT: u16 = 3;
+ const EXIF_SUBIFD_OFFSET: u32 = IFD0_OFFSET + 2 + (IFD0_ENTRY_COUNT as u32 * 12) + 4; // 50
+ const EXIF_SUBIFD_ENTRY_COUNT: u16 = 2;
+ const STRING_AREA: u32 = EXIF_SUBIFD_OFFSET + 2 + (EXIF_SUBIFD_ENTRY_COUNT as u32 * 12) + 4; // 80
+
+ let make_bytes: Vec = {
+ let mut v = make.as_bytes().to_vec();
+ v.push(0);
+ v
+ };
+ let model_bytes: Vec = {
+ let mut v = model.as_bytes().to_vec();
+ v.push(0);
+ v
+ };
+ let dt_bytes: Vec = {
+ let mut v = datetime_original.as_bytes().to_vec();
+ v.push(0);
+ v
+ };
+ // EXIF spec: OffsetTimeOriginal is ASCII, count includes the NUL.
+ let offset_bytes: Vec = {
+ let mut v = offset.as_bytes().to_vec();
+ v.push(0);
+ v
+ };
+
+ let make_len = u32::try_from(make_bytes.len()).expect("make too long");
+ let model_len = u32::try_from(model_bytes.len()).expect("model too long");
+ let dt_len = u32::try_from(dt_bytes.len()).expect("datetime too long");
+ let offset_len = u32::try_from(offset_bytes.len()).expect("offset too long");
+
+ let make_offset = STRING_AREA;
+ let model_offset = make_offset + make_len;
+ let dt_offset = model_offset + model_len;
+ let offset_str_offset = dt_offset + dt_len;
+
+ let mut buf = Vec::new();
+
+ // TIFF header.
+ buf.extend_from_slice(b"II");
+ buf.extend_from_slice(&42_u16.to_le_bytes());
+ buf.extend_from_slice(&IFD0_OFFSET.to_le_bytes());
+
+ // IFD0.
+ buf.extend_from_slice(&IFD0_ENTRY_COUNT.to_le_bytes());
+ // Make (0x010F)
+ buf.extend_from_slice(&TAG_MAKE.to_le_bytes());
+ buf.extend_from_slice(&TYPE_ASCII.to_le_bytes());
+ buf.extend_from_slice(&make_len.to_le_bytes());
+ buf.extend_from_slice(&make_offset.to_le_bytes());
+ // Model (0x0110)
+ buf.extend_from_slice(&TAG_MODEL.to_le_bytes());
+ buf.extend_from_slice(&TYPE_ASCII.to_le_bytes());
+ buf.extend_from_slice(&model_len.to_le_bytes());
+ buf.extend_from_slice(&model_offset.to_le_bytes());
+ // ExifIFDPointer (0x8769)
+ buf.extend_from_slice(&TAG_EXIF_IFD_POINTER.to_le_bytes());
+ buf.extend_from_slice(&TYPE_LONG.to_le_bytes());
+ buf.extend_from_slice(&1_u32.to_le_bytes());
+ buf.extend_from_slice(&EXIF_SUBIFD_OFFSET.to_le_bytes());
+ // IFD0 next-IFD pointer.
+ buf.extend_from_slice(&0_u32.to_le_bytes());
+
+ // ExifSubIFD (entries must be sorted ascending by tag).
+ buf.extend_from_slice(&EXIF_SUBIFD_ENTRY_COUNT.to_le_bytes());
+ // DateTimeOriginal (0x9003)
+ buf.extend_from_slice(&TAG_DATETIME_ORIGINAL.to_le_bytes());
+ buf.extend_from_slice(&TYPE_ASCII.to_le_bytes());
+ buf.extend_from_slice(&dt_len.to_le_bytes());
+ buf.extend_from_slice(&dt_offset.to_le_bytes());
+ // OffsetTimeOriginal (0x9011)
+ buf.extend_from_slice(&TAG_OFFSET_TIME_ORIGINAL.to_le_bytes());
+ buf.extend_from_slice(&TYPE_ASCII.to_le_bytes());
+ buf.extend_from_slice(&offset_len.to_le_bytes());
+ buf.extend_from_slice(&offset_str_offset.to_le_bytes());
+ // ExifSubIFD next-IFD pointer.
+ buf.extend_from_slice(&0_u32.to_le_bytes());
+
+ // String area.
+ buf.extend_from_slice(&make_bytes);
+ buf.extend_from_slice(&model_bytes);
+ buf.extend_from_slice(&dt_bytes);
+ buf.extend_from_slice(&offset_bytes);
+
+ buf
+}
+
/// Synthesise a minimal valid MP4 with one ~1 second AVC video track.
///
/// WHY programmatic: checking binary fixtures into git bloats the repo
@@ -209,6 +442,68 @@ fn image_extractor_jpeg_exif() {
assert_eq!(meta.mime_type.as_deref(), Some("image/jpeg"));
}
+#[test]
+fn image_extractor_jpeg_exif_with_offset() {
+ // WHY: exercises the FixedOffset branch of as_time_components() in
+ // read_exif. The OffsetTimeOriginal tag (0x9011, "+08:00") causes
+ // nom-exif to populate the Option field, which should
+ // produce an RFC 3339 string with the offset suffix.
+ let td = tempdir().expect("tempdir");
+ let path = td.path().join("exif_offset.jpg");
+ make_jpeg_with_exif_offset("2024:06:01 12:34:56", "+08:00", "Canon", "EOS R5", &path);
+
+ let bytes = std::fs::read(&path).expect("read jpeg");
+ assert_eq!(&bytes[0..2], &[0xFF, 0xD8], "must start with JPEG SOI");
+
+ let extractor = ImageExtractor::new();
+ let meta = extractor
+ .extract(dummy_hash(), &path, "image/jpeg")
+ .expect("extract jpeg with offset");
+ assert_eq!(
+ meta.captured_at.as_deref(),
+ Some("2024-06-01T12:34:56+08:00"),
+ "tz-aware EXIF must produce RFC 3339 with offset suffix",
+ );
+ assert_eq!(meta.camera_make.as_deref(), Some("Canon"));
+ assert_eq!(meta.camera_model.as_deref(), Some("EOS R5"));
+}
+
+#[test]
+fn image_extractor_jpeg_without_exif_returns_default() {
+ // WHY: exercises the has_exif() short-circuit path. A JPEG with only
+ // SOI + APP0(JFIF) + EOI has no APP1 segment, so nom-exif should
+ // report has_exif() == false and read_exif returns (None, None, None).
+ let td = tempdir().expect("tempdir");
+ let path = td.path().join("no_exif.jpg");
+
+ // Build a minimal JPEG: SOI + APP0(JFIF) + EOI — no APP1.
+ {
+ let file = File::create(&path).expect("create jpeg");
+ let mut w = BufWriter::new(file);
+ w.write_all(&[0xFF, 0xD8]).expect("SOI");
+ w.write_all(&[0xFF, 0xE0, 0x00, 0x10])
+ .expect("APP0 marker+len");
+ w.write_all(b"JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00")
+ .expect("JFIF payload");
+ w.write_all(&[0xFF, 0xD9]).expect("EOI");
+ w.flush().expect("flush");
+ }
+
+ let extractor = ImageExtractor::new();
+ let meta = extractor
+ .extract(dummy_hash(), &path, "image/jpeg")
+ .expect("extract jpeg without exif");
+ assert_eq!(
+ meta.captured_at, None,
+ "no-EXIF JPEG must have captured_at = None",
+ );
+ assert_eq!(meta.camera_make, None, "no-EXIF JPEG must have make = None");
+ assert_eq!(
+ meta.camera_model, None,
+ "no-EXIF JPEG must have model = None",
+ );
+}
+
// ---------------------------------------------------------------------
// VideoExtractor tests
// ---------------------------------------------------------------------