Merge remote-tracking branch 'origin/main' into i18n-371-spanish
This commit is contained in:
commit
a0e49629d3
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "しきい値",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Порог",
|
||||
|
|
|
|||
|
|
@ -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": "阈值",
|
||||
|
|
|
|||
|
|
@ -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": "臨界值",
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue