Compare commits

..
Author SHA1 Message Date
ClaudeandElliot Graham cad024fbff Part of #372: painted-text layout tests from #376
The tests from @QuirkyTurtle94's #376, a duplicate fix for #372 opened
while #379 was in review: they read the painted text shapes (not the
registered rects) at 1x and 2x scale, in English, German and Brazilian
Portuguese, at panel widths 250-520, and check that tile labels fit
unelided inside their tiles, that a long mask name is elided before the
eye, and that a long Describe prompt keeps Select inside the panel.
They pass unchanged against main's implementation.

Co-Authored-By: Elliot Graham <elliotgraham94@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:47:07 +00:00
BFlat a673b2bb7a Merge pull request #382 from storytold/claude/i18n-371-spanish
Fix #371: Spanish interface
2026-10-09 03:41:48 +07:00
Claude 305ff71474 Part of #371: Spanish for the SAM 3 install dialog (#366)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
(cherry picked from commit 73b22c8524ef17b209ff2fffbb16c248524e2e96)
2026-10-08 20:41:31 +00:00
Claude a0e49629d3 Merge remote-tracking branch 'origin/main' into i18n-371-spanish 2026-10-08 20:41:31 +00:00
BFlat c8f697a128 Merge pull request #381 from storytold/claude/fix-366-sam3-install-help
Fix #366: the SAM 3 dialog without a download location shows how to install by hand
2026-10-09 03:41:25 +07:00
BFlat 232cbace63 Merge pull request #379 from storytold/claude/fix-372-mask-labels
Fix #372: masking tile labels and long mask names stay inside their boxes
2026-10-09 03:41:22 +07:00
Claude 0b587a0a8a Fix #371: Spanish interface
A Spanish (es) catalog and formats catalog covering the same messages as
the Brazilian Portuguese, Japanese and Simplified Chinese catalogs (1595
messages, 180 formats), an Edit > Language > Español command
(app.language.spanish), es tag tests (es, es-ES, es_MX.UTF-8, es-419),
docs/localization-es.md with the terminology used, and the language
lists in docs/localization.md, parity and What's New.

Instructions use infinitives or impersonal forms (no usted). Plural
endings follow the existing convention: the English "s" passes through
where Spanish adds one, else {:.0} swallows it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:20:17 +00:00
Claude 60a98354d9 Fix #366: the SAM 3 dialog without a download location shows how to install by hand
With no mirror configured the dialog was titled "Download the SAM 3
Model?", only said to put "the files" in the folder (see
docs/ai-masks.md) and offered Close. It is now titled "Install the SAM 3
Model", names the three files (model.safetensors, vocab.json,
merges.txt from facebook/sam3), and has a Show in Finder / Explorer /
File Manager button that creates and shows the model folder, plus a link
to the installation guide. Object and Describe work as soon as the files
are there.

The download location itself (a hosted mirror) is still a maintainer
decision: DEFAULT_MIRRORS stays empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:18:47 +00:00
Claude f16f413942 Fix #372: masking tile labels and long mask names stay inside their boxes
The Create New Mask tiles painted their (untranslated) labels with an
unconstrained Painter::text into tiles capped at 52 pt, so Background and
Luminance ran past their tiles, and translations would more so. The grid
now measures the translated labels: four 52 pt tiles when they fit (at
10.5 pt, or 9.5 pt before dropping a column), else three or two, a tile
growing past 52 pt for a long label; only in a panel too narrow for even
that is a label cut short (full text on hover).

A mask row's name is cut short with an ellipsis before the eye (full name
on hover). The Describe field places the translated Select button first
and gives the field what is left, instead of reserving a fixed 64 pt.
Status lines wrap explicitly.

The painted label and name rects are registered (maskNewLabel:*,
maskName:*), so the sidebar test checks the text, not just the widgets,
at four panel widths with a long name and a long prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 20:09:16 +00:00
BFlat 48cd9c5e3a Merge pull request #375 from storytold/claude/integrate-batch-4
Integrate #279 (Canon CR3 decoding) and #362 (file dialog filters)
2026-10-09 03:08:11 +07:00
22 changed files with 2125 additions and 38 deletions

No files matched your search

+3 -1
View File
@@ -649,6 +649,7 @@
"Horizontal": "Horizontal",
"Horizontal text": "Horizontaler Text",
"Hours": "Stunden",
"How to install the model": "So installierst du das Modell",
"Hue": "Farbton",
"Hue Range": "Farbtonbereich",
"ISO Speed": "ISO-Empfindlichkeit",
@@ -694,6 +695,7 @@
"Info overlay": "Infoüberlagerung",
"Insert at the cursor": "An Cursorposition einfügen",
"Inspect Photo": "Foto untersuchen",
"Install the SAM 3 Model": "SAM-3-Modell installieren",
"Instant ’70s": "Sofortbild der 70er",
"Interface": "Oberfläche",
"Interface language": "Sprache der Oberfläche",
@@ -1455,7 +1457,7 @@
"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 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.",
"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",
+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
+3 -1
View File
@@ -499,6 +499,7 @@
"High Contrast B&W": "ハイコントラスト白黒",
"Home": "ホーム",
"Hours": "時間",
"How to install the model": "モデルのインストール方法",
"ISO Speed": "ISO感度",
"ISO speed": "ISO感度",
"Import Point Curve Presets…": "ポイントカーブのプリセットを読み込む…",
@@ -512,6 +513,7 @@
"Info Overlay: File": "情報オーバーレイ:ファイル",
"Insert at the cursor": "カーソル位置に挿入",
"Inspect Photo": "写真を調べる",
"Install the SAM 3 Model": "SAM 3モデルのインストール",
"Instant ’70s": "インスタント’70s",
"Invert Component": "構成要素を反転",
"JPEG (Large)": "JPEG(大)",
@@ -1319,7 +1321,7 @@
"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 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 を上のフォルダに置いてください。置いた時点で「オブジェクト」と「説明で選択」が使えるようになります。",
"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": "しきい値",
+3 -1
View File
@@ -623,6 +623,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",
@@ -663,6 +664,7 @@
"Info overlay": "Sobreposição de informações",
"Insert at the cursor": "Inserir no cursor",
"Inspect Photo": "Inspecionar foto",
"Install the SAM 3 Model": "Instalar o modelo SAM 3",
"Instant ’70s": "Instantâneo anos 70",
"Interface": "Interface",
"Interface language": "Idioma da interface",
@@ -1401,7 +1403,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
@@ -509,6 +509,7 @@
"History & Activity (Y)": "История и активность (Y)",
"Horizontal": "По горизонтали",
"Horizontal text": "Горизонтальный текст",
"How to install the model": "Как установить модель",
"Hue": "Тон",
"Hue Range": "Диапазон тонов",
"If file exists": "Если файл существует",
@@ -541,6 +542,7 @@
"Info (I)": "Информация (I)",
"Info overlay": "Информационная накладка",
"Inspect Photo": "Просмотр фото",
"Install the SAM 3 Model": "Установка модели SAM 3",
"Instant ’70s": "Мгновенный стиль 70-х",
"Interface": "Интерфейс",
"Interface language": "Язык интерфейса",
@@ -1151,6 +1153,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": "Порог",
+3 -1
View File
@@ -505,6 +505,7 @@
"Has GPS": "有 GPS",
"Home": "主文件夹",
"Hours": "小时",
"How to install the model": "如何安装模型",
"ISO Speed": "ISO 感光度",
"ISO speed": "ISO 感光度",
"Import as new": "作为新照片导入",
@@ -513,6 +514,7 @@
"Info Overlay: Exposure": "信息叠加:曝光",
"Info Overlay: File": "信息叠加:文件",
"Insert at the cursor": "在光标处插入",
"Install the SAM 3 Model": "安装 SAM 3 模型",
"Invert Component": "反相组件",
"Join the ArtCraft community on Discord": "加入 Discord 上的 ArtCraft 社区",
"Label": "标签",
@@ -1388,7 +1390,7 @@
"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 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 放入上方的文件夹:放入后即可使用“对象”和“描述”。",
"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": "阈值",
+3 -1
View File
@@ -505,6 +505,7 @@
"Has GPS": "有 GPS",
"Home": "個人資料夾",
"Hours": "小時",
"How to install the model": "如何安裝模型",
"ISO Speed": "ISO 感光度",
"ISO speed": "ISO 感光度",
"Import as new": "以新照片匯入",
@@ -513,6 +514,7 @@
"Info Overlay: Exposure": "資訊疊加:曝光",
"Info Overlay: File": "資訊疊加:檔案",
"Insert at the cursor": "在游標處插入",
"Install the SAM 3 Model": "安裝 SAM 3 模型",
"Invert Component": "反轉元件",
"Join the ArtCraft community on Discord": "加入 Discord 上的 ArtCraft 社群",
"Label": "標籤",
@@ -1388,7 +1390,7 @@
"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 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 放入上方的資料夾:放入後即可使用「物件」與「描述」。",
"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": "臨界值",
+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
@@ -39,6 +39,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",
@@ -829,7 +831,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",
@@ -925,6 +927,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};
@@ -971,12 +989,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
@@ -49,8 +49,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`)