LR-META-XMP: Retry automatic XMP sidecar writes that failed

A sidecar write that failed because the folder was offline or read-only was only logged. Keep those photos waiting, write them again once their files are reachable, and tell the user.
This commit is contained in:
Michael Anderson
2026-10-14 13:56:23 -05:00
parent 3b4279fc24
commit 30ec48d9b3
26 changed files with 167 additions and 5 deletions
+12
View File
@@ -108,6 +108,18 @@ pub fn specs() -> Vec<CommandSpec> {
always,
prefs
),
cmd!(
"library.retrySidecars",
"Write Waiting XMP Sidecars",
[],
None,
"{} — writes again the automatic XMP sidecars that failed (folder offline, read-only or full) whose originals are reachable now → {written, waiting}",
always,
|s, _| {
let written = s.retry_unsaved_sidecars();
Ok(json!({"written": written, "waiting": s.unsaved_sidecars()}))
}
),
cmd!("library.toggleAutoWriteXmp", "Automatically Write Changes into XMP", ["File"], None, "{} → {autoWrite}", always, |s, _| {
let on = !s.xmp.auto_write;
prefs(s, &json!({"autoWrite": on}))
+4
View File
@@ -206,6 +206,9 @@ pub struct Session {
/// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on
/// (a catalog-only edit of data the sidecar writer does not emit); consumed when the command ends.
pub(crate) skip_auto_write: bool,
/// Photos whose automatic XMP sidecar write failed (their folder offline, read-only or full), written again by
/// [`Session::retry_unsaved_sidecars`] once their file is reachable.
pub(crate) unsaved_sidecars: std::collections::BTreeSet<PhotoId>,
/// AI denoise: the model in use, the photos that have their picture and the work in progress.
pub(crate) denoise: denoise::State,
/// Where the host keeps face models (one folder each); `None` where there is no file system (the web).
@@ -353,6 +356,7 @@ impl Session {
pending_sidecar_repairs: Vec::new(),
interaction: None,
skip_auto_write: false,
unsaved_sidecars: Default::default(),
denoise: Default::default(),
face_models_dir: None,
face_downloads: Default::default(),
+52 -4
View File
@@ -713,13 +713,61 @@ impl Session {
}
let owners = StemOwners::among(&self.catalog, &ids);
for id in ids {
if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some() && p.copy_of.is_none())
&& let Err(e) = self.save_sidecar_with(id, &owners)
{
log::warn!("auto-write XMP: {e}");
if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some() && p.copy_of.is_none()) {
match self.save_sidecar_with(id, &owners) {
Ok(_) => {
self.unsaved_sidecars.remove(&id);
}
Err(e) => {
// kept for a retry once the file is reachable: a network share that dropped out mustn't leave
// the sidecar silently behind the edit
log::warn!("auto-write XMP: {e}");
self.unsaved_sidecars.insert(id);
}
}
}
}
}
/// How many photos have an automatic sidecar write waiting (it failed: the folder offline, read-only or full).
pub fn unsaved_sidecars(&self) -> usize {
self.unsaved_sidecars.len()
}
/// Write the waiting sidecars whose originals are reachable again (the availability cache answers without
/// touching the disk for the others); photos that are gone, or with auto-write off, are dropped from the wait.
/// Returns how many were written.
pub fn retry_unsaved_sidecars(&mut self) -> usize {
if !self.xmp.auto_write {
self.unsaved_sidecars.clear();
return 0;
}
let mut ready = Vec::new();
for id in self.unsaved_sidecars.clone() {
match self.catalog.photo(id).filter(|p| p.copy_of.is_none()).and_then(|p| file_path(p).map(str::to_owned)) {
None => {
self.unsaved_sidecars.remove(&id);
}
Some(path) if self.media.availability.exists(&path) == Some(true) => ready.push(id),
Some(_) => {}
}
}
if ready.is_empty() {
return 0;
}
let owners = StemOwners::among(&self.catalog, &ready);
let mut written = 0;
for id in ready {
match self.save_sidecar_with(id, &owners) {
Ok(_) => {
self.unsaved_sidecars.remove(&id);
written += 1;
}
Err(e) => log::debug!("retry XMP: {e}"),
}
}
written
}
}
#[cfg(test)]
+39
View File
@@ -1209,3 +1209,42 @@ fn files_sharing_a_stem_get_their_own_sidecars() {
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&lib);
}
/// An automatic sidecar write that fails (here the folder is read-only, as a network share that dropped out or went
/// read-only) waits instead of being forgotten, and `library.retrySidecars` writes it once the folder takes writes
/// again.
#[cfg(unix)]
#[test]
fn a_failed_auto_write_waits_and_is_written_on_retry() {
use std::os::unix::fs::PermissionsExt;
let src = temp_dir("auto-retry");
write_png(&src.join("a.png"), 4);
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o555)).unwrap();
// (root writes anyway: nothing to test there)
if std::fs::write(src.join(".probe"), b"x").is_ok() {
std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o755)).unwrap();
return;
}
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
let sidecar = src.join("a.xmp");
assert!(!sidecar.exists());
assert_eq!(s.unsaved_sidecars(), 1, "the failed write waits");
let r = s.execute("library.retrySidecars", &json!({})).unwrap();
assert_eq!(r, json!({"written": 0, "waiting": 1}), "still read-only");
std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o755)).unwrap();
let r = s.execute("library.retrySidecars", &json!({})).unwrap();
assert_eq!(r, json!({"written": 1, "waiting": 0}));
assert!(std::fs::read_to_string(&sidecar).unwrap().contains("<xmp:Rating>4</xmp:Rating>"));
// a later edit that writes fine leaves nothing waiting; turning auto-write off drops what still waits
std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o555)).unwrap();
s.execute("photo.rate", &json!({"rating": 3})).unwrap();
assert_eq!(s.unsaved_sidecars(), 1);
s.execute("library.xmpPreferences", &json!({"autoWrite": false})).unwrap();
s.execute("library.retrySidecars", &json!({})).unwrap();
assert_eq!(s.unsaved_sidecars(), 0);
std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o755)).unwrap();
let _ = std::fs::remove_dir_all(&src);
}
+1
View File
@@ -61,6 +61,7 @@
"Import cancelled · {} photo{} added": "Import abgebrochen · {} Foto{} hinzugefügt",
"Import failed: {e}": "Import fehlgeschlagen: {e}",
"Import warnings ({n}):\n{warnings}": "Importwarnungen ({n}):\n{warnings}",
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Die XMP-Begleitdateien von {n} Fotos konnten nicht geschrieben werden (ist ihr Ordner offline?). LightCraft versucht es weiter.",
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "XMP-Profil \"{profile}\" wurde für {path} nicht angewendet; Farben können von Lightroom abweichen.",
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Lightroom-Geometrie wurde für {path} nicht beibehalten; {mode} wird neu berechnet.",
"Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Lightroom-Geometrie für {path} wurde nicht beibehalten; gespeicherte Transformation wird ignoriert, weil Upright deaktiviert ist.",
+1
View File
@@ -1617,6 +1617,7 @@
"Window": "Fenster",
"Winter Blue": "Winterblau",
"Without Saving and Quit (issue #100). It no longer quietly starts a demo session that looked like a reset library and": "Ohne Speichern fortfahren und Beenden (Issue #100). Sie startet keine unbemerkte Demo-Sitzung mehr, die wie eine zurückgesetzte Bibliothek wirkte und",
"Wrote the XMP sidecars that were waiting": "Ausstehende XMP-Begleitdateien geschrieben",
"XMP Sidecar Preferences": "Einstellungen für XMP-Begleitdateien",
"XMP sidecars": "XMP-Begleitdateien",
"Year": "Jahr",
+1
View File
@@ -61,6 +61,7 @@
"Import cancelled · {} photo{} added": "Importación cancelada · {0} foto{1} añadida{1}",
"Import failed: {e}": "Error de importación: {e}",
"Import warnings ({n}):\n{warnings}": "Avisos de importación ({n}):\n{warnings}",
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "No se pudieron escribir los archivos XMP de {n} fotos (¿su carpeta está desconectada?). LightCraft lo sigue intentando.",
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "El perfil XMP \"{profile}\" no se aplicó a {path}; los colores pueden diferir de Lightroom.",
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "La geometría de Lightroom no se conservó para {path}; {mode} se recalculará.",
"Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "La geometría de Lightroom no se conservó para {path}; la transformación guardada se ignora porque Vertical está desactivado.",
+1
View File
@@ -1581,6 +1581,7 @@
"Will be restored": "Se restaurará",
"Window": "Ventana",
"Winter Blue": "Azul invernal",
"Wrote the XMP sidecars that were waiting": "Se escribieron los archivos XMP pendientes",
"XMP Sidecar Preferences": "Preferencias de archivos XMP complementarios",
"XMP sidecars": "Archivos XMP complementarios",
"Year": "Año",
+1
View File
@@ -65,6 +65,7 @@
"Imported {n} preset{}": "{n} préréglage{} importé(s)",
"Imported {n} preset{}, {f} file{} not readable": "{n} préréglage{} importé(s), {f} fichier{} illisible(s)",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "{profiles} profil{} importé(s) (Navigateur de profils → leurs groupes)",
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Les fichiers annexes XMP de {n} photos n'ont pas pu être écrits (leur dossier est-il hors ligne ?). LightCraft continue d'essayer.",
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Le profil XMP « {profile} » n’a pas été appliqué à {path} ; les couleurs peuvent différer de Lightroom.",
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "La géométrie Lightroom de {path} n’a pas été conservée ; {mode} sera recalculé.",
"Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "La géométrie Lightroom de {path} n’a pas été conservée ; la transformation enregistrée est ignorée, car Upright est désactivé.",
+1
View File
@@ -1543,6 +1543,7 @@
"Will be restored": "Sera restaurée",
"Window": "Fenêtre",
"Winter Blue": "Bleu hivernal",
"Wrote the XMP sidecars that were waiting": "Fichiers annexes XMP en attente écrits",
"XMP Sidecar Preferences": "Préférences des fichiers annexes XMP",
"XMP sidecars": "Fichiers annexes XMP",
"Year": "Année",
+1
View File
@@ -66,6 +66,7 @@
"Show {hidden} hidden location{}": "非表示の{hidden}か所を表示{:.0}",
"Showing {n} of {}": "{}枚中{n}枚を表示",
"Smart album: {}": "スマートアルバム:{}",
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "{n} 枚の写真の XMP サイドカーを書き込めませんでした(フォルダーがオフラインではありませんか?)。LightCraft は再試行を続けます。",
"Undo {}": "取り消す:{}",
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "バージョン{} — Rust製の写真管理・RAW現像アプリ",
"Version: {}": "バージョン:{}",
+1
View File
@@ -1471,6 +1471,7 @@
"Will be restored": "復元されます",
"Window": "ウィンドウ",
"Winter Blue": "ウィンターブルー",
"Wrote the XMP sidecars that were waiting": "保留中の XMP サイドカーを書き込みました",
"XMP Sidecar Preferences": "XMPサイドカーファイルの環境設定",
"XMP sidecars": "XMPサイドカーファイル",
"Year": "年",
@@ -87,6 +87,7 @@
"Show {hidden} hidden location{}": "Mostrar {hidden} local(is) oculto(s){:.0}",
"Showing {n} of {}": "Mostrando {n} de {}",
"Smart album: {}": "Álbum inteligente: {}",
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Não foi possível gravar os arquivos XMP de {n} fotos (a pasta está offline?). O LightCraft continua tentando.",
"Undo {}": "Desfazer {}",
"Updated “{name}”": "“{name}” atualizado",
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "Versão {} — uma biblioteca de fotos e revelador RAW em Rust puro, desenvolvida de forma independente.",
+1
View File
@@ -1566,6 +1566,7 @@
"Will be restored": "Será restaurada",
"Window": "Janela",
"Winter Blue": "Azul invernal",
"Wrote the XMP sidecars that were waiting": "Os arquivos XMP pendentes foram gravados",
"XMP Sidecar Preferences": "Preferências de sidecar XMP",
"XMP sidecars": "Sidecars XMP",
"Year": "Ano",
+1
View File
@@ -64,6 +64,7 @@
"Import cancelled · {} photo{} added": "Импорт отменён · добавлено фото: {}{:.0}",
"Import failed: {e}": "Ошибка импорта: {e}",
"Import warnings ({n}):\n{warnings}": "Предупреждения импорта ({n}):\n{warnings}",
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Не удалось записать XMP-файлы {n} фото (папка недоступна?). LightCraft продолжает попытки.",
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Профиль XMP \"{profile}\" не применён к {path}; цвета могут отличаться от Lightroom.",
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Геометрия Lightroom для {path} не сохранена; режим {mode} будет пересчитан.",
"Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Геометрия Lightroom для {path} не сохранена; сохранённое преобразование игнорируется, поскольку Upright выключен.",
+1
View File
@@ -1309,6 +1309,7 @@
"Width × Height": "Ширина × высота",
"Window": "Окно",
"Winter Blue": "Зимняя синева",
"Wrote the XMP sidecars that were waiting": "Ожидавшие XMP-файлы записаны",
"XMP Sidecar Preferences": "Настройки XMP-файлов",
"XMP sidecars": "XMP-файлы",
"Year": "Год",
+1
View File
@@ -65,6 +65,7 @@
"Imported {n} preset{}": "Імпортовано наборів налаштувань: {n}{:.0}",
"Imported {n} preset{}, {f} file{} not readable": "Імпортовано наборів налаштувань: {n}{:.0}, файлів не читається: {f}{:.0}",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "Імпортовано профілів: {profiles}{:.0} (Оглядач профілів → їхні групи)",
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Не вдалося записати XMP-файли {n} фото (папка недоступна?). LightCraft продовжує спроби.",
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Профіль XMP «{profile}» не застосовано до {path}; кольори можуть відрізнятися від Lightroom.",
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Геометрію Lightroom для {path} не збережено; режим {mode} буде перераховано.",
"Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Геометрію Lightroom для {path} не збережено; збережене перетворення ігнорується, оскільки Upright вимкнено.",
+1
View File
@@ -305,6 +305,7 @@
"Whites": "Білі тони",
"Width": "Ширина",
"Window": "Вікно",
"Wrote the XMP sidecars that were waiting": "XMP-файли, що очікували, записано",
"Yellow": "Жовтий",
"Yes": "Так",
"Zoom": "Масштаб",
@@ -66,6 +66,7 @@
"Show {hidden} hidden location{}": "显示 {hidden} 个隐藏位置{0:.0}",
"Showing {n} of {}": "显示 {0} 张中的 {n} 张",
"Smart album: {}": "智能相册:{0}",
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "无法写入 {n} 张照片的 XMP 附属文件(其文件夹是否离线?)。LightCraft 会继续重试。",
"Undo {}": "撤销 {0}",
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "版本 {0} — 净室实现的纯 Rust 照片图库与 RAW 编辑工具。",
"Version: {}": "版本:{0}",
+1
View File
@@ -1547,6 +1547,7 @@
"Will be restored": "将恢复",
"Window": "窗口",
"Winter Blue": "冬日蓝调",
"Wrote the XMP sidecars that were waiting": "已写入等待中的 XMP 附属文件",
"XMP Sidecar Preferences": "XMP 附属文件偏好设置",
"XMP sidecars": "XMP 附属文件",
"Year": "年",
@@ -83,6 +83,7 @@
"Show {hidden} hidden location{}": "顯示 {hidden} 個隱藏的位置{:.0}",
"Showing {n} of {}": "顯示 {n}/{} 張",
"Smart album: {}": "智慧型相簿:{}",
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "無法寫入 {n} 張照片的 XMP 附屬檔案(其資料夾是否離線?)。LightCraft 會持續重試。",
"Undo {}": "復原 {}",
"Updated “{name}”": "已更新「{name}」",
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "版本 {} — 以純 Rust 獨立實作的照片管理與 RAW 顯像程式。",
+1
View File
@@ -1547,6 +1547,7 @@
"Will be restored": "將還原",
"Window": "視窗",
"Winter Blue": "冬日藍調",
"Wrote the XMP sidecars that were waiting": "已寫入等待中的 XMP 附屬檔案",
"XMP Sidecar Preferences": "XMP 附屬檔案偏好設定",
"XMP sidecars": "XMP 附屬檔案",
"Year": "年",
+27
View File
@@ -1092,6 +1092,33 @@ impl LightcraftApp {
}
ctx.request_repaint_after(std::time::Duration::from_secs(3));
}
// automatic XMP writes that failed (a network share offline): tried again every few seconds once their files
// are reachable, and said once when they start waiting and once when they're written
#[cfg(not(target_arch = "wasm32"))]
{
if self.session.unsaved_sidecars() > 0 {
let now = ctx.input(|i| i.time);
if now - self.ui.sidecar_retry_at >= 10.0 {
self.ui.sidecar_retry_at = now;
self.session.retry_unsaved_sidecars();
}
ctx.request_repaint_after(std::time::Duration::from_secs(10));
}
let waiting = self.session.unsaved_sidecars();
if waiting > 0 && self.ui.sidecars_waiting == 0 {
self.toast_for(
ctx,
crate::i18n::tr_format!(
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.",
n = waiting
),
8.0,
);
} else if waiting == 0 && self.ui.sidecars_waiting > 0 {
self.toast(ctx, crate::i18n::tr("Wrote the XMP sidecars that were waiting"));
}
self.ui.sidecars_waiting = waiting;
}
self.session.persist_if_dirty();
panels::denoise::pump(self, ctx);
model_setup::pump(self, ctx);
+7
View File
@@ -613,6 +613,11 @@ pub struct UiState {
/// When the watched folder was last scanned (egui time).
#[serde(skip)]
pub auto_import_at: f64,
/// When the automatic XMP writes that failed were last tried again (egui time), and how many were waiting then.
#[serde(skip)]
pub sidecar_retry_at: f64,
#[serde(skip)]
pub sidecars_waiting: usize,
/// The develop control whose slider is being dragged (geometry sliders show a grid).
#[serde(skip)]
pub dragging_control: Option<String>,
@@ -1101,6 +1106,8 @@ impl Default for UiState {
external_edits: Vec::new(),
was_focused: true,
auto_import_at: 0.0,
sidecar_retry_at: 0.0,
sidecars_waiting: 0,
search: String::new(),
focus_search: false,
renaming_mask: None,
+2 -1
View File
@@ -1,6 +1,6 @@
# Lightroom parity checklist
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-META-XMP: failed automatic sidecar writes are retried; integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
@@ -790,6 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | patch | LR-META-XMP: automatic sidecar writes that fail (folder offline or read-only) wait and are written again once the files are reachable, with a notice (`cmd:library.retrySidecars`); status unchanged |
| 2026-10-13 | patch | LR-VIEW-COMPARE, LRC-LIB-COMPARE: ⌘/Ctrl- and Shift-clicks on the filmstrip in Compare change the selection as in the grid; the two photos shown stay selected and the active photo is unchanged (#187); statuses unchanged |
| 2026-10-13 | minor | KEYC-SELECTONLYACTIVE: Edit → Select Only Active Photo (⌘⇧D) lands as a new row; status 🟡 (headless-tested, native macOS menu path unverified) |
| 2026-10-13 | patch | LRC-LIB-CATALOG-IMPORT: a catalog whose tables are all still in its `-wal` file (main file schema format 0) opens instead of failing as an unsupported schema format; status unchanged |
+6
View File
@@ -2,6 +2,12 @@
## 2026-10-14
### XMP sidecars catch up after a network share comes back
- With automatic XMP writing on, an edit to a photo whose folder can't be written (a network share that dropped
out, a folder that went read-only) used to be written only to the library, with a line in the log, so the sidecar
stayed behind the edit. LightCraft now says once that the sidecars are waiting, tries again every few seconds once
the files are reachable, and says when they're written. `library.retrySidecars` does it on request.
### Hard-edged Heal and Clone spots
- A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity
changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged.