Merge remote-tracking branch 'origin/main' into integ-b6

# Conflicts:
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant.json
This commit is contained in:
Claude committed 2026-10-08 20:48:15 +00:00
commit d4c0a7a3d4
22 files changed
+2129 -42

No files matched your search

+4 -2
View File
@@ -1495,7 +1495,6 @@
"They move to Recently Deleted and can be restored; no file on disk is touched.": "Sie werden nach „Zuletzt gelöscht“ verschoben und können wiederhergestellt werden; keine Datei auf dem Datenträger wird verändert.",
"Thirds": "Drittel",
"This Computer": "Dieser Computer",
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).": "Diese Version enthält noch keine Downloadquelle für das Modell: Lege die Dateien selbst im obigen Ordner ab (siehe docs/ai-masks.md).",
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Dieser Ordner ist nicht verfügbar (Laufwerk getrennt?). Smart-Vorschauen werden erst wieder erstellt oder verwendet, wenn er verfügbar ist oder du einen anderen Ordner auswählst.",
"This photo can't be opened": "Dieses Foto kann nicht geöffnet werden",
"Threshold": "Schwellenwert",
@@ -1789,5 +1788,8 @@
"Preview only: editing the camera's embedded JPEG": "Nur Vorschau: Bearbeitung des eingebetteten JPEGs der Kamera",
"Captured from": "Aufgenommen ab",
"Captured until": "Aufgenommen bis",
"Only": "Nur diese"
"Only": "Nur diese",
"How to install the model": "So installierst du das Modell",
"Install the SAM 3 Model": "SAM-3-Modell installieren",
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Diese Version kann das Modell noch nicht herunterladen. Zum Installieren von Hand lege model.safetensors, vocab.json und merges.txt aus Metas facebook/sam3 in den obigen Ordner: Objekt und Beschreiben funktionieren, sobald sie dort liegen."
}
+182
View File
@@ -0,0 +1,182 @@
{
" ({unsure} with several look-alike files: use Locate)": " ({unsure} con varios archivos parecidos: usar Localizar)",
" ({} in Recently Deleted)": " ({} en Eliminadas recientemente)",
" · {:.0}% clipped": " · {:.0}% recortado",
" · {failed} couldn't be rendered": " · no se pudieron renderizar: {failed}",
" · {skipped} skipped (file exists)": " · omitidas: {skipped} (el archivo existe)",
" · {} duplicate{} skipped": " · {0} duplicado{1} omitido{1}",
" · {} failed: {}": " · {} con errores: {}",
" · {} moved": " · movidas: {}",
" · {} not readable": " · ilegibles: {}",
" · {} original{} left at the source": " · originales conservados en el origen: {}{:.0}",
" · {} restored from Recently Deleted": " · restauradas desde Eliminadas recientemente: {}",
" — not carried over: {}{}": " — no transferido: {}{}",
"+{} more": "+{} más",
", {failed} file{} not readable": ", {failed} archivo{0} ilegible{0}",
"; {kept} already had a location": "; ya tenían ubicación: {kept}",
"; {outside} outside its time range": "; fuera de su intervalo de tiempo: {outside}",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PR, {} commits, +{} / −{} líneas (Δ {}), +{} / −{} recursos binarios, {} – {}",
"Add {local_n} to My Photos": "Añadir {local_n} a Mis fotos",
"Add {} Photo{}": "Añadir {} foto{}",
"Add {} Photo{}, restore {}": "Añadir {} foto{}, restaurar {}",
"Add “{}” to Favorites": "Añadir “{}” a Favoritos",
"Added {n} photo{} to My Photos": "{n} foto{0} añadida{0} a Mis fotos",
"Added {n} photo{} to “{}”": "{n} foto{0} añadida{0} a “{1}”",
"Added {what} to {name}": "{what} añadido a {name}",
"Adding photos… {} of {}": "Añadiendo fotos… {} de {}",
"All {n} selected photos move by the same amount.": "Las {n} fotos seleccionadas se desplazan la misma cantidad.",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Ya en la biblioteca, en Eliminadas recientemente: {0} foto{1} seleccionada{1} · clic derecho ▸ Restaurar, o Eliminar definitivamente para importar de nuevo",
"Already in the library: {} photo{} selected in All Photos": "Ya en la biblioteca: {0} foto{1} seleccionada{1} en Todas las fotos",
"Auto ({})": "Automático ({})",
"Auto Import: added {} photo{}": "Importación automática: {0} foto{1} añadida{1}",
"Auto Sync: edits apply to {n} photos": "Sincronización automática: las ediciones se aplican a {n} fotos",
"Automatic ({} MB)": "Automático ({} MB)",
"Build 1:1 Previews ({scope})": "Generar vistas previas 1:1 ({scope})",
"Build Smart Previews ({scope})": "Generar vistas previas inteligentes ({scope})",
"Build Standard-Sized Previews ({scope})": "Generar vistas previas de tamaño estándar ({scope})",
"Building previews for {} photos…": "Generando vistas previas de {} fotos…",
"Click {}:1": "Clic {}:1",
"Couldn't open the editor: {e}": "No se pudo abrir el editor: {e}",
"Create {n} Virtual Copies": "Crear {n} copias virtuales",
"Creates {} stacks from {} of {scope}": "Crea {} pilas a partir de {} de {scope}",
"Decoder: {reason}": "Decodificador: {reason}",
"Delete \"{label}\"": "Eliminar \"{label}\"",
"Delete \"{label}\" (and the mask)": "Eliminar \"{label}\" (y la máscara)",
"Delete {n} Photos": "Eliminar {n} fotos",
"Delete “{current}”": "Eliminar “{current}”",
"Discard Smart Previews ({scope})": "Descartar vistas previas inteligentes ({scope})",
"Done ({what}): {done} photo{plural}": "Listo ({what}): {done} foto{plural}",
"Duplicate \"{label}\"": "Duplicar \"{label}\"",
"Example: {example}": "Ejemplo: {example}",
"Exported {n} photo{}": "{n} foto{0} exportada{0}",
"Exported {ok} of {total} photo{}": "Exportadas {ok} de {total} foto{}",
"Exported {} point curve preset{}": "{0} ajuste{1} preestablecido{1} de curva de puntos exportado{1}",
"Exported {} preset{}": "{0} ajuste{1} preestablecido{1} exportado{1}",
"Exporting {} of {total}": "Exportando {} de {total}",
"Filter bar — {active} active filter{}": "Barra de filtros: {active} filtro{0} activo{0}",
"Focus {:.0}": "Enfoque {:.0}",
"Folder “{}” (and its subfolders)": "Carpeta “{}” (y sus subcarpetas)",
"Found {n} missing photo{}; {left} still missing{unsure}": "Fotos que faltaban encontradas: {n}{:.0}; siguen faltando: {left}{unsure}",
"Healed {n} dust spot{}": "{n} mancha{0} de polvo corregida{0}",
"Import cancelled · {} photo{} added": "Importación cancelada · {0} foto{1} añadida{1}",
"Import failed: {e}": "Error de importación: {e}",
"Imported {n} point curve preset{}": "{n} ajuste{0} preestablecido{0} de curva de puntos importado{0}",
"Imported {n} preset{}": "{n} ajuste{0} preestablecido{0} importado{0}",
"Imported {n} preset{}, {f} file{} not readable": "{n} ajuste{0} preestablecido{0} importado{0}, {f} archivo{1} ilegible{1}",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "Perfiles importados: {profiles}{:.0} (Explorador de perfiles ▸ sus grupos)",
"Imported {} photo{}": "{0} foto{1} importada{1}",
"Insert at the cursor (also written {})": "Insertar en el cursor (también se escribe {})",
"Label Set: {name}": "Conjunto de etiquetas: {name}",
"LightCraft could not open its window: {e}": "LightCraft no pudo abrir su ventana: {e}",
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft no pudo abrir su ventana: {e}\n\nLos detalles están en el archivo de registro: {path}",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Fuentes: {} (OFL). Iconos: originales.",
"Matches: {}": "Coincidencias: {}",
"Merging… {stage} {:.0}%": "Combinando… {stage} {:.0}%",
"Move {what} to Recently Deleted?": "¿Mover {what} a Eliminadas recientemente?",
"Named ({named_n})": "Con nombre ({named_n})",
"Painting “{k}”: click photos to add or remove it · Esc stops": "Pintando “{k}”: hacer clic en las fotos para añadirla o quitarla · Esc para terminar",
"Paste into {n} photo{} — only settings that were copied are pasted": "Pegar en {n} foto{}: solo se pegan los ajustes copiados",
"Preset: {name}": "Ajuste preestablecido: {name}",
"Preview only — editing the camera's embedded JPEG ({})": "Solo vista previa: editando el JPEG incrustado de la cámara ({})",
"Preview only — {}": "Solo vista previa: {}",
"Previews ready for {done} photo{plural}": "Vistas previas listas para {done} foto{plural}",
"Profile: {}": "Perfil: {}",
"Proof Copy ({})": "Copia de prueba ({})",
"Proof Preview · {}": "Vista previa de prueba · {}",
"Quick Develop · {n} photos": "Revelado rápido · {n} fotos",
"Quick Develop: {name}": "Revelado rápido: {name}",
"Rated {}": "Valorada con {}",
"Reading photos… {done} of {total}": "Leyendo fotos… {done} de {total}",
"Reading photos… {} of {}": "Leyendo fotos… {} de {}",
"Redo {}": "Rehacer {}",
"Remove “{name}” and its {count} photo{} from the library?": "¿Quitar “{name}” y sus {count} foto{} de la biblioteca?",
"Remove “{}” from Favorites": "Quitar “{}” de Favoritos",
"Removed {what} from {name}": "{what} quitado de {name}",
"Rename {n} Photos…": "Cambiar nombre de {n} fotos…",
"Rename “{name}”": "Cambiar nombre de “{name}”",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Cambia el nombre de “{from}” en {n} foto{} (también las palabras clave que dependen de ella). | separa niveles, p. ej. Viajes|Italia. Un nombre existente combina las dos.",
"Rendering on the CPU: {why}": "Renderizado en la CPU: {why}",
"Replace {} with:": "Reemplazar {} por:",
"Reset {name}": "Restablecer {name}",
"Reset {} Channel": "Restablecer canal {}",
"Restore {} Photo{}": "Restaurar {} foto{}",
"Saved export preset “{}”": "Ajuste preestablecido de exportación “{}” guardado",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Puntúa el enfoque y la exposición de {scope} y busca tomas similares hechas con pocos segundos de diferencia (ráfagas).",
"Show {hidden} hidden location{}": "Mostrar ubicaciones ocultas: {hidden}{:.0}",
"Showing {n} of {}": "Mostrando {n} de {}",
"Smart album: {}": "Álbum inteligente: {}",
"Tagged {n} photo{} from the tracklog": "{n} foto{0} ubicada{0} desde el registro de ruta",
"The SAM 3 download failed: {e}": "Error en la descarga de SAM 3: {e}",
"Undo {}": "Deshacer {}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "Etiqueta{s} desconocida{s} {} sin cambios{:.0} (ver Etiquetas)",
"Updated “{name}”": "“{name}” actualizado",
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "Versión {}: biblioteca de fotos y revelador RAW de desarrollo independiente (clean-room) en Rust puro.",
"Version: {}": "Versión: {}",
"Virtual copy “{name}” of {of}": "Copia virtual “{name}” de {of}",
"Working on {what} for {} photos…": "Procesando {what} para {} fotos…",
"none (CPU rendering): {why}": "ninguna (renderizado por CPU): {why}",
"the {n} selected photos": "las {n} fotos seleccionadas",
"the {} photos in view": "las {} fotos de la vista",
"these {count} photos": "estas {count} fotos",
"{count} smart preview{} · {:.1} MB": "{count} vista{0} previa{0} inteligente{0} · {1:.1} MB",
"{f} folders (and their subfolders)": "{f} carpetas (y sus subcarpetas)",
"{label} Grading": "Gradación de {label}",
"{label} {d:+} on every selected photo": "{label} {d:+} en cada foto seleccionada",
"{label} {index} of {n}": "{label} {index} de {n}",
"{label}, {n} photos": "{label}, {n} fotos",
"{matching} of {t} photos": "{matching} de {t} fotos",
"{matching} photos": "{matching} fotos",
"{mode} spot {} of {}": "{mode}: mancha {} de {}",
"{month}": "{month:02}",
"{month} {year}": "{month:02}/{year}",
"{msg} ({failed} failed)": "{msg} ({failed} con errores)",
"{name} Label": "Etiqueta {name}",
"{name} Off": "{name}: desactivado",
"{name} On": "{name}: activado",
"{name} opened for editing; it is stacked with the original": "{name} abierta para editar; está apilada con el original",
"{name} — double-click to reset it": "{name}: doble clic para restablecer",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Cambios que no se pudieron escribir en el disco: {n}{0:.0}: {e}\nSi se sale ahora, se perderán.{1:.0}",
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Cambios guardados en memoria, pero no escritos en el disco: {n}{0:.0}: {e}\nLightCraft vuelve a intentarlo automáticamente; si se sale ahora, se perderán.{1:.0}",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Cambios guardados solo en memoria: {n}{:.0}: {e}; LightCraft volverá a intentarlo",
"{n} found · {sel} selected": "{n} encontradas · {sel} seleccionadas",
"{n} photos": "{n} fotos",
"{n} photos in {} are hidden by the filters above": "Los filtros de arriba ocultan {n} fotos en {}",
"{n} photo{}": "{n} foto{}",
"{n} photo{} match · updates automatically as photos change": "{n} foto{0} coincidente{0} · se actualiza automáticamente cuando cambian las fotos",
"{n} photo{} now · updates automatically as photos change": "{n} foto{} ahora · se actualiza automáticamente cuando cambian las fotos",
"{pid} (missing)": "{pid} (falta)",
"{sel_n} selected · {counted}": "{sel_n} seleccionadas · {counted}",
"{stage}… {:.0}%": "{stage}… {:.0}%",
"{verb} {n} Photo{}": "{verb} {n} foto{}",
"{weekday}, {day}": "{weekday}, {day}",
"{weekday}, {day} {month} {year}": "{weekday}, {day}/{month:02}/{year}",
"{what} merge added": "Combinación {what} añadida",
"{what} merge failed: {e}": "Error en la combinación {what}: {e}",
"{year}": "{year}",
"{{date:…}} directives: {}": "Directivas de {{date:…}}: {}",
"{} and higher": "{} o más",
"{} contributors · {} commits": "{} colaboradores · {} commits",
"{} failed": "Con errores: {}",
"{} files": "{} archivos",
"{} files and {f} folder{}": "{} archivos y {f} carpeta{}",
"{} item{}": "{0} elemento{1}",
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} de 4 guías: arrastrar a lo largo de líneas que deban ser verticales u horizontales.",
"{} photos": "{} fotos",
"{} photos · exposures {} EV": "{} fotos · exposiciones {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "{} fotos · {} · {:.0}° × {:.0}°",
"{} photo{} not readable": "{0} foto{1} ilegible{1}",
"{} sample{}": "{} muestra{}",
"{} sec": "{} s",
"{} spot(s) on this photo": "{} mancha(s) en esta foto",
"{} — show their photos": "{}: mostrar sus fotos",
"· {dups} already in the library": "· ya en la biblioteca: {dups}",
"· {n} photo{}": "· {n} foto{}",
"… and {more} more": "… y {more} más",
"LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft aún no puede decodificar esta variante RAW ({}). Se está editando la vista previa JPEG incrustada de la cámara, que ya incluye el estilo de imagen de la cámara (p. ej. Monocromo) y el balance de blancos.",
"Filtered: {label}": "Filtrado: {label}",
"{n} similar photo{} · View ▸ Clear Filters to see all": "Fotos similares: {n}{:.0} · Vista ▸ Borrar filtros para ver todas",
"Moved; {} photo(s) relinked": "Movida; {} foto(s) vinculada(s) de nuevo",
"{} label (click more labels to show any of them)": "Etiqueta {} (hacer clic en más etiquetas para mostrar cualquiera de ellas)",
", {} star{}": ", {} estrella{}",
", {} label": ", etiqueta {}"
}
File diff suppressed because it is too large. Load diff
+4 -2
View File
@@ -1359,7 +1359,6 @@
"The photo has these settings": "この写真に適用されている設定",
"They can be restored from Recently Deleted until it is emptied.": "「最近削除した写真」を空にするまでは復元できます",
"Thirds": "三分割",
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).": "このビルドにはモデルのダウンロード元がまだありません。上のフォルダにファイルを手動で置いてください(docs/ai-masks.md を参照)",
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "このフォルダにアクセスできません。ドライブを接続するか別のフォルダを選ぶまで、スマートプレビューは作成・使用されません",
"This photo can't be opened": "この写真を開けません",
"Threshold": "しきい値",
@@ -1634,5 +1633,8 @@
"≤ at most": "≤ 以下",
"≥ at least": "≥ 以上",
"日本語": "日本語",
"繁體中文(台灣)": "繁體中文(台灣)"
"繁體中文(台灣)": "繁體中文(台灣)",
"How to install the model": "モデルのインストール方法",
"Install the SAM 3 Model": "SAM 3モデルのインストール",
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "このビルドではまだモデルをダウンロードできません。手動でインストールするには、Meta の facebook/sam3 の model.safetensors、vocab.json、merges.txt を上のフォルダに置いてください。置いた時点で「オブジェクト」と「説明で選択」が使えるようになります。"
}
+3 -1
View File
@@ -630,6 +630,7 @@
"Horizontal": "Horizontal",
"Horizontal text": "Texto horizontal",
"Hours": "Horas",
"How to install the model": "Como instalar o modelo",
"Hue": "Matiz",
"Hue Range": "Faixa de matiz",
"ISO Speed": "Sensibilidade ISO",
@@ -677,6 +678,7 @@
"Inspect Lightroom Catalog": "Inspecionar catálogo do Lightroom",
"Inspect Photo": "Inspecionar foto",
"Inspecting Lightroom Catalog": "Inspecionando catálogo do Lightroom",
"Install the SAM 3 Model": "Instalar o modelo SAM 3",
"Instant ’70s": "Instantâneo anos 70",
"Interface": "Interface",
"Interface language": "Idioma da interface",
@@ -1441,7 +1443,7 @@
"They move to Recently Deleted and can be restored; no file on disk is touched.": "Elas vão para Excluídos recentemente e podem ser restauradas; nenhum arquivo no disco é alterado.",
"Thirds": "Terços",
"This Computer": "Este computador",
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).": "Esta versão ainda não tem um local de download para o modelo: coloque os arquivos você mesmo na pasta acima (veja docs/ai-masks.md).",
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Esta versão ainda não consegue baixar o modelo. Para instalá-lo manualmente, coloque model.safetensors, vocab.json e merges.txt do facebook/sam3 da Meta na pasta acima: Objeto e Descrever funcionam assim que eles estiverem lá.",
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Esta pasta não está disponível (unidade desconectada?). As pré-visualizações inteligentes não são geradas nem usadas até ela voltar ou você escolher outra pasta.",
"This photo can't be opened": "Não foi possível abrir esta foto",
"Threshold": "Limite",
+3
View File
@@ -516,6 +516,7 @@
"History & Activity (Y)": "История и активность (Y)",
"Horizontal": "По горизонтали",
"Horizontal text": "Горизонтальный текст",
"How to install the model": "Как установить модель",
"Hue": "Тон",
"Hue Range": "Диапазон тонов",
"If file exists": "Если файл существует",
@@ -555,6 +556,7 @@
"Inspect Lightroom Catalog": "Проверить каталог Lightroom",
"Inspect Photo": "Просмотр фото",
"Inspecting Lightroom Catalog": "Проверка каталога Lightroom",
"Install the SAM 3 Model": "Установка модели SAM 3",
"Instant ’70s": "Мгновенный стиль 70-х",
"Interface": "Интерфейс",
"Interface language": "Язык интерфейса",
@@ -1191,6 +1193,7 @@
"Theme": "Тема",
"They can be restored from Recently Deleted until it is emptied.": "Их можно восстановить из «Недавно удалённых», пока этот раздел не очищен.",
"Thirds": "Правило третей",
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Эта сборка пока не умеет скачивать модель. Чтобы установить её вручную, положите model.safetensors, vocab.json и merges.txt из facebook/sam3 от Meta в папку выше: «Объект» и «Описать» заработают, как только файлы окажутся там.",
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Эта папка недоступна (диск отключён?). Смарт-просмотры не создаются и не используются, пока она не появится снова или вы не выберете другую папку.",
"This photo can't be opened": "Это фото не удаётся открыть",
"Threshold": "Порог",
+4 -2
View File
@@ -1428,7 +1428,6 @@
"Theme": "主题",
"They can be restored from Recently Deleted until it is emptied.": "在“最近删除”被清空之前,都可以从那里恢复。",
"Thirds": "三分法",
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).": "此版本尚未提供模型的下载地址:请自行将文件放入上方的文件夹(参见 docs/ai-masks.md)。",
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "此文件夹不可用(驱动器已断开连接?)。在它恢复或您选择其他文件夹之前,不会构建或使用智能预览。",
"This photo can't be opened": "无法打开此照片",
"Threshold": "阈值",
@@ -1634,5 +1633,8 @@
"Expand": "展开",
"Remove Disk from Library": "从图库中移除磁盘",
"All the photos imported from this disk are included.": "包括从此磁盘导入的所有照片。",
"That includes the photos in the folders inside it.": "其中文件夹里的照片也包括在内。"
"That includes the photos in the folders inside it.": "其中文件夹里的照片也包括在内。",
"How to install the model": "如何安装模型",
"Install the SAM 3 Model": "安装 SAM 3 模型",
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚无法下载模型。如需手动安装,请将 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的文件夹:放入后即可使用“对象”和“描述”。"
}
+4 -2
View File
@@ -1428,7 +1428,6 @@
"Theme": "主題",
"They can be restored from Recently Deleted until it is emptied.": "清空「最近刪除」之前,可以從中還原照片。",
"Thirds": "三分法",
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).": "此版本尚未提供模型的下載位置:請自行將檔案放入上方的資料夾(請參閱 docs/ai-masks.md)。",
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "此資料夾無法使用(磁碟可能已中斷連線)。恢復連線或選擇其他資料夾之前,不會建立或使用智慧型預覽。",
"This photo can't be opened": "無法開啟此照片",
"Threshold": "臨界值",
@@ -1634,5 +1633,8 @@
"Expand": "展開",
"Remove Disk from Library": "從照片庫移除磁碟",
"All the photos imported from this disk are included.": "包含從此磁碟匯入的所有照片。",
"That includes the photos in the folders inside it.": "其中資料夾裡的照片也包含在內。"
"That includes the photos in the folders inside it.": "其中資料夾裡的照片也包含在內。",
"How to install the model": "如何安裝模型",
"Install the SAM 3 Model": "安裝 SAM 3 模型",
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚無法下載模型。如需手動安裝,請將 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的資料夾:放入後即可使用「物件」與「描述」。"
}
+5
View File
@@ -121,6 +121,7 @@ language_table! {
ZhHant, "zh-hant", "繁體中文(台灣)", "Hant", include_str!("../locales/zh-hant.json");
Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json");
PtBr, "pt-br", "Português (Brasil)", "Latn", include_str!("../locales/pt-br.json");
Es, "es", "Español", "Latn", include_str!("../locales/es.json");
De, "de", "Deutsch", "Latn", include_str!("../locales/de.json");
Ru, "ru", "Русский", "Cyrl", include_str!("../locales/ru.json");
}
@@ -449,6 +450,9 @@ mod tests {
for tag in ["ru", "ru-RU", "ru_RU.UTF-8", "ru-UA"] {
assert_eq!(Locale::parse_tag(tag), Some(Locale::Ru), "{tag}");
}
for tag in ["es", "es-ES", "es_MX.UTF-8", "es-419"] {
assert_eq!(Locale::parse_tag(tag), Some(Locale::Es), "{tag}");
}
assert_eq!(Locale::parse_tag("xx"), None);
}
@@ -571,6 +575,7 @@ mod tests {
("app.language.simplifiedChinese", Locale::ZhHans),
("app.language.japanese", Locale::Ja),
("app.language.portuguese", Locale::PtBr),
("app.language.spanish", Locale::Es),
("app.language.german", Locale::De),
("app.language.russian", Locale::Ru),
];
+2
View File
@@ -42,6 +42,8 @@ mod tests_library_problem;
#[cfg(test)]
mod tests_masking;
#[cfg(test)]
mod tests_masking_layout;
#[cfg(test)]
mod tests_offline;
#[cfg(test)]
mod tests_panels;
+2
View File
@@ -18,6 +18,7 @@ pub const LANGUAGE_COMMANDS: &[UiCommand] = &[
("app.language.traditionalChinese", crate::i18n::Locale::ZhHant.name(), None, "Edit>Language"),
("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"),
("app.language.portuguese", crate::i18n::Locale::PtBr.name(), None, "Edit>Language"),
("app.language.spanish", crate::i18n::Locale::Es.name(), None, "Edit>Language"),
("app.language.german", crate::i18n::Locale::De.name(), None, "Edit>Language"),
("app.language.russian", crate::i18n::Locale::Ru.name(), None, "Edit>Language"),
];
@@ -37,6 +38,7 @@ pub fn language_from_command(id: &str) -> Option<crate::i18n::Locale> {
"app.language.traditionalChinese" => Some(crate::i18n::Locale::ZhHant),
"app.language.japanese" => Some(crate::i18n::Locale::Ja),
"app.language.portuguese" => Some(crate::i18n::Locale::PtBr),
"app.language.spanish" => Some(crate::i18n::Locale::Es),
"app.language.german" => Some(crate::i18n::Locale::De),
"app.language.russian" => Some(crate::i18n::Locale::Ru),
_ => None,
+40 -2
View File
@@ -99,6 +99,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::ConfirmDelete { .. } => "Delete Photos",
Dialog::RemoveFolder { disk: true, .. } => "Remove Disk from Library",
Dialog::RemoveFolder { .. } => "Remove Folder from Library",
// (nothing to download from in this build: the dialog explains the manual install)
Dialog::SamModel { .. } if sam_by_hand(&app.session.segmenter) => "Install the SAM 3 Model",
Dialog::SamModel { .. } => "Download the SAM 3 Model?",
Dialog::About => "About LightCraft",
Dialog::Shortcuts => "Keyboard Shortcuts",
@@ -786,7 +788,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
let sam = &app.session.segmenter;
let (sam_installed, sam_running, sam_failed) = (sam.installed(), sam.download_status().running, sam.download_status().error.is_some());
// no download location in this build: nothing to offer but the manual install
let sam_nowhere = !sam_installed && !sam_running && sam.mirrors().is_empty();
let sam_nowhere = sam_by_hand(sam);
let cancel = match &dlg {
Dialog::SamModel { .. } if sam_running || sam_installed || sam_nowhere => "Close",
Dialog::SamModel { .. } => "Not Now",
@@ -883,6 +885,22 @@ pub fn keeps_open(app: &LightcraftApp, dlg: &Dialog) -> bool {
matches!(dlg, Dialog::SamModel { .. }) && !app.session.segmenter.installed()
}
/// No SAM 3 model, no download running and nowhere to download it from: installing it by hand is
/// all the dialog can offer.
fn sam_by_hand(sam: &lightcraft_engine::segment::Segmenter) -> bool {
!sam.installed() && !sam.download_status().running && sam.mirrors().is_empty()
}
/// The model installation guide.
const SAM_HELP: &str = "https://github.com/storytold/lightcraft/blob/main/docs/ai-masks.md#getting-the-model";
/// Show the SAM 3 model folder in the file manager (created first, so there is something to show).
fn show_model_folder(app: &mut LightcraftApp, dir: &std::path::Path) -> Result<(), String> {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let reveal = app.services.reveal.as_mut().ok_or("not available here")?;
reveal(&dir.to_string_lossy())
}
/// The SAM 3 dialog: what the model is, its size and licence, and the download's progress.
fn sam_model_body(app: &mut LightcraftApp, ui: &mut egui::Ui, error: Option<&str>) {
use lightcraft_engine::segment::{LICENSE_NAME, LICENSE_URL, MODEL_BYTES};
@@ -929,12 +947,32 @@ fn sam_model_body(app: &mut LightcraftApp, ui: &mut egui::Ui, error: Option<&str
return;
}
if seg.mirrors().is_empty() {
// nothing to download from: say how to install it by hand, with the folder and the guide a click away
let dir = seg.dir.clone();
ui.label(
egui::RichText::new(crate::i18n::tr(
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).",
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.",
))
.color(t.text_dim),
);
ui.horizontal_wrapped(|ui| {
if let Some(dir) = dir.filter(|_| app.services.reveal.is_some()) {
let r = ui.button(crate::i18n::tr(crate::menus::reveal_label()));
crate::widgets::register(ui.ctx(), "button:samFolder", r.rect);
if r.clicked()
&& let Err(e) = show_model_folder(app, &dir)
{
app.toast(ui.ctx(), e);
}
}
let r = ui.link(crate::i18n::tr("How to install the model")).on_hover_text(SAM_HELP);
crate::widgets::register(ui.ctx(), "link:samHelp", r.rect);
if r.clicked()
&& let Err(e) = crate::links::open(app, SAM_HELP)
{
app.toast(ui.ctx(), e);
}
});
}
if let Some(e) = error.map(str::to_string).or(d.error) {
ui.label(egui::RichText::new(format!("{} {e}", crate::i18n::tr("The download didn't work:"))).color(egui::Color32::from_rgb(230, 90, 80)));
+85 -23
View File
@@ -1,6 +1,6 @@
//! The Masking panel: create masks, list them, edit components and local adjustments.
use egui::{Align2, Rect, Sense, Stroke, pos2, vec2};
use egui::{Rect, Sense, Stroke, pos2, vec2};
use lightcraft_catalog::PhotoId;
use lightcraft_develop::{ControlSpec, LocalAdjustments, MaskShape, Section, Track};
use serde_json::json;
@@ -10,7 +10,7 @@ use super::right::header;
use crate::LightcraftApp;
use crate::icons::{Icon, paint};
use crate::theme::Tokens;
use crate::widgets::{divider, icon_button, register, slider, text_button};
use crate::widgets::{divider, icon_button, one_line, register, slider, text_button};
const fn spec(id: &'static str, label: &'static str, min: f64, max: f64, step: f64, decimals: u8, track: Track) -> ControlSpec {
ControlSpec { id, label, section: Section::Light, min, max, default: 0.0, step, decimals, track }
@@ -61,12 +61,29 @@ fn kind_label(s: &MaskShape) -> (&'static str, Icon) {
const TILE: f32 = 52.0;
const TILE_GAP: f32 = 6.0;
/// Room left and right of a tile's label.
const TILE_PAD: f32 = 3.0;
/// Columns and tile width of the Create New Mask grid in `width`: four tiles of up to 52 pt (at
/// least 48, so the labels fit), else three (narrowed if even those don't fit).
fn tile_layout(width: f32) -> (usize, f32) {
let tile = |n: f32| ((width - (n - 1.0) * TILE_GAP) / n).floor().min(TILE);
if tile(4.0) >= 48.0 { (4, tile(4.0)) } else { (3, tile(3.0).max(24.0)) }
/// Columns, tile width and label size of the Create New Mask grid in `width`, `label(size)` being
/// the widest label at a font size: four tiles of up to 52 pt (at least 48), else three, else two —
/// the first that fits the labels at 10.5 pt, or at 9.5 pt before a column is dropped. A tile grows
/// past 52 pt for a long (translated) label. In a panel too narrow even for that, two tiles whose
/// labels are cut short.
fn tile_layout(width: f32, label: impl Fn(f32) -> f32) -> (usize, f32, f32) {
let room = |n: usize| ((width - (n as f32 - 1.0) * TILE_GAP) / n as f32).floor();
for n in [4, 3, 2] {
let w = room(n);
if n == 4 && w < 48.0 {
continue;
}
for size in [10.5, 9.5] {
let need = (label(size) + 2.0 * TILE_PAD).ceil();
if need <= w {
return (n, w.min(TILE.max(need)), size);
}
}
}
(2, room(2).max(24.0), 9.5)
}
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
@@ -88,15 +105,29 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
("luminanceRange", "Luminance", Icon::Sliders),
("colorRange", "Color", Icon::Picker),
];
// four 52 pt tiles a row when they fit, else as many as fit (at least three, shrunk)
let (cols, tile) = tile_layout(ui.available_width());
// four 52 pt tiles a row when they and their labels fit, else fewer (wider for a long label)
let widest = |size: f32| {
let font = t.font(size);
tiles
.iter()
.map(|(_, l, _)| ui.painter().layout_no_wrap(crate::i18n::tr(l).to_string(), font.clone(), t.text_dim).size().x)
.fold(0.0, f32::max)
};
let (cols, tile, size) = tile_layout(ui.available_width(), widest);
egui::Grid::new("mask-tiles").spacing(vec2(TILE_GAP, TILE_GAP)).show(ui, |ui| {
for (i, (kind, label, icon)) in tiles.iter().enumerate() {
let (r, resp) = ui.allocate_exact_size(vec2(tile, 52.0), Sense::click());
register(ui.ctx(), format!("maskNew:{kind}"), r);
ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.inset });
paint(ui.painter(), Rect::from_center_size(r.center() - vec2(0.0, 7.0), vec2(20.0, 20.0)), *icon, t.text_label);
ui.painter().text(pos2(r.center().x, r.bottom() - 9.0), Align2::CENTER_CENTER, *label, t.font(10.5), t.text_dim);
// (cut short only in a panel too narrow for the label: the full name on hover)
let label = crate::i18n::tr(label);
let g = one_line(ui.painter(), label, t.font(size), t.text_dim, tile - 2.0 * TILE_PAD);
let elided = g.elided;
let at = pos2(r.center().x, r.bottom() - 9.0) - g.size() / 2.0;
register(ui.ctx(), format!("maskNewLabel:{kind}"), Rect::from_min_size(at, g.size()));
ui.painter().galley(at, g, t.text_dim);
let resp = if elided { resp.on_hover_text(label) } else { resp };
if resp.clicked() {
match *kind {
"colorRange" => {
@@ -136,7 +167,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
};
if let Some(status) = status {
ui.add_space(4.0);
ui.label(egui::RichText::new(crate::i18n::tr(status)).color(t.text_dim));
ui.add(egui::Label::new(egui::RichText::new(crate::i18n::tr(status)).color(t.text_dim)).wrap());
ui.ctx().request_repaint_after(std::time::Duration::from_millis(250));
}
// the model download, while its dialog is closed
@@ -207,13 +238,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
);
let icon = m.components.first().map(|c| kind_label(&c.shape).1).unwrap_or(Icon::Mask);
paint(ui.painter(), Rect::from_min_size(r.min + vec2(8.0, 7.0), vec2(16.0, 16.0)), icon, t.text_label);
ui.painter().text(
pos2(r.left() + 32.0, r.center().y),
Align2::LEFT_CENTER,
&m.name,
t.font(13.0),
if m.visible { t.text } else { t.text_disabled },
);
// a long name is cut short (with …) before the eye, the full name on hover
let color = if m.visible { t.text } else { t.text_disabled };
let g = one_line(ui.painter(), &m.name, t.font(13.0), color, r.width() - 32.0 - 32.0);
let elided = g.elided;
let at = pos2(r.left() + 32.0, r.center().y - g.size().y / 2.0);
register(ui.ctx(), format!("maskName:{}", m.id), Rect::from_min_size(at, g.size()));
ui.painter().galley(at, g, color);
let resp = if elided { resp.on_hover_text(&m.name) } else { resp };
// show / hide on hover (and always while hidden)
// (the pointer test, not `hovered`: over the eye, the row itself no longer counts as hovered)
if ui.rect_contains_pointer(r) || !m.visible {
@@ -706,11 +738,15 @@ fn describe_field(app: &mut LightcraftApp, ui: &mut egui::Ui, new_mask: bool) {
};
ui.label(crate::i18n::tr(prompt));
let mut submit = false;
ui.horizontal(|ui| {
// the (translated) Select button is placed first, at the right; the field takes what is left
ui.allocate_ui_with_layout(vec2(ui.available_width(), 24.0), egui::Layout::right_to_left(egui::Align::Center), |ui| {
if text_button(ui, "maskDescribeGo", crate::i18n::tr("Select"), false).clicked() {
submit = true;
}
let r = ui.add(
egui::TextEdit::singleline(&mut text)
.hint_text(crate::i18n::tr("e.g. sky · the red car · car, road"))
.desired_width(ui.available_width() - 64.0),
.desired_width(ui.available_width()),
);
register(ui.ctx(), "maskDescribe", r.rect);
if !r.has_focus() && !r.lost_focus() && text.is_empty() {
@@ -719,9 +755,6 @@ fn describe_field(app: &mut LightcraftApp, ui: &mut egui::Ui, new_mask: bool) {
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
submit = true;
}
if text_button(ui, "maskDescribeGo", crate::i18n::tr("Select"), false).clicked() {
submit = true;
}
});
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
app.ui.describe = None;
@@ -813,3 +846,32 @@ fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The grid keeps four 52 pt tiles when the labels fit, and gives a long (translated) label
/// room by dropping a column rather than drawing it past its tile.
#[test]
fn tile_labels_get_the_room_they_need() {
let short = |size: f32| size * 4.0; // "Luminance"-ish: 42 pt at 10.5
let long = |size: f32| size * 5.6; // "Background"/"Hintergrund": 59 pt at 10.5, 53 at 9.5
assert_eq!(tile_layout(224.0, short), (4, 51.0, 10.5));
// too wide for four at either size: three tiles, wider than 52 so the label fits
let (n, w, size) = tile_layout(224.0, long);
assert_eq!((n, size), (3, 10.5));
assert!(w >= long(10.5) + 2.0 * TILE_PAD && w <= (224.0 - 2.0 * TILE_GAP) / 3.0, "{w}");
// a smaller label keeps a column: 9.5 pt fits where 10.5 doesn't
let mid = |size: f32| size * 4.6; // 48.3 at 10.5, 43.7 at 9.5
assert_eq!(tile_layout(224.0, mid), (4, 51.0, 9.5));
// a wide panel: tiles stay 52 pt (or as wide as the label)
assert_eq!(tile_layout(474.0, short).0, 4);
assert_eq!(tile_layout(474.0, short).1, TILE);
// far too narrow for any label: two tiles (labels cut short), never a negative width
let (n, w, _) = tile_layout(60.0, long);
assert_eq!(n, 2);
assert!(w >= 24.0);
assert_eq!(tile_layout(f32::NAN, long).0, 2);
}
}
+14
View File
@@ -649,6 +649,20 @@ fn ai_masks_without_the_model_offer_the_download() {
// no location configured in this build: no Download button (only Close), and an agent
// confirming anyway gets the reason; the dialog stays
h.step();
// …but a way to install it by hand: the guide, and the model folder (created on demand)
assert!(h.app.widgets.iter().any(|(id, _)| id == "link:samHelp"), "no installation guide link");
let shown = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let log = shown.clone();
h.app.services.reveal = Some(Box::new(move |p: &str| {
log.lock().unwrap().push(p.to_string());
Ok(())
}));
h.step();
let r = h.request("ui.clickWidget", json!({"id": "button:samFolder"}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(dir.is_dir(), "the model folder is created to be shown");
assert_eq!(*shown.lock().unwrap(), vec![dir.to_string_lossy().to_string()]);
let _ = std::fs::remove_dir_all(&dir);
let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T);
assert_eq!(r["ok"], false, "{r}");
let r = h.request("ui.dialog.confirm", json!({}), T);
@@ -0,0 +1,92 @@
//! Check painted text as well as widget rectangles: custom painters can overflow a valid widget.
use egui::{Rect, pos2, vec2};
use serde_json::json;
use crate::{LightcraftApp, Services, headless::HeadlessView, i18n::Locale};
const LONG_NAME: &str = "The person standing beside the very long fence across the background";
fn text_shapes(shape: &egui::Shape, out: &mut Vec<egui::epaint::TextShape>) {
match shape {
egui::Shape::Text(text) => out.push(text.clone()),
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| text_shapes(s, out)),
_ => {}
}
}
fn panel(width: f32, scale: f32, locale: Locale, describe: bool) -> (Vec<egui::epaint::TextShape>, Vec<(String, Rect)>) {
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());
app.run("mask.add", json!({"kind": "subject", "name": LONG_NAME})).unwrap();
app.ui.describe = describe.then(|| ("new".into(), LONG_NAME.repeat(4)));
app.ui.detail_due = Some((100.0, 1));
let photo = app.session.active().unwrap();
let ctx = egui::Context::default();
crate::i18n::set_language(locale);
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx);
let mut texts = Vec::new();
let mut widgets = Vec::new();
for frame in 0..3 {
let raw = HeadlessView::raw_input(vec2(width, 1600.0), scale, frame as f64 / 60.0, vec![]);
let mut out = ctx.run_ui(raw, |ui| {
egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| {
crate::panels::masking::show(&mut app, ui, photo);
});
});
out.textures_delta.clear();
texts.clear();
for shape in &out.shapes {
text_shapes(&shape.shape, &mut texts);
}
widgets = crate::widgets::take_registry(&ctx);
}
if describe {
assert_eq!(app.ui.describe.as_ref().unwrap().1, LONG_NAME.repeat(4), "layout must not shorten the actual prompt");
}
crate::i18n::set_language(Locale::En);
(texts, widgets)
}
#[test]
fn masking_tile_labels_fit_without_eliding_at_supported_panel_widths() {
for locale in [Locale::En, Locale::De, Locale::PtBr] {
for width in [250.0, 270.0, 330.0, 520.0] {
for scale in [1.0, 2.0] {
let (texts, widgets) = panel(width, scale, locale, false);
for (kind, label) in [("background", "Background"), ("luminanceRange", "Luminance"), ("prompt", "Describe")] {
crate::i18n::set_language(locale);
let label = crate::i18n::tr(label).to_string();
crate::i18n::set_language(Locale::En);
let tile = widgets.iter().find(|(id, _)| id == &format!("maskNew:{kind}")).unwrap().1;
let text = texts.iter().find(|t| t.galley.job.text == label && t.pos.y < tile.bottom()).unwrap();
assert!(!text.galley.elided, "{locale:?} {width} {label} should be readable in full");
assert!(
tile.shrink2(vec2(2.0, 0.0)).contains_rect(text.visual_bounding_rect()),
"{locale:?} {width} {scale}: {label} exceeds {tile:?}"
);
}
}
}
}
}
#[test]
fn masking_long_names_and_describe_controls_stay_inside_the_panel() {
for locale in [Locale::En, Locale::De, Locale::PtBr] {
for width in [250.0, 270.0, 520.0] {
let (texts, widgets) = panel(width, 2.0, locale, true);
let row = widgets.iter().find(|(id, _)| id == "mask:1").unwrap().1;
let name = texts.iter().find(|t| t.galley.job.text == LONG_NAME).unwrap();
assert!(name.galley.elided, "long mask names should be elided");
assert!(
Rect::from_min_max(pos2(row.left() + 32.0, row.top()), pos2(row.right() - 28.0, row.bottom()))
.contains_rect(name.visual_bounding_rect())
);
let field = widgets.iter().find(|(id, _)| id == "maskDescribe").unwrap().1;
let button = widgets.iter().find(|(id, _)| id == "button:maskDescribeGo").unwrap().1;
assert!(field.right() <= button.left(), "the prompt must not overlap Select");
assert!(field.left() >= 24.0 && button.right() <= width - 22.0, "{locale:?} {width}: {field:?} {button:?}");
}
}
}
+20
View File
@@ -117,7 +117,27 @@ fn masking_contents_fit_the_right_panel_at_any_width() {
T,
);
assert_eq!(r["ok"], true, "{r}");
let long = "The person standing beside the very long fence across the background";
let r = h.request("engine.execute", json!({"command": "mask.rename", "params": {"id": 1, "name": long}}), T);
assert_eq!(r["ok"], true, "{r}");
for width in [RIGHT_WIDTH.min, RIGHT_WIDTH.default, 330.0, RIGHT_WIDTH.max] {
// a long Describe prompt being typed: the field and its Select button stay in the panel
h.app.ui.describe = Some(("new".into(), long.repeat(2)));
h.step();
h.step();
assert_inside_right_panel(&h, &format!("width {width}, describing"));
let (field, go) = (widget(&h, "maskDescribe"), widget(&h, "button:maskDescribeGo"));
assert!(field.right() <= go.left() && go.right() <= widget(&h, "panel:right_panel").right(), "width {width}: {field:?} {go:?}");
h.app.ui.describe = None;
// the painted text, not just the widgets: tile labels inside their tiles, a long mask
// name cut short before the eye
h.step();
for (id, r) in h.app.widgets.iter().filter(|(id, _)| id.starts_with("maskNewLabel:")) {
let tile = widget(&h, &id.replace("maskNewLabel:", "maskNew:"));
assert!(tile.contains_rect(*r), "width {width}: {id} {r:?} outside its tile {tile:?}");
}
let (name, row) = (widget(&h, "maskName:1"), widget(&h, "mask:1"));
assert!(name.right() <= row.right() - 27.0, "width {width}: the name {name:?} runs under the eye of {row:?}");
for (selected, tool) in [(false, ""), (true, ""), (true, "brush")] {
let mask = if selected { json!(2) } else { serde_json::Value::Null };
h.request("engine.execute", json!({"command": "mask.select", "params": {"id": mask}}), T);
+9
View File
@@ -593,6 +593,15 @@ mod access_tests {
}
}
/// `text` laid out on one line, cut short with … when it is wider than `max` (the galley's
/// `elided` says whether it was): for text painted into a fixed box (a tile, a row before its
/// buttons), which a plain `Painter::text` would draw past.
pub(crate) fn one_line(painter: &egui::Painter, text: &str, font: egui::FontId, color: Color32, max: f32) -> std::sync::Arc<egui::Galley> {
let mut job = egui::text::LayoutJob::simple_singleline(text.to_string(), font, color);
job.wrap = egui::text::TextWrapping::truncate_at_width(max.max(1.0));
painter.layout_job(job)
}
/// `text` shortened to fit `max` (as `width` measures it): leading folders drop first
/// (`Users/me/Pictures/Lightroom` → `…/Pictures/Lightroom`) so the end of a path, which says the
/// most, stays; a single name still too long loses its end (`2024-06-12 Tri…`: folders tend to
+3 -1
View File
@@ -80,7 +80,9 @@ The model is fetched from an ordered list of mirrors: base URLs where `<base>/mo
> **Maintainers:** the built-in list is **empty** until LightCraft's own CDN locations exist
> (see the `TODO(maintainer)` there): add them in order of preference, host the three files
> unchanged, and pin `vocab.json` / `merges.txt` (size + SHA-256) in `SAM3_FILES` at the same
> time. Until then the in-app download needs a user-configured mirror, and the dialog says so.
> time. Until then the in-app download needs a user-configured mirror, and the dialog says so:
> it names the three files to put in the model folder by hand, with a button that shows that folder
> (created if needed) and a link to this guide.
Hugging Face's `facebook/sam3` can't be a default: it is **gated** (each person must accept the
licence there, wait for approval and download with their own token). Someone with access can
+47
View File
@@ -0,0 +1,47 @@
# LightCraft en español
Elegir **Editar → Idioma → Español** o **Ajustes → General → Idioma**. El cambio se aplica al
momento y se conserva para el próximo inicio (`language: "es"` en `ui.json`). `LIGHTCRAFT_LANGUAGE`
acepta cualquier variante del español (`es`, `es-ES`, `es_MX.UTF-8`, `es-419`…).
El catálogo en español cubre los menús, los métodos abreviados de teclado, la biblioteca, el
revelado, el recorte, las máscaras, la importación y la exportación, los ajustes, los ajustes
preestablecidos y perfiles integrados, el historial, las fechas y los mensajes de progreso. Como en
los demás idiomas, los errores técnicos de los niveles inferiores siguen en inglés, y los textos
que aún no están en el catálogo se muestran en inglés.
Los nombres de archivo, los identificadores de comandos, las plantillas y los álbumes, ajustes
preestablecidos y metadatos con nombres propios no se traducen. La fuente de la interfaz, Inter,
incluye todos los caracteres del español; no hacen falta fuentes adicionales.
## Terminología
Se usan infinitivos o formas impersonales en las instrucciones («Hacer clic…», «Elegir…»), sin
tratamiento de usted. Algunos términos que conviene mantener coherentes:
| Inglés | Español |
|---|---|
| Highlights / Shadows | Iluminaciones / Sombras |
| Dehaze | Eliminar neblina |
| Vibrance | Intensidad |
| Tint | Matiz |
| Hue (HSL) | Tono |
| Color Grading | Gradación de color |
| Preset | Ajuste preestablecido |
| Pick / Reject | Seleccionada / Rechazada |
| Upright | Vertical |
| Lens Corrections | Correcciones de lente |
Las correcciones de hablantes nativos son bienvenidas: abrir un issue o un pull request con el
texto inglés, la traducción actual y la propuesta.
## Mantenimiento y comprobación
Textos fijos: `crates/ui-egui/locales/es.json`. Mensajes con valores:
`crates/ui-egui/locales/es-formats.json`. Las claves en inglés no cambian; el compilador de Rust
comprueba los marcadores al compilar. Las terminaciones de plural del inglés se ocultan con `{:.0}`.
```sh
cargo test -p lightcraft-ui-egui i18n::tests -- --nocapture
LIGHTCRAFT_LANGUAGE=es lightcraft-cli snapshot --demo -o espanol.png --size 1600x1000
```
+2 -2
View File
@@ -2,9 +2,9 @@
The interface ships in the language it is written in (English) plus every language in the table in
`crates/ui-egui/src/i18n.rs`. Today that is English, Simplified Chinese (`zh-hans`), Traditional
Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), German (`de`) and Russian (`ru`). This file is the reference for **adding or maintaining a language**; the per-language notes
Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), Spanish (`es`), German (`de`) and Russian (`ru`). This file is the reference for **adding or maintaining a language**; the per-language notes
are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-hant.md`](localization-zh-hant.md),
[`localization-ja.md`](localization-ja.md), [`localization-pt-br.md`](localization-pt-br.md),
[`localization-ja.md`](localization-ja.md), [`localization-pt-br.md`](localization-pt-br.md), [`localization-es.md`](localization-es.md),
[`localization-de.md`](localization-de.md) and [`localization-ru.md`](localization-ru.md).
## Adding a language
+3 -3
View File
@@ -344,7 +344,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-MASK-SUBJECT | Select subject | P2 | 🟡 | `cmd:mask.add` (`subject`), `crates/pipeline/src/masks.rs` | saliency heuristic, no segmentation model |
| LR-MASK-SKY | Select sky | P2 | 🟡 | `cmd:mask.add` (`sky`) | heuristic |
| LR-MASK-BACKGROUND | Select background | P2 | 🟡 | `cmd:mask.add` (`background`) | inverse of the subject heuristic |
| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand; see docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) |
| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, shows the model folder and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) |
| LR-MASK-PEOPLE | People parts | P2 | ⬜ | | |
| LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty |
| LR-MASK-BRUSH | Brush mask | P0 | ✅ | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs` | size/feather/flow/density/erase; Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure |
@@ -520,8 +520,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | |
| LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | |
| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader |
| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.german`, `cmd:app.language.russian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) |
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, German and Russian UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language is a table entry plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the German catalog covers every existing catalog key, command/control label and rule-editor label; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German; Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds only the Japanese face. |
| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) |
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German and Russian UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language is a table entry plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the German catalog covers every existing catalog key, command/control label and rule-editor label; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German; Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds only the Japanese face. |
| LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | |
| LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights |
| LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | |
+2 -2
View File
@@ -60,8 +60,8 @@
or unchecks a range of photos (issue #338).
### Languages
- The interface is available in German and Russian (Edit ▸ Language), alongside English, Chinese (Simplified and
Traditional), Japanese and Brazilian Portuguese.
- The interface is available in Spanish (issue #371), German and Russian (Edit ▸ Language), alongside English,
Chinese (Simplified and Traditional), Japanese and Brazilian Portuguese.
### Editing
- Type an exact value into any slider (issue #322): click the number next to its name, type (`1.5`, `-20`, `5600`)