mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-05 17:35:33 -03:00
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9dacaf06f7 | ||
|
|
f94c6b7497 | ||
|
|
cf3bff9922 | ||
|
|
43ef1e86f6 | ||
|
|
35f1ced41a | ||
|
|
b36f2099ee | ||
|
|
896ce5eddb | ||
|
|
f8aba393fe | ||
|
|
515469054c | ||
|
|
a5bd3845da | ||
|
|
034660d8c4 | ||
|
|
2a667df98c | ||
|
|
693e7e8dca | ||
|
|
4ece4b42ff | ||
|
|
7b2a108596 | ||
|
|
3aa32120df | ||
|
|
2193ec8f38 | ||
|
|
eeb9270827 | ||
|
|
42fa8294df | ||
|
|
18504c712c | ||
|
|
013c378a7a | ||
|
|
faeb66a23d | ||
|
|
69691b17a1 | ||
|
|
b90d60f043 | ||
|
|
c3a9350155 | ||
|
|
0dd8d74032 | ||
|
|
0a262cbe0c | ||
|
|
ebd2b5b3e0 | ||
|
|
0f7aa85b0b | ||
|
|
e7c1c07db0 | ||
|
|
485679223b | ||
|
|
7aee964448 | ||
|
|
62c144d80a | ||
|
|
d21f209bad | ||
|
|
a6fca8612f | ||
|
|
5e4462822d | ||
|
|
6e45ef566c | ||
|
|
16430aef21 | ||
|
|
f5e983eaaa | ||
|
|
297d8787bd | ||
|
|
20d8c22390 |
@@ -10,6 +10,9 @@ civitai/
|
||||
stats/
|
||||
wildcards/
|
||||
backups/
|
||||
# Portable-mode centralized sidecar storage (<repo>/sidecars): user data that
|
||||
# must survive pulls and stay out of git status
|
||||
/sidecars/
|
||||
logs/
|
||||
node_modules/
|
||||
coverage/
|
||||
|
||||
@@ -266,6 +266,13 @@ If a cross-layer issue ever needs a live server, the sandboxed helpers live in
|
||||
them during migration/import. Never write, modify, or delete them, and never
|
||||
propose doing so as a fix — LoRA Manager's own metadata lives in the
|
||||
`.metadata.json` sidecar it owns.
|
||||
- **Sidecar/preview path derivation must go through `py/utils/sidecar_paths.py`**
|
||||
helpers (never inline `splitext + ".metadata.json"`): the centralized storage
|
||||
mode (`sidecar_storage_mode` / `sidecar_storage_path` settings) relocates
|
||||
`.metadata.json` files and preview images under a mirror tree, so any
|
||||
hand-built path is wrong in that mode. `.civitai.info` stays co-located with
|
||||
the model file in both modes. The new settings keys live only in
|
||||
`DEFAULT_SETTINGS` — `settings.json.example` stays minimal (see below).
|
||||
- **`settings.json.example` must stay minimal**: only `use_portable_settings`,
|
||||
`civitai_api_key`, and the four core `folder_paths` keys (`loras`,
|
||||
`checkpoints`, `unet`, `embeddings`). Do NOT add optional/default keys
|
||||
|
||||
@@ -74,6 +74,7 @@ Enriches models linked to an external model site with metadata extracted by an L
|
||||
| ModelScope (`modelscope.cn`) | yes | yes | yes |
|
||||
| ModelScope International (`modelscope.ai`) | yes | yes | yes |
|
||||
| TensorArt | yes | no (see below) | no |
|
||||
| OpenModelDB | yes | yes (card data from the catalogue, no README) | yes |
|
||||
|
||||
`modelscope.cn` and `modelscope.ai` are **separate catalogues, not mirrors** — a
|
||||
repository published on one is routinely absent from the other — so each is
|
||||
@@ -85,6 +86,8 @@ tables in `modelSourceHelpers.js` and `registry.py` in step when adding a site.
|
||||
|
||||
TensorArt is link-only: `tensor.art` sits behind a Cloudflare managed challenge and its internal API requires session authorization, so the backend cannot read its model pages. Linking still stores the canonical page URL and the "View on TensorArt" link works.
|
||||
|
||||
OpenModelDB is the upscaler catalogue: model ids are flat tokens (no `owner/name`), there are no revisions and no README — `fetch_model_card_context()` reads everything (description, license, tags, example images) from the disk-cached bulk catalogue in `py/services/openmodeldb_client.py`. Only PyTorch resources (`.pth`/`.safetensors`) are downloadable, and only via mirrors that serve raw bytes: HTML-gateway hosts (`mediafire.com`, `mega.nz`, `drive.google.com`) are skipped in favour of a direct mirror, and a model with only gateway mirrors reports a manual-download hint instead of a file list. Filenames are derived from URL path segments (mediafire buries them mid-path) or synthesized as `{model_id}.{type}` for folder links.
|
||||
|
||||
**What it does**:
|
||||
1. Reads the model's `.metadata.json` to get the source (`source_platform` + `source_url`, or the legacy `hf_url`)
|
||||
2. Fetches the model card through the provider in `py/services/model_sources/` — the README via `fetch_model_card()`, plus any extras the site keeps outside it via `fetch_model_card_context()`
|
||||
|
||||
@@ -4,7 +4,7 @@ This document is the canonical set of conventions for translating LoRA Manager U
|
||||
It applies to **human translators and AI agents** alike. Read it before editing anything in
|
||||
`locales/`.
|
||||
|
||||
Source of truth: `locales/en.json` (10 locales, 2025 leaf keys; all locales share the exact
|
||||
Source of truth: `locales/en.json` (10 locales, 2128 leaf keys; all locales share the exact
|
||||
same key structure).
|
||||
|
||||
Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
|
||||
@@ -78,6 +78,68 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
|
||||
> the `toast.loras.filenameTemplate*` / `toast.settings.filenameTemplates*` toasts. All 9
|
||||
> locales are translated (terminology in §2, "Filename Templates feature").
|
||||
|
||||
> **Status (2026-09, folder delete verification):** the folder delete modal no longer trusts the
|
||||
> sidebar's "empty folder" prediction — it dry-runs the delete against the backend and renders
|
||||
> the answer, so a folder whose models are all *excluded* (invisible to the model lists, still
|
||||
> real weight files on disk) is refused with an explanation instead of contradicting itself.
|
||||
> That added 5 keys (`sidebar.deleteFolderModal.notEmptyMessageCount`,
|
||||
> `.notEmptyMessageExcluded`, `.busyTitle`, `.checking`, `sidebar.deleteFolderResult.notEmptyWithCount`);
|
||||
> all 9 locales are translated (terminology in §2, "Folder sidebar feature"), so the
|
||||
> "no remaining placeholders" claim holds again.
|
||||
|
||||
> **Status (2026-09, sidecar storage):** optional centralized storage for `.metadata.json`
|
||||
> sidecars and preview images added 23 keys — `settings.sections.sidecarStorage`,
|
||||
> the 18 `settings.sidecarStorage.*` labels/help/status/confirm strings, and the 4
|
||||
> `modals.sidecarMigrationConfirm.*` titles/button. The pull request merged them as
|
||||
> `[TODO: Translate]` copies; all 9 locales are now translated (terminology in §2,
|
||||
> "Sidecar storage feature"), so no placeholder remains and the "no remaining placeholders"
|
||||
> claim holds again.
|
||||
|
||||
> **Status (2026-09, sidecar storage UX follow-up):** the migration UX follow-up added
|
||||
> 7 `settings.sidecarStorage.open*`/path-display keys, `modals.sidecarMigrationConfirm.destination`,
|
||||
> and the 13-key `modals.sidecarMigrationResult.*` summary block (which replaces
|
||||
> `settings.sidecarStorage.migrateSuccess` — the result modal is now the success feedback,
|
||||
> mirroring `modals.metadataFetchSummary.*`/`modals.downloadBatchSummary.*` stat-card and
|
||||
> failure-table conventions; reuse each locale's existing renderings of those sibling keys).
|
||||
> All 9 locales are translated in the same pass (terminology in §2, "Sidecar storage feature").
|
||||
|
||||
> **Status (2026-10, unknown base model routing):** the download-routing inversion added
|
||||
> 4 keys (`settings.unknownBaseModelRouting.label`, `.help`, `.options.diffusionModel`,
|
||||
> `.options.checkpoint`) — the option labels reuse each locale's existing
|
||||
> `checkpoints.modelTypes.diffusion_model` / `.checkpoint` renderings. The same pass also
|
||||
> translated the leftover `doctor.issues.sidecar_mirror_orphans.title` ("Centralized
|
||||
> Sidecars", §2 "Sidecar storage feature" terminology). All 9 locales are translated,
|
||||
> so the "no remaining placeholders" claim holds again. Terminology in §2, "Download
|
||||
> routing feature".
|
||||
|
||||
> **Status (2026-10, routing-override follow-up):** the download modal's location step
|
||||
> gained a manual "Destination type" toggle (Checkpoint | Diffusion Model) for when the
|
||||
> auto routing misdetects, adding 2 keys (`modals.download.routingOverride.label`,
|
||||
> `.tooltip`). The tooltip quotes each locale's `modals.download.useDefaultPath` label
|
||||
> verbatim (switching turns it off for the session), using that locale's UI-label quoting
|
||||
> style. All 9 locales are translated (terminology in §2, "Download routing feature"),
|
||||
> so the "no remaining placeholders" claim holds again.
|
||||
|
||||
> **Status (2026-10, OpenModelDB):** the OpenModelDB metadata-provider toggle added 2 keys
|
||||
> (`settings.metadataArchive.enableOpenmodeldbApi(Help)`); all 9 locales are translated
|
||||
> (terminology in §2, "OpenModelDB feature"), so the "no remaining placeholders" claim
|
||||
> holds again.
|
||||
|
||||
> **Status (2026-10, Civitai ids in model modal):** the model modal's hash footnote now
|
||||
> shows the Civitai model id and version id (right-aligned, with copy buttons), adding
|
||||
> 4 keys (`modals.model.metadata.civitaiModelId` / `.civitaiVersionId`,
|
||||
> `modals.model.actions.copyCivitaiId` / `.civitaiIdCopied`). The same pass removed the
|
||||
> search-options "hash" toggle (`header.search.filters.hash`) because hash/id search is
|
||||
> now always on. All 9 locales are translated (terminology in §2, "Civitai ids feature"),
|
||||
> so the "no remaining placeholders" claim holds again.
|
||||
|
||||
> **Status (2026-10, showcase layout option):** the model modal's example images became
|
||||
> switchable between the on-demand gallery and the classic vertical list (issue #1136),
|
||||
> adding 6 keys (`settings.layoutSettings.showcaseLayout`, `.showcaseLayoutHelp`,
|
||||
> `.showcaseLayoutOptions.gallery` / `.vertical`, `modals.model.showcase.layoutGallery` /
|
||||
> `.layoutList`). All 9 locales are translated (terminology in §2, "Showcase layout
|
||||
> feature"), so the "no remaining placeholders" claim holds again.
|
||||
|
||||
---
|
||||
|
||||
## 1. Hard rules (do not violate)
|
||||
@@ -391,11 +453,32 @@ The model-root sidebar manages on-disk folders. "Folder" reuses the noun already
|
||||
| tree view / list view | zh-CN 树形视图 / 列表视图 · zh-TW 樹狀檢視 / 清單檢視 · ja ツリー表示 / リスト表示 · ko 트리 보기 / 목록 보기 · fr Vue arborescente / Vue liste · de Baumansicht / Listenansicht · es Vista de árbol / Vista de lista · ru Дерево / Список · he תצוגת עץ / תצוגת רשימה |
|
||||
| sidebar | reuse each locale's `sidebar.hideOnThisPage` noun: zh-CN 侧边栏 · zh-TW 側邊欄 · ja サイドバー · ko 사이드바 · fr barre latérale · de Seitenleiste · es barra lateral · ru боковая панель · he סרגל צד |
|
||||
|
||||
Deleting a folder **never cascades over model files** — the backend refuses it and
|
||||
`sidebar.deleteFolderModal.notEmptyMessage` states the rule in every locale, so keep that
|
||||
clause (and its `—`) when the copy is edited. The `{name}` / `{count}` / `{message}` tokens in
|
||||
`sidebar.createFolderResult.*`, `sidebar.deleteFolderResult.*` and `sidebar.renameFolderResult.*`
|
||||
are verbatim §1-R2 placeholders; `successWithFiles` is the only key carrying `{count}`.
|
||||
Deleting a folder **never cascades over model files** — the backend refuses it and the
|
||||
`sidebar.deleteFolderModal.notEmptyMessage*` keys state the rule in every locale, so keep that
|
||||
clause (and its `—`) when the copy is edited. The three variants split by what the modal knows:
|
||||
`notEmptyMessage` (no counts), `notEmptyMessageCount` (`{count}`, the blocking models are all
|
||||
listed) and `notEmptyMessageExcluded` (`{count}` + `{excluded}`, at least one is hidden by the
|
||||
`exclude` flag — the case where the folder legitimately looks empty). `checking` ("Checking the
|
||||
folder contents...", ASCII ellipsis) shows while the backend dry run is pending, `busyTitle`
|
||||
titles the already-pending-staged-delete state, and `notEmptyWithCount` mirrors
|
||||
`deleteFolderResult.notEmpty` with the count for the stale-tree toast.
|
||||
|
||||
| Term | Rendering |
|
||||
|---|---|
|
||||
| excluded from the library | zh-CN 已从模型库中排除 · zh-TW 已從模型庫中排除 · ja ライブラリから除外 · ko 라이브러리에서 제외 · fr exclu de la bibliothèque · de von der Bibliothek ausgeschlossen · es excluido de la biblioteca · ru исключены из библиотеки · he מוחרגים מהספרייה |
|
||||
| un-exclude (verb) | zh-CN 取消排除 · zh-TW 取消排除 · ja 除外を解除 · ko 제외를 해제 · fr annuler l'exclusion · de den Ausschluss aufheben · es anular la exclusión · ru снять исключение · he לבטל את ההחרגה |
|
||||
| "Manage Excluded Models" quoted in prose | zh-CN “管理已排除的模型” · zh-TW 「管理已排除的模型」 · ja 「除外モデルを管理」 · ko '제외된 모델 관리' · fr « Gérer les modèles exclus » · de „Ausgeschlossene Modelle verwalten“ · es «Gestionar modelos excluidos» · ru «Управление исключёнными моделями» · he «ניהול מודלים מוחרגים» |
|
||||
|
||||
A UI label quoted inside prose follows each locale's existing help-text style (zh-CN “ ”,
|
||||
zh-TW/ja 「 」, ko ASCII `' '`, fr/ru/es/he « », de „ “) — see `settings.hideEarlyAccessUpdates.help`
|
||||
/ `settings.civitaiHost.help` as the precedent. `קובצי מודלים` is the Hebrew model-file noun
|
||||
(`notEmptyMessage`); keep it identical in all four Hebrew keys.
|
||||
|
||||
The `{name}` / `{count}` / `{excluded}` / `{message}` tokens in `sidebar.createFolderResult.*`,
|
||||
`sidebar.deleteFolderResult.*` and `sidebar.renameFolderResult.*` are verbatim §1-R2
|
||||
placeholders. The keys carrying `{count}` are `successWithFiles`, `notEmptyMessageCount`,
|
||||
`notEmptyMessageExcluded` and `notEmptyWithCount`; `notEmptyMessageExcluded` is the only key
|
||||
carrying `{excluded}`.
|
||||
|
||||
### Settings Organization tab
|
||||
|
||||
@@ -418,6 +501,48 @@ the **noun for arranging files**, matching each locale's existing
|
||||
|
||||
zh-CN/zh-TW use 整理 ("tidying/arranging"), not 组织/組織 (an organization as a group).
|
||||
|
||||
### Sidecar storage feature (centralized `.metadata.json` / preview storage)
|
||||
|
||||
The Library settings tab hosts an optional mode that stores `.metadata.json` sidecars and
|
||||
preview images either **alongside** each model file or in a single **centralized** mirror tree,
|
||||
plus the manual migration that moves existing files between the two. Everything lives in
|
||||
`settings.sections.sidecarStorage` (the section header inside the Library tab),
|
||||
`settings.sidecarStorage.*` and `modals.sidecarMigrationConfirm.*`.
|
||||
|
||||
- **`sidecar` is a technical noun, not a brand**, so each locale either borrows it or uses its
|
||||
own companion-file word — one rendering per file:
|
||||
|
||||
| Term | Rendering |
|
||||
|---|---|
|
||||
| sidecar (noun) | zh-CN 附属文件 · zh-TW 附屬檔案 · ja サイドカーファイル · ko 사이드카 파일 · fr fichier sidecar · de Sidecar-Datei · es archivo sidecar · ru sidecar-файл · he קובץ לוואי |
|
||||
| centralized storage | zh-CN 集中存储 · zh-TW 集中儲存 · ja 集中保存 · ko 중앙 집중식 저장 · fr stockage centralisé · de zentrale Speicherung · es almacenamiento centralizado · ru централизованное хранилище · he אחסון מרכזי |
|
||||
| alongside model files | zh-CN 与模型文件放在一起 · zh-TW 與模型檔案放在一起 · ja モデルファイルの隣 · ko 모델 파일 옆 · fr à côté des fichiers de modèle · de neben den Modelldateien · es junto a los archivos de modelo · ru рядом с файлами моделей · he לצד קובצי המודלים |
|
||||
| migrate (verb/noun) | zh-CN 迁移 · zh-TW 遷移 · ja 移動 · ko 이동 · fr migrer / migration · de verschieben / Migration · es migrar / migración · ru перенести / перенос · he להעביר / העברה |
|
||||
| mirror (verb) | zh-CN 镜像 · zh-TW 對應 · ja ミラーリング · ko 미러링 · fr refléter · de spiegeln · es reflejar · ru повторять структуру · he לשקף |
|
||||
| preview images | zh-CN 预览图片 · zh-TW 預覽圖片 · ja プレビュー画像 · ko 미리보기 이미지 · fr images d’aperçu · de Vorschaubilder · es imágenes de vista previa · ru изображения превью · he תמונות תצוגה מקדימה |
|
||||
| (effective) storage location | zh-CN (实际)存储位置 · zh-TW (實際)儲存位置 · ja (実際の)保存場所 · ko (실제) 저장 위치 · fr emplacement de stockage (effectif) · de (tatsächlicher) Speicherort · es ubicación de almacenamiento (efectiva) · ru (фактическое) расположение хранилища · he מיקום האחסון (בפועל) |
|
||||
| Open Folder (button) | zh-CN 打开文件夹 · zh-TW 開啟資料夾 · ja フォルダを開く · ko 폴더 열기 · fr Ouvrir le dossier · de Ordner öffnen · es Abrir carpeta · ru Открыть папку · he פתח תיקייה |
|
||||
| installation folder | zh-CN 安装目录 · zh-TW 安裝目錄 · ja インストールフォルダ · ko 설치 폴더 · fr dossier d’installation · de Installationsordner · es carpeta de instalación · ru папка установки · he תיקיית ההתקנה |
|
||||
|
||||
- `ja`/`ko` follow the file's existing storage-relocation verb (ja 移動, ko 이동, from
|
||||
`settings.folderSettings.recipesPathMigrating`) rather than a transliteration of "migration";
|
||||
`ru` uses перенос for the same reason, and `de` keeps the loan noun `Migration` while the verbs
|
||||
use `verschieben`.
|
||||
- **`.metadata.json`**, **`.civitai.info`** and the default-path literal
|
||||
`(<settings dir>/sidecars)` stay byte-identical in every locale — they are file names and a
|
||||
path, not prose (§6 exception). Hebrew drops the wrapping parentheses to avoid bidi mirroring
|
||||
and writes the literal bare.
|
||||
- `migrationDeferred` names a navigation path ("Settings → Library → Sidecar Storage"), so each
|
||||
locale renders it with its **own** settings label and Library tab label
|
||||
(`common.actions.settings` + `settings.nav.library` + the new section label), using the same
|
||||
arrow and quoting style its other nav-path strings already use — zh-CN “设置 → 库 → …”,
|
||||
zh-TW/ja 「設定 > … > …」, ko `설정 → …` bare, fr/de/es bare
|
||||
(`Paramètres` / `Einstellungen` / `Configuración` → …), ru «Настройки → …»,
|
||||
he `הגדרות > …` bare (cf. `other.noPaths.descriptionStandalone`).
|
||||
- The migrate-button label is quoted inside `confirmToCentralized` / `confirmToAlongside` with
|
||||
each locale's UI-label quoting style (zh-CN “ ”, zh-TW/ja 「 」, ko `' '`, fr/ru/es/he « »,
|
||||
de „ “), matching `settings.sidecarStorage.migrateButton` verbatim so the two never drift.
|
||||
|
||||
### Filename Templates feature
|
||||
|
||||
Per-model-type templates that name downloaded model files; "Apply to Library Now"
|
||||
@@ -455,6 +580,84 @@ short and imperative and do not append a keyboard hint in any locale.
|
||||
|
||||
The grip itself is an icon and is never translated.
|
||||
|
||||
### Download routing feature (unknown base model routing)
|
||||
|
||||
The `settings.unknownBaseModelRouting.*` keys (Settings → Library → Folder Settings) decide
|
||||
which library a checkpoint download lands in when CivitAI reports a baseModel that is in
|
||||
neither the known-checkpoint nor the known-diffusion-model list. The option labels reuse
|
||||
each locale's `checkpoints.modelTypes.diffusion_model` / `.checkpoint` renderings
|
||||
(model-type names, R3 — ja/ko keep the Latin loanword), pluralized only where the locale
|
||||
pluralizes (de Diffusionsmodelle, es Modelos de difusión, fr Modèles de diffusion,
|
||||
ru Диффузионные модели, he מודלי דיפוזיה; CJK stays singular, "Checkpoint(s)" follows
|
||||
`header.navigation.checkpoints`).
|
||||
|
||||
| Term | Rendering |
|
||||
|---|---|
|
||||
| routing (noun, of a download into a library) | zh-CN 路由 · zh-TW 路由 · ja 振り分け · ko 라우팅 · fr routage · de Routing · es enrutamiento · ru маршрутизация · he ניתוב |
|
||||
| unknown base model | zh-CN 未知基础模型 · zh-TW 未知基礎模型 · ja 不明なベースモデル · ko 알 수 없는 베이스 모델 · fr modèle de base inconnu · de unbekanntes Basismodell · es modelo base desconocido · ru неизвестная базовая модель · he מודל בסיס לא מוכר |
|
||||
| destination type (download-modal toggle label) | zh-CN 目标类型 · zh-TW 目標類型 · ja 保存先タイプ · ko 대상 유형 · fr type de destination · de Zieltyp · es tipo de destino · ru тип назначения · he סוג יעד |
|
||||
|
||||
The baseModel family names in the help text (`SD 1.x/2.x/3.x, SDXL, Pony, Illustrious,
|
||||
NoobAI`) are CivitAI baseModel values and stay verbatim in every locale.
|
||||
|
||||
The routing-override toggle (`modals.download.routingOverride.*`) sits on the checkpoints
|
||||
page of the download modal; its two button labels come from `checkpoints.modelTypes.*`
|
||||
directly (model-type names, R3). The tooltip quotes the `modals.download.useDefaultPath`
|
||||
label verbatim with each locale's UI-label quoting style (zh-CN “ ”, zh-TW/ja 「 」,
|
||||
ko `' '`, fr « … », de „ … “, es/ru/he «…»).
|
||||
|
||||
### OpenModelDB feature
|
||||
|
||||
**OpenModelDB** is a brand name and stays Latin in every locale (R3, same as CivitAI /
|
||||
CivArchive); `openmodeldb.info` is a URL and stays verbatim. **Upscaler** follows the
|
||||
Other Models rule (model-type name, Latin everywhere). The label/help mirror each
|
||||
locale's existing `settings.metadataArchive.enableCivarchiveApi(Help)` phrasing, and
|
||||
"metadata" uses the §5 rendering per locale.
|
||||
|
||||
| Term | Rendering |
|
||||
|---|---|
|
||||
| catalogue (the OpenModelDB catalogue) | zh-CN 目录 · zh-TW 目錄 · ja カタログ · ko 카탈로그 · fr catalogue · de Katalog · es catálogo · ru каталог · he קטלוג |
|
||||
|
||||
### Civitai ids feature (model/version id in the model modal)
|
||||
|
||||
The model modal's hash footnote shows the Civitai **model id** and **version id** with
|
||||
copy buttons (`modals.model.metadata.civitaiModelId` / `.civitaiVersionId` labels,
|
||||
`modals.model.actions.copyCivitaiId` tooltip, `.civitaiIdCopied` toast). **"ID" stays
|
||||
Latin in every locale** (same precedent as `recipes.*.copyId`), and `Civitai` is the
|
||||
brand (R3) — it is never translated or transliterated; the casing mirrors `en.json`
|
||||
verbatim (R9). The copy/copied strings reuse each locale's existing clipboard patterns
|
||||
(`modals.model.actions.copyHash` / `openFileLocation.copied`).
|
||||
|
||||
| Term | Rendering |
|
||||
|---|---|
|
||||
| Model ID (label) | zh-CN 模型 ID · zh-TW 模型 ID · ja モデル ID · ko 모델 ID · fr ID du modèle · de Modell-ID · es ID del modelo · ru ID модели · he מזהה מודל |
|
||||
| Version ID (label) | zh-CN 版本 ID · zh-TW 版本 ID · ja バージョン ID · ko 버전 ID · fr ID de version · de Versions-ID · es ID de versión · ru ID версии · he מזהה גרסה |
|
||||
|
||||
Hebrew uses its established מזהה ("identifier") noun instead of Latin `ID` in these
|
||||
labels, matching `recipes.*.copyId` (העתק מזהה מתכון).
|
||||
|
||||
### Showcase layout feature (gallery / vertical list toggle)
|
||||
|
||||
The model modal's example images can switch between a one-at-a-time **gallery** and the
|
||||
classic **vertical list**, via a Settings select (`settings.layoutSettings.showcaseLayout*`,
|
||||
mirroring the `recipesLayout*` select shape) and an in-modal segmented toggle whose two
|
||||
icon buttons are labelled by `modals.model.showcase.layoutGallery` / `.layoutList`
|
||||
(kept short — they are icon-button tooltips).
|
||||
|
||||
"Showcase Layout" is rendered as the **example-images layout** in most locales (the
|
||||
section's user-facing content), reusing each locale's fixed "example images" noun
|
||||
(`modelCardFooterActionOptions.exampleImages`); ja keeps its established ショーケース
|
||||
loanword instead. The `layoutList` tooltip reuses the fixed "list view" noun from the
|
||||
folder-sidebar row (above), so it stays byte-consistent with `sidebar.listView` where
|
||||
that form fits a tooltip (ru shortens both toggle labels to bare «Галерея» / «Список»).
|
||||
|
||||
| Term | Rendering |
|
||||
|---|---|
|
||||
| showcase layout (settings label) | zh-CN 示例图片布局 · zh-TW 範例圖片版面 · ja ショーケースのレイアウト · ko 예시 이미지 레이아웃 · fr Disposition des images d'exemple · de Beispielbilder-Layout · es Diseño de imágenes de ejemplo · ru Макет примеров изображений · he פריסת תמונות דוגמה |
|
||||
| example images | zh-CN 示例图片 · zh-TW 範例圖片 · ja 例画像 · ko 예시 이미지 · fr images d'exemple · de Beispielbilder · es imágenes de ejemplo · ru примеры изображений · he תמונות דוגמה |
|
||||
| gallery / vertical list (option labels) | zh-CN 画廊 / 纵向列表 · zh-TW 圖庫 / 垂直清單 · ja ギャラリー / 縦並びリスト · ko 갤러리 / 세로 목록 · fr Galerie / Liste verticale · de Galerie / Vertikale Liste · es Galería / Lista vertical · ru Галерея / Вертикальный список · he גלריה / רשימה אנכית |
|
||||
| gallery view / list view (toggle tooltips) | zh-CN 画廊视图 / 列表视图 · zh-TW 圖庫檢視 / 清單檢視 · ja ギャラリー表示 / リスト表示 · ko 갤러리 보기 / 목록 보기 · fr Vue galerie / Vue liste · de Galerieansicht / Listenansicht · es Vista de galería / Vista de lista · ru Галерея / Список · he תצוגת גלריה / תצוגת רשימה |
|
||||
|
||||
---
|
||||
|
||||
## 3. Cross-cutting confusion hot-spots (must-fix list)
|
||||
|
||||
@@ -24,11 +24,16 @@ this change and refresh the page. This Python node needs no Vue widget build.
|
||||
model/LoRA names, local resolution status and warnings. The original `report`
|
||||
output remains notes followed by formatted JSON; it is not a pure JSON string.
|
||||
|
||||
`model_name`, `sampler_name`, and `scheduler` use COMBO outputs for converted
|
||||
dropdown inputs in current ComfyUI. `model_name` contains the matching local
|
||||
checkpoint or diffusion-model filename. The report identifies the resolved type;
|
||||
connect it to the appropriate loader. Lookup searches both categories regardless
|
||||
of how the original metadata labels the model.
|
||||
`model_name`, `sampler_name`, and `scheduler` are declared as untyped (`*`)
|
||||
outputs so they can feed the dropdown widget inputs on both
|
||||
**Load Checkpoint**/**KSampler** and the LoRA Manager loaders. Typing them
|
||||
`COMBO` does not work: ComfyUI only accepts a `COMBO` output into a node that
|
||||
declares its dropdown as `COMBO`/`IO.Combo`, while classic dropdowns expose a
|
||||
plain option list, and the server rejects the link with "Return type mismatch
|
||||
between linked nodes". `model_name` contains the matching local checkpoint or
|
||||
diffusion-model filename. The report identifies the resolved type; connect it to
|
||||
the appropriate loader. Lookup searches both categories regardless of how the
|
||||
original metadata labels the model.
|
||||
|
||||
For a diffusion-model workflow, connect `model_name` to **Unet Loader
|
||||
(LoraManager)** and select the correct text encoder(s), VAE, latent node and
|
||||
|
||||
@@ -11,6 +11,33 @@ This document defines the complete schema for `.metadata.json` files used by Lor
|
||||
|
||||
---
|
||||
|
||||
## Storage Location (Alongside vs Centralized)
|
||||
|
||||
By default, `.metadata.json` sidecars and preview images live **alongside** their model files. An optional centralized mode stores them under a single root directory instead. Two settings control this (Settings → Library → Sidecar Storage):
|
||||
|
||||
| Setting | Values | Default |
|
||||
|---------|--------|---------|
|
||||
| `sidecar_storage_mode` | `"alongside"` \| `"centralized"` | `"alongside"` |
|
||||
| `sidecar_storage_path` | Absolute path string; empty = `<settings dir>/sidecars` | `""` |
|
||||
|
||||
In centralized mode, sidecars and previews mirror each model root's directory structure:
|
||||
|
||||
```
|
||||
<sidecar_root>/<root_component>/<rel_dir>/<name>.metadata.json
|
||||
```
|
||||
|
||||
- `<rel_dir>` is the model's directory relative to the model root containing the file; the longest matching root wins, so nested roots mirror under the most specific root.
|
||||
- `<root_component>` identifies the model root and **survives the root being moved or renamed**. It starts as the deterministic `<sanitized basename>-<path digest>` — so mirrors created by older builds, and mirrors left behind by a relocated sidecar root, still resolve — and is then pinned in `<sidecar_root>/.lm-sidecar-roots.json` alongside the root's last known path and a few sample subdirectories. Two roots sharing a basename (e.g. `/mnt/a/loras` and `/mnt/b/loras`) always get distinct components and never collide. Each path component is sanitized to filesystem-safe characters.
|
||||
- **Moving or renaming a model root does not strand its sidecars.** On the next run the mirror identity is re-anchored to the root's new path (matched by basename and recorded sample directories), so favorites, notes, tags and usage tips keep resolving. An existing hash-named mirror from an older build is adopted as-is on first use.
|
||||
- If an identity cannot be re-anchored unambiguously (e.g. two same-named candidate roots), nothing is guessed: the mirror stays on disk untouched and surfaces as an orphan in **Doctor → Centralized Sidecars** (and in the log). Restoring the original root path re-links it automatically.
|
||||
- `.civitai.info` files always stay next to the model file, in both modes.
|
||||
- Changing the mode does **not** move existing files automatically — run the migration (`POST /api/lm/sidecars/migrate` with `{"direction": "to_centralized" | "to_alongside"}`, or the "Migrate Sidecars Now" button in settings). The migration covers excluded (hidden) models too, so un-excluding one later never strands its sidecar in the old layout. The result payload includes a `sidecar_root` field with the resolved centralized root, and the settings UI shows the outcome counters plus an "Open Folder" shortcut.
|
||||
- Changing `sidecar_storage_path` while centralized likewise needs a root relocation: `{"direction": "relocate_root", "old_root": "<previous path>"}` moves the whole mirror tree to the new root (the settings UI offers this automatically). The identity map travels with the tree, and its entries win over any map the destination acquired beforehand — so a mirror that was re-anchored earlier keeps its name even if something resolved against the new path before the relocation ran.
|
||||
- The settings UI always shows the resolved effective storage root (via the `sidecar_storage_root*` fields in `GET /api/lm/settings`), with `POST /api/lm/sidecars/open-location` opening it in the file manager. When the resolved root lies inside the plugin installation folder (portable settings mode), the UI warns: reinstalling or clean-updating the plugin would delete the sidecars, so an explicit path outside the installation folder is recommended. The repo `.gitignore` excludes the portable-mode default (`/sidecars/`).
|
||||
- All sidecar/preview path derivation goes through the helpers in `py/utils/sidecar_paths.py`; never construct paths inline. In the default `alongside` mode these helpers do no extra I/O at all — the identity map is only loaded and reconciled when centralized storage is actually in use.
|
||||
|
||||
---
|
||||
|
||||
## Base Fields (All Model Types)
|
||||
|
||||
These fields are present in all model metadata files.
|
||||
@@ -272,9 +299,29 @@ The `metadata_source` field indicates which provider last updated the metadata:
|
||||
|-------|--------|
|
||||
| `"civitai_api"` | Civitai API |
|
||||
| `"civarchive"` | CivArchive API |
|
||||
| `"openmodeldb"` | OpenModelDB catalogue (upscaler models only; hash-matched) |
|
||||
| `"archive_db"` | Metadata Archive Database |
|
||||
| `null` | No external source (user-defined only) |
|
||||
|
||||
When `metadata_source` is `"openmodeldb"`, the `civitai` payload is a
|
||||
CivitAI-shaped version dict synthesized from the OpenModelDB catalogue entry
|
||||
(no numeric `id`/`modelId`), and the OpenModelDB-native details live in its
|
||||
`openmodeldb` block (`id`, `url`, `authors`, `architecture`,
|
||||
`architectureName`, `scale`, `license`, `date`).
|
||||
|
||||
In that payload, `images[].url` is always a displayable asset: paired
|
||||
comparisons use the site-hosted thumbnail because the `LR`/`SR` originals
|
||||
are ephemeral imgdiff viewer sessions that 404 outside them (the original
|
||||
viewer link is kept as `images[].meta.comparisonUrl` for reference), and the
|
||||
model-level thumbnail leads the list since the card preview derives from
|
||||
`images[0]`. `files[].name` is derived from any URL path segment with a
|
||||
model extension (mediafire-style mirrors bury it mid-path) or synthesized as
|
||||
`{model_id}.{type}` for folder links.
|
||||
|
||||
Models downloaded from OpenModelDB additionally carry `source_platform:
|
||||
"openmodeldb"` and `source_url` (the model page URL); their download-time
|
||||
hydration is recorded as `metadata_source: "source:openmodeldb"`.
|
||||
|
||||
---
|
||||
|
||||
## Auto-Update Behavior
|
||||
|
||||
+86
-2
@@ -251,7 +251,6 @@
|
||||
"modelname": "Modellname",
|
||||
"tags": "Tags",
|
||||
"creator": "Ersteller",
|
||||
"hash": "Hash",
|
||||
"title": "Rezept-Titel",
|
||||
"loraName": "LoRA-Dateiname",
|
||||
"loraModel": "LoRA-Modellname",
|
||||
@@ -353,6 +352,14 @@
|
||||
"placeholder": "Leer lassen, um aria2c aus dem PATH zu verwenden"
|
||||
},
|
||||
"aria2HelpLink": "Erfahren Sie, wie Sie das aria2-Download-Backend einrichten",
|
||||
"unknownBaseModelRouting": {
|
||||
"label": "Routing unbekannter Basismodelle",
|
||||
"help": "Legt fest, wohin Checkpoint-Downloads gehen, wenn CivitAI ein Basismodell meldet, das weder ein bekanntes Checkpoint (SD 1.x/2.x/3.x, SDXL, Pony, Illustrious, NoobAI) noch ein bekanntes Diffusionsmodell ist. Neue Diffusionsarchitekturen erscheinen häufig, daher ist das Routing zu Diffusionsmodellen meist korrekt.",
|
||||
"options": {
|
||||
"diffusionModel": "Diffusionsmodelle",
|
||||
"checkpoint": "Checkpoints"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "CivitAI-Host-Einstellung verfügbar",
|
||||
"content": "CivitAI verwendet jetzt civitai.com für SFW-Inhalte und civitai.red für uneingeschränkte Inhalte. In den Einstellungen können Sie ändern, welche Seite standardmäßig geöffnet wird.",
|
||||
@@ -383,6 +390,7 @@
|
||||
"exampleImages": "Beispielbilder",
|
||||
"autoOrganize": "Auto-Organisierung",
|
||||
"metadata": "Metadaten",
|
||||
"sidecarStorage": "Sidecar-Speicherung",
|
||||
"proxySettings": "Proxy-Einstellungen"
|
||||
},
|
||||
"nav": {
|
||||
@@ -517,6 +525,12 @@
|
||||
"hover": "Bei Hover anzeigen"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Wählen Sie, wann Modellinformationen und Aktionsschaltflächen angezeigt werden sollen",
|
||||
"showcaseLayout": "Beispielbilder-Layout",
|
||||
"showcaseLayoutHelp": "Wählen Sie, wie Beispielbilder im Modell-Dialog angezeigt werden: eine Galerie mit jeweils einem Bild oder die klassische vertikale Liste.",
|
||||
"showcaseLayoutOptions": {
|
||||
"gallery": "Galerie",
|
||||
"vertical": "Vertikale Liste"
|
||||
},
|
||||
"showVersionOnCard": "Version auf Karte anzeigen",
|
||||
"showVersionOnCardHelp": "Den Versionsnamen auf Modellkarten ein- oder ausblenden",
|
||||
"modelCardFooterAction": "Aktion der Modellkarten-Schaltfläche",
|
||||
@@ -784,11 +798,41 @@
|
||||
"downloadComplete": "Download erfolgreich abgeschlossen",
|
||||
"enableCivarchiveApi": "CivArchive API als Metadaten-Anbieter aktivieren",
|
||||
"enableCivarchiveApiHelp": "Wenn aktiviert, wird die CivArchive API als alternative Quelle für Modell-Metadaten verwendet (z. B. für von CivitAI gelöschte Modelle). Deaktivieren, um die Ratenbegrenzungen von CivArchive vollständig zu vermeiden.",
|
||||
"enableOpenmodeldbApi": "OpenModelDB als Metadaten-Anbieter aktivieren",
|
||||
"enableOpenmodeldbApiHelp": "Wenn aktiviert, werden Upscaler-Metadaten zusätzlich im OpenModelDB-Katalog (openmodeldb.info) nachgeschlagen, wenn CivitAI keinen Eintrag hat. Der Katalog wird lokal zwischengespeichert und täglich aktualisiert.",
|
||||
"providerOrder": "Reihenfolge der Metadaten-Anbieter",
|
||||
"providerOrderHelp": "Die CivitAI API wird immer zuerst versucht. Wählen Sie die Reihenfolge der übrigen Anbieter bei der Metadatensuche.",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "Sidecar-Speichermodus",
|
||||
"modeHelp": "Wählen Sie, wo .metadata.json-Sidecar-Dateien und Vorschaubilder gespeichert werden: neben jeder Modelldatei oder in einem einzelnen zentralen Verzeichnis, das Ihre Bibliotheksstruktur spiegelt. .civitai.info-Dateien bleiben immer neben der Modelldatei.",
|
||||
"modeOptions": {
|
||||
"alongside": "Neben den Modelldateien (Standard)",
|
||||
"centralized": "Zentrale Speicherung"
|
||||
},
|
||||
"path": "Pfad der zentralen Speicherung",
|
||||
"pathHelp": "Stammverzeichnis für die zentrale Sidecar-Speicherung. Leer lassen, um den Standardspeicherort (<settings dir>/sidecars) zu verwenden.",
|
||||
"pathPlaceholder": "Leer = <settings dir>/sidecars",
|
||||
"management": "Sidecar-Migration",
|
||||
"managementHelp": "Verschieben Sie vorhandene .metadata.json-Sidecar-Dateien und Vorschaubilder zwischen der Ablage neben den Modelldateien und der zentralen Speicherung, passend zum aktuell gewählten Modus. Ein Moduswechsel verschiebt vorhandene Dateien nicht automatisch.",
|
||||
"migrateButton": "Sidecar-Dateien jetzt verschieben",
|
||||
"migratingButton": "Wird verschoben...",
|
||||
"migrating": "Sidecar-Dateien werden verschoben...",
|
||||
"migrateFailed": "Sidecar-Migration fehlgeschlagen: {message}",
|
||||
"migrationDeferred": "Vorhandene Sidecar-Dateien wurden nicht verschoben. Sie können sie später unter Einstellungen → Bibliothek → Sidecar-Speicherung verschieben.",
|
||||
"confirmToCentralized": "Der Speichermodus wurde geändert, aber vorhandene .metadata.json-Sidecar-Dateien und Vorschaubilder werden nicht automatisch verschoben. Jetzt in das zentrale Speicherverzeichnis verschieben? Sie können das auch später über die Schaltfläche „Sidecar-Dateien jetzt verschieben“ tun.",
|
||||
"confirmToAlongside": "Der Speichermodus wurde geändert, aber vorhandene .metadata.json-Sidecar-Dateien und Vorschaubilder werden nicht automatisch verschoben. Jetzt wieder neben ihre Modelldateien verschieben? Sie können das auch später über die Schaltfläche „Sidecar-Dateien jetzt verschieben“ tun.",
|
||||
"confirmRelocateRoot": "Das zentrale Speicherverzeichnis wurde geändert, aber vorhandene Sidecar-Dateien und Vorschaubilder liegen noch im vorherigen Verzeichnis. Jetzt in das neue Verzeichnis verschieben?",
|
||||
"effectivePathLabel": "Tatsächlicher Speicherort:",
|
||||
"openFolderButton": "Ordner öffnen",
|
||||
"repoWarning": "Der tatsächliche Speicherort liegt innerhalb des LoRA-Manager-Installationsordners. Eine Neuinstallation des Plugins oder eine saubere Aktualisierung kann ihn löschen — legen Sie einen expliziten Speicherpfad außerhalb des Installationsordners fest.",
|
||||
"openLocationSuccess": "Sidecar-Speicherordner geöffnet",
|
||||
"openLocationCopied": "Sidecar-Speicherpfad in die Zwischenablage kopiert: {path}",
|
||||
"openLocationClipboardFallback": "Kopieren Sie den Sidecar-Speicherpfad manuell: {path}",
|
||||
"openLocationFailed": "Sidecar-Speicherordner konnte nicht geöffnet werden"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "App-Proxy aktivieren",
|
||||
"enableProxyHelp": "Aktivieren Sie benutzerdefinierte Proxy-Einstellungen für diese Anwendung. Überschreibt die System-Proxy-Einstellungen.",
|
||||
@@ -1350,6 +1394,10 @@
|
||||
"emptyNote": "Dieser Ordner enthält keine Modelle. Alle anderen darin enthaltenen Dateien werden ebenfalls gelöscht.",
|
||||
"notEmptyTitle": "Ordner ist nicht leer",
|
||||
"notEmptyMessage": "Dieser Ordner enthält noch Modelle. Löschen oder verschieben Sie diese zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.",
|
||||
"notEmptyMessageCount": "Dieser Ordner enthält noch {count} Modelldatei(en). Löschen oder verschieben Sie diese zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.",
|
||||
"notEmptyMessageExcluded": "Dieser Ordner enthält noch {count} Modelldatei(en), davon {excluded} von der Bibliothek ausgeschlossen. Heben Sie den Ausschluss unter „Ausgeschlossene Modelle verwalten“ auf und löschen Sie sie zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.",
|
||||
"busyTitle": "Eine Löschung steht noch aus",
|
||||
"checking": "Ordnerinhalt wird geprüft...",
|
||||
"confirm": "Ordner löschen"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1358,6 +1406,7 @@
|
||||
"restored": "Ordner wiederhergestellt",
|
||||
"failed": "Ordner konnte nicht gelöscht werden: {message}",
|
||||
"notEmpty": "Dieser Ordner enthält noch Modelle. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.",
|
||||
"notEmptyWithCount": "Dieser Ordner enthält noch {count} Modelldatei(en). Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.",
|
||||
"busy": "In diesem Ordner steht noch eine Löschung aus. Warten Sie, bis das Zeitfenster für das Rückgängigmachen abgelaufen ist.",
|
||||
"unsupported": "Das Löschen von Ordnern wird auf dieser Seite nicht unterstützt",
|
||||
"noRoot": "Es ist kein Modell-Stammverzeichnis konfiguriert"
|
||||
@@ -1521,6 +1570,10 @@
|
||||
"selectRootDirectory": "Wählen Sie ein Stammverzeichnis",
|
||||
"selectModelRoot": "Modell-Stammverzeichnis auswählen:",
|
||||
"selectTypeRoot": "{type}-Stammverzeichnis auswählen:",
|
||||
"routingOverride": {
|
||||
"label": "Zieltyp:",
|
||||
"tooltip": "Automatisch aus den Modell-Metadaten erkannt. Wechseln Sie, wenn die Erkennung falsch war; ein Wechsel deaktiviert „Standardpfad verwenden“ für diesen Download."
|
||||
},
|
||||
"targetFolderPath": "Zielordnerpfad:",
|
||||
"browseFolders": "Ordner durchsuchen:",
|
||||
"createNewFolder": "Neuen Ordner erstellen",
|
||||
@@ -1637,6 +1690,28 @@
|
||||
"titleRevert": "Ursprüngliche Dateinamen wiederherstellen?",
|
||||
"revertButton": "Ursprüngliche Dateinamen wiederherstellen"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "Sidecar-Dateien in die zentrale Speicherung verschieben?",
|
||||
"titleToAlongside": "Sidecar-Dateien zurück neben die Modelldateien verschieben?",
|
||||
"confirmButton": "Jetzt verschieben",
|
||||
"titleRelocateRoot": "Sidecar-Dateien in das neue Speicherverzeichnis verschieben?",
|
||||
"destination": "Ziel: {path}"
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "Sidecar-Migrationsübersicht",
|
||||
"completedSuccessfully": "Migration erfolgreich abgeschlossen",
|
||||
"completedWithErrors": "Abgeschlossen mit {count} Fehler(n)",
|
||||
"statMoved": "Verschobene Dateien",
|
||||
"statModels": "Modelle",
|
||||
"statSkipped": "Übersprungen",
|
||||
"statConflicts": "Gelöste Konflikte",
|
||||
"statErrors": "Fehler",
|
||||
"failedItems": "Fehlgeschlagene Elemente ({count})",
|
||||
"columnModel": "Modell",
|
||||
"columnError": "Fehler",
|
||||
"successMessage": "{moved} Dateien für {models} Modelle verschoben",
|
||||
"location": "Speicherort: {path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Tags zu mehreren Modellen hinzufügen",
|
||||
"description": "Tags hinzufügen zu",
|
||||
@@ -1769,6 +1844,8 @@
|
||||
"sendToWorkflow": "An ComfyUI senden",
|
||||
"sendToWorkflowText": "An ComfyUI senden",
|
||||
"copyHash": "Hash kopieren",
|
||||
"copyCivitaiId": "Civitai-ID kopieren",
|
||||
"civitaiIdCopied": "Civitai-ID in die Zwischenablage kopiert",
|
||||
"deleteModelWithShortcut": "Modell löschen (Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
@@ -1787,6 +1864,8 @@
|
||||
"baseModel": "Basismodell",
|
||||
"size": "Größe",
|
||||
"hashes": "Hashes",
|
||||
"civitaiModelId": "Modell-ID",
|
||||
"civitaiVersionId": "Versions-ID",
|
||||
"unknown": "Unbekannt",
|
||||
"usageTips": "Nutzungstipps",
|
||||
"additionalNotes": "Zusätzliche Notizen",
|
||||
@@ -1904,7 +1983,9 @@
|
||||
"nsfwMature": "Nicht jugendfreie Inhalte",
|
||||
"nsfwR": "Inhalte ab 18 (R)",
|
||||
"nsfwX": "Inhalte mit X-Einstufung",
|
||||
"nsfwXxx": "Inhalte mit XXX-Einstufung"
|
||||
"nsfwXxx": "Inhalte mit XXX-Einstufung",
|
||||
"layoutGallery": "Galerieansicht",
|
||||
"layoutList": "Listenansicht"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Modellversionen",
|
||||
@@ -2713,6 +2794,9 @@
|
||||
"filename_conflicts": {
|
||||
"title": "Konflikte durch doppelte Dateinamen"
|
||||
},
|
||||
"sidecar_mirror_orphans": {
|
||||
"title": "Zentralisierte Sidecar-Dateien"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI-Version"
|
||||
}
|
||||
|
||||
+86
-2
@@ -251,7 +251,6 @@
|
||||
"modelname": "Model Name",
|
||||
"tags": "Tags",
|
||||
"creator": "Creator",
|
||||
"hash": "Hash",
|
||||
"title": "Recipe Title",
|
||||
"loraName": "LoRA Filename",
|
||||
"loraModel": "LoRA Model Name",
|
||||
@@ -353,6 +352,14 @@
|
||||
"placeholder": "Leave empty to use aria2c from PATH"
|
||||
},
|
||||
"aria2HelpLink": "Learn how to set up the aria2 download backend",
|
||||
"unknownBaseModelRouting": {
|
||||
"label": "Unknown base model routing",
|
||||
"help": "Decides where checkpoint downloads go when CivitAI reports a base model that is neither a known checkpoint (SD 1.x/2.x/3.x, SDXL, Pony, Illustrious, NoobAI) nor a known diffusion model. New diffusion architectures appear frequently, so routing them to diffusion models is usually correct.",
|
||||
"options": {
|
||||
"diffusionModel": "Diffusion models",
|
||||
"checkpoint": "Checkpoints"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "CivitAI host preference available",
|
||||
"content": "CivitAI now uses civitai.com for SFW content and civitai.red for unrestricted content. You can change which site opens by default in Settings.",
|
||||
@@ -383,6 +390,7 @@
|
||||
"exampleImages": "Example Images",
|
||||
"autoOrganize": "Auto-organize",
|
||||
"metadata": "Metadata",
|
||||
"sidecarStorage": "Sidecar Storage",
|
||||
"proxySettings": "Proxy Settings"
|
||||
},
|
||||
"nav": {
|
||||
@@ -517,6 +525,12 @@
|
||||
"hover": "Reveal on Hover"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Choose when to display model information and action buttons",
|
||||
"showcaseLayout": "Showcase Layout",
|
||||
"showcaseLayoutHelp": "Choose how example images are displayed in the model modal: a one-at-a-time gallery or the classic vertical list.",
|
||||
"showcaseLayoutOptions": {
|
||||
"gallery": "Gallery",
|
||||
"vertical": "Vertical List"
|
||||
},
|
||||
"showVersionOnCard": "Show Version on Card",
|
||||
"showVersionOnCardHelp": "Show or hide the version name on model cards",
|
||||
"modelCardFooterAction": "Model Card Button Action",
|
||||
@@ -784,11 +798,41 @@
|
||||
"downloadComplete": "Download completed successfully",
|
||||
"enableCivarchiveApi": "Enable CivArchive API as metadata provider",
|
||||
"enableCivarchiveApiHelp": "When on, CivArchive API is used as a fallback source for model metadata (e.g. for models deleted from CivitAI). Turn off to avoid CivArchive rate limits entirely.",
|
||||
"enableOpenmodeldbApi": "Enable OpenModelDB as metadata provider",
|
||||
"enableOpenmodeldbApiHelp": "When on, upscaler metadata is also looked up in the OpenModelDB catalogue (openmodeldb.info) when CivitAI has no record. The catalogue is cached locally and refreshed daily.",
|
||||
"providerOrder": "Metadata provider fallback order",
|
||||
"providerOrderHelp": "CivitAI API is always tried first. Choose the order of the remaining providers when looking up metadata.",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "Sidecar Storage Mode",
|
||||
"modeHelp": "Choose where .metadata.json sidecars and preview images are stored: next to each model file, or in a single centralized directory that mirrors your library structure. .civitai.info files always stay next to the model file.",
|
||||
"modeOptions": {
|
||||
"alongside": "Alongside model files (default)",
|
||||
"centralized": "Centralized storage"
|
||||
},
|
||||
"path": "Centralized Storage Path",
|
||||
"pathHelp": "Root directory for centralized sidecar storage. Leave empty to use the default location (<settings dir>/sidecars).",
|
||||
"pathPlaceholder": "Empty = <settings dir>/sidecars",
|
||||
"management": "Sidecar Migration",
|
||||
"managementHelp": "Move existing .metadata.json sidecars and preview images between alongside and centralized storage, matching the currently selected mode. Changing the mode does not move existing files automatically.",
|
||||
"migrateButton": "Migrate Sidecars Now",
|
||||
"migratingButton": "Migrating...",
|
||||
"migrating": "Migrating sidecars...",
|
||||
"migrateFailed": "Sidecar migration failed: {message}",
|
||||
"migrationDeferred": "Existing sidecars were not moved. You can migrate them later from Settings → Library → Sidecar Storage.",
|
||||
"confirmToCentralized": "The storage mode changed, but existing .metadata.json sidecars and preview images are not moved automatically. Move them into the centralized storage directory now? You can also do this later with the \"Migrate Sidecars Now\" button.",
|
||||
"confirmToAlongside": "The storage mode changed, but existing .metadata.json sidecars and preview images are not moved automatically. Move them back next to their model files now? You can also do this later with the \"Migrate Sidecars Now\" button.",
|
||||
"confirmRelocateRoot": "The centralized storage directory changed, but existing sidecars and preview images are still in the previous directory. Move them to the new directory now?",
|
||||
"effectivePathLabel": "Effective storage location:",
|
||||
"openFolderButton": "Open Folder",
|
||||
"repoWarning": "The effective storage location is inside the LoRA Manager installation folder. Reinstalling the plugin or a clean update can delete it — set an explicit storage path outside the installation folder.",
|
||||
"openLocationSuccess": "Opened sidecar storage folder",
|
||||
"openLocationCopied": "Sidecar storage path copied to clipboard: {path}",
|
||||
"openLocationClipboardFallback": "Copy the sidecar storage path manually: {path}",
|
||||
"openLocationFailed": "Failed to open the sidecar storage folder"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "Enable App-level Proxy",
|
||||
"enableProxyHelp": "Enable custom proxy settings for this application, overriding system proxy settings",
|
||||
@@ -1350,6 +1394,10 @@
|
||||
"emptyNote": "This folder contains no models. Any other files it holds will be deleted too.",
|
||||
"notEmptyTitle": "Folder is not empty",
|
||||
"notEmptyMessage": "This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.",
|
||||
"notEmptyMessageCount": "This folder still contains {count} model file(s). Delete or move them first — deleting a folder never cascades over model files.",
|
||||
"notEmptyMessageExcluded": "This folder still contains {count} model file(s), {excluded} of them excluded from the library. Un-exclude them in Manage Excluded Models and delete them first — deleting a folder never cascades over model files.",
|
||||
"busyTitle": "A deletion is still pending",
|
||||
"checking": "Checking the folder contents...",
|
||||
"confirm": "Delete folder"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1358,6 +1406,7 @@
|
||||
"restored": "Folder restored",
|
||||
"failed": "Failed to delete folder: {message}",
|
||||
"notEmpty": "This folder still contains models. Refresh the sidebar and try again.",
|
||||
"notEmptyWithCount": "This folder still contains {count} model file(s). Refresh the sidebar and try again.",
|
||||
"busy": "A deletion is still pending inside this folder. Wait for the undo window to expire.",
|
||||
"unsupported": "Folder deletion is not supported on this page",
|
||||
"noRoot": "No model root is configured"
|
||||
@@ -1521,6 +1570,10 @@
|
||||
"selectRootDirectory": "Select a root directory",
|
||||
"selectModelRoot": "Select Model Root:",
|
||||
"selectTypeRoot": "Select {type} Root:",
|
||||
"routingOverride": {
|
||||
"label": "Destination type:",
|
||||
"tooltip": "Auto-detected from the model metadata. Switch if it was misdetected; switching turns off \"Use Default Path\" for this download."
|
||||
},
|
||||
"targetFolderPath": "Target Folder Path:",
|
||||
"browseFolders": "Browse Folders:",
|
||||
"createNewFolder": "Create new folder",
|
||||
@@ -1637,6 +1690,28 @@
|
||||
"titleRevert": "Restore original filenames?",
|
||||
"revertButton": "Restore Original Filenames"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "Move sidecars to centralized storage?",
|
||||
"titleToAlongside": "Move sidecars back next to model files?",
|
||||
"confirmButton": "Migrate Now",
|
||||
"titleRelocateRoot": "Move sidecars to the new storage directory?",
|
||||
"destination": "Destination: {path}"
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "Sidecar Migration Summary",
|
||||
"completedSuccessfully": "Migration completed successfully",
|
||||
"completedWithErrors": "Completed with {count} error(s)",
|
||||
"statMoved": "Moved Files",
|
||||
"statModels": "Models",
|
||||
"statSkipped": "Skipped",
|
||||
"statConflicts": "Conflicts Resolved",
|
||||
"statErrors": "Errors",
|
||||
"failedItems": "Failed Items ({count})",
|
||||
"columnModel": "Model",
|
||||
"columnError": "Error",
|
||||
"successMessage": "Moved {moved} files for {models} models",
|
||||
"location": "Storage location: {path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Add Tags to Multiple Models",
|
||||
"description": "Add tags to",
|
||||
@@ -1769,6 +1844,8 @@
|
||||
"sendToWorkflow": "Send to ComfyUI",
|
||||
"sendToWorkflowText": "Send to ComfyUI",
|
||||
"copyHash": "Copy hash",
|
||||
"copyCivitaiId": "Copy Civitai ID",
|
||||
"civitaiIdCopied": "Civitai ID copied to clipboard",
|
||||
"deleteModelWithShortcut": "Delete model (Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
@@ -1787,6 +1864,8 @@
|
||||
"baseModel": "Base Model",
|
||||
"size": "Size",
|
||||
"hashes": "Hashes",
|
||||
"civitaiModelId": "Model ID",
|
||||
"civitaiVersionId": "Version ID",
|
||||
"unknown": "Unknown",
|
||||
"usageTips": "Usage Tips",
|
||||
"additionalNotes": "Additional Notes",
|
||||
@@ -1904,7 +1983,9 @@
|
||||
"nsfwMature": "Mature Content",
|
||||
"nsfwR": "R-rated Content",
|
||||
"nsfwX": "X-rated Content",
|
||||
"nsfwXxx": "XXX-rated Content"
|
||||
"nsfwXxx": "XXX-rated Content",
|
||||
"layoutGallery": "Gallery view",
|
||||
"layoutList": "List view"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Model versions",
|
||||
@@ -2713,6 +2794,9 @@
|
||||
"filename_conflicts": {
|
||||
"title": "Duplicate Filename Conflicts"
|
||||
},
|
||||
"sidecar_mirror_orphans": {
|
||||
"title": "Centralized Sidecars"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI Version"
|
||||
}
|
||||
|
||||
+86
-2
@@ -251,7 +251,6 @@
|
||||
"modelname": "Nombre del modelo",
|
||||
"tags": "Etiquetas",
|
||||
"creator": "Creador",
|
||||
"hash": "Hash",
|
||||
"title": "Título de la receta",
|
||||
"loraName": "Nombre de archivo LoRA",
|
||||
"loraModel": "Nombre del modelo LoRA",
|
||||
@@ -353,6 +352,14 @@
|
||||
"placeholder": "Déjalo vacío para usar aria2c desde el PATH"
|
||||
},
|
||||
"aria2HelpLink": "Aprende a configurar el backend de descarga aria2",
|
||||
"unknownBaseModelRouting": {
|
||||
"label": "Enrutamiento de modelos base desconocidos",
|
||||
"help": "Decide a dónde van las descargas de Checkpoint cuando CivitAI informa de un modelo base que no es ni un Checkpoint conocido (SD 1.x/2.x/3.x, SDXL, Pony, Illustrious, NoobAI) ni un modelo de difusión conocido. Las nuevas arquitecturas de difusión aparecen con frecuencia, por lo que enrutarlas a modelos de difusión suele ser correcto.",
|
||||
"options": {
|
||||
"diffusionModel": "Modelos de difusión",
|
||||
"checkpoint": "Checkpoints"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "Preferencia de host de CivitAI disponible",
|
||||
"content": "CivitAI ahora usa civitai.com para contenido SFW y civitai.red para contenido sin restricciones. Puedes cambiar en Ajustes qué sitio se abre por defecto.",
|
||||
@@ -383,6 +390,7 @@
|
||||
"exampleImages": "Imágenes de ejemplo",
|
||||
"autoOrganize": "Organización automática",
|
||||
"metadata": "Metadatos",
|
||||
"sidecarStorage": "Almacenamiento de archivos sidecar",
|
||||
"proxySettings": "Configuración de proxy"
|
||||
},
|
||||
"nav": {
|
||||
@@ -517,6 +525,12 @@
|
||||
"hover": "Mostrar al pasar el ratón"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Elige cuándo mostrar información del modelo y botones de acción",
|
||||
"showcaseLayout": "Diseño de imágenes de ejemplo",
|
||||
"showcaseLayoutHelp": "Elige cómo se muestran las imágenes de ejemplo en el modal del modelo: una galería que las muestra de una en una o la clásica lista vertical.",
|
||||
"showcaseLayoutOptions": {
|
||||
"gallery": "Galería",
|
||||
"vertical": "Lista vertical"
|
||||
},
|
||||
"showVersionOnCard": "Mostrar versión en la tarjeta",
|
||||
"showVersionOnCardHelp": "Mostrar u ocultar el nombre de versión en las tarjetas de modelo",
|
||||
"modelCardFooterAction": "Acción del botón de tarjeta de modelo",
|
||||
@@ -784,11 +798,41 @@
|
||||
"downloadComplete": "Descarga completada exitosamente",
|
||||
"enableCivarchiveApi": "Habilitar CivArchive API como proveedor de metadatos",
|
||||
"enableCivarchiveApiHelp": "Al activarlo, la API de CivArchive se usa como fuente alternativa de metadatos de modelos (p. ej. para modelos eliminados de CivitAI). Desactívelo para evitar por completo los límites de velocidad de CivArchive.",
|
||||
"enableOpenmodeldbApi": "Habilitar OpenModelDB como proveedor de metadatos",
|
||||
"enableOpenmodeldbApiHelp": "Al activarlo, los metadatos de Upscaler también se consultan en el catálogo de OpenModelDB (openmodeldb.info) cuando CivitAI no tiene ningún registro. El catálogo se almacena en caché localmente y se actualiza a diario.",
|
||||
"providerOrder": "Orden de proveedores de metadatos de respaldo",
|
||||
"providerOrderHelp": "La API de CivitAI siempre se intenta primero. Elija el orden de los demás proveedores al buscar metadatos.",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "Modo de almacenamiento de archivos sidecar",
|
||||
"modeHelp": "Elige dónde se guardan los archivos sidecar .metadata.json y las imágenes de vista previa: junto a cada archivo de modelo, o en un único directorio centralizado que refleja la estructura de tu biblioteca. Los archivos .civitai.info siempre permanecen junto al archivo de modelo.",
|
||||
"modeOptions": {
|
||||
"alongside": "Junto a los archivos de modelo (predeterminado)",
|
||||
"centralized": "Almacenamiento centralizado"
|
||||
},
|
||||
"path": "Ruta del almacenamiento centralizado",
|
||||
"pathHelp": "Carpeta raíz del almacenamiento centralizado de archivos sidecar. Déjalo vacío para usar la ubicación predeterminada (<settings dir>/sidecars).",
|
||||
"pathPlaceholder": "Vacío = <settings dir>/sidecars",
|
||||
"management": "Migración de archivos sidecar",
|
||||
"managementHelp": "Mueve los archivos sidecar .metadata.json y las imágenes de vista previa existentes entre el almacenamiento junto a los modelos y el almacenamiento centralizado, según el modo seleccionado. Cambiar el modo no mueve los archivos existentes automáticamente.",
|
||||
"migrateButton": "Migrar archivos sidecar ahora",
|
||||
"migratingButton": "Migrando...",
|
||||
"migrating": "Migrando archivos sidecar...",
|
||||
"migrateFailed": "Error al migrar los archivos sidecar: {message}",
|
||||
"migrationDeferred": "Los archivos sidecar existentes no se movieron. Puedes migrarlos más tarde desde Configuración → Biblioteca → Almacenamiento de archivos sidecar.",
|
||||
"confirmToCentralized": "El modo de almacenamiento ha cambiado, pero los archivos sidecar .metadata.json y las imágenes de vista previa existentes no se mueven automáticamente. ¿Moverlos ahora al directorio de almacenamiento centralizado? También puedes hacerlo más tarde con el botón «Migrar archivos sidecar ahora».",
|
||||
"confirmToAlongside": "El modo de almacenamiento ha cambiado, pero los archivos sidecar .metadata.json y las imágenes de vista previa existentes no se mueven automáticamente. ¿Devolverlos ahora junto a sus archivos de modelo? También puedes hacerlo más tarde con el botón «Migrar archivos sidecar ahora».",
|
||||
"confirmRelocateRoot": "El directorio de almacenamiento centralizado ha cambiado, pero los archivos sidecar y las imágenes de vista previa existentes siguen en el directorio anterior. ¿Moverlos ahora al nuevo directorio?",
|
||||
"effectivePathLabel": "Ubicación de almacenamiento efectiva:",
|
||||
"openFolderButton": "Abrir carpeta",
|
||||
"repoWarning": "La ubicación de almacenamiento efectiva está dentro de la carpeta de instalación de LoRA Manager. Reinstalar el plugin o una actualización limpia puede eliminarla — define una ruta de almacenamiento explícita fuera de la carpeta de instalación.",
|
||||
"openLocationSuccess": "Carpeta de almacenamiento de archivos sidecar abierta",
|
||||
"openLocationCopied": "Ruta de almacenamiento de archivos sidecar copiada al portapapeles: {path}",
|
||||
"openLocationClipboardFallback": "Copia manualmente la ruta de almacenamiento de archivos sidecar: {path}",
|
||||
"openLocationFailed": "No se pudo abrir la carpeta de almacenamiento de archivos sidecar"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "Habilitar proxy a nivel de aplicación",
|
||||
"enableProxyHelp": "Habilita la configuración de proxy personalizada para esta aplicación, sobrescribiendo la configuración de proxy del sistema",
|
||||
@@ -1350,6 +1394,10 @@
|
||||
"emptyNote": "Esta carpeta no contiene modelos. Los demás archivos que contenga también se eliminarán.",
|
||||
"notEmptyTitle": "La carpeta no está vacía",
|
||||
"notEmptyMessage": "Esta carpeta aún contiene modelos. Elimínalos o muévelos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.",
|
||||
"notEmptyMessageCount": "Esta carpeta aún contiene {count} archivo(s) de modelo. Elimínalos o muévelos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.",
|
||||
"notEmptyMessageExcluded": "Esta carpeta aún contiene {count} archivo(s) de modelo, de los cuales {excluded} están excluidos de la biblioteca. Anula su exclusión en «Gestionar modelos excluidos» y elimínalos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.",
|
||||
"busyTitle": "Todavía hay una eliminación pendiente",
|
||||
"checking": "Comprobando el contenido de la carpeta...",
|
||||
"confirm": "Eliminar carpeta"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1358,6 +1406,7 @@
|
||||
"restored": "Carpeta restaurada",
|
||||
"failed": "Error al eliminar la carpeta: {message}",
|
||||
"notEmpty": "Esta carpeta aún contiene modelos. Actualiza la barra lateral e inténtalo de nuevo.",
|
||||
"notEmptyWithCount": "Esta carpeta aún contiene {count} archivo(s) de modelo. Actualiza la barra lateral e inténtalo de nuevo.",
|
||||
"busy": "Todavía hay una eliminación pendiente dentro de esta carpeta. Espera a que caduque la ventana de deshacer.",
|
||||
"unsupported": "La eliminación de carpetas no es compatible con esta página",
|
||||
"noRoot": "No hay ninguna raíz de modelo configurada"
|
||||
@@ -1521,6 +1570,10 @@
|
||||
"selectRootDirectory": "Selecciona un directorio raíz",
|
||||
"selectModelRoot": "Seleccionar raíz del modelo:",
|
||||
"selectTypeRoot": "Seleccionar raíz de {type}:",
|
||||
"routingOverride": {
|
||||
"label": "Tipo de destino:",
|
||||
"tooltip": "Detectado automáticamente a partir de los metadatos del modelo. Cámbialo si la detección fue incorrecta; cambiar desactiva «Usar ruta predeterminada» para esta descarga."
|
||||
},
|
||||
"targetFolderPath": "Ruta de carpeta de destino:",
|
||||
"browseFolders": "Explorar carpetas:",
|
||||
"createNewFolder": "Crear nueva carpeta",
|
||||
@@ -1637,6 +1690,28 @@
|
||||
"titleRevert": "¿Restaurar los nombres de archivo originales?",
|
||||
"revertButton": "Restaurar nombres de archivo originales"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "¿Mover los archivos sidecar al almacenamiento centralizado?",
|
||||
"titleToAlongside": "¿Devolver los archivos sidecar junto a los archivos de modelo?",
|
||||
"confirmButton": "Migrar ahora",
|
||||
"titleRelocateRoot": "¿Mover los archivos sidecar al nuevo directorio de almacenamiento?",
|
||||
"destination": "Destino: {path}"
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "Resumen de la migración de archivos sidecar",
|
||||
"completedSuccessfully": "Migración completada correctamente",
|
||||
"completedWithErrors": "Completado con {count} error(es)",
|
||||
"statMoved": "Archivos movidos",
|
||||
"statModels": "Modelos",
|
||||
"statSkipped": "Omitidos",
|
||||
"statConflicts": "Conflictos resueltos",
|
||||
"statErrors": "Errores",
|
||||
"failedItems": "Elementos fallidos ({count})",
|
||||
"columnModel": "Modelo",
|
||||
"columnError": "Error",
|
||||
"successMessage": "Se movieron {moved} archivos para {models} modelos",
|
||||
"location": "Ubicación de almacenamiento: {path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Añadir etiquetas a múltiples modelos",
|
||||
"description": "Añadir etiquetas a",
|
||||
@@ -1769,6 +1844,8 @@
|
||||
"sendToWorkflow": "Enviar a ComfyUI",
|
||||
"sendToWorkflowText": "Enviar a ComfyUI",
|
||||
"copyHash": "Copiar hash",
|
||||
"copyCivitaiId": "Copiar ID de Civitai",
|
||||
"civitaiIdCopied": "ID de Civitai copiado al portapapeles",
|
||||
"deleteModelWithShortcut": "Eliminar modelo (Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
@@ -1787,6 +1864,8 @@
|
||||
"baseModel": "Modelo base",
|
||||
"size": "Tamaño",
|
||||
"hashes": "Hashes",
|
||||
"civitaiModelId": "ID del modelo",
|
||||
"civitaiVersionId": "ID de versión",
|
||||
"unknown": "Desconocido",
|
||||
"usageTips": "Consejos de uso",
|
||||
"additionalNotes": "Notas adicionales",
|
||||
@@ -1904,7 +1983,9 @@
|
||||
"nsfwMature": "Contenido para adultos",
|
||||
"nsfwR": "Contenido clasificación R",
|
||||
"nsfwX": "Contenido clasificación X",
|
||||
"nsfwXxx": "Contenido clasificación XXX"
|
||||
"nsfwXxx": "Contenido clasificación XXX",
|
||||
"layoutGallery": "Vista de galería",
|
||||
"layoutList": "Vista de lista"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Versiones del modelo",
|
||||
@@ -2713,6 +2794,9 @@
|
||||
"filename_conflicts": {
|
||||
"title": "Conflictos de nombres de archivo duplicados"
|
||||
},
|
||||
"sidecar_mirror_orphans": {
|
||||
"title": "Archivos sidecar centralizados"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "Versión de la interfaz"
|
||||
}
|
||||
|
||||
+86
-2
@@ -251,7 +251,6 @@
|
||||
"modelname": "Nom du modèle",
|
||||
"tags": "Tags",
|
||||
"creator": "Créateur",
|
||||
"hash": "Hash",
|
||||
"title": "Titre de la recipe",
|
||||
"loraName": "Nom de fichier LoRA",
|
||||
"loraModel": "Nom du modèle LoRA",
|
||||
@@ -353,6 +352,14 @@
|
||||
"placeholder": "Laisser vide pour utiliser aria2c depuis le PATH"
|
||||
},
|
||||
"aria2HelpLink": "Apprenez à configurer le backend de téléchargement aria2",
|
||||
"unknownBaseModelRouting": {
|
||||
"label": "Routage des modèles de base inconnus",
|
||||
"help": "Détermine où vont les téléchargements de Checkpoint lorsque CivitAI signale un modèle de base qui n’est ni un Checkpoint connu (SD 1.x/2.x/3.x, SDXL, Pony, Illustrious, NoobAI) ni un modèle de diffusion connu. De nouvelles architectures de diffusion apparaissent fréquemment, donc les router vers les modèles de diffusion est généralement correct.",
|
||||
"options": {
|
||||
"diffusionModel": "Modèles de diffusion",
|
||||
"checkpoint": "Checkpoints"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "Préférence d’hôte CivitAI disponible",
|
||||
"content": "CivitAI utilise désormais civitai.com pour le contenu SFW et civitai.red pour le contenu sans restriction. Vous pouvez modifier dans les paramètres le site ouvert par défaut.",
|
||||
@@ -383,6 +390,7 @@
|
||||
"exampleImages": "Images d'exemple",
|
||||
"autoOrganize": "Organisation automatique",
|
||||
"metadata": "Métadonnées",
|
||||
"sidecarStorage": "Stockage des fichiers sidecar",
|
||||
"proxySettings": "Paramètres du proxy"
|
||||
},
|
||||
"nav": {
|
||||
@@ -517,6 +525,12 @@
|
||||
"hover": "Révéler au survol"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Choisissez quand afficher les informations du modèle et les boutons d'action",
|
||||
"showcaseLayout": "Disposition des images d'exemple",
|
||||
"showcaseLayoutHelp": "Choisissez comment les images d'exemple sont affichées dans la fenêtre modale du modèle : une galerie une image à la fois ou la liste verticale classique.",
|
||||
"showcaseLayoutOptions": {
|
||||
"gallery": "Galerie",
|
||||
"vertical": "Liste verticale"
|
||||
},
|
||||
"showVersionOnCard": "Afficher la version sur la carte",
|
||||
"showVersionOnCardHelp": "Afficher ou masquer le nom de version sur les cartes de modèle",
|
||||
"modelCardFooterAction": "Action du bouton de carte de modèle",
|
||||
@@ -784,11 +798,41 @@
|
||||
"downloadComplete": "Téléchargement terminé avec succès",
|
||||
"enableCivarchiveApi": "Activer l'API CivArchive comme fournisseur de métadonnées",
|
||||
"enableCivarchiveApiHelp": "Lorsqu'elle est activée, l'API CivArchive est utilisée comme source de secours pour les métadonnées des modèles (par ex. pour les modèles supprimés de CivitAI). Désactivez pour éviter entièrement les limites de débit de CivArchive.",
|
||||
"enableOpenmodeldbApi": "Activer OpenModelDB comme fournisseur de métadonnées",
|
||||
"enableOpenmodeldbApiHelp": "Lorsqu’elle est activée, les métadonnées des Upscaler sont également recherchées dans le catalogue OpenModelDB (openmodeldb.info) lorsque CivitAI n’a aucune fiche. Le catalogue est mis en cache localement et actualisé quotidiennement.",
|
||||
"providerOrder": "Ordre de secours des fournisseurs de métadonnées",
|
||||
"providerOrderHelp": "L'API CivitAI est toujours essayée en premier. Choisissez l'ordre des autres fournisseurs lors de la recherche de métadonnées.",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "Mode de stockage des fichiers sidecar",
|
||||
"modeHelp": "Choisissez où sont stockés les fichiers sidecar .metadata.json et les images d’aperçu : à côté de chaque fichier de modèle, ou dans un seul dossier centralisé qui reflète la structure de votre bibliothèque. Les fichiers .civitai.info restent toujours à côté du fichier de modèle.",
|
||||
"modeOptions": {
|
||||
"alongside": "À côté des fichiers de modèle (par défaut)",
|
||||
"centralized": "Stockage centralisé"
|
||||
},
|
||||
"path": "Chemin du stockage centralisé",
|
||||
"pathHelp": "Dossier racine du stockage centralisé des fichiers sidecar. Laissez vide pour utiliser l’emplacement par défaut (<settings dir>/sidecars).",
|
||||
"pathPlaceholder": "Vide = <settings dir>/sidecars",
|
||||
"management": "Migration des fichiers sidecar",
|
||||
"managementHelp": "Déplacez les fichiers sidecar .metadata.json et les images d’aperçu existants entre le stockage à côté des modèles et le stockage centralisé, selon le mode sélectionné. Changer de mode ne déplace pas les fichiers existants automatiquement.",
|
||||
"migrateButton": "Migrer les fichiers sidecar maintenant",
|
||||
"migratingButton": "Migration en cours...",
|
||||
"migrating": "Migration des fichiers sidecar en cours...",
|
||||
"migrateFailed": "Échec de la migration des fichiers sidecar : {message}",
|
||||
"migrationDeferred": "Les fichiers sidecar existants n’ont pas été déplacés. Vous pouvez les migrer plus tard depuis Paramètres → Bibliothèque → Stockage des fichiers sidecar.",
|
||||
"confirmToCentralized": "Le mode de stockage a changé, mais les fichiers sidecar .metadata.json et les images d’aperçu existants ne sont pas déplacés automatiquement. Les déplacer maintenant dans le dossier de stockage centralisé ? Vous pouvez aussi le faire plus tard avec le bouton « Migrer les fichiers sidecar maintenant ».",
|
||||
"confirmToAlongside": "Le mode de stockage a changé, mais les fichiers sidecar .metadata.json et les images d’aperçu existants ne sont pas déplacés automatiquement. Les remettre maintenant à côté de leurs fichiers de modèle ? Vous pouvez aussi le faire plus tard avec le bouton « Migrer les fichiers sidecar maintenant ».",
|
||||
"confirmRelocateRoot": "Le dossier de stockage centralisé a changé, mais les fichiers sidecar et les images d’aperçu existants se trouvent encore dans le dossier précédent. Les déplacer maintenant dans le nouveau dossier ?",
|
||||
"effectivePathLabel": "Emplacement de stockage effectif :",
|
||||
"openFolderButton": "Ouvrir le dossier",
|
||||
"repoWarning": "L’emplacement de stockage effectif se trouve dans le dossier d’installation de LoRA Manager. Une réinstallation de l’extension ou une mise à jour propre peut le supprimer — définissez un chemin de stockage explicite en dehors du dossier d’installation.",
|
||||
"openLocationSuccess": "Dossier de stockage des fichiers sidecar ouvert",
|
||||
"openLocationCopied": "Chemin de stockage des fichiers sidecar copié dans le presse-papiers : {path}",
|
||||
"openLocationClipboardFallback": "Copiez manuellement le chemin de stockage des fichiers sidecar : {path}",
|
||||
"openLocationFailed": "Échec de l’ouverture du dossier de stockage des fichiers sidecar"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "Activer le proxy au niveau de l'application",
|
||||
"enableProxyHelp": "Activer les paramètres de proxy personnalisés pour cette application, remplaçant les paramètres de proxy système",
|
||||
@@ -1350,6 +1394,10 @@
|
||||
"emptyNote": "Ce dossier ne contient aucun modèle. Les autres fichiers qu’il contient seront également supprimés.",
|
||||
"notEmptyTitle": "Le dossier n’est pas vide",
|
||||
"notEmptyMessage": "Ce dossier contient encore des modèles. Supprimez-les ou déplacez-les d’abord — la suppression d’un dossier n’entraîne jamais celle des fichiers de modèles.",
|
||||
"notEmptyMessageCount": "Ce dossier contient encore {count} fichier(s) de modèle. Supprimez-les ou déplacez-les d’abord — la suppression d’un dossier n’entraîne jamais celle des fichiers de modèles.",
|
||||
"notEmptyMessageExcluded": "Ce dossier contient encore {count} fichier(s) de modèle, dont {excluded} sont exclus de la bibliothèque. Annulez leur exclusion dans « Gérer les modèles exclus » et supprimez-les d’abord — la suppression d’un dossier n’entraîne jamais celle des fichiers de modèles.",
|
||||
"busyTitle": "Une suppression est encore en attente",
|
||||
"checking": "Vérification du contenu du dossier...",
|
||||
"confirm": "Supprimer le dossier"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1358,6 +1406,7 @@
|
||||
"restored": "Dossier restauré",
|
||||
"failed": "Échec de la suppression du dossier : {message}",
|
||||
"notEmpty": "Ce dossier contient encore des modèles. Actualisez la barre latérale et réessayez.",
|
||||
"notEmptyWithCount": "Ce dossier contient encore {count} fichier(s) de modèle. Actualisez la barre latérale et réessayez.",
|
||||
"busy": "Une suppression est encore en attente dans ce dossier. Attendez la fin de la fenêtre d’annulation.",
|
||||
"unsupported": "La suppression de dossiers n’est pas prise en charge sur cette page",
|
||||
"noRoot": "Aucune racine de modèle n’est configurée"
|
||||
@@ -1521,6 +1570,10 @@
|
||||
"selectRootDirectory": "Sélectionner un répertoire racine",
|
||||
"selectModelRoot": "Sélectionner la racine du modèle :",
|
||||
"selectTypeRoot": "Sélectionner la racine {type} :",
|
||||
"routingOverride": {
|
||||
"label": "Type de destination :",
|
||||
"tooltip": "Détecté automatiquement à partir des métadonnées du modèle. Changez si la détection est incorrecte ; changer désactive « Utiliser le chemin par défaut » pour ce téléchargement."
|
||||
},
|
||||
"targetFolderPath": "Chemin du dossier cible :",
|
||||
"browseFolders": "Parcourir les dossiers :",
|
||||
"createNewFolder": "Créer un nouveau dossier",
|
||||
@@ -1637,6 +1690,28 @@
|
||||
"titleRevert": "Restaurer les noms de fichier d'origine ?",
|
||||
"revertButton": "Restaurer les noms de fichier d'origine"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "Déplacer les fichiers sidecar vers le stockage centralisé ?",
|
||||
"titleToAlongside": "Remettre les fichiers sidecar à côté des fichiers de modèle ?",
|
||||
"confirmButton": "Migrer maintenant",
|
||||
"titleRelocateRoot": "Déplacer les fichiers sidecar vers le nouveau dossier de stockage ?",
|
||||
"destination": "Destination : {path}"
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "Résumé de la migration des fichiers sidecar",
|
||||
"completedSuccessfully": "Migration terminée avec succès",
|
||||
"completedWithErrors": "Terminé avec {count} erreur(s)",
|
||||
"statMoved": "Fichiers déplacés",
|
||||
"statModels": "Modèles",
|
||||
"statSkipped": "Ignorés",
|
||||
"statConflicts": "Conflits résolus",
|
||||
"statErrors": "Erreurs",
|
||||
"failedItems": "Éléments en échec ({count})",
|
||||
"columnModel": "Modèle",
|
||||
"columnError": "Erreur",
|
||||
"successMessage": "{moved} fichiers déplacés pour {models} modèles",
|
||||
"location": "Emplacement de stockage : {path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Ajouter des tags à plusieurs modèles",
|
||||
"description": "Ajouter des tags à",
|
||||
@@ -1769,6 +1844,8 @@
|
||||
"sendToWorkflow": "Envoyer vers ComfyUI",
|
||||
"sendToWorkflowText": "Envoyer vers ComfyUI",
|
||||
"copyHash": "Copier le hash",
|
||||
"copyCivitaiId": "Copier l’ID Civitai",
|
||||
"civitaiIdCopied": "ID Civitai copié dans le presse-papiers",
|
||||
"deleteModelWithShortcut": "Supprimer le modèle (Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
@@ -1787,6 +1864,8 @@
|
||||
"baseModel": "Modèle de base",
|
||||
"size": "Taille",
|
||||
"hashes": "Hashes",
|
||||
"civitaiModelId": "ID du modèle",
|
||||
"civitaiVersionId": "ID de version",
|
||||
"unknown": "Inconnu",
|
||||
"usageTips": "Conseils d'utilisation",
|
||||
"additionalNotes": "Notes supplémentaires",
|
||||
@@ -1904,7 +1983,9 @@
|
||||
"nsfwMature": "Contenu pour adultes",
|
||||
"nsfwR": "Contenu classé R",
|
||||
"nsfwX": "Contenu classé X",
|
||||
"nsfwXxx": "Contenu classé XXX"
|
||||
"nsfwXxx": "Contenu classé XXX",
|
||||
"layoutGallery": "Vue galerie",
|
||||
"layoutList": "Vue liste"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Versions du modèle",
|
||||
@@ -2713,6 +2794,9 @@
|
||||
"filename_conflicts": {
|
||||
"title": "Conflits de noms de fichiers en double"
|
||||
},
|
||||
"sidecar_mirror_orphans": {
|
||||
"title": "Fichiers sidecar centralisés"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "Version de l'interface"
|
||||
}
|
||||
|
||||
+86
-2
@@ -251,7 +251,6 @@
|
||||
"modelname": "שם מודל",
|
||||
"tags": "תגיות",
|
||||
"creator": "יוצר",
|
||||
"hash": "hash",
|
||||
"title": "כותרת מתכון",
|
||||
"loraName": "שם קובץ LoRA",
|
||||
"loraModel": "שם מודל LoRA",
|
||||
@@ -353,6 +352,14 @@
|
||||
"placeholder": "השאר ריק כדי להשתמש ב-aria2c מתוך ה-PATH"
|
||||
},
|
||||
"aria2HelpLink": "למד כיצד להגדיר את מנוע ההורדה aria2",
|
||||
"unknownBaseModelRouting": {
|
||||
"label": "ניתוב מודל בסיס לא מוכר",
|
||||
"help": "קובע לאן מועברות הורדות של Checkpoint כאשר CivitAI מדווח על מודל בסיס שהוא לא Checkpoint מוכר (SD 1.x/2.x/3.x, SDXL, Pony, Illustrious, NoobAI) ולא מודל דיפוזיה מוכר. ארכיטקטורות דיפוזיה חדשות מופיעות לעיתים קרובות, ולכן ניתוב שלהן למודלי דיפוזיה הוא בדרך כלל הנכון.",
|
||||
"options": {
|
||||
"diffusionModel": "מודלי דיפוזיה",
|
||||
"checkpoint": "Checkpoints"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "העדפת מארח CivitAI זמינה",
|
||||
"content": "CivitAI משתמש כעת ב-civitai.com עבור תוכן SFW וב-civitai.red עבור תוכן ללא הגבלות. ניתן לשנות בהגדרות איזה אתר ייפתח כברירת מחדל.",
|
||||
@@ -383,6 +390,7 @@
|
||||
"exampleImages": "תמונות דוגמה",
|
||||
"autoOrganize": "ארגון אוטומטי",
|
||||
"metadata": "מטא-נתונים",
|
||||
"sidecarStorage": "אחסון קובצי לוואי",
|
||||
"proxySettings": "הגדרות פרוקסי"
|
||||
},
|
||||
"nav": {
|
||||
@@ -517,6 +525,12 @@
|
||||
"hover": "חשוף בריחוף"
|
||||
},
|
||||
"cardInfoDisplayHelp": "בחר מתי להציג מידע על המודל וכפתורי פעולה",
|
||||
"showcaseLayout": "פריסת תמונות דוגמה",
|
||||
"showcaseLayoutHelp": "בחר כיצד יוצגו תמונות הדוגמה בחלון המודל: גלריה המציגה תמונה אחת בכל פעם או הרשימה האנכית הקלאסית.",
|
||||
"showcaseLayoutOptions": {
|
||||
"gallery": "גלריה",
|
||||
"vertical": "רשימה אנכית"
|
||||
},
|
||||
"showVersionOnCard": "הצג גרסה בכרטיס",
|
||||
"showVersionOnCardHelp": "הצג או הסתר את שם הגרסה בכרטיסי המודל",
|
||||
"modelCardFooterAction": "פעולת כפתור כרטיס מודל",
|
||||
@@ -784,11 +798,41 @@
|
||||
"downloadComplete": "ההורדה הושלמה בהצלחה",
|
||||
"enableCivarchiveApi": "הפעל את CivArchive API כספק מטא-נתונים",
|
||||
"enableCivarchiveApiHelp": "כאשר מופעל, CivArchive API משמש כמקור גיבוי למטא-נתונים של מודלים (למשל עבור מודלים שנמחקו מ-CivitAI). כבה כדי להימנע לחלוטין ממגבלות הקצב של CivArchive.",
|
||||
"enableOpenmodeldbApi": "הפעל את OpenModelDB כספק מטא-נתונים",
|
||||
"enableOpenmodeldbApiHelp": "כאשר מופעל, מטא-נתונים של Upscaler מחופשים גם בקטלוג OpenModelDB (openmodeldb.info) כאשר ל-CivitAI אין רשומה. הקטלוג נשמר במטמון מקומי ומתרענן מדי יום.",
|
||||
"providerOrder": "סדר ספקי מטא-נתונים לגיבוי",
|
||||
"providerOrderHelp": "CivitAI API תמיד מנוסה ראשון. בחר את סדר הספקים הנותרים בעת חיפוש מטא-נתונים.",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "מצב אחסון קובצי לוואי",
|
||||
"modeHelp": "בחר היכן יאוחסנו קובצי הלוואי .metadata.json ותמונות התצוגה המקדימה: לצד כל קובץ מודל, או בתיקייה מרכזית אחת שמשקפת את מבנה הספרייה שלך. קובצי .civitai.info נשארים תמיד לצד קובץ המודל.",
|
||||
"modeOptions": {
|
||||
"alongside": "לצד קובצי המודלים (ברירת מחדל)",
|
||||
"centralized": "אחסון מרכזי"
|
||||
},
|
||||
"path": "נתיב האחסון המרכזי",
|
||||
"pathHelp": "תיקיית השורש של אחסון קובצי הלוואי המרכזי. השאר ריק כדי להשתמש במיקום ברירת המחדל <settings dir>/sidecars.",
|
||||
"pathPlaceholder": "ריק = <settings dir>/sidecars",
|
||||
"management": "העברת קובצי לוואי",
|
||||
"managementHelp": "העבר קובצי לוואי .metadata.json ותמונות תצוגה מקדימה קיימים בין אחסון לצד המודלים לאחסון המרכזי, בהתאם למצב שנבחר. שינוי המצב אינו מעביר קבצים קיימים באופן אוטומטי.",
|
||||
"migrateButton": "העבר קובצי לוואי כעת",
|
||||
"migratingButton": "מעביר...",
|
||||
"migrating": "מעביר קובצי לוואי...",
|
||||
"migrateFailed": "העברת קובצי הלוואי נכשלה: {message}",
|
||||
"migrationDeferred": "קובצי הלוואי הקיימים לא הועברו. ניתן להעביר אותם מאוחר יותר דרך הגדרות > ספרייה > אחסון קובצי לוואי.",
|
||||
"confirmToCentralized": "מצב האחסון השתנה, אך קובצי הלוואי .metadata.json ותמונות התצוגה המקדימה הקיימים אינם מועברים אוטומטית. להעביר אותם כעת לתיקיית האחסון המרכזי? ניתן לעשות זאת גם מאוחר יותר באמצעות הכפתור «העבר קובצי לוואי כעת».",
|
||||
"confirmToAlongside": "מצב האחסון השתנה, אך קובצי הלוואי .metadata.json ותמונות התצוגה המקדימה הקיימים אינם מועברים אוטומטית. להחזיר אותם כעת לצד קובצי המודל שלהם? ניתן לעשות זאת גם מאוחר יותר באמצעות הכפתור «העבר קובצי לוואי כעת».",
|
||||
"confirmRelocateRoot": "תיקיית האחסון המרכזי השתנתה, אך קובצי הלוואי ותמונות התצוגה המקדימה הקיימים עדיין נמצאים בתיקייה הקודמת. להעביר אותם כעת לתיקייה החדשה?",
|
||||
"effectivePathLabel": "מיקום האחסון בפועל:",
|
||||
"openFolderButton": "פתח תיקייה",
|
||||
"repoWarning": "מיקום האחסון בפועל נמצא בתוך תיקיית ההתקנה של LoRA Manager. התקנה מחדש של התוסף או עדכון נקי עלולים למחוק אותו — הגדר נתיב אחסון מפורש מחוץ לתיקיית ההתקנה.",
|
||||
"openLocationSuccess": "תיקיית אחסון קובצי הלוואי נפתחה",
|
||||
"openLocationCopied": "נתיב אחסון קובצי הלוואי הועתק ללוח: {path}",
|
||||
"openLocationClipboardFallback": "העתק את נתיב אחסון קובצי הלוואי ידנית: {path}",
|
||||
"openLocationFailed": "פתיחת תיקיית אחסון קובצי הלוואי נכשלה"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "הפעל פרוקסי ברמת האפליקציה",
|
||||
"enableProxyHelp": "אפשר הגדרות פרוקסי מותאמות אישית עבור יישום זה, במקום הגדרות הפרוקסי של המערכת",
|
||||
@@ -1350,6 +1394,10 @@
|
||||
"emptyNote": "אין מודלים בתיקייה זו. קבצים אחרים שבה יימחקו גם הם.",
|
||||
"notEmptyTitle": "התיקייה אינה ריקה",
|
||||
"notEmptyMessage": "בתיקייה זו עדיין יש מודלים. מחק או העבר אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
|
||||
"notEmptyMessageCount": "בתיקייה זו עדיין יש {count} קובצי מודלים. מחק או העבר אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
|
||||
"notEmptyMessageExcluded": "בתיקייה זו עדיין יש {count} קובצי מודלים, שמהם {excluded} מוחרגים מהספרייה. בטל את ההחרגה ב«ניהול מודלים מוחרגים» ומחק אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
|
||||
"busyTitle": "מחיקה עדיין ממתינה",
|
||||
"checking": "בודק את תוכן התיקייה...",
|
||||
"confirm": "מחק תיקייה"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1358,6 +1406,7 @@
|
||||
"restored": "התיקייה שוחזרה",
|
||||
"failed": "מחיקת התיקייה נכשלה: {message}",
|
||||
"notEmpty": "בתיקייה זו עדיין יש מודלים. רענן את סרגל הצד ונסה שוב.",
|
||||
"notEmptyWithCount": "בתיקייה זו עדיין יש {count} קובצי מודלים. רענן את סרגל הצד ונסה שוב.",
|
||||
"busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.",
|
||||
"unsupported": "מחיקת תיקיות אינה נתמכת בדף זה",
|
||||
"noRoot": "לא הוגדר שורש מודלים"
|
||||
@@ -1521,6 +1570,10 @@
|
||||
"selectRootDirectory": "בחר ספריית שורש",
|
||||
"selectModelRoot": "בחר שורש מודל:",
|
||||
"selectTypeRoot": "בחר שורש {type}:",
|
||||
"routingOverride": {
|
||||
"label": "סוג יעד:",
|
||||
"tooltip": "זוהה אוטומטית ממטא-נתוני המודל. החלף אם הזיהוי שגוי; החלפה מכבה את «השתמש בנתיב ברירת מחדל» עבור הורדה זו."
|
||||
},
|
||||
"targetFolderPath": "נתיב תיקיית יעד:",
|
||||
"browseFolders": "דפדף בתיקיות:",
|
||||
"createNewFolder": "צור תיקייה חדשה",
|
||||
@@ -1637,6 +1690,28 @@
|
||||
"titleRevert": "לשחזר שמות קבצים מקוריים?",
|
||||
"revertButton": "שחזר שמות קבצים מקוריים"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "להעביר את קובצי הלוואי לאחסון המרכזי?",
|
||||
"titleToAlongside": "להחזיר את קובצי הלוואי לצד קובצי המודל?",
|
||||
"confirmButton": "העבר כעת",
|
||||
"titleRelocateRoot": "להעביר את קובצי הלוואי לתיקיית האחסון החדשה?",
|
||||
"destination": "יעד: {path}"
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "סיכום העברת קובצי לוואי",
|
||||
"completedSuccessfully": "ההעברה הושלמה בהצלחה",
|
||||
"completedWithErrors": "הושלם עם {count} שגיאות",
|
||||
"statMoved": "קבצים שהועברו",
|
||||
"statModels": "מודלים",
|
||||
"statSkipped": "דולגו",
|
||||
"statConflicts": "קונפליקטים שנפתרו",
|
||||
"statErrors": "שגיאות",
|
||||
"failedItems": "פריטים שנכשלו ({count})",
|
||||
"columnModel": "מודל",
|
||||
"columnError": "שגיאה",
|
||||
"successMessage": "הועברו {moved} קבצים עבור {models} מודלים",
|
||||
"location": "מיקום אחסון: {path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "הוסף תגיות למספר מודלים",
|
||||
"description": "הוסף תגיות ל-",
|
||||
@@ -1769,6 +1844,8 @@
|
||||
"sendToWorkflow": "שלח ל-ComfyUI",
|
||||
"sendToWorkflowText": "שלח ל-ComfyUI",
|
||||
"copyHash": "העתק hash",
|
||||
"copyCivitaiId": "העתק מזהה Civitai",
|
||||
"civitaiIdCopied": "מזהה Civitai הועתק ללוח העריכה",
|
||||
"deleteModelWithShortcut": "מחק מודל (Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
@@ -1787,6 +1864,8 @@
|
||||
"baseModel": "מודל בסיס",
|
||||
"size": "גודל",
|
||||
"hashes": "hashes",
|
||||
"civitaiModelId": "מזהה מודל",
|
||||
"civitaiVersionId": "מזהה גרסה",
|
||||
"unknown": "לא ידוע",
|
||||
"usageTips": "טיפים לשימוש",
|
||||
"additionalNotes": "הערות נוספות",
|
||||
@@ -1904,7 +1983,9 @@
|
||||
"nsfwMature": "תוכן למבוגרים",
|
||||
"nsfwR": "תוכן בדירוג R",
|
||||
"nsfwX": "תוכן בדירוג X",
|
||||
"nsfwXxx": "תוכן בדירוג XXX"
|
||||
"nsfwXxx": "תוכן בדירוג XXX",
|
||||
"layoutGallery": "תצוגת גלריה",
|
||||
"layoutList": "תצוגת רשימה"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "גרסאות המודל",
|
||||
@@ -2713,6 +2794,9 @@
|
||||
"filename_conflicts": {
|
||||
"title": "התנגשויות שמות קבצים כפולים"
|
||||
},
|
||||
"sidecar_mirror_orphans": {
|
||||
"title": "קבצי לוואי מרוכזים"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "גרסת הממשק"
|
||||
}
|
||||
|
||||
+86
-2
@@ -251,7 +251,6 @@
|
||||
"modelname": "モデル名",
|
||||
"tags": "タグ",
|
||||
"creator": "作成者",
|
||||
"hash": "ハッシュ",
|
||||
"title": "レシピタイトル",
|
||||
"loraName": "LoRAファイル名",
|
||||
"loraModel": "LoRAモデル名",
|
||||
@@ -353,6 +352,14 @@
|
||||
"placeholder": "空欄のままにすると PATH 上の aria2c を使用します"
|
||||
},
|
||||
"aria2HelpLink": "aria2 ダウンロードバックエンドの設定方法",
|
||||
"unknownBaseModelRouting": {
|
||||
"label": "不明なベースモデルの振り分け",
|
||||
"help": "CivitAIが報告するベースモデルが既知のCheckpoint(SD 1.x/2.x/3.x、SDXL、Pony、Illustrious、NoobAI)でも既知のDiffusion Modelでもない場合に、Checkpointのダウンロードの保存先を決定します。新しい拡散アーキテクチャは頻繁に登場するため、通常はDiffusion Modelへの振り分けが正しい選択です。",
|
||||
"options": {
|
||||
"diffusionModel": "Diffusion Model",
|
||||
"checkpoint": "Checkpoint"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "CivitAI ホスト設定を利用できます",
|
||||
"content": "CivitAI は現在、SFW コンテンツには civitai.com、制限なしコンテンツには civitai.red を使用しています。設定で既定で開くサイトを変更できます。",
|
||||
@@ -383,6 +390,7 @@
|
||||
"exampleImages": "例画像",
|
||||
"autoOrganize": "自動整理",
|
||||
"metadata": "メタデータ",
|
||||
"sidecarStorage": "サイドカーファイルの保存",
|
||||
"proxySettings": "プロキシ設定"
|
||||
},
|
||||
"nav": {
|
||||
@@ -517,6 +525,12 @@
|
||||
"hover": "ホバー時に表示"
|
||||
},
|
||||
"cardInfoDisplayHelp": "モデル情報とアクションボタンの表示タイミングを選択",
|
||||
"showcaseLayout": "ショーケースのレイアウト",
|
||||
"showcaseLayoutHelp": "モデルモーダルでの例画像の表示方法を選択:1枚ずつ表示するギャラリー、または従来の縦並びリスト。",
|
||||
"showcaseLayoutOptions": {
|
||||
"gallery": "ギャラリー",
|
||||
"vertical": "縦並びリスト"
|
||||
},
|
||||
"showVersionOnCard": "カードにバージョンを表示",
|
||||
"showVersionOnCardHelp": "モデルカード上のバージョン名の表示/非表示を切り替えます",
|
||||
"modelCardFooterAction": "モデルカードボタンのアクション",
|
||||
@@ -784,11 +798,41 @@
|
||||
"downloadComplete": "ダウンロードが正常に完了しました",
|
||||
"enableCivarchiveApi": "CivArchive API をメタデータプロバイダーとして有効化",
|
||||
"enableCivarchiveApiHelp": "有効にすると、CivArchive API がモデルメタデータの代替ソースとして使用されます(例:CivitAI から削除されたモデルの場合)。オフにすると、CivArchive のレート制限を完全に回避できます。",
|
||||
"enableOpenmodeldbApi": "OpenModelDB をメタデータプロバイダーとして有効化",
|
||||
"enableOpenmodeldbApiHelp": "有効にすると、CivitAI に記録がない場合に、OpenModelDB カタログ(openmodeldb.info)でも Upscaler のメタデータを検索します。カタログはローカルにキャッシュされ、毎日更新されます。",
|
||||
"providerOrder": "メタデータプロバイダーのフォールバック順序",
|
||||
"providerOrderHelp": "CivitAI API が常に最初に試行されます。メタデータ検索時の残りのプロバイダーの順序を選択してください。",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "サイドカーファイルの保存モード",
|
||||
"modeHelp": ".metadata.json サイドカーファイルとプレビュー画像の保存先を選択します。各モデルファイルの隣に保存するか、ライブラリ構造をミラーリングした単一の集中ディレクトリに保存します。.civitai.info ファイルは常にモデルファイルの隣に置かれます。",
|
||||
"modeOptions": {
|
||||
"alongside": "モデルファイルの隣(デフォルト)",
|
||||
"centralized": "集中保存"
|
||||
},
|
||||
"path": "集中保存先パス",
|
||||
"pathHelp": "サイドカーファイルを集中保存するルートディレクトリ。空欄の場合は既定の場所(<settings dir>/sidecars)を使用します。",
|
||||
"pathPlaceholder": "空欄 = <settings dir>/sidecars",
|
||||
"management": "サイドカーファイルの移動",
|
||||
"managementHelp": "既存の .metadata.json サイドカーファイルとプレビュー画像を、現在選択されているモードに合わせて「モデルファイルの隣」と「集中保存」の間で移動します。モードを変更しても既存ファイルは自動では移動しません。",
|
||||
"migrateButton": "今すぐサイドカーファイルを移動",
|
||||
"migratingButton": "移動しています...",
|
||||
"migrating": "サイドカーファイルを移動しています...",
|
||||
"migrateFailed": "サイドカーファイルの移動に失敗しました:{message}",
|
||||
"migrationDeferred": "既存のサイドカーファイルは移動されませんでした。後で「設定 > ライブラリ > サイドカーファイルの保存」から移動できます。",
|
||||
"confirmToCentralized": "保存モードが変更されましたが、既存の .metadata.json サイドカーファイルとプレビュー画像は自動では移動しません。今すぐ集中保存ディレクトリに移動しますか?「今すぐサイドカーファイルを移動」ボタンで後から実行することもできます。",
|
||||
"confirmToAlongside": "保存モードが変更されましたが、既存の .metadata.json サイドカーファイルとプレビュー画像は自動では移動しません。今すぐ各モデルファイルの隣に戻しますか?「今すぐサイドカーファイルを移動」ボタンで後から実行することもできます。",
|
||||
"confirmRelocateRoot": "集中保存ディレクトリが変更されましたが、既存のサイドカーファイルとプレビュー画像はまだ以前のディレクトリにあります。今すぐ新しいディレクトリに移動しますか?",
|
||||
"effectivePathLabel": "実際の保存場所:",
|
||||
"openFolderButton": "フォルダを開く",
|
||||
"repoWarning": "実際の保存場所が LoRA Manager のインストールフォルダ内にあります。プラグインの再インストールやクリーンアップデートで削除される可能性があります。インストールフォルダの外に明示的な保存パスを設定してください。",
|
||||
"openLocationSuccess": "サイドカーファイルの保存フォルダを開きました",
|
||||
"openLocationCopied": "サイドカーファイルの保存パスをクリップボードにコピーしました:{path}",
|
||||
"openLocationClipboardFallback": "サイドカーファイルの保存パスを手動でコピーしてください:{path}",
|
||||
"openLocationFailed": "サイドカーファイルの保存フォルダを開けませんでした"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "アプリレベルのプロキシを有効化",
|
||||
"enableProxyHelp": "このアプリケーション専用のカスタムプロキシ設定を有効にします(システムのプロキシ設定を上書きします)",
|
||||
@@ -1350,6 +1394,10 @@
|
||||
"emptyNote": "このフォルダにはモデルがありません。他のファイルもすべて削除されます。",
|
||||
"notEmptyTitle": "フォルダが空ではありません",
|
||||
"notEmptyMessage": "このフォルダにはまだモデルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
|
||||
"notEmptyMessageCount": "このフォルダにはまだ {count} 個のモデルファイルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
|
||||
"notEmptyMessageExcluded": "このフォルダにはまだ {count} 個のモデルファイルがあり、そのうち {excluded} 個はライブラリから除外されています。「除外モデルを管理」で除外を解除してから削除してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
|
||||
"busyTitle": "保留中の削除があります",
|
||||
"checking": "フォルダの内容を確認しています...",
|
||||
"confirm": "フォルダを削除"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1358,6 +1406,7 @@
|
||||
"restored": "フォルダを復元しました",
|
||||
"failed": "フォルダの削除に失敗しました: {message}",
|
||||
"notEmpty": "このフォルダにはまだモデルがあります。サイドバーを再読み込みしてからもう一度お試しください。",
|
||||
"notEmptyWithCount": "このフォルダにはまだ {count} 個のモデルファイルがあります。サイドバーを再読み込みしてからもう一度お試しください。",
|
||||
"busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。",
|
||||
"unsupported": "このページではフォルダを削除できません",
|
||||
"noRoot": "モデルルートが設定されていません"
|
||||
@@ -1521,6 +1570,10 @@
|
||||
"selectRootDirectory": "ルートディレクトリを選択",
|
||||
"selectModelRoot": "モデルルートを選択:",
|
||||
"selectTypeRoot": "{type}ルートを選択:",
|
||||
"routingOverride": {
|
||||
"label": "保存先タイプ:",
|
||||
"tooltip": "モデルのメタデータから自動検出されます。誤検出の場合は切り替えてください。切り替えると、このダウンロードでは「デフォルトパスを使用」がオフになります。"
|
||||
},
|
||||
"targetFolderPath": "ターゲットフォルダパス:",
|
||||
"browseFolders": "フォルダを参照:",
|
||||
"createNewFolder": "新しいフォルダを作成",
|
||||
@@ -1637,6 +1690,28 @@
|
||||
"titleRevert": "元のファイル名を復元しますか?",
|
||||
"revertButton": "元のファイル名を復元"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "サイドカーファイルを集中保存に移動しますか?",
|
||||
"titleToAlongside": "サイドカーファイルをモデルファイルの隣に戻しますか?",
|
||||
"confirmButton": "今すぐ移動",
|
||||
"titleRelocateRoot": "サイドカーファイルを新しい保存ディレクトリに移動しますか?",
|
||||
"destination": "移動先:{path}"
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "サイドカーファイル移動の概要",
|
||||
"completedSuccessfully": "移動が正常に完了しました",
|
||||
"completedWithErrors": "{count} 件のエラーで完了しました",
|
||||
"statMoved": "移動したファイル",
|
||||
"statModels": "モデル",
|
||||
"statSkipped": "スキップ",
|
||||
"statConflicts": "解決した競合",
|
||||
"statErrors": "エラー",
|
||||
"failedItems": "失敗した項目({count})",
|
||||
"columnModel": "モデル",
|
||||
"columnError": "エラー",
|
||||
"successMessage": "{models} 個のモデルの {moved} 個のファイルを移動しました",
|
||||
"location": "保存場所:{path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "複数モデルにタグを追加",
|
||||
"description": "タグを追加するモデル:",
|
||||
@@ -1769,6 +1844,8 @@
|
||||
"sendToWorkflow": "ComfyUI に送信",
|
||||
"sendToWorkflowText": "ComfyUI に送信",
|
||||
"copyHash": "ハッシュをコピー",
|
||||
"copyCivitaiId": "Civitai IDをコピー",
|
||||
"civitaiIdCopied": "Civitai IDをクリップボードにコピーしました",
|
||||
"deleteModelWithShortcut": "モデルを削除(Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
@@ -1787,6 +1864,8 @@
|
||||
"baseModel": "ベースモデル",
|
||||
"size": "サイズ",
|
||||
"hashes": "ハッシュ",
|
||||
"civitaiModelId": "モデル ID",
|
||||
"civitaiVersionId": "バージョン ID",
|
||||
"unknown": "不明",
|
||||
"usageTips": "使用のヒント",
|
||||
"additionalNotes": "追加メモ",
|
||||
@@ -1904,7 +1983,9 @@
|
||||
"nsfwMature": "成人向けコンテンツ",
|
||||
"nsfwR": "R指定コンテンツ",
|
||||
"nsfwX": "X指定コンテンツ",
|
||||
"nsfwXxx": "XXX指定コンテンツ"
|
||||
"nsfwXxx": "XXX指定コンテンツ",
|
||||
"layoutGallery": "ギャラリー表示",
|
||||
"layoutList": "リスト表示"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "モデルバージョン",
|
||||
@@ -2713,6 +2794,9 @@
|
||||
"filename_conflicts": {
|
||||
"title": "ファイル名重複競合"
|
||||
},
|
||||
"sidecar_mirror_orphans": {
|
||||
"title": "集中保存のサイドカーファイル"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI バージョン"
|
||||
}
|
||||
|
||||
+86
-2
@@ -251,7 +251,6 @@
|
||||
"modelname": "모델명",
|
||||
"tags": "태그",
|
||||
"creator": "제작자",
|
||||
"hash": "해시",
|
||||
"title": "레시피 제목",
|
||||
"loraName": "LoRA 파일명",
|
||||
"loraModel": "LoRA 모델명",
|
||||
@@ -353,6 +352,14 @@
|
||||
"placeholder": "비워 두면 PATH의 aria2c를 사용합니다"
|
||||
},
|
||||
"aria2HelpLink": "aria2 다운로드 백엔드 설정 방법 알아보기",
|
||||
"unknownBaseModelRouting": {
|
||||
"label": "알 수 없는 베이스 모델 라우팅",
|
||||
"help": "CivitAI가 보고한 베이스 모델이 알려진 Checkpoint(SD 1.x/2.x/3.x, SDXL, Pony, Illustrious, NoobAI)도 알려진 Diffusion Model도 아닐 때 Checkpoint 다운로드의 저장 위치를 결정합니다. 새로운 확산 아키텍처는 자주 등장하므로 일반적으로 Diffusion Model로 라우팅하는 것이 올바릅니다.",
|
||||
"options": {
|
||||
"diffusionModel": "Diffusion Model",
|
||||
"checkpoint": "Checkpoint"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "CivitAI 호스트 기본 설정 사용 가능",
|
||||
"content": "이제 CivitAI는 SFW 콘텐츠에 civitai.com을, 무제한 콘텐츠에 civitai.red를 사용합니다. 설정에서 기본으로 열 사이트를 변경할 수 있습니다.",
|
||||
@@ -383,6 +390,7 @@
|
||||
"exampleImages": "예시 이미지",
|
||||
"autoOrganize": "자동 정리",
|
||||
"metadata": "메타데이터",
|
||||
"sidecarStorage": "사이드카 파일 저장",
|
||||
"proxySettings": "프록시 설정"
|
||||
},
|
||||
"nav": {
|
||||
@@ -517,6 +525,12 @@
|
||||
"hover": "호버 시 표시"
|
||||
},
|
||||
"cardInfoDisplayHelp": "모델 정보 및 액션 버튼을 언제 표시할지 선택하세요",
|
||||
"showcaseLayout": "예시 이미지 레이아웃",
|
||||
"showcaseLayoutHelp": "모델 모달에서 예시 이미지의 표시 방식을 선택하세요: 한 번에 하나씩 보여주는 갤러리 또는 기존 세로 목록.",
|
||||
"showcaseLayoutOptions": {
|
||||
"gallery": "갤러리",
|
||||
"vertical": "세로 목록"
|
||||
},
|
||||
"showVersionOnCard": "카드에 버전 표시",
|
||||
"showVersionOnCardHelp": "모델 카드에 버전 이름 표시 여부를 전환합니다",
|
||||
"modelCardFooterAction": "모델 카드 버튼 동작",
|
||||
@@ -784,11 +798,41 @@
|
||||
"downloadComplete": "다운로드가 성공적으로 완료되었습니다",
|
||||
"enableCivarchiveApi": "CivArchive API를 메타데이터 제공자로 활성화",
|
||||
"enableCivarchiveApiHelp": "활성화하면 CivArchive API가 모델 메타데이터의 대체 소스로 사용됩니다 (예: CivitAI에서 삭제된 모델의 경우). 비활성화하면 CivArchive의 속도 제한을 완전히 피할 수 있습니다.",
|
||||
"enableOpenmodeldbApi": "OpenModelDB를 메타데이터 제공자로 활성화",
|
||||
"enableOpenmodeldbApiHelp": "활성화하면 CivitAI에 기록이 없을 때 OpenModelDB 카탈로그(openmodeldb.info)에서도 Upscaler 메타데이터를 조회합니다. 카탈로그는 로컬에 캐시되며 매일 새로 고쳐집니다.",
|
||||
"providerOrder": "메타데이터 제공자 폴백 순서",
|
||||
"providerOrderHelp": "CivitAI API가 항상 먼저 시도됩니다. 메타데이터 조회 시 나머지 제공자의 순서를 선택하세요.",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "사이드카 파일 저장 모드",
|
||||
"modeHelp": ".metadata.json 사이드카 파일과 미리보기 이미지를 저장할 위치를 선택하세요. 각 모델 파일 옆에 저장하거나, 라이브러리 구조를 미러링하는 단일 중앙 집중식 디렉터리에 저장할 수 있습니다. .civitai.info 파일은 항상 모델 파일 옆에 남습니다.",
|
||||
"modeOptions": {
|
||||
"alongside": "모델 파일 옆 (기본값)",
|
||||
"centralized": "중앙 집중식 저장"
|
||||
},
|
||||
"path": "중앙 집중식 저장 경로",
|
||||
"pathHelp": "사이드카 파일을 중앙 집중식으로 저장할 루트 디렉터리입니다. 비워 두면 기본 위치(<settings dir>/sidecars)를 사용합니다.",
|
||||
"pathPlaceholder": "비움 = <settings dir>/sidecars",
|
||||
"management": "사이드카 파일 이동",
|
||||
"managementHelp": "기존 .metadata.json 사이드카 파일과 미리보기 이미지를 현재 선택한 모드에 맞게 '모델 파일 옆'과 '중앙 집중식 저장' 사이에서 이동합니다. 모드를 변경해도 기존 파일은 자동으로 이동되지 않습니다.",
|
||||
"migrateButton": "지금 사이드카 파일 이동",
|
||||
"migratingButton": "이동 중...",
|
||||
"migrating": "사이드카 파일을 이동하는 중...",
|
||||
"migrateFailed": "사이드카 파일 이동 실패: {message}",
|
||||
"migrationDeferred": "기존 사이드카 파일은 이동되지 않았습니다. 나중에 설정 → 라이브러리 → 사이드카 파일 저장에서 이동할 수 있습니다.",
|
||||
"confirmToCentralized": "저장 모드가 변경되었지만 기존 .metadata.json 사이드카 파일과 미리보기 이미지는 자동으로 이동되지 않습니다. 지금 중앙 집중식 저장 디렉터리로 이동할까요? '지금 사이드카 파일 이동' 버튼으로 나중에 실행할 수도 있습니다.",
|
||||
"confirmToAlongside": "저장 모드가 변경되었지만 기존 .metadata.json 사이드카 파일과 미리보기 이미지는 자동으로 이동되지 않습니다. 지금 각 모델 파일 옆으로 되돌릴까요? '지금 사이드카 파일 이동' 버튼으로 나중에 실행할 수도 있습니다.",
|
||||
"confirmRelocateRoot": "중앙 집중식 저장 디렉터리가 변경되었지만 기존 사이드카 파일과 미리보기 이미지는 아직 이전 디렉터리에 있습니다. 지금 새 디렉터리로 이동할까요?",
|
||||
"effectivePathLabel": "실제 저장 위치:",
|
||||
"openFolderButton": "폴더 열기",
|
||||
"repoWarning": "실제 저장 위치가 LoRA Manager 설치 폴더 안에 있습니다. 플러그인을 재설치하거나 클린 업데이트하면 삭제될 수 있으니 설치 폴더 밖에 명시적인 저장 경로를 설정하세요.",
|
||||
"openLocationSuccess": "사이드카 파일 저장 폴더를 열었습니다",
|
||||
"openLocationCopied": "사이드카 파일 저장 경로가 클립보드에 복사되었습니다: {path}",
|
||||
"openLocationClipboardFallback": "사이드카 파일 저장 경로를 직접 복사하세요: {path}",
|
||||
"openLocationFailed": "사이드카 파일 저장 폴더를 열지 못했습니다"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "앱 수준 프록시 활성화",
|
||||
"enableProxyHelp": "이 애플리케이션에 대한 사용자 지정 프록시 설정을 활성화하여 시스템 프록시 설정을 무시합니다",
|
||||
@@ -1350,6 +1394,10 @@
|
||||
"emptyNote": "이 폴더에는 모델이 없습니다. 폴더 안의 다른 파일도 함께 삭제됩니다.",
|
||||
"notEmptyTitle": "폴더가 비어 있지 않습니다",
|
||||
"notEmptyMessage": "이 폴더에는 아직 모델이 있습니다. 먼저 해당 모델을 삭제하거나 이동하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
|
||||
"notEmptyMessageCount": "이 폴더에는 아직 모델 파일이 {count}개 있습니다. 먼저 해당 모델을 삭제하거나 이동하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
|
||||
"notEmptyMessageExcluded": "이 폴더에는 아직 모델 파일이 {count}개 있으며, 그중 {excluded}개는 라이브러리에서 제외되어 있습니다. '제외된 모델 관리'에서 제외를 해제한 뒤 먼저 삭제하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
|
||||
"busyTitle": "대기 중인 삭제 작업이 있습니다",
|
||||
"checking": "폴더 내용을 확인하는 중...",
|
||||
"confirm": "폴더 삭제"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1358,6 +1406,7 @@
|
||||
"restored": "폴더를 복원했습니다",
|
||||
"failed": "폴더 삭제 실패: {message}",
|
||||
"notEmpty": "이 폴더에는 아직 모델이 있습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
|
||||
"notEmptyWithCount": "이 폴더에는 아직 모델 파일이 {count}개 있습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
|
||||
"busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.",
|
||||
"unsupported": "이 페이지에서는 폴더를 삭제할 수 없습니다",
|
||||
"noRoot": "모델 루트가 설정되지 않았습니다"
|
||||
@@ -1521,6 +1570,10 @@
|
||||
"selectRootDirectory": "루트 디렉토리를 선택하세요",
|
||||
"selectModelRoot": "모델 루트 선택:",
|
||||
"selectTypeRoot": "{type} 루트 선택:",
|
||||
"routingOverride": {
|
||||
"label": "대상 유형:",
|
||||
"tooltip": "모델 메타데이터에서 자동으로 감지됩니다. 잘못 감지된 경우 전환하세요. 전환하면 이 다운로드에서 '기본 경로 사용'이 꺼집니다."
|
||||
},
|
||||
"targetFolderPath": "대상 폴더 경로:",
|
||||
"browseFolders": "폴더 탐색:",
|
||||
"createNewFolder": "새 폴더 만들기",
|
||||
@@ -1637,6 +1690,28 @@
|
||||
"titleRevert": "원본 파일명을 복원하시겠습니까?",
|
||||
"revertButton": "원본 파일명 복원"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "사이드카 파일을 중앙 집중식 저장으로 이동할까요?",
|
||||
"titleToAlongside": "사이드카 파일을 모델 파일 옆으로 되돌릴까요?",
|
||||
"confirmButton": "지금 이동",
|
||||
"titleRelocateRoot": "사이드카 파일을 새 저장 디렉터리로 이동할까요?",
|
||||
"destination": "대상 위치: {path}"
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "사이드카 파일 이동 요약",
|
||||
"completedSuccessfully": "이동이 성공적으로 완료되었습니다",
|
||||
"completedWithErrors": "{count}개의 오류와 함께 완료되었습니다",
|
||||
"statMoved": "이동된 파일",
|
||||
"statModels": "모델",
|
||||
"statSkipped": "건너뜀",
|
||||
"statConflicts": "해결된 충돌",
|
||||
"statErrors": "오류",
|
||||
"failedItems": "실패한 항목 ({count})",
|
||||
"columnModel": "모델",
|
||||
"columnError": "오류",
|
||||
"successMessage": "{models}개 모델의 파일 {moved}개를 이동했습니다",
|
||||
"location": "저장 위치: {path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "여러 모델에 태그 추가",
|
||||
"description": "다음에 태그를 추가합니다:",
|
||||
@@ -1769,6 +1844,8 @@
|
||||
"sendToWorkflow": "ComfyUI로 보내기",
|
||||
"sendToWorkflowText": "ComfyUI로 보내기",
|
||||
"copyHash": "해시 복사",
|
||||
"copyCivitaiId": "Civitai ID 복사",
|
||||
"civitaiIdCopied": "Civitai ID가 클립보드에 복사되었습니다",
|
||||
"deleteModelWithShortcut": "모델 삭제(Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
@@ -1787,6 +1864,8 @@
|
||||
"baseModel": "베이스 모델",
|
||||
"size": "크기",
|
||||
"hashes": "해시",
|
||||
"civitaiModelId": "모델 ID",
|
||||
"civitaiVersionId": "버전 ID",
|
||||
"unknown": "알 수 없음",
|
||||
"usageTips": "사용 팁",
|
||||
"additionalNotes": "추가 메모",
|
||||
@@ -1904,7 +1983,9 @@
|
||||
"nsfwMature": "성인 콘텐츠",
|
||||
"nsfwR": "R등급 콘텐츠",
|
||||
"nsfwX": "X등급 콘텐츠",
|
||||
"nsfwXxx": "XXX등급 콘텐츠"
|
||||
"nsfwXxx": "XXX등급 콘텐츠",
|
||||
"layoutGallery": "갤러리 보기",
|
||||
"layoutList": "목록 보기"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "모델 버전",
|
||||
@@ -2713,6 +2794,9 @@
|
||||
"filename_conflicts": {
|
||||
"title": "파일명 중복 충돌"
|
||||
},
|
||||
"sidecar_mirror_orphans": {
|
||||
"title": "중앙 집중식 사이드카 파일"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI 버전"
|
||||
}
|
||||
|
||||
+86
-2
@@ -251,7 +251,6 @@
|
||||
"modelname": "Название модели",
|
||||
"tags": "Теги",
|
||||
"creator": "Автор",
|
||||
"hash": "Хэш",
|
||||
"title": "Название рецепта",
|
||||
"loraName": "Имя файла LoRA",
|
||||
"loraModel": "Название модели LoRA",
|
||||
@@ -353,6 +352,14 @@
|
||||
"placeholder": "Оставьте пустым, чтобы использовать aria2c из PATH"
|
||||
},
|
||||
"aria2HelpLink": "Узнайте, как настроить сервер загрузки aria2",
|
||||
"unknownBaseModelRouting": {
|
||||
"label": "Маршрутизация неизвестных базовых моделей",
|
||||
"help": "Определяет, куда отправляются загрузки Checkpoint, когда CivitAI сообщает базовую модель, которая не является ни известным Checkpoint (SD 1.x/2.x/3.x, SDXL, Pony, Illustrious, NoobAI), ни известной диффузионной моделью. Новые диффузионные архитектуры появляются часто, поэтому маршрутизация их в диффузионные модели обычно правильная.",
|
||||
"options": {
|
||||
"diffusionModel": "Диффузионные модели",
|
||||
"checkpoint": "Checkpoints"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "Доступна настройка хоста CivitAI",
|
||||
"content": "Теперь CivitAI использует civitai.com для контента SFW и civitai.red для контента без ограничений. В настройках можно изменить, какой сайт открывать по умолчанию.",
|
||||
@@ -383,6 +390,7 @@
|
||||
"exampleImages": "Примеры изображений",
|
||||
"autoOrganize": "Автоорганизация",
|
||||
"metadata": "Метаданные",
|
||||
"sidecarStorage": "Хранилище sidecar-файлов",
|
||||
"proxySettings": "Настройки прокси"
|
||||
},
|
||||
"nav": {
|
||||
@@ -517,6 +525,12 @@
|
||||
"hover": "Показать при наведении"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Выберите когда отображать информацию о модели и кнопки действий",
|
||||
"showcaseLayout": "Макет примеров изображений",
|
||||
"showcaseLayoutHelp": "Выберите, как отображаются примеры изображений в окне модели: галерея с просмотром по одному изображению или классический вертикальный список.",
|
||||
"showcaseLayoutOptions": {
|
||||
"gallery": "Галерея",
|
||||
"vertical": "Вертикальный список"
|
||||
},
|
||||
"showVersionOnCard": "Показывать версию на карточке",
|
||||
"showVersionOnCardHelp": "Показать или скрыть название версии на карточках моделей",
|
||||
"modelCardFooterAction": "Действие кнопки карточки модели",
|
||||
@@ -784,11 +798,41 @@
|
||||
"downloadComplete": "Загрузка успешно завершена",
|
||||
"enableCivarchiveApi": "Включить CivArchive API как источник метаданных",
|
||||
"enableCivarchiveApiHelp": "При включении CivArchive API используется как резервный источник метаданных моделей (например, для моделей, удалённых с CivitAI). Отключите, чтобы полностью избежать ограничений скорости CivArchive.",
|
||||
"enableOpenmodeldbApi": "Включить OpenModelDB как источник метаданных",
|
||||
"enableOpenmodeldbApiHelp": "При включении метаданные Upscaler также ищутся в каталоге OpenModelDB (openmodeldb.info), если у CivitAI нет записи. Каталог кэшируется локально и обновляется ежедневно.",
|
||||
"providerOrder": "Порядок резервных источников метаданных",
|
||||
"providerOrderHelp": "CivitAI API всегда проверяется первым. Выберите порядок остальных источников при поиске метаданных.",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "Режим хранения sidecar-файлов",
|
||||
"modeHelp": "Выберите, где хранить sidecar-файлы .metadata.json и изображения превью: рядом с каждым файлом модели или в одном централизованном каталоге, повторяющем структуру вашей библиотеки. Файлы .civitai.info всегда остаются рядом с файлом модели.",
|
||||
"modeOptions": {
|
||||
"alongside": "Рядом с файлами моделей (по умолчанию)",
|
||||
"centralized": "Централизованное хранилище"
|
||||
},
|
||||
"path": "Путь централизованного хранилища",
|
||||
"pathHelp": "Корневой каталог централизованного хранилища sidecar-файлов. Оставьте пустым, чтобы использовать расположение по умолчанию (<settings dir>/sidecars).",
|
||||
"pathPlaceholder": "Пусто = <settings dir>/sidecars",
|
||||
"management": "Перенос sidecar-файлов",
|
||||
"managementHelp": "Переносите существующие sidecar-файлы .metadata.json и изображения превью между хранением рядом с моделями и централизованным хранилищем в соответствии с выбранным режимом. Смена режима не переносит существующие файлы автоматически.",
|
||||
"migrateButton": "Перенести sidecar-файлы сейчас",
|
||||
"migratingButton": "Перенос...",
|
||||
"migrating": "Перенос sidecar-файлов...",
|
||||
"migrateFailed": "Не удалось перенести sidecar-файлы: {message}",
|
||||
"migrationDeferred": "Существующие sidecar-файлы не были перенесены. Вы можете перенести их позже в разделе «Настройки → Библиотека → Хранилище sidecar-файлов».",
|
||||
"confirmToCentralized": "Режим хранения изменён, но существующие sidecar-файлы .metadata.json и изображения превью не переносятся автоматически. Перенести их сейчас в централизованное хранилище? Это также можно сделать позже кнопкой «Перенести sidecar-файлы сейчас».",
|
||||
"confirmToAlongside": "Режим хранения изменён, но существующие sidecar-файлы .metadata.json и изображения превью не переносятся автоматически. Вернуть их сейчас рядом с их файлами моделей? Это также можно сделать позже кнопкой «Перенести sidecar-файлы сейчас».",
|
||||
"confirmRelocateRoot": "Каталог централизованного хранилища изменён, но существующие sidecar-файлы и изображения превью всё ещё находятся в прежнем каталоге. Перенести их сейчас в новый каталог?",
|
||||
"effectivePathLabel": "Фактическое расположение хранилища:",
|
||||
"openFolderButton": "Открыть папку",
|
||||
"repoWarning": "Фактическое расположение хранилища находится внутри папки установки LoRA Manager. Переустановка плагина или чистое обновление может удалить его — задайте явный путь хранения вне папки установки.",
|
||||
"openLocationSuccess": "Папка хранилища sidecar-файлов открыта",
|
||||
"openLocationCopied": "Путь к хранилищу sidecar-файлов скопирован в буфер обмена: {path}",
|
||||
"openLocationClipboardFallback": "Скопируйте путь к хранилищу sidecar-файлов вручную: {path}",
|
||||
"openLocationFailed": "Не удалось открыть папку хранилища sidecar-файлов"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "Включить прокси на уровне приложения",
|
||||
"enableProxyHelp": "Включить пользовательские настройки прокси для этого приложения, переопределяя системные настройки прокси",
|
||||
@@ -1350,6 +1394,10 @@
|
||||
"emptyNote": "В этой папке нет моделей. Остальные файлы в ней тоже будут удалены.",
|
||||
"notEmptyTitle": "Папка не пуста",
|
||||
"notEmptyMessage": "В этой папке ещё есть модели. Сначала удалите или переместите их — удаление папки никогда не затрагивает файлы моделей.",
|
||||
"notEmptyMessageCount": "В этой папке ещё есть {count} файл(ов) модели. Сначала удалите или переместите их — удаление папки никогда не затрагивает файлы моделей.",
|
||||
"notEmptyMessageExcluded": "В этой папке ещё есть {count} файл(ов) модели, из них {excluded} исключены из библиотеки. Снимите исключение в разделе «Управление исключёнными моделями» и удалите их — удаление папки никогда не затрагивает файлы моделей.",
|
||||
"busyTitle": "Удаление всё ещё отложено",
|
||||
"checking": "Проверка содержимого папки...",
|
||||
"confirm": "Удалить папку"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1358,6 +1406,7 @@
|
||||
"restored": "Папка восстановлена",
|
||||
"failed": "Не удалось удалить папку: {message}",
|
||||
"notEmpty": "В этой папке ещё есть модели. Обновите боковую панель и повторите попытку.",
|
||||
"notEmptyWithCount": "В этой папке ещё есть {count} файл(ов) модели. Обновите боковую панель и повторите попытку.",
|
||||
"busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.",
|
||||
"unsupported": "Удаление папок не поддерживается на этой странице",
|
||||
"noRoot": "Корневая папка моделей не настроена"
|
||||
@@ -1521,6 +1570,10 @@
|
||||
"selectRootDirectory": "Выберите корневую папку",
|
||||
"selectModelRoot": "Выберите корень моделей:",
|
||||
"selectTypeRoot": "Выберите корень {type}:",
|
||||
"routingOverride": {
|
||||
"label": "Тип назначения:",
|
||||
"tooltip": "Определяется автоматически из метаданных модели. Переключите, если определение неверно; переключение отключает «Использовать путь по умолчанию» для этой загрузки."
|
||||
},
|
||||
"targetFolderPath": "Путь к целевой папке:",
|
||||
"browseFolders": "Обзор папок:",
|
||||
"createNewFolder": "Создать новую папку",
|
||||
@@ -1637,6 +1690,28 @@
|
||||
"titleRevert": "Восстановить исходные имена файлов?",
|
||||
"revertButton": "Восстановить исходные имена файлов"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "Перенести sidecar-файлы в централизованное хранилище?",
|
||||
"titleToAlongside": "Вернуть sidecar-файлы рядом с файлами моделей?",
|
||||
"confirmButton": "Перенести сейчас",
|
||||
"titleRelocateRoot": "Перенести sidecar-файлы в новый каталог хранилища?",
|
||||
"destination": "Назначение: {path}"
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "Сводка переноса sidecar-файлов",
|
||||
"completedSuccessfully": "Перенос успешно завершён",
|
||||
"completedWithErrors": "Завершено с ошибками ({count})",
|
||||
"statMoved": "Перенесено файлов",
|
||||
"statModels": "Модели",
|
||||
"statSkipped": "Пропущено",
|
||||
"statConflicts": "Разрешено конфликтов",
|
||||
"statErrors": "Ошибки",
|
||||
"failedItems": "Элементы с ошибками ({count})",
|
||||
"columnModel": "Модель",
|
||||
"columnError": "Ошибка",
|
||||
"successMessage": "Перенесено {moved} файлов для {models} моделей",
|
||||
"location": "Расположение хранилища: {path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Добавить теги к нескольким моделям",
|
||||
"description": "Добавить теги к",
|
||||
@@ -1769,6 +1844,8 @@
|
||||
"sendToWorkflow": "Отправить в ComfyUI",
|
||||
"sendToWorkflowText": "Отправить в ComfyUI",
|
||||
"copyHash": "Копировать хеш",
|
||||
"copyCivitaiId": "Копировать ID Civitai",
|
||||
"civitaiIdCopied": "ID Civitai скопирован в буфер обмена",
|
||||
"deleteModelWithShortcut": "Удалить модель (Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
@@ -1787,6 +1864,8 @@
|
||||
"baseModel": "Базовая модель",
|
||||
"size": "Размер",
|
||||
"hashes": "Хэши",
|
||||
"civitaiModelId": "ID модели",
|
||||
"civitaiVersionId": "ID версии",
|
||||
"unknown": "Неизвестно",
|
||||
"usageTips": "Советы по использованию",
|
||||
"additionalNotes": "Дополнительные заметки",
|
||||
@@ -1904,7 +1983,9 @@
|
||||
"nsfwMature": "Контент для взрослых",
|
||||
"nsfwR": "Контент с рейтингом R",
|
||||
"nsfwX": "Контент с рейтингом X",
|
||||
"nsfwXxx": "Контент с рейтингом XXX"
|
||||
"nsfwXxx": "Контент с рейтингом XXX",
|
||||
"layoutGallery": "Галерея",
|
||||
"layoutList": "Список"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Версии модели",
|
||||
@@ -2713,6 +2794,9 @@
|
||||
"filename_conflicts": {
|
||||
"title": "Конфликты дублирующихся имён файлов"
|
||||
},
|
||||
"sidecar_mirror_orphans": {
|
||||
"title": "Централизованные sidecar-файлы"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "Версия интерфейса"
|
||||
}
|
||||
|
||||
+86
-2
@@ -251,7 +251,6 @@
|
||||
"modelname": "模型名称",
|
||||
"tags": "标签",
|
||||
"creator": "创作者",
|
||||
"hash": "哈希",
|
||||
"title": "配方标题",
|
||||
"loraName": "LoRA 文件名",
|
||||
"loraModel": "LoRA 模型名称",
|
||||
@@ -353,6 +352,14 @@
|
||||
"placeholder": "留空则使用 PATH 中的 aria2c"
|
||||
},
|
||||
"aria2HelpLink": "了解如何配置 aria2 下载后端",
|
||||
"unknownBaseModelRouting": {
|
||||
"label": "未知基础模型路由",
|
||||
"help": "当 CivitAI 报告的基础模型既不是已知的 Checkpoint(SD 1.x/2.x/3.x、SDXL、Pony、Illustrious、NoobAI),也不是已知的扩散模型时,决定 Checkpoint 下载的保存位置。新的扩散架构频繁出现,因此将其路由到扩散模型通常是正确的。",
|
||||
"options": {
|
||||
"diffusionModel": "扩散模型",
|
||||
"checkpoint": "Checkpoint"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "已提供 CivitAI 站点偏好设置",
|
||||
"content": "CivitAI 现在使用 civitai.com 提供 SFW 内容,使用 civitai.red 提供无限制内容。你可以在设置中更改默认打开的站点。",
|
||||
@@ -383,6 +390,7 @@
|
||||
"exampleImages": "示例图片",
|
||||
"autoOrganize": "自动整理",
|
||||
"metadata": "元数据",
|
||||
"sidecarStorage": "附属文件存储",
|
||||
"proxySettings": "代理设置"
|
||||
},
|
||||
"nav": {
|
||||
@@ -517,6 +525,12 @@
|
||||
"hover": "悬停时显示"
|
||||
},
|
||||
"cardInfoDisplayHelp": "选择何时显示模型信息和操作按钮",
|
||||
"showcaseLayout": "示例图片布局",
|
||||
"showcaseLayoutHelp": "选择示例图片在模型弹窗中的显示方式:一次显示一张的画廊,或经典的纵向列表。",
|
||||
"showcaseLayoutOptions": {
|
||||
"gallery": "画廊",
|
||||
"vertical": "纵向列表"
|
||||
},
|
||||
"showVersionOnCard": "在卡片上显示版本",
|
||||
"showVersionOnCardHelp": "在模型卡片上显示或隐藏版本名称",
|
||||
"modelCardFooterAction": "模型卡片按钮操作",
|
||||
@@ -784,11 +798,41 @@
|
||||
"downloadComplete": "下载成功完成",
|
||||
"enableCivarchiveApi": "启用 CivArchive API 作为元数据提供者",
|
||||
"enableCivarchiveApiHelp": "开启后,CivArchive API 将作为模型元数据的备用来源(例如用于已从 CivitAI 删除的模型)。关闭可完全避免 CivArchive 的速率限制。",
|
||||
"enableOpenmodeldbApi": "启用 OpenModelDB 作为元数据提供者",
|
||||
"enableOpenmodeldbApiHelp": "开启后,当 CivitAI 没有记录时,也会在 OpenModelDB 目录(openmodeldb.info)中查询 Upscaler 元数据。目录会缓存在本地并每天刷新。",
|
||||
"providerOrder": "元数据提供者回退顺序",
|
||||
"providerOrderHelp": "CivitAI API 始终优先尝试。选择查找元数据时其余提供者的顺序。",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "附属文件存储模式",
|
||||
"modeHelp": "选择 .metadata.json 附属文件和预览图片的存放位置:与每个模型文件放在一起,或集中存放在一个镜像模型库结构的目录中。.civitai.info 文件始终与模型文件放在一起。",
|
||||
"modeOptions": {
|
||||
"alongside": "与模型文件放在一起(默认)",
|
||||
"centralized": "集中存储"
|
||||
},
|
||||
"path": "集中存储路径",
|
||||
"pathHelp": "集中存储附属文件的根目录。留空则使用默认位置(<settings dir>/sidecars)。",
|
||||
"pathPlaceholder": "留空 = <settings dir>/sidecars",
|
||||
"management": "附属文件迁移",
|
||||
"managementHelp": "在“与模型文件放在一起”和“集中存储”之间迁移现有的 .metadata.json 附属文件和预览图片,以匹配当前选择的模式。更改模式不会自动迁移现有文件。",
|
||||
"migrateButton": "立即迁移附属文件",
|
||||
"migratingButton": "迁移中...",
|
||||
"migrating": "正在迁移附属文件...",
|
||||
"migrateFailed": "附属文件迁移失败:{message}",
|
||||
"migrationDeferred": "现有附属文件未迁移。你可以稍后在“设置 → 库 → 附属文件存储”中迁移它们。",
|
||||
"confirmToCentralized": "存储模式已更改,但现有的 .metadata.json 附属文件和预览图片不会自动迁移。要现在将它们移入集中存储目录吗?你也可以稍后使用“立即迁移附属文件”按钮完成。",
|
||||
"confirmToAlongside": "存储模式已更改,但现有的 .metadata.json 附属文件和预览图片不会自动迁移。要现在将它们移回各自的模型文件旁边吗?你也可以稍后使用“立即迁移附属文件”按钮完成。",
|
||||
"confirmRelocateRoot": "集中存储目录已更改,但现有的附属文件和预览图片仍在原目录中。要现在将它们移到新目录吗?",
|
||||
"effectivePathLabel": "实际存储位置:",
|
||||
"openFolderButton": "打开文件夹",
|
||||
"repoWarning": "实际存储位置位于 LoRA Manager 安装目录内。重新安装插件或干净更新可能会将其删除——请在安装目录之外设置一个明确的存储路径。",
|
||||
"openLocationSuccess": "已打开附属文件存储文件夹",
|
||||
"openLocationCopied": "附属文件存储路径已复制到剪贴板:{path}",
|
||||
"openLocationClipboardFallback": "请手动复制附属文件存储路径:{path}",
|
||||
"openLocationFailed": "打开附属文件存储文件夹失败"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "启用应用级代理",
|
||||
"enableProxyHelp": "为此应用启用自定义代理设置,覆盖系统代理设置",
|
||||
@@ -1350,6 +1394,10 @@
|
||||
"emptyNote": "该文件夹中没有模型,其中的其他文件也会一并删除。",
|
||||
"notEmptyTitle": "文件夹不为空",
|
||||
"notEmptyMessage": "该文件夹中仍有模型,请先删除或移出这些模型 —— 删除文件夹不会级联删除模型文件。",
|
||||
"notEmptyMessageCount": "该文件夹中仍有 {count} 个模型文件,请先删除或移出这些模型 —— 删除文件夹不会级联删除模型文件。",
|
||||
"notEmptyMessageExcluded": "该文件夹中仍有 {count} 个模型文件,其中 {excluded} 个已从模型库中排除。请先在“管理已排除的模型”中取消排除并删除它们 —— 删除文件夹不会级联删除模型文件。",
|
||||
"busyTitle": "仍有删除操作待处理",
|
||||
"checking": "正在检查文件夹内容...",
|
||||
"confirm": "删除文件夹"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1358,6 +1406,7 @@
|
||||
"restored": "文件夹已恢复",
|
||||
"failed": "删除文件夹失败: {message}",
|
||||
"notEmpty": "该文件夹中仍有模型。请刷新侧边栏后重试。",
|
||||
"notEmptyWithCount": "该文件夹中仍有 {count} 个模型文件。请刷新侧边栏后重试。",
|
||||
"busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。",
|
||||
"unsupported": "此页面不支持删除文件夹",
|
||||
"noRoot": "未配置模型根目录"
|
||||
@@ -1521,6 +1570,10 @@
|
||||
"selectRootDirectory": "选择根目录",
|
||||
"selectModelRoot": "选择模型根目录:",
|
||||
"selectTypeRoot": "选择 {type} 根目录:",
|
||||
"routingOverride": {
|
||||
"label": "目标类型:",
|
||||
"tooltip": "根据模型元数据自动检测。如果检测结果有误,请切换;切换会为本次下载关闭“使用默认路径”。"
|
||||
},
|
||||
"targetFolderPath": "目标文件夹路径:",
|
||||
"browseFolders": "浏览文件夹:",
|
||||
"createNewFolder": "新建文件夹",
|
||||
@@ -1637,6 +1690,28 @@
|
||||
"titleRevert": "恢复原始文件名?",
|
||||
"revertButton": "恢复原始文件名"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "要将附属文件移到集中存储吗?",
|
||||
"titleToAlongside": "要将附属文件移回模型文件旁边吗?",
|
||||
"confirmButton": "立即迁移",
|
||||
"titleRelocateRoot": "要将附属文件移到新的存储目录吗?",
|
||||
"destination": "目标位置:{path}"
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "附属文件迁移摘要",
|
||||
"completedSuccessfully": "迁移已成功完成",
|
||||
"completedWithErrors": "完成,但有 {count} 个错误",
|
||||
"statMoved": "已迁移文件",
|
||||
"statModels": "模型",
|
||||
"statSkipped": "已跳过",
|
||||
"statConflicts": "已解决冲突",
|
||||
"statErrors": "错误",
|
||||
"failedItems": "失败项({count})",
|
||||
"columnModel": "模型",
|
||||
"columnError": "错误",
|
||||
"successMessage": "已为 {models} 个模型迁移 {moved} 个文件",
|
||||
"location": "存储位置:{path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "批量添加标签",
|
||||
"description": "为多个模型添加标签",
|
||||
@@ -1769,6 +1844,8 @@
|
||||
"sendToWorkflow": "发送到 ComfyUI",
|
||||
"sendToWorkflowText": "发送到 ComfyUI",
|
||||
"copyHash": "复制哈希值",
|
||||
"copyCivitaiId": "复制 Civitai ID",
|
||||
"civitaiIdCopied": "Civitai ID 已复制到剪贴板",
|
||||
"deleteModelWithShortcut": "删除模型(Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
@@ -1787,6 +1864,8 @@
|
||||
"baseModel": "基础模型",
|
||||
"size": "大小",
|
||||
"hashes": "哈希值",
|
||||
"civitaiModelId": "模型 ID",
|
||||
"civitaiVersionId": "版本 ID",
|
||||
"unknown": "未知",
|
||||
"usageTips": "使用提示",
|
||||
"additionalNotes": "附加备注",
|
||||
@@ -1904,7 +1983,9 @@
|
||||
"nsfwMature": "成熟内容",
|
||||
"nsfwR": "R 级内容",
|
||||
"nsfwX": "X 级内容",
|
||||
"nsfwXxx": "XXX 级内容"
|
||||
"nsfwXxx": "XXX 级内容",
|
||||
"layoutGallery": "画廊视图",
|
||||
"layoutList": "列表视图"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "模型版本",
|
||||
@@ -2713,6 +2794,9 @@
|
||||
"filename_conflicts": {
|
||||
"title": "文件名重复冲突"
|
||||
},
|
||||
"sidecar_mirror_orphans": {
|
||||
"title": "集中存储的附属文件"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI 版本"
|
||||
}
|
||||
|
||||
+86
-2
@@ -251,7 +251,6 @@
|
||||
"modelname": "模型名稱",
|
||||
"tags": "標籤",
|
||||
"creator": "創作者",
|
||||
"hash": "雜湊",
|
||||
"title": "配方標題",
|
||||
"loraName": "LoRA 檔案名稱",
|
||||
"loraModel": "LoRA 模型名稱",
|
||||
@@ -353,6 +352,14 @@
|
||||
"placeholder": "留空則使用 PATH 中的 aria2c"
|
||||
},
|
||||
"aria2HelpLink": "了解如何設定 aria2 下載後端",
|
||||
"unknownBaseModelRouting": {
|
||||
"label": "未知基礎模型路由",
|
||||
"help": "當 CivitAI 回報的基礎模型既不是已知的 Checkpoint(SD 1.x/2.x/3.x、SDXL、Pony、Illustrious、NoobAI),也不是已知的擴散模型時,決定 Checkpoint 下載的儲存位置。新的擴散架構頻繁出現,因此將其路由到擴散模型通常是正確的。",
|
||||
"options": {
|
||||
"diffusionModel": "擴散模型",
|
||||
"checkpoint": "Checkpoint"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "已提供 CivitAI 站點偏好設定",
|
||||
"content": "CivitAI 現在使用 civitai.com 提供 SFW 內容,使用 civitai.red 提供無限制內容。您可以在設定中變更預設開啟的站點。",
|
||||
@@ -383,6 +390,7 @@
|
||||
"exampleImages": "範例圖片",
|
||||
"autoOrganize": "自動整理",
|
||||
"metadata": "中繼資料",
|
||||
"sidecarStorage": "附屬檔案儲存",
|
||||
"proxySettings": "代理設定"
|
||||
},
|
||||
"nav": {
|
||||
@@ -517,6 +525,12 @@
|
||||
"hover": "滑鼠懸停顯示"
|
||||
},
|
||||
"cardInfoDisplayHelp": "選擇何時顯示模型資訊與操作按鈕",
|
||||
"showcaseLayout": "範例圖片版面",
|
||||
"showcaseLayoutHelp": "選擇範例圖片在模型彈窗中的顯示方式:一次一張的圖庫,或傳統的垂直清單。",
|
||||
"showcaseLayoutOptions": {
|
||||
"gallery": "圖庫",
|
||||
"vertical": "垂直清單"
|
||||
},
|
||||
"showVersionOnCard": "在卡片上顯示版本",
|
||||
"showVersionOnCardHelp": "在模型卡片上顯示或隱藏版本名稱",
|
||||
"modelCardFooterAction": "模型卡片按鈕操作",
|
||||
@@ -784,11 +798,41 @@
|
||||
"downloadComplete": "下載成功完成",
|
||||
"enableCivarchiveApi": "啟用 CivArchive API 作為中繼資料提供者",
|
||||
"enableCivarchiveApiHelp": "開啟後,CivArchive API 將作為模型中繼資料的備用來源(例如用於已從 CivitAI 刪除的模型)。關閉可完全避免 CivArchive 的速率限制。",
|
||||
"enableOpenmodeldbApi": "啟用 OpenModelDB 作為中繼資料提供者",
|
||||
"enableOpenmodeldbApiHelp": "開啟後,當 CivitAI 沒有記錄時,也會在 OpenModelDB 目錄(openmodeldb.info)中查詢 Upscaler 中繼資料。目錄會快取在本機並每日更新。",
|
||||
"providerOrder": "中繼資料提供者回退順序",
|
||||
"providerOrderHelp": "CivitAI API 始終優先嘗試。選擇查詢中繼資料時其餘提供者的順序。",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "附屬檔案儲存模式",
|
||||
"modeHelp": "選擇 .metadata.json 附屬檔案與預覽圖片的存放位置:與每個模型檔案放在一起,或集中在一個對應模型庫結構的目錄中。.civitai.info 檔案一律與模型檔案放在一起。",
|
||||
"modeOptions": {
|
||||
"alongside": "與模型檔案放在一起(預設)",
|
||||
"centralized": "集中儲存"
|
||||
},
|
||||
"path": "集中儲存路徑",
|
||||
"pathHelp": "集中儲存附屬檔案的根目錄。留空則使用預設位置(<settings dir>/sidecars)。",
|
||||
"pathPlaceholder": "留空 = <settings dir>/sidecars",
|
||||
"management": "附屬檔案遷移",
|
||||
"managementHelp": "在「與模型檔案放在一起」與「集中儲存」之間遷移現有的 .metadata.json 附屬檔案與預覽圖片,以符合目前選擇的模式。變更模式不會自動遷移現有檔案。",
|
||||
"migrateButton": "立即遷移附屬檔案",
|
||||
"migratingButton": "遷移中...",
|
||||
"migrating": "正在遷移附屬檔案...",
|
||||
"migrateFailed": "附屬檔案遷移失敗:{message}",
|
||||
"migrationDeferred": "現有附屬檔案未遷移。您稍後可以在「設定 > 模型庫 > 附屬檔案儲存」中遷移它們。",
|
||||
"confirmToCentralized": "儲存模式已變更,但現有的 .metadata.json 附屬檔案與預覽圖片不會自動遷移。要現在將它們移入集中儲存目錄嗎?您也可以稍後使用「立即遷移附屬檔案」按鈕完成。",
|
||||
"confirmToAlongside": "儲存模式已變更,但現有的 .metadata.json 附屬檔案與預覽圖片不會自動遷移。要現在將它們移回各自的模型檔案旁邊嗎?您也可以稍後使用「立即遷移附屬檔案」按鈕完成。",
|
||||
"confirmRelocateRoot": "集中儲存目錄已變更,但現有的附屬檔案與預覽圖片仍在原目錄中。要現在將它們移到新目錄嗎?",
|
||||
"effectivePathLabel": "實際儲存位置:",
|
||||
"openFolderButton": "開啟資料夾",
|
||||
"repoWarning": "實際儲存位置位於 LoRA Manager 安裝目錄內。重新安裝外掛或乾淨更新可能會將其刪除——請在安裝目錄之外設定明確的儲存路徑。",
|
||||
"openLocationSuccess": "已開啟附屬檔案儲存資料夾",
|
||||
"openLocationCopied": "附屬檔案儲存路徑已複製到剪貼簿:{path}",
|
||||
"openLocationClipboardFallback": "請手動複製附屬檔案儲存路徑:{path}",
|
||||
"openLocationFailed": "無法開啟附屬檔案儲存資料夾"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "啟用應用程式代理",
|
||||
"enableProxyHelp": "啟用此應用程式的自訂代理設定,將覆蓋系統代理設定",
|
||||
@@ -1350,6 +1394,10 @@
|
||||
"emptyNote": "該資料夾中沒有模型,其中的其他檔案也會一併刪除。",
|
||||
"notEmptyTitle": "資料夾不是空的",
|
||||
"notEmptyMessage": "該資料夾中仍有模型,請先刪除或移出這些模型 —— 刪除資料夾不會串聯刪除模型檔案。",
|
||||
"notEmptyMessageCount": "該資料夾中仍有 {count} 個模型檔案,請先刪除或移出這些模型 —— 刪除資料夾不會串聯刪除模型檔案。",
|
||||
"notEmptyMessageExcluded": "該資料夾中仍有 {count} 個模型檔案,其中 {excluded} 個已從模型庫中排除。請先在「管理已排除的模型」中取消排除並刪除它們 —— 刪除資料夾不會串聯刪除模型檔案。",
|
||||
"busyTitle": "仍有刪除操作待處理",
|
||||
"checking": "正在檢查資料夾內容...",
|
||||
"confirm": "刪除資料夾"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
@@ -1358,6 +1406,7 @@
|
||||
"restored": "資料夾已還原",
|
||||
"failed": "刪除資料夾失敗: {message}",
|
||||
"notEmpty": "該資料夾中仍有模型。請重新整理側邊欄後再試。",
|
||||
"notEmptyWithCount": "該資料夾中仍有 {count} 個模型檔案。請重新整理側邊欄後再試。",
|
||||
"busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。",
|
||||
"unsupported": "此頁面不支援刪除資料夾",
|
||||
"noRoot": "未設定模型根目錄"
|
||||
@@ -1521,6 +1570,10 @@
|
||||
"selectRootDirectory": "選擇根目錄",
|
||||
"selectModelRoot": "選擇模型根目錄:",
|
||||
"selectTypeRoot": "選擇 {type} 根目錄:",
|
||||
"routingOverride": {
|
||||
"label": "目標類型:",
|
||||
"tooltip": "根據模型中繼資料自動偵測。如果偵測結果有誤,請切換;切換會為本次下載關閉「使用預設路徑」。"
|
||||
},
|
||||
"targetFolderPath": "目標資料夾路徑:",
|
||||
"browseFolders": "瀏覽資料夾:",
|
||||
"createNewFolder": "建立新資料夾",
|
||||
@@ -1637,6 +1690,28 @@
|
||||
"titleRevert": "要還原原始檔案名稱嗎?",
|
||||
"revertButton": "還原原始檔案名稱"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "要將附屬檔案移到集中儲存嗎?",
|
||||
"titleToAlongside": "要將附屬檔案移回模型檔案旁邊嗎?",
|
||||
"confirmButton": "立即遷移",
|
||||
"titleRelocateRoot": "要將附屬檔案移到新的儲存目錄嗎?",
|
||||
"destination": "目標位置:{path}"
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "附屬檔案遷移摘要",
|
||||
"completedSuccessfully": "遷移已成功完成",
|
||||
"completedWithErrors": "完成,但有 {count} 個錯誤",
|
||||
"statMoved": "已遷移檔案",
|
||||
"statModels": "模型",
|
||||
"statSkipped": "已跳過",
|
||||
"statConflicts": "已解決衝突",
|
||||
"statErrors": "錯誤",
|
||||
"failedItems": "失敗項目({count})",
|
||||
"columnModel": "模型",
|
||||
"columnError": "錯誤",
|
||||
"successMessage": "已為 {models} 個模型遷移 {moved} 個檔案",
|
||||
"location": "儲存位置:{path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "新增標籤到多個模型",
|
||||
"description": "新增標籤到",
|
||||
@@ -1769,6 +1844,8 @@
|
||||
"sendToWorkflow": "傳送到 ComfyUI",
|
||||
"sendToWorkflowText": "傳送到 ComfyUI",
|
||||
"copyHash": "複製雜湊值",
|
||||
"copyCivitaiId": "複製 Civitai ID",
|
||||
"civitaiIdCopied": "Civitai ID 已複製到剪貼簿",
|
||||
"deleteModelWithShortcut": "刪除模型(Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
@@ -1787,6 +1864,8 @@
|
||||
"baseModel": "基礎模型",
|
||||
"size": "大小",
|
||||
"hashes": "雜湊值",
|
||||
"civitaiModelId": "模型 ID",
|
||||
"civitaiVersionId": "版本 ID",
|
||||
"unknown": "未知",
|
||||
"usageTips": "使用提示",
|
||||
"additionalNotes": "附加備註",
|
||||
@@ -1904,7 +1983,9 @@
|
||||
"nsfwMature": "成熟內容",
|
||||
"nsfwR": "R 級內容",
|
||||
"nsfwX": "X 級內容",
|
||||
"nsfwXxx": "XXX 級內容"
|
||||
"nsfwXxx": "XXX 級內容",
|
||||
"layoutGallery": "圖庫檢視",
|
||||
"layoutList": "清單檢視"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "模型版本",
|
||||
@@ -2713,6 +2794,9 @@
|
||||
"filename_conflicts": {
|
||||
"title": "檔案名稱重複衝突"
|
||||
},
|
||||
"sidecar_mirror_orphans": {
|
||||
"title": "集中儲存的附屬檔案"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI 版本"
|
||||
}
|
||||
|
||||
@@ -891,6 +891,17 @@ class Config:
|
||||
if self.recipes_path:
|
||||
preview_roots.update(self._expand_preview_root(self.recipes_path))
|
||||
|
||||
# Centralized sidecar storage holds preview assets outside the model
|
||||
# roots; allow serving them when the mode is active.
|
||||
try:
|
||||
from .utils.sidecar_paths import get_sidecar_root # Local import to avoid circular dependency
|
||||
|
||||
sidecar_root = get_sidecar_root()
|
||||
except Exception: # pragma: no cover - defensive fallback
|
||||
sidecar_root = ""
|
||||
if sidecar_root:
|
||||
preview_roots.update(self._expand_preview_root(sidecar_root))
|
||||
|
||||
for target, link in self._path_mappings.items():
|
||||
preview_roots.update(self._expand_preview_root(target))
|
||||
preview_roots.update(self._expand_preview_root(link))
|
||||
@@ -1494,6 +1505,15 @@ class Config:
|
||||
self.other_roots = self._init_other_paths()
|
||||
self._rebuild_preview_roots()
|
||||
|
||||
def refresh_preview_roots(self) -> None:
|
||||
"""Rebuild the preview allowlist after path-affecting settings change.
|
||||
|
||||
Called when ``sidecar_storage_mode`` / ``sidecar_storage_path`` are
|
||||
updated so centralized preview assets become servable (or stop being
|
||||
servable) without a restart.
|
||||
"""
|
||||
self._rebuild_preview_roots()
|
||||
|
||||
def get_other_models_availability(self) -> Dict[str, Any]:
|
||||
"""Report the other-model folders the host can actually expose.
|
||||
|
||||
|
||||
@@ -172,12 +172,16 @@ async def download_preview(
|
||||
"""
|
||||
from ..services.downloader import get_downloader
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.sidecar_paths import get_preview_dir
|
||||
|
||||
if not url or not url.strip():
|
||||
return None
|
||||
|
||||
base_name = os.path.splitext(os.path.basename(model_path))[0]
|
||||
preview_dir = os.path.dirname(model_path)
|
||||
preview_dir = get_preview_dir(model_path)
|
||||
# Centralized mirrors may not exist yet (unlike the model's own directory
|
||||
# in alongside mode).
|
||||
os.makedirs(preview_dir, exist_ok=True)
|
||||
output_path = os.path.join(preview_dir, base_name + ".webp")
|
||||
|
||||
downloader = await get_downloader()
|
||||
|
||||
@@ -118,9 +118,16 @@ class LoadImageMetadataLM:
|
||||
"Connect lora_stack to Lora Loader. Convert loader/sampler widgets to inputs for the other outputs. "
|
||||
"Extraction failures use starter defaults and are shown as ERROR messages in readable_report."
|
||||
)
|
||||
# model_name, sampler_name and scheduler select a value from a loader or
|
||||
# sampler dropdown. They must stay untyped (Any, "*"): ComfyUI rejects a
|
||||
# "COMBO" (and a "STRING") output linked into the classic list-style combo
|
||||
# inputs used by Load Checkpoint, KSampler, and the LoRA Manager loaders
|
||||
# (comfy_execution/validation.py refuses a non-string input type), which
|
||||
# surfaced as "Return type mismatch between linked nodes" at queue time.
|
||||
# "*" is the same type ComfyUI's own Primitive node uses to feed widgets.
|
||||
RETURN_TYPES = (
|
||||
"IMAGE", "MASK", "STRING", "STRING", "COMBO", "LORA_STACK", "STRING",
|
||||
"INT", "INT", "FLOAT", "COMBO", "COMBO", "INT", "INT", "FLOAT", "STRING", "STRING", "STRING",
|
||||
"IMAGE", "MASK", "STRING", "STRING", "*", "LORA_STACK", "STRING",
|
||||
"INT", "INT", "FLOAT", "*", "*", "INT", "INT", "FLOAT", "STRING", "STRING", "STRING",
|
||||
)
|
||||
RETURN_NAMES = (
|
||||
"image", "mask", "positive", "negative", "model_name", "lora_stack", "lora_stack_text",
|
||||
|
||||
@@ -55,6 +55,11 @@ class DownloadRoutingHandler:
|
||||
status=400,
|
||||
)
|
||||
|
||||
# CivitAI ModelType.UNet downloads go through the checkpoint branch,
|
||||
# same as in the download manager.
|
||||
if model_type.lower() == "unet":
|
||||
model_type = "checkpoint"
|
||||
|
||||
if model_type.lower() in VALID_OTHER_CIVITAI_TYPES:
|
||||
from ...services.settings_manager import get_settings_manager
|
||||
|
||||
@@ -96,10 +101,15 @@ class DownloadRoutingHandler:
|
||||
}
|
||||
)
|
||||
|
||||
from ...services.settings_manager import get_settings_manager
|
||||
|
||||
is_diffusion = is_diffusion_model_download(
|
||||
model_type,
|
||||
file_types=(str(t) for t in file_types),
|
||||
base_model=base_model,
|
||||
unknown_base_model_default=get_settings_manager().get(
|
||||
"unknown_base_model_routing", "diffusion_model"
|
||||
),
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
|
||||
@@ -45,6 +45,8 @@ from ...services.llm_service import (
|
||||
get_provider_model_ids,
|
||||
)
|
||||
from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
|
||||
from ...services.use_cases.sidecar_migration_use_case import SidecarMigrationUseCase
|
||||
from ...services.websocket_progress_callback import WebSocketBroadcastCallback
|
||||
from ...utils.models import BaseModelMetadata
|
||||
from ...utils.constants import (
|
||||
CIVITAI_USER_MODEL_TYPES,
|
||||
@@ -68,6 +70,14 @@ from ...utils.example_images_paths import (
|
||||
)
|
||||
from ...utils.lora_metadata import extract_trained_words
|
||||
from ...utils.session_logging import get_standalone_session_log_snapshot
|
||||
from ...utils.sidecar_paths import (
|
||||
describe_sidecar_root,
|
||||
get_configured_sidecar_root,
|
||||
get_metadata_path,
|
||||
get_preview_dir,
|
||||
get_storage_mode,
|
||||
get_unmatched_sidecar_components,
|
||||
)
|
||||
from ...utils.usage_stats import UsageStats
|
||||
from .base_model_handlers import BaseModelHandlerSet
|
||||
|
||||
@@ -798,6 +808,7 @@ class DoctorHandler:
|
||||
await self._check_civitai_api_key(),
|
||||
await self._check_cache_health(),
|
||||
await self._check_filename_conflicts(),
|
||||
self._check_sidecar_mirror_orphans(),
|
||||
self._check_ui_version(client_version, app_version),
|
||||
]
|
||||
|
||||
@@ -943,15 +954,24 @@ class DoctorHandler:
|
||||
|
||||
os.rename(path, new_path)
|
||||
|
||||
for suffix in (".metadata.json", ".civitai.info"):
|
||||
old_sidecar = old_base_no_ext + suffix
|
||||
new_sidecar = new_base_no_ext + suffix
|
||||
if os.path.exists(old_sidecar):
|
||||
os.rename(old_sidecar, new_sidecar)
|
||||
old_metadata_path = get_metadata_path(path)
|
||||
new_metadata_path = get_metadata_path(new_path)
|
||||
if os.path.exists(old_metadata_path):
|
||||
os.rename(old_metadata_path, new_metadata_path)
|
||||
|
||||
old_sidecar = old_base_no_ext + ".civitai.info"
|
||||
new_sidecar = new_base_no_ext + ".civitai.info"
|
||||
if os.path.exists(old_sidecar):
|
||||
os.rename(old_sidecar, new_sidecar)
|
||||
|
||||
for preview_ext in PREVIEW_EXTENSIONS:
|
||||
old_preview = old_base_no_ext + preview_ext
|
||||
new_preview = new_base_no_ext + preview_ext
|
||||
old_preview = os.path.join(
|
||||
get_preview_dir(path), base_name + preview_ext
|
||||
)
|
||||
new_preview = os.path.join(
|
||||
get_preview_dir(new_path),
|
||||
candidate_base + preview_ext,
|
||||
)
|
||||
if os.path.exists(old_preview):
|
||||
os.rename(old_preview, new_preview)
|
||||
|
||||
@@ -963,7 +983,10 @@ class DoctorHandler:
|
||||
old_preview_url = entry["preview_url"].replace("\\", "/")
|
||||
preview_ext = os.path.splitext(old_preview_url)[1]
|
||||
if preview_ext:
|
||||
entry["preview_url"] = (new_base_no_ext + preview_ext).replace(os.sep, "/")
|
||||
entry["preview_url"] = os.path.join(
|
||||
get_preview_dir(new_path),
|
||||
candidate_base + preview_ext,
|
||||
).replace(os.sep, "/")
|
||||
await scanner.update_single_model_cache(
|
||||
path, new_path, entry
|
||||
)
|
||||
@@ -1022,6 +1045,71 @@ class DoctorHandler:
|
||||
logger.error("Error exporting doctor bundle: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
def _check_sidecar_mirror_orphans(self) -> dict[str, Any]:
|
||||
"""Flag centralized sidecars stranded by a moved/removed model root.
|
||||
|
||||
Centralized sidecars live under a per-root mirror directory. A root
|
||||
that was moved, renamed, or dropped from the configuration leaves its
|
||||
mirror behind; without this check the loss is silent, because the
|
||||
scanner simply rebuilds default metadata at the new location.
|
||||
"""
|
||||
|
||||
actions = [{"id": "open-settings", "label": "Open Settings"}]
|
||||
try:
|
||||
mode = get_storage_mode()
|
||||
except Exception as exc: # pragma: no cover - defensive fallback
|
||||
logger.debug("Doctor: sidecar mode lookup failed: %s", exc)
|
||||
mode = "alongside"
|
||||
|
||||
if mode != "centralized":
|
||||
return {
|
||||
"id": "sidecar_mirror_orphans",
|
||||
"title": "Centralized Sidecars",
|
||||
"status": "ok",
|
||||
"summary": "Sidecar metadata is stored alongside the models.",
|
||||
"details": [],
|
||||
"actions": actions,
|
||||
}
|
||||
|
||||
try:
|
||||
orphans = get_unmatched_sidecar_components()
|
||||
except Exception as exc: # pragma: no cover - defensive fallback
|
||||
logger.warning("Doctor: sidecar orphan check failed: %s", exc)
|
||||
orphans = []
|
||||
|
||||
if not orphans:
|
||||
return {
|
||||
"id": "sidecar_mirror_orphans",
|
||||
"title": "Centralized Sidecars",
|
||||
"status": "ok",
|
||||
"summary": "Every mirrored sidecar directory is linked to a model root.",
|
||||
"details": [f"Root: {describe_sidecar_root().get('root', '')}"],
|
||||
"actions": actions,
|
||||
}
|
||||
|
||||
details = [
|
||||
"Metadata (favorites, notes, tags, usage tips) for these models is on disk but is not being read.",
|
||||
"This usually means a model root was moved, renamed, or removed. Restore the original root path in Settings; the mirror is re-linked automatically.",
|
||||
]
|
||||
details.extend(
|
||||
f"{item['component']} — last known root: {item['last_path'] or 'unknown'}"
|
||||
for item in orphans[:5]
|
||||
)
|
||||
if len(orphans) > 5:
|
||||
details.append(f"… and {len(orphans) - 5} more")
|
||||
return {
|
||||
"id": "sidecar_mirror_orphans",
|
||||
"title": "Centralized Sidecars",
|
||||
"status": "warning",
|
||||
"summary": (
|
||||
f"{len(orphans)} sidecar "
|
||||
f"director{'y' if len(orphans) == 1 else 'ies'} could not be "
|
||||
"linked to a configured model root."
|
||||
),
|
||||
"details": details,
|
||||
"actions": actions,
|
||||
}
|
||||
|
||||
async def _check_civitai_api_key(self) -> dict[str, Any]:
|
||||
api_key = (self._settings.get("civitai_api_key", "") or "").strip()
|
||||
if not api_key:
|
||||
@@ -1612,6 +1700,19 @@ class SettingsHandler:
|
||||
settings_file = getattr(self._settings, "settings_file", None)
|
||||
if settings_file:
|
||||
response_data["settings_file"] = settings_file
|
||||
# Resolved centralized sidecar root (mode-independent): lets the
|
||||
# settings UI show where sidecars actually live, including when the
|
||||
# path setting is empty and the default kicks in. inside_repo flags
|
||||
# the portable-mode hazard (root inside the plugin folder).
|
||||
try:
|
||||
sidecar_info = describe_sidecar_root()
|
||||
response_data["sidecar_storage_root"] = sidecar_info["root"]
|
||||
response_data["sidecar_storage_root_is_default"] = sidecar_info["is_default"]
|
||||
response_data["sidecar_storage_root_in_repo"] = sidecar_info["inside_repo"]
|
||||
except Exception as sidecar_error: # pragma: no cover - defensive
|
||||
logger.debug(
|
||||
"Could not resolve sidecar storage info: %s", sidecar_error
|
||||
)
|
||||
messages_getter: Any = getattr(self._settings, "get_startup_messages", None)
|
||||
messages = list(messages_getter()) if messages_getter else []
|
||||
return web.json_response(
|
||||
@@ -1706,6 +1807,7 @@ class SettingsHandler:
|
||||
if key in (
|
||||
"enable_metadata_archive_db",
|
||||
"enable_civarchive_api",
|
||||
"enable_openmodeldb_api",
|
||||
"metadata_provider_order",
|
||||
):
|
||||
await self._metadata_provider_updater()
|
||||
@@ -3345,6 +3447,18 @@ class FileSystemHandler:
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.Popen(["open", path])
|
||||
else:
|
||||
if not _has_gui_display():
|
||||
# Headless/SSH session: xdg-open cannot open a file
|
||||
# manager, so hand the path to the browser for copying
|
||||
# instead of reporting a success that never happened.
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Headless session: path available for copying",
|
||||
"path": path,
|
||||
"mode": "clipboard",
|
||||
}
|
||||
)
|
||||
subprocess.Popen(["xdg-open", path])
|
||||
|
||||
return web.json_response(
|
||||
@@ -3501,6 +3615,24 @@ class FileSystemHandler:
|
||||
logger.error("Failed to open wildcards location: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def open_sidecar_location(self, request: web.Request) -> web.Response:
|
||||
"""Open the centralized sidecar storage root in the file manager."""
|
||||
|
||||
try:
|
||||
root = get_configured_sidecar_root()
|
||||
if not root:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Sidecar storage root is not resolvable"},
|
||||
status=404,
|
||||
)
|
||||
# Create on demand so the button also works before the first
|
||||
# migration/download has materialized the directory.
|
||||
os.makedirs(root, exist_ok=True)
|
||||
return await self._open_path(root)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Failed to open sidecar location: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def browse_directory(self, request: web.Request) -> web.Response:
|
||||
"""Browse a directory for the settings-UI directory picker."""
|
||||
try:
|
||||
@@ -4123,6 +4255,64 @@ class NodeRegistryHandler:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
class SidecarMigrationHandler:
|
||||
"""Migrate sidecar metadata and previews between storage layouts."""
|
||||
|
||||
_VALID_DIRECTIONS = ("to_centralized", "to_alongside", "relocate_root")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
use_case_factory: Callable[[], SidecarMigrationUseCase] = SidecarMigrationUseCase,
|
||||
progress_callback_factory: Callable[[], Any] = WebSocketBroadcastCallback,
|
||||
) -> None:
|
||||
self._use_case_factory = use_case_factory
|
||||
self._progress_callback_factory = progress_callback_factory
|
||||
|
||||
async def migrate_sidecars(self, request: web.Request) -> web.Response:
|
||||
"""Run a sidecar migration; accepts POST JSON or GET query params."""
|
||||
try:
|
||||
if request.method == "GET":
|
||||
params: Mapping[str, Any] = request.query
|
||||
else:
|
||||
try:
|
||||
params = await request.json()
|
||||
except Exception: # empty/invalid body: fall back to query
|
||||
params = request.query
|
||||
|
||||
direction = str(params.get("direction") or "").strip()
|
||||
if direction not in self._VALID_DIRECTIONS:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "direction must be 'to_centralized', 'to_alongside' or 'relocate_root'",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
force = params.get("force") in (True, 1, "true", "1")
|
||||
old_root = str(params.get("old_root") or "").strip()
|
||||
if direction == "relocate_root" and not old_root:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "old_root is required for relocate_root"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
use_case = self._use_case_factory()
|
||||
progress_cb = self._progress_callback_factory()
|
||||
result = await use_case.execute_with_error_handling(
|
||||
direction=direction,
|
||||
progress_cb=progress_cb,
|
||||
force=force,
|
||||
old_root=old_root,
|
||||
)
|
||||
status = 200 if result.get("success") else 400
|
||||
return web.json_response(result, status=status)
|
||||
except Exception as exc:
|
||||
logger.error("Sidecar migration failed: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
class MiscHandlerSet:
|
||||
"""Aggregate handlers into a lookup compatible with the registrar."""
|
||||
|
||||
@@ -4149,6 +4339,7 @@ class MiscHandlerSet:
|
||||
model_source_handler: Any = None,
|
||||
agent_handler: Any = None,
|
||||
download_routing: Any = None,
|
||||
sidecar_migration: Any = None,
|
||||
) -> None:
|
||||
self.health = health
|
||||
self.settings = settings
|
||||
@@ -4170,6 +4361,7 @@ class MiscHandlerSet:
|
||||
self.model_source_handler = model_source_handler
|
||||
self.agent_handler = agent_handler
|
||||
self.download_routing = download_routing
|
||||
self.sidecar_migration = sidecar_migration
|
||||
|
||||
def to_route_mapping(
|
||||
self,
|
||||
@@ -4215,6 +4407,7 @@ class MiscHandlerSet:
|
||||
"open_settings_location": self.filesystem.open_settings_location,
|
||||
"open_backup_location": self.filesystem.open_backup_location,
|
||||
"open_wildcards_location": self.filesystem.open_wildcards_location,
|
||||
"open_sidecar_location": self.filesystem.open_sidecar_location,
|
||||
"browse_directory": self.filesystem.browse_directory,
|
||||
"validate_path": self.filesystem.validate_path,
|
||||
"search_custom_words": self.custom_words.search_custom_words,
|
||||
@@ -4236,6 +4429,8 @@ class MiscHandlerSet:
|
||||
"cancel_agent_skill": self.agent_handler.cancel_agent_skill,
|
||||
# Download routing handler
|
||||
"get_download_routing": self.download_routing.get_download_routing,
|
||||
# Sidecar migration handler
|
||||
"migrate_sidecars": self.sidecar_migration.migrate_sidecars,
|
||||
# Base model handlers
|
||||
"get_base_models": self.base_model.get_base_models,
|
||||
"refresh_base_models": self.base_model.refresh_base_models,
|
||||
|
||||
@@ -50,6 +50,7 @@ from ...services.errors import RateLimitError, ResourceNotFoundError
|
||||
from ...utils.civitai_utils import resolve_license_payload
|
||||
from ...utils.file_utils import calculate_sha256
|
||||
from ...utils.metadata_manager import MetadataManager
|
||||
from ...utils.sidecar_paths import get_metadata_path
|
||||
from ...utils.url_utils import relative_root_prefix
|
||||
|
||||
LICENSE_FIELDS = (
|
||||
@@ -386,7 +387,6 @@ class ModelListingHandler:
|
||||
== "true",
|
||||
"tags": request.query.get("search_tags", "false").lower() == "true",
|
||||
"creator": request.query.get("search_creator", "false").lower() == "true",
|
||||
"hash": request.query.get("search_hash", "false").lower() == "true",
|
||||
"recursive": request.query.get("recursive", "true").lower() == "true",
|
||||
}
|
||||
|
||||
@@ -676,7 +676,7 @@ class ModelManagementHandler:
|
||||
status=400,
|
||||
)
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
local_metadata = await self._metadata_sync.load_local_metadata(
|
||||
metadata_path
|
||||
)
|
||||
@@ -1760,7 +1760,8 @@ class ModelDownloadHandler:
|
||||
payload = await request.json()
|
||||
result = await self._download_use_case.execute(payload)
|
||||
if not result.get("success", False):
|
||||
return web.json_response(result, status=500)
|
||||
status = 429 if result.get("reason") == "rate_limited" else 500
|
||||
return web.json_response(result, status=status)
|
||||
return web.json_response(result)
|
||||
except DownloadModelValidationError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||
@@ -1818,7 +1819,8 @@ class ModelDownloadHandler:
|
||||
mock_request = type("MockRequest", (), {"json": lambda self=None: future})()
|
||||
result = await self._download_use_case.execute(data)
|
||||
if not result.get("success", False):
|
||||
return web.json_response(result, status=500)
|
||||
status = 429 if result.get("reason") == "rate_limited" else 500
|
||||
return web.json_response(result, status=status)
|
||||
return web.json_response(result)
|
||||
except DownloadModelValidationError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||
|
||||
@@ -31,7 +31,6 @@ from ...services.model_sources import (
|
||||
detect_source,
|
||||
get_download_source,
|
||||
hydrate_from_source,
|
||||
is_valid_source_id,
|
||||
list_sources,
|
||||
normalize_metadata_source,
|
||||
)
|
||||
@@ -39,7 +38,12 @@ from ...services.settings_manager import get_settings_manager
|
||||
from ...services.service_registry import ServiceRegistry
|
||||
from ...services.websocket_manager import ws_manager
|
||||
from ...utils.metadata_manager import MetadataManager
|
||||
from ...utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
|
||||
from ...utils.models import (
|
||||
LoraMetadata,
|
||||
CheckpointMetadata,
|
||||
EmbeddingMetadata,
|
||||
OtherModelMetadata,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -78,6 +82,11 @@ def _infer_model_type(model_root: str) -> tuple[Any, str]:
|
||||
if os.path.normpath(p).replace(os.sep, "/") == norm:
|
||||
return EmbeddingMetadata, "get_embedding_scanner"
|
||||
|
||||
# Other-model roots (VAE, text encoders, upscalers, ...)
|
||||
for p in config.other_roots or []:
|
||||
if os.path.normpath(p).replace(os.sep, "/") == norm:
|
||||
return OtherModelMetadata, "get_other_scanner"
|
||||
|
||||
# Fallback — should not happen in normal use
|
||||
logger.warning(
|
||||
"Could not determine model type for root '%s'; defaulting to LoRA",
|
||||
@@ -213,12 +222,14 @@ def _find_matching_root(dest_dir: str) -> str | None:
|
||||
config.extra_unet_roots or [],
|
||||
config.embeddings_roots or [],
|
||||
config.extra_embeddings_roots or [],
|
||||
config.other_roots or [],
|
||||
):
|
||||
all_roots.extend([os.path.normpath(p).replace(os.sep, "/") for p in root_list])
|
||||
# Find the longest matching prefix
|
||||
# Find the longest matching prefix. The boundary check prevents a root like
|
||||
# `/models/vae` from swallowing a sibling directory like `/models/vae-old`.
|
||||
match: str | None = None
|
||||
for root in all_roots:
|
||||
if norm.startswith(root):
|
||||
if norm == root or norm.startswith(root + "/"):
|
||||
if match is None or len(root) > len(match):
|
||||
match = root
|
||||
return match
|
||||
@@ -265,9 +276,7 @@ class ModelSourceHandler:
|
||||
"supports_enrichment": source.supports_enrichment,
|
||||
"supports_download": source.supports_download,
|
||||
"default_revision": source.default_revision,
|
||||
"example_url": source.canonical_url(
|
||||
"user/repo" if source.platform != "tensorart" else "827823520299086029"
|
||||
),
|
||||
"example_url": source.canonical_url(source.example_source_id),
|
||||
}
|
||||
for source in list_sources()
|
||||
])
|
||||
@@ -314,9 +323,7 @@ class ModelSourceHandler:
|
||||
"error": (
|
||||
"Unsupported model URL. Supported formats: "
|
||||
+ ", ".join(
|
||||
f"{s.label} ({s.canonical_url('user/repo')})"
|
||||
if s.platform != "tensorart"
|
||||
else f"{s.label} (https://tensor.art/models/<id>)"
|
||||
f"{s.label} ({s.canonical_url(s.example_source_id)})"
|
||||
for s in list_sources()
|
||||
)
|
||||
),
|
||||
@@ -412,9 +419,9 @@ class ModelSourceHandler:
|
||||
source = get_download_source(platform)
|
||||
if source is None:
|
||||
return _unsupported_platform_error(platform)
|
||||
if not is_valid_source_id(repo):
|
||||
if not source.is_valid_source_id(repo):
|
||||
return web.json_response(
|
||||
{"error": "Missing or invalid 'repo' parameter (expected owner/name)"},
|
||||
{"error": "Missing or invalid 'repo' parameter"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
@@ -480,10 +487,11 @@ class ModelSourceHandler:
|
||||
{"error": "Missing required fields: 'repo' and 'filename'"}, status=400
|
||||
)
|
||||
|
||||
# `owner/name` only; the components become path segments below.
|
||||
if not is_valid_source_id(repo):
|
||||
# The id becomes a path segment below; each site defines what a safe
|
||||
# id looks like (`owner/name` for repository sites, a flat token for
|
||||
# OpenModelDB).
|
||||
if not source.is_valid_source_id(repo):
|
||||
return web.json_response({"error": f"Invalid repo format: {repo}"}, status=400)
|
||||
owner, repo_name = repo.split("/", 1)
|
||||
|
||||
# Validate filename — must not contain path traversal
|
||||
if ".." in filename:
|
||||
@@ -509,7 +517,7 @@ class ModelSourceHandler:
|
||||
base_dir = os.path.normpath(os.path.join(os.getcwd(), "models", model_root))
|
||||
|
||||
if use_default_paths:
|
||||
target_dir = os.path.join(base_dir, source.default_subdir, owner, repo_name)
|
||||
target_dir = os.path.join(base_dir, *source.default_subdir_parts(repo))
|
||||
elif relative_path:
|
||||
target_dir = os.path.join(base_dir, relative_path)
|
||||
else:
|
||||
@@ -524,7 +532,10 @@ class ModelSourceHandler:
|
||||
|
||||
# Built per request: sites that redirect to a CDN hand out a
|
||||
# time-limited token in the redirect, so the URL must never be cached.
|
||||
resolve_url = source.file_download_url(repo, filename, revision)
|
||||
try:
|
||||
resolve_url = await source.resolve_download_url(repo, filename, revision)
|
||||
except ModelSourceError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=exc.status)
|
||||
ref = SourceRef(
|
||||
platform=source.platform, source_id=repo, url=source.canonical_url(repo)
|
||||
)
|
||||
|
||||
@@ -1284,6 +1284,21 @@ class RecipeManagementHandler:
|
||||
_original_image_url,
|
||||
) = await self._download_remote_media(image_url)
|
||||
|
||||
# CivitAI's optimized rendition is re-encoded and metadata-free, so an
|
||||
# embedded ComfyUI workflow only exists in the original. Fetch it
|
||||
# lazily: unlike the URL import path (which needs the original for
|
||||
# metadata parsing anyway), this path would download it purely for the
|
||||
# workflow, so it is skipped unless the API reports one.
|
||||
original_workflow = None
|
||||
if _original_image_url and self._meta_indicates_comfy_workflow(
|
||||
civitai_meta_raw
|
||||
):
|
||||
_raw_original, original_workflow = await self._fetch_original_media(
|
||||
_original_image_url
|
||||
)
|
||||
if original_workflow:
|
||||
metadata["workflow"] = original_workflow
|
||||
|
||||
# Build a version-cached map of local model hashes to cache items so
|
||||
# CivitaiApiMetadataParser can skip CivitAI API calls for models that
|
||||
# exist on disk. Built once and shared by every parse pass below.
|
||||
@@ -1806,6 +1821,10 @@ class RecipeManagementHandler:
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
# Opt-in workflow embedding. The widget historically POSTs with no
|
||||
# body at all, so a missing/empty body is not an error.
|
||||
workflow = await self._read_optional_json_field(request, "workflow")
|
||||
|
||||
analysis = await self._analysis_service.analyze_widget_metadata(
|
||||
recipe_scanner=recipe_scanner
|
||||
)
|
||||
@@ -1818,6 +1837,7 @@ class RecipeManagementHandler:
|
||||
recipe_scanner=recipe_scanner,
|
||||
metadata=metadata,
|
||||
image_bytes=image_bytes,
|
||||
workflow=workflow,
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
except RecipeValidationError as exc:
|
||||
@@ -1882,6 +1902,24 @@ class RecipeManagementHandler:
|
||||
return []
|
||||
return [tag.strip() for tag in tag_text.split(",") if tag.strip()]
|
||||
|
||||
async def _read_optional_json_field(
|
||||
self, request: web.Request, field: str
|
||||
) -> Any:
|
||||
"""Read one field from an optional JSON request body.
|
||||
|
||||
Some callers (notably the widget's long-standing "Save Recipe" action)
|
||||
POST with no body at all, and a stale cached extension may still do so
|
||||
after a body is introduced. A missing, empty or malformed body is
|
||||
therefore treated as "no value" rather than a request error.
|
||||
"""
|
||||
if not request.can_read_body:
|
||||
return None
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return None
|
||||
return data.get(field) if isinstance(data, dict) else None
|
||||
|
||||
async def _count_recipe_loras(
|
||||
self, recipe_scanner: Any, recipe_id: Optional[str]
|
||||
) -> Optional[int]:
|
||||
@@ -2090,6 +2128,90 @@ class RecipeManagementHandler:
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def _read_embedded_workflow(self, image_path: Optional[str]) -> Optional[str]:
|
||||
"""Return a ComfyUI workflow embedded in ``image_path``, if any.
|
||||
|
||||
``ExifUtils.extract_image_metadata`` stops at the generation
|
||||
parameters, so the UI-format workflow has to be read through the
|
||||
structured metadata reader. Failures map to ``None``.
|
||||
"""
|
||||
if not image_path or not os.path.exists(image_path):
|
||||
return None
|
||||
try:
|
||||
metadata = ExifUtils._load_structured_metadata(image_path)
|
||||
except Exception as exc:
|
||||
self._logger.debug(
|
||||
"Failed to read embedded workflow from %s: %s", image_path, exc
|
||||
)
|
||||
return None
|
||||
workflow = metadata.get("workflow") if isinstance(metadata, dict) else None
|
||||
return workflow if isinstance(workflow, str) and workflow else None
|
||||
|
||||
@staticmethod
|
||||
def _meta_indicates_comfy_workflow(civitai_meta_raw: Any) -> bool:
|
||||
"""Whether CivitAI reports an embedded ComfyUI workflow for an image.
|
||||
|
||||
``meta.comfy`` is the payload CivitAI captured from the original image,
|
||||
so its presence is the signal that fetching the original is worth the
|
||||
bandwidth when the caller does not already need it for metadata
|
||||
parsing.
|
||||
"""
|
||||
if not isinstance(civitai_meta_raw, dict):
|
||||
return False
|
||||
inner = civitai_meta_raw.get("meta")
|
||||
if isinstance(inner, dict) and inner.get("comfy"):
|
||||
return True
|
||||
return bool(civitai_meta_raw.get("comfy"))
|
||||
|
||||
async def _fetch_original_media(
|
||||
self, original_image_url: Optional[str]
|
||||
) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Download the original rendition and read its embedded media.
|
||||
|
||||
CivitAI's optimized renditions are re-encoded and carry no metadata, so
|
||||
the original is the only source for embedded generation metadata and
|
||||
for the UI-format ComfyUI workflow (the raw extractor's fallback chain
|
||||
ends at ``workflow`` only when no prompt is present).
|
||||
|
||||
Returns ``(raw_metadata, workflow)``; either element is ``None`` when
|
||||
unavailable. Failures never raise — imports keep working with the
|
||||
optimized rendition when the original cannot be fetched.
|
||||
"""
|
||||
if not original_image_url:
|
||||
return None, None
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp_file:
|
||||
temp_path = temp_file.name
|
||||
try:
|
||||
downloader = await self._downloader_factory()
|
||||
success, _result = await downloader.download_file(
|
||||
original_image_url, temp_path, use_auth=False
|
||||
)
|
||||
if not success:
|
||||
self._logger.warning(
|
||||
"Failed to download original rendition: %s", original_image_url
|
||||
)
|
||||
return None, None
|
||||
|
||||
raw_metadata = await asyncio.to_thread(
|
||||
ExifUtils.extract_image_metadata, temp_path
|
||||
)
|
||||
workflow = await asyncio.to_thread(
|
||||
self._read_embedded_workflow, temp_path
|
||||
)
|
||||
return raw_metadata, workflow
|
||||
except Exception as exc:
|
||||
self._logger.warning(
|
||||
"Failed to read original rendition %s: %s", original_image_url, exc
|
||||
)
|
||||
return None, None
|
||||
finally:
|
||||
try:
|
||||
if os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _safe_int(self, value: Any) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
@@ -2295,6 +2417,7 @@ class RecipeManagementHandler:
|
||||
"Failed to extract embedded metadata: %s", exc
|
||||
)
|
||||
|
||||
original_workflow: Optional[str] = None
|
||||
if not parsed_embedded and original_image_url:
|
||||
self._logger.debug(
|
||||
"Optimized image has no embedded metadata, "
|
||||
@@ -2302,48 +2425,32 @@ class RecipeManagementHandler:
|
||||
original_image_url,
|
||||
)
|
||||
try:
|
||||
downloader = await self._downloader_factory()
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".png", delete=False
|
||||
) as tmp:
|
||||
orig_tmp_path = tmp.name
|
||||
try:
|
||||
success, _ = await downloader.download_file(
|
||||
original_image_url, orig_tmp_path, use_auth=False
|
||||
)
|
||||
if success:
|
||||
raw_orig = await asyncio.to_thread(
|
||||
ExifUtils.extract_image_metadata, orig_tmp_path
|
||||
raw_orig, original_workflow = await self._fetch_original_media(
|
||||
original_image_url
|
||||
)
|
||||
diagnostics["exif_present"] = bool(raw_orig) or bool(
|
||||
diagnostics.get("exif_present")
|
||||
)
|
||||
if raw_orig:
|
||||
parser = (
|
||||
self._analysis_service._recipe_parser_factory.create_parser(
|
||||
raw_orig
|
||||
)
|
||||
diagnostics["exif_present"] = bool(raw_orig)
|
||||
if raw_orig:
|
||||
parser = (
|
||||
self._analysis_service._recipe_parser_factory.create_parser(
|
||||
raw_orig
|
||||
)
|
||||
)
|
||||
if parser:
|
||||
diagnostics["exif_parser"] = parser.__class__.__name__
|
||||
if isinstance(parser, CivitaiApiMetadataParser):
|
||||
parsed_embedded = await parser.parse_metadata(
|
||||
raw_orig,
|
||||
recipe_scanner=recipe_scanner,
|
||||
local_cache=local_cache,
|
||||
)
|
||||
if parser:
|
||||
diagnostics["exif_parser"] = parser.__class__.__name__
|
||||
if isinstance(parser, CivitaiApiMetadataParser):
|
||||
parsed_embedded = await parser.parse_metadata(
|
||||
raw_orig,
|
||||
recipe_scanner=recipe_scanner,
|
||||
local_cache=local_cache,
|
||||
)
|
||||
else:
|
||||
parsed_embedded = await parser.parse_metadata(
|
||||
raw_orig, recipe_scanner=recipe_scanner
|
||||
)
|
||||
if (
|
||||
parsed_embedded
|
||||
and "gen_params" in parsed_embedded
|
||||
):
|
||||
embedded_gen_params = parsed_embedded[
|
||||
"gen_params"
|
||||
]
|
||||
finally:
|
||||
if os.path.exists(orig_tmp_path):
|
||||
os.unlink(orig_tmp_path)
|
||||
else:
|
||||
parsed_embedded = await parser.parse_metadata(
|
||||
raw_orig, recipe_scanner=recipe_scanner
|
||||
)
|
||||
if parsed_embedded and "gen_params" in parsed_embedded:
|
||||
embedded_gen_params = parsed_embedded["gen_params"]
|
||||
except Exception as exc:
|
||||
self._logger.warning(
|
||||
"Failed to extract metadata from original image: %s", exc
|
||||
@@ -2391,6 +2498,8 @@ class RecipeManagementHandler:
|
||||
"gen_params": embedded_gen_params or {},
|
||||
"source_path": image_url,
|
||||
}
|
||||
if original_workflow:
|
||||
metadata["workflow"] = original_workflow
|
||||
|
||||
# Extract preview_nsfw_level from the CivitAI API response
|
||||
# (injected into civitai_meta_raw by _download_remote_media).
|
||||
|
||||
@@ -113,6 +113,16 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/download/routing", "get_download_routing"
|
||||
),
|
||||
# Sidecar storage layout migration (GET supported for the extension)
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/sidecars/migrate", "migrate_sidecars"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/sidecars/migrate", "migrate_sidecars"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/sidecars/open-location", "open_sidecar_location"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/download-model-source", "download_model_source"
|
||||
),
|
||||
|
||||
@@ -32,6 +32,7 @@ from .handlers.misc_handlers import (
|
||||
NodeRegistry,
|
||||
NodeRegistryHandler,
|
||||
SettingsHandler,
|
||||
SidecarMigrationHandler,
|
||||
SupportersHandler,
|
||||
TrainedWordsHandler,
|
||||
UsageStatsHandler,
|
||||
@@ -142,6 +143,7 @@ class MiscRoutes:
|
||||
model_source_handler = ModelSourceHandler()
|
||||
agent_handler = AgentHandler()
|
||||
download_routing = DownloadRoutingHandler()
|
||||
sidecar_migration = SidecarMigrationHandler()
|
||||
|
||||
return self._handler_set_factory(
|
||||
health=health,
|
||||
@@ -164,6 +166,7 @@ class MiscRoutes:
|
||||
model_source_handler=model_source_handler,
|
||||
agent_handler=agent_handler,
|
||||
download_routing=download_routing,
|
||||
sidecar_migration=sidecar_migration,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ import os
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from ..utils.sidecar_paths import get_metadata_path
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - type-check only; runtime imports are local
|
||||
from .model_scanner import ModelScanner
|
||||
|
||||
@@ -41,7 +43,7 @@ def _resolve_autov3(file_path: str) -> str:
|
||||
safetensors header hash. Returns ``''`` when neither is available.
|
||||
"""
|
||||
try:
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
if os.path.exists(metadata_path):
|
||||
with open(metadata_path, "r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
|
||||
@@ -645,6 +645,11 @@ class BatchImportService:
|
||||
if payload.get("checkpoint"):
|
||||
metadata["checkpoint"] = payload["checkpoint"]
|
||||
|
||||
# A workflow recovered from the source's original rendition
|
||||
# travels as metadata and is embedded into the stored image.
|
||||
if payload.get("workflow"):
|
||||
metadata["workflow"] = payload["workflow"]
|
||||
|
||||
nsfw = payload.get("preview_nsfw_level")
|
||||
if isinstance(nsfw, int) and nsfw > 0:
|
||||
metadata["preview_nsfw_level"] = nsfw
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional
|
||||
from ..utils.models import CheckpointMetadata
|
||||
from ..utils.file_utils import find_preview_file, normalize_path, calculate_autov3
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.sidecar_paths import get_preview_dir, is_centralized
|
||||
from ..config import config
|
||||
from .model_scanner import ModelScanner, _is_excluded_dir
|
||||
from .model_hash_index import ModelHashIndex
|
||||
@@ -61,10 +62,9 @@ class CheckpointScanner(ModelScanner):
|
||||
return None
|
||||
|
||||
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
dir_path = os.path.dirname(file_path)
|
||||
|
||||
# Find preview image
|
||||
preview_url = find_preview_file(base_name, dir_path)
|
||||
preview_url = find_preview_file(base_name, get_preview_dir(file_path))
|
||||
|
||||
# AutoV3 reads only the safetensors header, so it is cheap even for
|
||||
# large checkpoints; record the checked state at creation time ("" =
|
||||
@@ -322,6 +322,11 @@ class CheckpointScanner(ModelScanner):
|
||||
|
||||
async def _find_pending_models_from_filesystem(self) -> List[Dict[str, Any]]:
|
||||
"""Scan filesystem for checkpoint metadata files with pending hash status."""
|
||||
# Centralized mode stores sidecars in the mirror tree, not next to the
|
||||
# models; walk the mirror instead of the model folders.
|
||||
if is_centralized():
|
||||
return self._find_pending_models_in_sidecar_mirror()
|
||||
|
||||
pending_models = []
|
||||
|
||||
for root_path in self.get_model_roots():
|
||||
|
||||
+169
-12
@@ -38,12 +38,14 @@ from ..utils.preview_selection import resolve_mature_threshold, select_preview_m
|
||||
from ..utils.utils import calculate_filename_for_model, sanitize_folder_name
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.sidecar_paths import get_metadata_path, get_preview_dir
|
||||
from .service_registry import ServiceRegistry
|
||||
from .download_routing import is_diffusion_model_download, resolve_other_download_sub_type
|
||||
from .settings_manager import get_settings_manager
|
||||
from .metadata_service import get_default_metadata_provider, get_metadata_provider
|
||||
from .downloader import get_downloader, DownloadProgress, DownloadStreamControl
|
||||
from .errors import RateLimitError
|
||||
from .errors import DownloadRateLimitError, RateLimitError
|
||||
from .rate_limit_coordinator import RateLimitCoordinator
|
||||
from .aria2_downloader import Aria2Error, get_aria2_downloader
|
||||
from .aria2_transfer_state import Aria2TransferStateStore
|
||||
from .download_queue_service import DownloadQueueService
|
||||
@@ -59,6 +61,15 @@ CIVITAI_DOWNLOAD_URL_PREFIXES = (
|
||||
"https://civitai.red/api/download/",
|
||||
)
|
||||
|
||||
# Hosts a model download may hit (metadata + file transfer). The pre-flight
|
||||
# cooldown gate consults the RateLimitCoordinator for these before a download
|
||||
# occupies a concurrency slot.
|
||||
DOWNLOAD_PREFLIGHT_HOSTS = ("civitai.com", "civitai.red", "civarchive.com")
|
||||
|
||||
# Fallback retry_after when neither the vendor nor the coordinator can supply
|
||||
# a number (matches the Retry-After parsing default in downloader.py).
|
||||
DEFAULT_RATE_LIMIT_RETRY_AFTER_SECONDS = 60
|
||||
|
||||
|
||||
# File types that are never the intended download target even when CivitAI
|
||||
# marks them primary — configs/archives/workflows are auxiliary artifacts.
|
||||
@@ -202,11 +213,30 @@ class DownloadManager:
|
||||
)
|
||||
except Aria2Error as exc:
|
||||
logger.error("aria2 download failed for %s: %s", download_url, exc)
|
||||
# Best-effort 429 detection: aria2 reports HTTP status errors
|
||||
# via its error message (e.g. "status=429") without exposing
|
||||
# the vendor's Retry-After. Surface the structured rate-limit
|
||||
# error so the queue contract behaves the same as the python
|
||||
# backend; the coordinator backoff supplies the wait time.
|
||||
message = str(exc)
|
||||
if "429" in message or "rate limit" in message.lower():
|
||||
host = self._url_host(download_url)
|
||||
coordinator = await RateLimitCoordinator.get_instance()
|
||||
if coordinator.enabled:
|
||||
coordinator.register_rate_limit(host, None)
|
||||
raise DownloadRateLimitError(
|
||||
f"Download rate limited (429): {message}",
|
||||
retry_after=None,
|
||||
host=host,
|
||||
) from exc
|
||||
return False, str(exc)
|
||||
|
||||
download_kwargs: Dict[str, Any] = {
|
||||
"progress_callback": progress_callback,
|
||||
"use_auth": use_auth,
|
||||
# The model download queue contract requires structured 429
|
||||
# propagation (reason="rate_limited"), not a plain error string.
|
||||
"raise_on_rate_limit": True,
|
||||
}
|
||||
|
||||
if pause_control is not None:
|
||||
@@ -215,6 +245,88 @@ class DownloadManager:
|
||||
downloader = await get_downloader()
|
||||
return await downloader.download_file(download_url, save_path, **download_kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _url_host(url: str) -> str:
|
||||
"""Extract the normalized hostname from a URL (fallback: ``unknown``)."""
|
||||
hostname = urlparse(url).hostname
|
||||
return hostname.lower() if hostname else "unknown"
|
||||
|
||||
async def _preflight_rate_limit_error(self) -> Optional[DownloadRateLimitError]:
|
||||
"""Fail fast when a download target host is in a rate-limit cooldown.
|
||||
|
||||
Consults the RateLimitCoordinator's per-host cooldown state for the
|
||||
hosts a model download may hit. Runs BEFORE the concurrency semaphore
|
||||
is acquired so queued items never occupy a slot during a 429 episode.
|
||||
Deliberately non-blocking: the caller is expected to pace itself (the
|
||||
companion extension auto-pauses on the structured 429 response).
|
||||
"""
|
||||
coordinator = await RateLimitCoordinator.get_instance()
|
||||
worst_host: Optional[str] = None
|
||||
worst_remaining = 0.0
|
||||
for host in DOWNLOAD_PREFLIGHT_HOSTS:
|
||||
remaining = coordinator.remaining_seconds(host)
|
||||
if remaining > worst_remaining:
|
||||
worst_host = host
|
||||
worst_remaining = remaining
|
||||
if worst_host is None or worst_remaining <= 0:
|
||||
return None
|
||||
retry_after = max(1, int(worst_remaining + 0.5))
|
||||
return DownloadRateLimitError(
|
||||
f"Download rate limited: '{worst_host}' is in cooldown, "
|
||||
f"retry after {retry_after}s",
|
||||
retry_after=worst_remaining,
|
||||
host=worst_host,
|
||||
)
|
||||
|
||||
async def _handle_rate_limited_download(
|
||||
self,
|
||||
task_id: str,
|
||||
exc: RateLimitError,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the structured rate-limit result for a failed download.
|
||||
|
||||
The queue row goes back to ``queued`` (NOT history) so a later retry
|
||||
simply starts the download again — this is what lets the companion
|
||||
extension auto-pause the queue during a 429 episode and resume it
|
||||
after ``retry_after`` seconds.
|
||||
"""
|
||||
retry_after = exc.retry_after
|
||||
host = getattr(exc, "host", None) or exc.provider
|
||||
if retry_after is None or retry_after <= 0:
|
||||
# The coordinator clamps/backoffs via register_rate_limit, so its
|
||||
# remaining cooldown supplies the number when the vendor didn't.
|
||||
coordinator = await RateLimitCoordinator.get_instance()
|
||||
remaining = coordinator.remaining_seconds(host)
|
||||
if remaining > 0:
|
||||
retry_after = remaining
|
||||
if retry_after is None or retry_after <= 0:
|
||||
retry_after = float(DEFAULT_RATE_LIMIT_RETRY_AFTER_SECONDS)
|
||||
retry_after_seconds = max(1, int(retry_after + 0.5))
|
||||
|
||||
message = str(exc) or (
|
||||
f"Download rate limited, retry after {retry_after_seconds}s"
|
||||
)
|
||||
|
||||
if task_id in self._active_downloads:
|
||||
self._active_downloads[task_id]["status"] = "queued"
|
||||
self._active_downloads[task_id]["error"] = message
|
||||
self._active_downloads[task_id]["bytes_per_second"] = 0.0
|
||||
|
||||
try:
|
||||
queue_service = await DownloadQueueService.get_instance()
|
||||
await queue_service.update_status(task_id, "queued", error=message)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to re-queue rate-limited download %s", task_id, exc_info=True
|
||||
)
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"reason": "rate_limited",
|
||||
"retry_after": retry_after_seconds,
|
||||
"error": message,
|
||||
}
|
||||
|
||||
async def _get_lora_scanner(self):
|
||||
"""Get the lora scanner from registry"""
|
||||
return await ServiceRegistry.get_lora_scanner()
|
||||
@@ -557,6 +669,15 @@ class DownloadManager:
|
||||
original_callback, snapshot, progress_value
|
||||
)
|
||||
|
||||
# Pre-flight cooldown gate: fail fast (without holding a semaphore
|
||||
# slot) when a target host is still cooling down from an earlier 429.
|
||||
preflight_error = await self._preflight_rate_limit_error()
|
||||
if preflight_error is not None:
|
||||
logger.info(
|
||||
"Download %s skipped: %s", task_id, preflight_error
|
||||
)
|
||||
return await self._handle_rate_limited_download(task_id, preflight_error)
|
||||
|
||||
# Acquire semaphore to limit concurrent downloads
|
||||
try:
|
||||
async with self._download_semaphore:
|
||||
@@ -661,6 +782,12 @@ class DownloadManager:
|
||||
|
||||
logger.info(f"Download cancelled for task {task_id}")
|
||||
raise
|
||||
except RateLimitError as e:
|
||||
# 429 (real vendor response or cooldown gate): re-queue
|
||||
# instead of completing as failed so a later retry just
|
||||
# starts the download again.
|
||||
logger.info(f"Download rate limited for task {task_id}: {e}")
|
||||
return await self._handle_rate_limited_download(task_id, e)
|
||||
except Exception as e:
|
||||
# Handle other errors
|
||||
logger.error(
|
||||
@@ -831,7 +958,7 @@ class DownloadManager:
|
||||
)
|
||||
|
||||
for file_path in target_files:
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
deleted = await self._delete_file_with_retries(metadata_path)
|
||||
if not deleted and os.path.exists(metadata_path):
|
||||
logger.error(f"Error deleting metadata file: {metadata_path}")
|
||||
@@ -1522,7 +1649,11 @@ class DownloadManager:
|
||||
return {"success": False, "error": "Failed to fetch model metadata"}
|
||||
|
||||
model_type_from_info = version_info.get("model", {}).get("type", "").lower()
|
||||
if model_type_from_info == "checkpoint":
|
||||
# CivitAI ModelType has no model-level diffusion variant: DiT
|
||||
# models are uploaded as "Checkpoint" or "UNet". Both go through
|
||||
# the checkpoint branch so the standard diffusion routing chain
|
||||
# (file type -> baseModel lists -> unknown default) applies.
|
||||
if model_type_from_info in ("checkpoint", "unet"):
|
||||
model_type = "checkpoint"
|
||||
elif model_type_from_info in VALID_LORA_TYPES:
|
||||
model_type = "lora"
|
||||
@@ -1669,6 +1800,9 @@ class DownloadManager:
|
||||
model_type,
|
||||
file_types=(f.get("type", "") for f in version_info.get("files", [])),
|
||||
base_model=base_model_value,
|
||||
unknown_base_model_default=get_settings_manager().get(
|
||||
"unknown_base_model_routing", "diffusion_model"
|
||||
),
|
||||
)
|
||||
|
||||
# Existence check after the metadata fetch (#1058):
|
||||
@@ -2107,6 +2241,10 @@ class DownloadManager:
|
||||
|
||||
return result
|
||||
|
||||
except RateLimitError:
|
||||
# Structured 429 propagation must reach _download_with_semaphore
|
||||
# unmodified so the queue row is re-queued instead of failed.
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error in download_from_civitai: {e}", exc_info=True)
|
||||
# Check if this might be an early access error
|
||||
@@ -2447,7 +2585,7 @@ class DownloadManager:
|
||||
return {"success": False, "error": save_path}
|
||||
|
||||
part_path = save_path + ".part"
|
||||
metadata_path = os.path.splitext(save_path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(save_path)
|
||||
|
||||
pause_control = self._pause_events.get(download_id) if download_id else None
|
||||
|
||||
@@ -2464,6 +2602,10 @@ class DownloadManager:
|
||||
# Download preview image if available
|
||||
images = version_info.get("images", [])
|
||||
if images:
|
||||
# Centralized preview mirrors may not exist yet (unlike the
|
||||
# model's own directory in alongside mode).
|
||||
os.makedirs(get_preview_dir(save_path), exist_ok=True)
|
||||
|
||||
if progress_callback:
|
||||
await progress_callback(
|
||||
1
|
||||
@@ -2503,7 +2645,10 @@ class DownloadManager:
|
||||
|
||||
if media_type == "video":
|
||||
preview_ext = _extension_from_url(preview_url, ".mp4")
|
||||
preview_path = os.path.splitext(save_path)[0] + preview_ext
|
||||
preview_path = os.path.join(
|
||||
get_preview_dir(save_path),
|
||||
os.path.splitext(os.path.basename(save_path))[0] + preview_ext,
|
||||
)
|
||||
rewritten_url, rewritten = rewrite_preview_url(
|
||||
preview_url, media_type="video"
|
||||
)
|
||||
@@ -2530,7 +2675,10 @@ class DownloadManager:
|
||||
)
|
||||
if rewritten and rewritten_url:
|
||||
preview_ext = _extension_from_url(preview_url, ".png")
|
||||
preview_path = os.path.splitext(save_path)[0] + preview_ext
|
||||
preview_path = os.path.join(
|
||||
get_preview_dir(save_path),
|
||||
os.path.splitext(os.path.basename(save_path))[0] + preview_ext,
|
||||
)
|
||||
success, _ = await downloader.download_file(
|
||||
rewritten_url, preview_path, use_auth=False
|
||||
)
|
||||
@@ -2557,8 +2705,9 @@ class DownloadManager:
|
||||
temp_file_handle.write(
|
||||
content if isinstance(content, bytes) else content.encode("utf-8")
|
||||
)
|
||||
preview_path = (
|
||||
os.path.splitext(save_path)[0] + ".webp"
|
||||
preview_path = os.path.join(
|
||||
get_preview_dir(save_path),
|
||||
os.path.splitext(os.path.basename(save_path))[0] + ".webp",
|
||||
)
|
||||
|
||||
optimized_data, _ = ExifUtils.optimize_image(
|
||||
@@ -2788,9 +2937,7 @@ class DownloadManager:
|
||||
entry = cast(Any, adjusted_entry)
|
||||
metadata_entries[index] = entry
|
||||
|
||||
metadata_file_path = (
|
||||
os.path.splitext(entry.file_path)[0] + ".metadata.json"
|
||||
)
|
||||
metadata_file_path = get_metadata_path(entry.file_path)
|
||||
metadata_files_for_cleanup.append(metadata_file_path)
|
||||
|
||||
await MetadataManager.save_metadata(entry.file_path, entry)
|
||||
@@ -2820,6 +2967,10 @@ class DownloadManager:
|
||||
|
||||
return {"success": True}
|
||||
|
||||
except RateLimitError:
|
||||
# Structured 429 propagation must reach _download_with_semaphore
|
||||
# unmodified so the queue row is re-queued instead of failed.
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error in _execute_download: {e}", exc_info=True)
|
||||
cleanup_targets = {
|
||||
@@ -3049,7 +3200,11 @@ class DownloadManager:
|
||||
extension = os.path.splitext(preview_path)[1] or ".webp"
|
||||
|
||||
targets = [
|
||||
os.path.splitext(entry.file_path)[0] + extension for entry in entries
|
||||
os.path.join(
|
||||
get_preview_dir(entry.file_path),
|
||||
os.path.splitext(os.path.basename(entry.file_path))[0] + extension,
|
||||
)
|
||||
for entry in entries
|
||||
]
|
||||
|
||||
if not targets:
|
||||
@@ -3057,10 +3212,12 @@ class DownloadManager:
|
||||
|
||||
first_target = targets[0]
|
||||
if preview_path != first_target:
|
||||
os.makedirs(os.path.dirname(first_target), exist_ok=True)
|
||||
os.replace(preview_path, first_target)
|
||||
source_path = first_target
|
||||
|
||||
for target in targets[1:]:
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
shutil.copyfile(source_path, target)
|
||||
|
||||
return targets
|
||||
|
||||
@@ -13,6 +13,7 @@ import logging
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from ..utils.constants import (
|
||||
CHECKPOINT_BASE_MODELS,
|
||||
CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE,
|
||||
CIVITAI_TYPE_TO_OTHER_SUB_TYPE,
|
||||
DIFFUSION_MODEL_BASE_MODELS,
|
||||
@@ -24,17 +25,27 @@ logger = logging.getLogger(__name__)
|
||||
# model (loaded via UNETLoader in ComfyUI) rather than a full checkpoint.
|
||||
DIFFUSION_FILE_TYPES = frozenset({"UNet", "Diffusion Model"})
|
||||
|
||||
# Allowed values for the "unknown_base_model_routing" setting / the
|
||||
# unknown_base_model_default parameter below.
|
||||
ROUTING_DIFFUSION_MODEL = "diffusion_model"
|
||||
ROUTING_CHECKPOINT = "checkpoint"
|
||||
|
||||
|
||||
def is_diffusion_model_download(
|
||||
model_type: str,
|
||||
file_types: Iterable[str] = (),
|
||||
base_model: str = "",
|
||||
unknown_base_model_default: str = ROUTING_DIFFUSION_MODEL,
|
||||
) -> bool:
|
||||
"""Return True when a download should be routed to the unet roots.
|
||||
|
||||
Only applies to downloads initiated from the checkpoint library.
|
||||
Priority: (1) any file has type "UNet" or "Diffusion Model" (the more
|
||||
direct signal from CivitAI), (2) baseModel is a known diffusion model.
|
||||
direct signal from CivitAI), (2) baseModel is a known diffusion model,
|
||||
(3) baseModel is a known full checkpoint -> not diffusion, (4) unknown
|
||||
or empty baseModel -> the ``unknown_base_model_default`` setting, which
|
||||
defaults to diffusion because the set of true checkpoint families is
|
||||
closed while new DiT base models appear all the time.
|
||||
"""
|
||||
if model_type != "checkpoint":
|
||||
return False
|
||||
@@ -54,7 +65,16 @@ def is_diffusion_model_download(
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
if base_model in CHECKPOINT_BASE_MODELS:
|
||||
return False
|
||||
|
||||
is_diffusion = unknown_base_model_default != ROUTING_CHECKPOINT
|
||||
logger.info(
|
||||
"baseModel '%s' is unknown, routing to %s folder (unknown_base_model_routing)",
|
||||
base_model,
|
||||
"unet" if is_diffusion else "checkpoint",
|
||||
)
|
||||
return is_diffusion
|
||||
|
||||
|
||||
def resolve_other_download_sub_type(
|
||||
|
||||
@@ -31,7 +31,7 @@ from .connectivity_guard import (
|
||||
OFFLINE_FRIENDLY_MESSAGE,
|
||||
ConnectivityGuard,
|
||||
)
|
||||
from .errors import RateLimitError
|
||||
from .errors import DownloadRateLimitError, RateLimitError
|
||||
from .rate_limit_coordinator import RateLimitCoordinator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -434,6 +434,7 @@ class Downloader:
|
||||
custom_headers: Optional[Dict[str, str]] = None,
|
||||
allow_resume: bool = True,
|
||||
pause_event: Optional[DownloadStreamControl] = None,
|
||||
raise_on_rate_limit: bool = False,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Download a file with resumable downloads and retry mechanism
|
||||
@@ -446,6 +447,11 @@ class Downloader:
|
||||
custom_headers: Additional headers to include in request
|
||||
allow_resume: Whether to support resumable downloads
|
||||
pause_event: Optional stream control used to pause/resume and request reconnects
|
||||
raise_on_rate_limit: When True, a 429 response raises
|
||||
``DownloadRateLimitError`` instead of returning a plain error
|
||||
string, so callers (the model download manager) can build the
|
||||
structured rate-limit result required by the download queue
|
||||
contract. Defaults to the legacy tuple behavior.
|
||||
|
||||
Returns:
|
||||
Tuple[bool, str]: (success, save_path or error message)
|
||||
@@ -610,6 +616,12 @@ class Downloader:
|
||||
logger.warning(
|
||||
f"Rate limited (429) for {url}, retry_after={retry_after}"
|
||||
)
|
||||
if raise_on_rate_limit:
|
||||
raise DownloadRateLimitError(
|
||||
f"Download rate limited (429), retry after {retry_after}s",
|
||||
retry_after=retry_after,
|
||||
host=self._guard_destination(url),
|
||||
)
|
||||
return False, f"Download rate limited (429), retry after {retry_after}s"
|
||||
else:
|
||||
logger.error(
|
||||
@@ -902,6 +914,11 @@ class Downloader:
|
||||
f"Network error after {self.max_retries + 1} attempts: {str(e)}",
|
||||
)
|
||||
|
||||
except DownloadRateLimitError:
|
||||
# 429s are never retried in-band; the structured error must
|
||||
# reach the caller (download manager) unmodified.
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected download error: {e}")
|
||||
return False, str(e)
|
||||
@@ -931,6 +948,7 @@ class Downloader:
|
||||
use_auth: bool = False,
|
||||
custom_headers: Optional[Dict[str, str]] = None,
|
||||
return_headers: bool = False,
|
||||
raise_on_rate_limit: bool = False,
|
||||
) -> Tuple[bool, Union[bytes, str], Optional[Dict[str, Any]]]:
|
||||
"""
|
||||
Download a file to memory (for small files like preview images)
|
||||
@@ -940,6 +958,10 @@ class Downloader:
|
||||
use_auth: Whether to include authentication headers
|
||||
custom_headers: Additional headers to include in request
|
||||
return_headers: Whether to return response headers along with content
|
||||
raise_on_rate_limit: When True, a 429 response raises
|
||||
``DownloadRateLimitError`` instead of returning a plain error
|
||||
string (see ``download_file``). Defaults to the legacy tuple
|
||||
behavior.
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Union[bytes, str], Optional[Dict]]: (success, content or error message, response headers if requested)
|
||||
@@ -1002,11 +1024,21 @@ class Downloader:
|
||||
"Rate limited (429) for %s, no Retry-After header; defaulting to %ss",
|
||||
url, retry_after,
|
||||
)
|
||||
if raise_on_rate_limit:
|
||||
raise DownloadRateLimitError(
|
||||
f"Rate limited (429), retry after {retry_after}s",
|
||||
retry_after=retry_after,
|
||||
host=destination,
|
||||
)
|
||||
return False, f"Rate limited (429), retry after {retry_after}s", None
|
||||
else:
|
||||
error_msg = f"Download failed with status {response.status}"
|
||||
return False, error_msg, None
|
||||
|
||||
except DownloadRateLimitError:
|
||||
# Structured rate-limit errors must reach the caller unmodified.
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
if guard.is_network_unreachable_error(e):
|
||||
guard.register_network_failure(e, destination)
|
||||
|
||||
@@ -20,6 +20,25 @@ class RateLimitError(RuntimeError):
|
||||
self.provider = provider
|
||||
|
||||
|
||||
class DownloadRateLimitError(RateLimitError):
|
||||
"""Raised when a file download is rejected with HTTP 429.
|
||||
|
||||
Carries the vendor's ``Retry-After`` hint (when present) and the target
|
||||
host so the download manager can build the structured rate-limit result
|
||||
the download queue contract expects.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
retry_after: Optional[float] = None,
|
||||
host: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(message, retry_after=retry_after)
|
||||
self.host = host
|
||||
|
||||
|
||||
class ResourceNotFoundError(RuntimeError):
|
||||
"""Raised when a remote resource is permanently missing."""
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from .model_metadata_provider import (
|
||||
SQLiteModelMetadataProvider,
|
||||
CivitaiModelMetadataProvider,
|
||||
CivArchiveModelMetadataProvider,
|
||||
OpenModelDBModelMetadataProvider,
|
||||
FallbackMetadataProvider,
|
||||
RateLimitRetryingProvider,
|
||||
)
|
||||
@@ -22,12 +23,18 @@ logger = logging.getLogger(__name__)
|
||||
_PROVIDER_DISPLAY_NAMES = {
|
||||
"civitai_api": "CivitAI",
|
||||
"civarchive_api": "CivArchive",
|
||||
"openmodeldb_api": "OpenModelDB",
|
||||
"sqlite": "Archive DB",
|
||||
}
|
||||
|
||||
# Preset fallback chains. civitai_api is always first (richest metadata).
|
||||
# openmodeldb_api sits right after it: its lookups are local index hits over a
|
||||
# cached bulk dump (no rate-limit budget spent), and it covers upscalers that
|
||||
# CivArchive only has when they once existed on CivitAI. Providers that are not
|
||||
# registered (disabled/unavailable) are skipped, so presets degrade gracefully.
|
||||
_PRESET_PROVIDER_ORDERS = {
|
||||
"civitai_archive_sqlite": ["civitai_api", "civarchive_api", "sqlite"],
|
||||
"civitai_sqlite_archive": ["civitai_api", "sqlite", "civarchive_api"],
|
||||
"civitai_archive_sqlite": ["civitai_api", "openmodeldb_api", "civarchive_api", "sqlite"],
|
||||
"civitai_sqlite_archive": ["civitai_api", "openmodeldb_api", "sqlite", "civarchive_api"],
|
||||
}
|
||||
|
||||
async def initialize_metadata_providers():
|
||||
@@ -42,6 +49,7 @@ async def initialize_metadata_providers():
|
||||
settings_manager = get_settings_manager()
|
||||
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
|
||||
enable_civarchive_api = settings_manager.get('enable_civarchive_api', True)
|
||||
enable_openmodeldb_api = settings_manager.get('enable_openmodeldb_api', True)
|
||||
provider_order = settings_manager.get('metadata_provider_order', 'civitai_archive_sqlite')
|
||||
|
||||
providers = []
|
||||
@@ -92,6 +100,22 @@ async def initialize_metadata_providers():
|
||||
else:
|
||||
logger.debug("CivArchive metadata provider disabled by setting 'enable_civarchive_api'")
|
||||
|
||||
# Register the OpenModelDB provider when enabled. It only covers upscaler
|
||||
# models (hash-matched against its catalogue dump), so it complements
|
||||
# rather than replaces the CivitAI-family providers; disabling it avoids
|
||||
# the one-time bulk dump download entirely.
|
||||
if enable_openmodeldb_api:
|
||||
try:
|
||||
openmodeldb_client = await ServiceRegistry.get_openmodeldb_client()
|
||||
openmodeldb_provider = OpenModelDBModelMetadataProvider(openmodeldb_client)
|
||||
provider_manager.register_provider('openmodeldb_api', openmodeldb_provider)
|
||||
providers.append(('openmodeldb_api', openmodeldb_provider))
|
||||
logger.debug("OpenModelDB metadata provider registered (also included in fallback)")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize OpenModelDB metadata provider: {e}")
|
||||
else:
|
||||
logger.debug("OpenModelDB metadata provider disabled by setting 'enable_openmodeldb_api'")
|
||||
|
||||
# Preset fallback orderings (see module-level _PRESET_PROVIDER_ORDERS).
|
||||
# civitai_api is always first (better metadata); the remaining providers
|
||||
# are arranged by the configured preset. Providers that are not
|
||||
@@ -135,6 +159,7 @@ async def update_metadata_providers():
|
||||
settings_manager = get_settings_manager()
|
||||
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
|
||||
enable_civarchive_api = settings_manager.get('enable_civarchive_api', True)
|
||||
enable_openmodeldb_api = settings_manager.get('enable_openmodeldb_api', True)
|
||||
provider_order = settings_manager.get('metadata_provider_order', 'civitai_archive_sqlite')
|
||||
|
||||
# Reinitialize all providers with new settings
|
||||
@@ -153,9 +178,10 @@ async def update_metadata_providers():
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Updated metadata providers: archive_db=%s, civarchive_api=%s, chain=%s",
|
||||
"Updated metadata providers: archive_db=%s, civarchive_api=%s, openmodeldb_api=%s, chain=%s",
|
||||
enable_archive_db,
|
||||
enable_civarchive_api,
|
||||
enable_openmodeldb_api,
|
||||
chain,
|
||||
)
|
||||
return provider_manager
|
||||
|
||||
@@ -12,13 +12,51 @@ from ..services.settings_manager import SettingsManager
|
||||
from ..utils.civitai_utils import resolve_license_payload
|
||||
from ..utils.model_utils import determine_base_model
|
||||
from ..utils.models import autov3_from_civitai_files
|
||||
from ..utils.sidecar_paths import get_metadata_path
|
||||
from .connectivity_guard import OFFLINE_FRIENDLY_MESSAGE, is_expected_offline_error
|
||||
from .errors import RateLimitError
|
||||
from .model_sources import has_external_source
|
||||
from .model_metadata_provider import _LOCAL_PROVIDER_LABELS
|
||||
from .model_sources import get_source_platform, has_external_source
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Providers restricted to specific model sub_types, keyed by their
|
||||
# registration label. Providers not listed apply to every model type.
|
||||
# OpenModelDB only indexes upscalers, so it is never consulted for other model
|
||||
# types — that keeps its one-time bulk-catalogue download from being paid by
|
||||
# users who manage no upscalers at all.
|
||||
_PROVIDER_SUB_TYPE_RESTRICTIONS: Dict[str, frozenset] = {
|
||||
"openmodeldb_api": frozenset({"upscaler"}),
|
||||
}
|
||||
|
||||
#: External-source platforms that have their own hash-lookup metadata
|
||||
#: provider. A model downloaded from one of these is refreshed against the
|
||||
#: source's own catalogue first (upgrading the download-time card to the full
|
||||
#: payload) before CivitAI is consulted at all.
|
||||
_EXTERNAL_SOURCE_METADATA_PROVIDERS: Dict[str, str] = {
|
||||
"openmodeldb": "openmodeldb_api",
|
||||
}
|
||||
|
||||
|
||||
def _restricted_providers_for_sub_type(sub_type: Optional[str]) -> list:
|
||||
"""Return the restricted provider labels that apply to ``sub_type``."""
|
||||
return [
|
||||
name
|
||||
for name, allowed in _PROVIDER_SUB_TYPE_RESTRICTIONS.items()
|
||||
if sub_type in allowed
|
||||
]
|
||||
|
||||
|
||||
def _inapplicable_providers_for_sub_type(sub_type: Optional[str]) -> set:
|
||||
"""Return the restricted provider labels that do NOT apply to ``sub_type``."""
|
||||
return {
|
||||
name
|
||||
for name, allowed in _PROVIDER_SUB_TYPE_RESTRICTIONS.items()
|
||||
if sub_type not in allowed
|
||||
}
|
||||
|
||||
|
||||
def _merge_ordered_unique(existing: Iterable[str], new: Iterable[str]) -> list[str]:
|
||||
"""Concatenate two word lists, dropping duplicates without reordering.
|
||||
|
||||
@@ -216,7 +254,7 @@ class MetadataSyncService:
|
||||
logger.error(error)
|
||||
return False, error
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
enable_archive = self._settings.get("enable_metadata_archive_db", False)
|
||||
previous_source = model_data.get("metadata_source") or (model_data.get("civitai") or {}).get("source")
|
||||
|
||||
@@ -225,6 +263,18 @@ class MetadataSyncService:
|
||||
sqlite_attempted = False
|
||||
|
||||
if model_data.get("civitai_deleted") is True:
|
||||
# Sub_type-restricted providers (e.g. OpenModelDB for
|
||||
# upscalers) stay reachable for deleted models: their
|
||||
# catalogues grow independently of CivitAI, so a model deleted
|
||||
# from CivitAI may still gain metadata there later.
|
||||
for restricted_name in _restricted_providers_for_sub_type(
|
||||
model_data.get("sub_type")
|
||||
):
|
||||
try:
|
||||
provider_attempts.append((restricted_name, await self._get_provider(restricted_name)))
|
||||
except Exception as exc: # pragma: no cover - provider resolution fault
|
||||
logger.debug("Unable to resolve %s provider: %s", restricted_name, exc)
|
||||
|
||||
if previous_source in (None, "civarchive"):
|
||||
try:
|
||||
provider_attempts.append(("civarchive_api", await self._get_provider("civarchive_api")))
|
||||
@@ -249,19 +299,44 @@ class MetadataSyncService:
|
||||
is_hf_source = has_external_source(model_data)
|
||||
if is_hf_source:
|
||||
# External-source model (Hugging Face / ModelScope /
|
||||
# TensorArt): only check CivitAI API directly.
|
||||
# CivArchive is almost guaranteed to have no record, and
|
||||
# hitting it wastes rate-limit budget.
|
||||
# TensorArt / OpenModelDB): a source with its own
|
||||
# hash-lookup provider (OpenModelDB) is consulted first,
|
||||
# then CivitAI API directly. CivArchive is almost
|
||||
# guaranteed to have no record, and hitting it wastes
|
||||
# rate-limit budget.
|
||||
# Use a distinct provider name ("civitai_api" not None) so
|
||||
# downstream code does NOT interpret a "Model not found"
|
||||
# response as civitai_api_not_found — which would mark the
|
||||
# model civitai_deleted=True when it was never on CivitAI.
|
||||
try:
|
||||
provider_attempts.append(("civitai_api", await self._get_provider("civitai_api")))
|
||||
except Exception as exc: # pragma: no cover - provider resolution fault
|
||||
logger.debug("Unable to resolve civitai_api provider: %s", exc)
|
||||
source_provider = _EXTERNAL_SOURCE_METADATA_PROVIDERS.get(
|
||||
get_source_platform(model_data)
|
||||
)
|
||||
provider_names = (
|
||||
[source_provider, "civitai_api"]
|
||||
if source_provider
|
||||
else ["civitai_api"]
|
||||
)
|
||||
for provider_name in provider_names:
|
||||
try:
|
||||
provider_attempts.append(
|
||||
(provider_name, await self._get_provider(provider_name))
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - provider resolution fault
|
||||
logger.debug(
|
||||
"Unable to resolve %s provider: %s", provider_name, exc
|
||||
)
|
||||
if not provider_attempts:
|
||||
provider_attempts.append((None, await self._get_default_provider()))
|
||||
default_provider = await self._get_default_provider()
|
||||
# Drop sub_type-restricted providers that cannot apply to
|
||||
# this model (e.g. OpenModelDB only indexes upscalers), so
|
||||
# their cold-start cost is never paid pointlessly.
|
||||
inapplicable = _inapplicable_providers_for_sub_type(
|
||||
model_data.get("sub_type")
|
||||
)
|
||||
excluding = getattr(default_provider, "excluding", None)
|
||||
if inapplicable and callable(excluding):
|
||||
default_provider = excluding(inapplicable)
|
||||
provider_attempts.append((None, default_provider))
|
||||
|
||||
civitai_metadata: Optional[Dict[str, Any]] = None
|
||||
metadata_provider: Optional[MetadataProviderProtocol] = None
|
||||
@@ -272,10 +347,11 @@ class MetadataSyncService:
|
||||
|
||||
skip_network_providers = False
|
||||
for provider_name, provider in provider_attempts:
|
||||
if skip_network_providers and provider_name != "sqlite":
|
||||
if skip_network_providers and provider_name not in _LOCAL_PROVIDER_LABELS:
|
||||
# A network provider was already rate-limited; failing
|
||||
# over to another network provider just spreads the flood
|
||||
# (#1085). The local sqlite archive stays as last resort.
|
||||
# (#1085). Local lookups (sqlite archive, the cached
|
||||
# OpenModelDB index) stay available as a last resort.
|
||||
continue
|
||||
try:
|
||||
civitai_metadata_candidate, error = await provider.get_model_by_hash(sha256)
|
||||
@@ -385,6 +461,7 @@ class MetadataSyncService:
|
||||
readable_source = {
|
||||
"civitai_api": "CivitAI API",
|
||||
"civarchive": "CivArchive API",
|
||||
"openmodeldb": "OpenModelDB",
|
||||
"archive_db": "Archive Database",
|
||||
}.get(source, source)
|
||||
|
||||
@@ -485,7 +562,7 @@ class MetadataSyncService:
|
||||
+ (f" with version: {model_version_id}" if model_version_id else "")
|
||||
)
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
await self.update_model_metadata(
|
||||
metadata_path,
|
||||
metadata,
|
||||
@@ -505,7 +582,7 @@ class MetadataSyncService:
|
||||
) -> Dict[str, Any]:
|
||||
"""Apply metadata updates and persist to disk and cache."""
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
metadata = await metadata_loader(metadata_path)
|
||||
|
||||
for key, value in updates.items():
|
||||
@@ -554,7 +631,7 @@ class MetadataSyncService:
|
||||
}
|
||||
|
||||
expected_hash: Optional[str] = None
|
||||
first_metadata_path = os.path.splitext(file_paths[0])[0] + ".metadata.json"
|
||||
first_metadata_path = get_metadata_path(file_paths[0])
|
||||
first_metadata = await metadata_loader(first_metadata_path)
|
||||
if first_metadata and "sha256" in first_metadata:
|
||||
expected_hash = first_metadata["sha256"].lower()
|
||||
@@ -565,7 +642,7 @@ class MetadataSyncService:
|
||||
|
||||
try:
|
||||
actual_hash = await hash_calculator(path)
|
||||
metadata_path = os.path.splitext(path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(path)
|
||||
metadata = await metadata_loader(metadata_path)
|
||||
stored_hash = metadata.get("sha256", "").lower()
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from abc import ABC, abstractmethod
|
||||
|
||||
from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs
|
||||
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE, MODEL_FILE_EXTENSIONS
|
||||
from ..utils.sidecar_paths import is_centralized, resolve_centralized_dir_for_dir
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from ..services.model_lifecycle_service import _require_path_in_library_roots
|
||||
from ..services.pending_delete_service import PENDING_DELETE_DIR_NAME
|
||||
@@ -15,6 +16,16 @@ from ..services.pending_delete_service import PENDING_DELETE_DIR_NAME
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_match_path(path: Any) -> str:
|
||||
"""Normalize a path for set membership tests.
|
||||
|
||||
Business paths only — symlinks are never resolved here, matching how the
|
||||
scanner stores ``excluded_models``. The forward-slash form keeps Windows
|
||||
comparisons working with the scanner's normalized entries.
|
||||
"""
|
||||
return os.path.normpath(os.path.abspath(str(path))).replace(os.sep, "/")
|
||||
|
||||
|
||||
class ProgressCallback(ABC):
|
||||
"""Abstract callback interface for progress reporting"""
|
||||
|
||||
@@ -567,8 +578,9 @@ class ModelMoveService:
|
||||
|
||||
Returns:
|
||||
Dictionary with the success flag plus a removal manifest
|
||||
(``model_count``/``file_count``/``dir_count``/``symlink_count``/
|
||||
``total_bytes``/``restorable``) on success.
|
||||
(``model_count``/``excluded_model_count``/``file_count``/
|
||||
``dir_count``/``symlink_count``/``total_bytes``/``restorable``)
|
||||
on success.
|
||||
"""
|
||||
try:
|
||||
if not folder_path or not str(folder_path).strip():
|
||||
@@ -608,13 +620,34 @@ class ModelMoveService:
|
||||
}
|
||||
|
||||
if manifest["model_count"] > 0:
|
||||
model_count = manifest["model_count"]
|
||||
excluded_count = manifest["excluded_model_count"]
|
||||
# Excluded models are hidden from the library lists but are
|
||||
# still real weight files, so they block the cascade just like
|
||||
# any other model. Naming them is what makes the refusal
|
||||
# actionable: the folder looks empty in the sidebar precisely
|
||||
# because everything in it is excluded.
|
||||
if excluded_count == model_count:
|
||||
error = (
|
||||
f"Folder still contains {model_count} model file(s), "
|
||||
"all excluded from the library; un-exclude or delete "
|
||||
"them first"
|
||||
)
|
||||
elif excluded_count:
|
||||
error = (
|
||||
f"Folder still contains {model_count} model file(s), "
|
||||
f"{excluded_count} of them excluded from the library; "
|
||||
"delete or move them first"
|
||||
)
|
||||
else:
|
||||
error = (
|
||||
f"Folder still contains {model_count} model "
|
||||
"file(s); delete or move them first"
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"code": "not_empty",
|
||||
"error": (
|
||||
f"Folder still contains {manifest['model_count']} model "
|
||||
"file(s); delete or move them first"
|
||||
),
|
||||
"error": error,
|
||||
"manifest": manifest,
|
||||
}
|
||||
|
||||
@@ -631,6 +664,21 @@ class ModelMoveService:
|
||||
|
||||
shutil.rmtree(absolute_path)
|
||||
|
||||
# Centralized mode: prune the folder's mirror subtree when it no
|
||||
# longer holds any sidecar files (per-model deletes already
|
||||
# removed their sidecars, so only empty directories are expected;
|
||||
# a non-empty mirror keeps its orphan sidecars).
|
||||
if is_centralized():
|
||||
mirror_dir = resolve_centralized_dir_for_dir(absolute_path)
|
||||
if mirror_dir and os.path.isdir(mirror_dir):
|
||||
for root, _dirs, files in os.walk(mirror_dir, topdown=False):
|
||||
if files:
|
||||
continue
|
||||
try:
|
||||
os.rmdir(root)
|
||||
except OSError: # pragma: no cover - best-effort cleanup
|
||||
pass
|
||||
|
||||
await self._forget_folder(relative_folder)
|
||||
|
||||
return {
|
||||
@@ -667,13 +715,20 @@ class ModelMoveService:
|
||||
Symbolic links are never followed (``os.walk`` default) and are counted
|
||||
separately — ``shutil.rmtree`` unlinks them without touching their
|
||||
targets.
|
||||
|
||||
``excluded_model_count`` splits the subset of ``model_count`` that the
|
||||
library hides behind the ``exclude`` flag: those files still block the
|
||||
delete, yet they are invisible to the model lists (and therefore to the
|
||||
folder tree, which derives "empty" from them).
|
||||
"""
|
||||
model_count = 0
|
||||
excluded_model_count = 0
|
||||
file_count = 0
|
||||
dir_count = 0
|
||||
symlink_count = 0
|
||||
total_bytes = 0
|
||||
pending_delete_job = False
|
||||
excluded_paths = self._excluded_model_paths()
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(absolute_path):
|
||||
if PENDING_DELETE_DIR_NAME in dirnames:
|
||||
@@ -692,6 +747,8 @@ class ModelMoveService:
|
||||
continue
|
||||
if self._is_model_file(name):
|
||||
model_count += 1
|
||||
if _normalize_match_path(full_path) in excluded_paths:
|
||||
excluded_model_count += 1
|
||||
else:
|
||||
file_count += 1
|
||||
try:
|
||||
@@ -701,6 +758,7 @@ class ModelMoveService:
|
||||
|
||||
return {
|
||||
"model_count": model_count,
|
||||
"excluded_model_count": excluded_model_count,
|
||||
"file_count": file_count,
|
||||
"dir_count": dir_count,
|
||||
"symlink_count": symlink_count,
|
||||
@@ -716,6 +774,21 @@ class ModelMoveService:
|
||||
),
|
||||
}
|
||||
|
||||
def _excluded_model_paths(self) -> Set[str]:
|
||||
"""Absolute paths of the models the library hides behind ``exclude``.
|
||||
|
||||
Best-effort: scanner stand-ins that do not expose the accessor simply
|
||||
report no excluded models.
|
||||
"""
|
||||
get_excluded = getattr(self.scanner, "get_excluded_models", None)
|
||||
if not callable(get_excluded):
|
||||
return set()
|
||||
try:
|
||||
paths = get_excluded() or []
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return set()
|
||||
return {_normalize_match_path(path) for path in paths if path}
|
||||
|
||||
async def _forget_folder(self, relative_folder: str) -> None:
|
||||
"""Drop a removed directory from the scanner's folder/cache records."""
|
||||
if not relative_folder:
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional, TYPE_CHECKING, cast
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, AsyncIterator, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional, TYPE_CHECKING, cast
|
||||
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..services.pending_delete_service import get_pending_delete_service
|
||||
from ..utils.constants import PREVIEW_EXTENSIONS
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.sidecar_paths import get_metadata_path, get_preview_dir, get_sidecar_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,16 +44,22 @@ async def load_local_metadata(metadata_path: str) -> Dict[str, Any]:
|
||||
async def delete_model_artifacts(
|
||||
target_dir: str, file_name: str, main_extension: str | None = None
|
||||
) -> List[str]:
|
||||
"""Delete the primary model artefacts within ``target_dir``."""
|
||||
"""Delete the primary model artefacts within ``target_dir``.
|
||||
|
||||
Sidecars and previews are taken from the model's sidecar directory — the
|
||||
model's own directory in alongside mode, the centralized mirror otherwise.
|
||||
"""
|
||||
|
||||
main_extension = ".safetensors" if main_extension is None else main_extension
|
||||
main_file = f"{file_name}{main_extension}" if main_extension else file_name
|
||||
patterns = [main_file, f"{file_name}.metadata.json"]
|
||||
model_path = os.path.join(target_dir, main_file)
|
||||
sidecar_dir = get_sidecar_dir(model_path)
|
||||
patterns = [os.path.basename(get_metadata_path(model_path))]
|
||||
for ext in PREVIEW_EXTENSIONS:
|
||||
patterns.append(f"{file_name}{ext}")
|
||||
|
||||
deleted: List[str] = []
|
||||
main_path = os.path.join(target_dir, main_file).replace(os.sep, "/")
|
||||
main_path = model_path.replace(os.sep, "/")
|
||||
|
||||
if os.path.exists(main_path):
|
||||
os.remove(main_path)
|
||||
@@ -58,8 +67,8 @@ async def delete_model_artifacts(
|
||||
else:
|
||||
logger.warning("Model file not found: %s", main_file)
|
||||
|
||||
for pattern in patterns[1:]:
|
||||
path = os.path.join(target_dir, pattern)
|
||||
for pattern in patterns:
|
||||
path = os.path.join(sidecar_dir, pattern)
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
@@ -100,6 +109,36 @@ def _require_path_in_library_roots(file_path: str, scanner, *, label: str = "pat
|
||||
)
|
||||
|
||||
|
||||
class BulkRenameContext:
|
||||
"""Per-session state threaded through ``rename_model`` calls of a bulk rename.
|
||||
|
||||
Holds the lazily built recipe hash index so a bulk rename loop pays the
|
||||
O(recipes) index build at most once (on the first recipe-touching rename)
|
||||
instead of rescanning every recipe per renamed file. Also tracks whether
|
||||
any recipe was re-pointed so the session finalizes recipe maintenance only
|
||||
when needed.
|
||||
"""
|
||||
|
||||
def __init__(self, recipe_scanner: Any) -> None:
|
||||
self._recipe_scanner = recipe_scanner
|
||||
self._recipe_hash_index: Optional[Dict[str, List[Dict[str, Any]]]] = None
|
||||
self.recipes_touched = False
|
||||
|
||||
@property
|
||||
def recipe_scanner(self) -> Any:
|
||||
return self._recipe_scanner
|
||||
|
||||
async def get_recipe_hash_index(self) -> Optional[Dict[str, List[Dict[str, Any]]]]:
|
||||
"""Return the lora-hash → recipes index, building it on first use."""
|
||||
if self._recipe_scanner is None:
|
||||
return None
|
||||
if self._recipe_hash_index is None:
|
||||
self._recipe_hash_index = (
|
||||
await self._recipe_scanner.build_lora_hash_index()
|
||||
)
|
||||
return self._recipe_hash_index
|
||||
|
||||
|
||||
class ModelLifecycleService:
|
||||
"""Co-ordinate destructive and mutating model operations."""
|
||||
|
||||
@@ -260,7 +299,7 @@ class ModelLifecycleService:
|
||||
|
||||
_require_path_in_library_roots(file_path, self._scanner, label="File path")
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
metadata = await self._metadata_loader(metadata_path)
|
||||
metadata["exclude"] = True
|
||||
|
||||
@@ -315,7 +354,7 @@ class ModelLifecycleService:
|
||||
if not os.path.exists(file_path):
|
||||
raise ValueError("Model file does not exist")
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
metadata_payload = await self._metadata_loader(metadata_path)
|
||||
metadata_payload["exclude"] = False
|
||||
|
||||
@@ -358,10 +397,45 @@ class ModelLifecycleService:
|
||||
|
||||
return await self._scanner.bulk_delete_models(file_paths)
|
||||
|
||||
@asynccontextmanager
|
||||
async def bulk_rename_session(self) -> AsyncIterator[BulkRenameContext]:
|
||||
"""Context for bulk rename loops (filename-template "Apply to Library").
|
||||
|
||||
While active, the per-file ``update_single_model_cache`` resort/persist
|
||||
chain and the per-file recipe folder-metadata refresh/resort are
|
||||
deferred; both run exactly once when the outermost session exits — see
|
||||
``ModelScanner.defer_cache_persist`` and
|
||||
``RecipeScanner.finalize_bulk_filename_updates``. The finalize steps run
|
||||
even on cancellation or mid-loop errors, because files are already
|
||||
renamed on disk and the caches must not be left diverging.
|
||||
|
||||
Yields a :class:`BulkRenameContext` to pass as ``bulk_context`` into
|
||||
each ``rename_model`` call of the loop.
|
||||
"""
|
||||
recipe_scanner = await self._recipe_scanner_factory()
|
||||
context = BulkRenameContext(recipe_scanner)
|
||||
async with self._scanner.defer_cache_persist():
|
||||
try:
|
||||
yield context
|
||||
finally:
|
||||
if recipe_scanner is not None and context.recipes_touched:
|
||||
try:
|
||||
await recipe_scanner.finalize_bulk_filename_updates()
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Error finalizing bulk recipe updates: %s", exc
|
||||
)
|
||||
|
||||
async def rename_model(
|
||||
self, *, file_path: str, new_file_name: str
|
||||
self, *, file_path: str, new_file_name: str, bulk_context: Optional[BulkRenameContext] = None
|
||||
) -> Dict[str, object]:
|
||||
"""Rename a model and its companion artefacts."""
|
||||
"""Rename a model and its companion artefacts.
|
||||
|
||||
When ``bulk_context`` is given (bulk rename loop), the recipe
|
||||
re-pointing uses the session's prebuilt hash index and defers recipe
|
||||
maintenance to the session finalize; the scanner cache persist is
|
||||
likewise deferred by the surrounding ``bulk_rename_session``.
|
||||
"""
|
||||
|
||||
if not file_path or not new_file_name:
|
||||
raise ValueError("File path and new file name are required")
|
||||
@@ -384,21 +458,26 @@ class ModelLifecycleService:
|
||||
if os.path.exists(new_file_path):
|
||||
raise ValueError("A file with this name already exists")
|
||||
|
||||
patterns = [
|
||||
f"{old_file_name}{old_extension}",
|
||||
f"{old_file_name}.metadata.json",
|
||||
f"{old_file_name}.metadata.json.bak",
|
||||
metadata_filename = os.path.basename(get_metadata_path(file_path))
|
||||
# Sidecars/previews live in the sidecar dir (the model's own dir in
|
||||
# alongside mode, the centralized mirror otherwise); the model file
|
||||
# itself always stays in target_dir.
|
||||
sidecar_dir = get_sidecar_dir(file_path)
|
||||
patterns: List[tuple[str, str]] = [
|
||||
(target_dir, f"{old_file_name}{old_extension}"),
|
||||
(sidecar_dir, metadata_filename),
|
||||
(sidecar_dir, f"{metadata_filename}.bak"),
|
||||
]
|
||||
for ext in PREVIEW_EXTENSIONS:
|
||||
patterns.append(f"{old_file_name}{ext}")
|
||||
patterns.append((sidecar_dir, f"{old_file_name}{ext}"))
|
||||
|
||||
existing_files: List[tuple[str, str]] = []
|
||||
for pattern in patterns:
|
||||
path = os.path.join(target_dir, pattern)
|
||||
for pattern_dir, pattern in patterns:
|
||||
path = os.path.join(pattern_dir, pattern)
|
||||
if os.path.exists(path):
|
||||
existing_files.append((path, pattern))
|
||||
|
||||
metadata_path = os.path.join(target_dir, f"{old_file_name}.metadata.json")
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
metadata: Optional[Dict[str, object]] = None
|
||||
hash_value: Optional[str] = None
|
||||
|
||||
@@ -407,20 +486,11 @@ class ModelLifecycleService:
|
||||
raw_hash = metadata.get("sha256") if isinstance(metadata, dict) else None
|
||||
hash_value = raw_hash if isinstance(raw_hash, str) else None
|
||||
|
||||
renamed_files: List[str] = []
|
||||
new_metadata_path: Optional[str] = None
|
||||
new_preview: Optional[str] = None
|
||||
|
||||
for old_path, pattern in existing_files:
|
||||
ext = self._get_multipart_ext(pattern)
|
||||
new_path = os.path.join(target_dir, f"{new_file_name}{ext}").replace(
|
||||
os.sep, "/"
|
||||
)
|
||||
os.rename(old_path, new_path)
|
||||
renamed_files.append(new_path)
|
||||
|
||||
if ext == ".metadata.json":
|
||||
new_metadata_path = new_path
|
||||
renamed_files, new_metadata_path = await asyncio.to_thread(
|
||||
self._rename_companion_files, existing_files, new_file_name
|
||||
)
|
||||
|
||||
if metadata and new_metadata_path:
|
||||
metadata["file_name"] = new_file_name
|
||||
@@ -432,9 +502,9 @@ class ModelLifecycleService:
|
||||
if metadata.get("preview_url"):
|
||||
old_preview = str(metadata["preview_url"])
|
||||
ext = self._get_multipart_ext(old_preview)
|
||||
new_preview = os.path.join(target_dir, f"{new_file_name}{ext}").replace(
|
||||
os.sep, "/"
|
||||
)
|
||||
new_preview = os.path.join(
|
||||
get_preview_dir(new_file_path), f"{new_file_name}{ext}"
|
||||
).replace(os.sep, "/")
|
||||
metadata["preview_url"] = new_preview
|
||||
|
||||
await self._metadata_manager.save_metadata(new_file_path, metadata)
|
||||
@@ -445,12 +515,26 @@ class ModelLifecycleService:
|
||||
)
|
||||
|
||||
if hash_value and getattr(self._scanner, "model_type", "") == "lora":
|
||||
recipe_scanner = await self._recipe_scanner_factory()
|
||||
if bulk_context is not None:
|
||||
recipe_scanner = bulk_context.recipe_scanner
|
||||
hash_index = await bulk_context.get_recipe_hash_index()
|
||||
defer_maintenance = True
|
||||
else:
|
||||
recipe_scanner = await self._recipe_scanner_factory()
|
||||
hash_index = None
|
||||
defer_maintenance = False
|
||||
if recipe_scanner:
|
||||
try:
|
||||
await recipe_scanner.update_lora_filename_by_hash(
|
||||
hash_value, new_file_name
|
||||
file_count, cache_count = (
|
||||
await recipe_scanner.update_lora_filename_by_hash(
|
||||
hash_value,
|
||||
new_file_name,
|
||||
hash_index=hash_index,
|
||||
defer_maintenance=defer_maintenance,
|
||||
)
|
||||
)
|
||||
if bulk_context is not None and (file_count or cache_count):
|
||||
bulk_context.recipes_touched = True
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Error updating recipe references for %s: %s",
|
||||
@@ -466,6 +550,34 @@ class ModelLifecycleService:
|
||||
"reload_required": False,
|
||||
}
|
||||
|
||||
def _rename_companion_files(
|
||||
self,
|
||||
existing_files: List[tuple[str, str]],
|
||||
new_file_name: str,
|
||||
) -> tuple[List[str], Optional[str]]:
|
||||
"""Rename all companion files, off the event loop thread.
|
||||
|
||||
Runs the blocking ``os.rename`` sequence for one model in a worker
|
||||
thread so a single file's HDD I/O does not stall the event loop.
|
||||
Never parallelized across files: one model's renames stay sequential
|
||||
and the helper holds no locks.
|
||||
"""
|
||||
renamed_files: List[str] = []
|
||||
new_metadata_path: Optional[str] = None
|
||||
|
||||
for old_path, pattern in existing_files:
|
||||
ext = self._get_multipart_ext(pattern)
|
||||
new_path = os.path.join(
|
||||
os.path.dirname(old_path), f"{new_file_name}{ext}"
|
||||
).replace(os.sep, "/")
|
||||
os.rename(old_path, new_path)
|
||||
renamed_files.append(new_path)
|
||||
|
||||
if ext == ".metadata.json":
|
||||
new_metadata_path = new_path
|
||||
|
||||
return renamed_files, new_metadata_path
|
||||
|
||||
@staticmethod
|
||||
def _get_multipart_ext(filename: str) -> str:
|
||||
"""Return the extension for files with compound suffixes."""
|
||||
|
||||
@@ -112,7 +112,10 @@ class _RateLimitRetryHelper:
|
||||
|
||||
# Labels of providers that are free to consult even while a network provider
|
||||
# is rate-limited (local lookups, no vendor cost).
|
||||
_LOCAL_PROVIDER_LABELS = frozenset({"sqlite"})
|
||||
# "openmodeldb_api" qualifies because its lookups hit a local index built from
|
||||
# a cached bulk dump; the underlying site is a static host (GitHub Pages), so
|
||||
# even a cold cache refresh is a single cheap GET against a different vendor.
|
||||
_LOCAL_PROVIDER_LABELS = frozenset({"sqlite", "openmodeldb_api"})
|
||||
|
||||
|
||||
class ModelMetadataProvider(ABC):
|
||||
@@ -480,6 +483,36 @@ class SQLiteModelMetadataProvider(ModelMetadataProvider):
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
class OpenModelDBModelMetadataProvider(ModelMetadataProvider):
|
||||
"""Provider that serves upscaler metadata from the OpenModelDB catalogue.
|
||||
|
||||
Only hash lookups are supported: OpenModelDB has no per-model or version
|
||||
API, so the remaining provider surface intentionally returns None and lets
|
||||
the fallback chain continue to the next provider.
|
||||
"""
|
||||
|
||||
def __init__(self, openmodeldb_client):
|
||||
self.client = openmodeldb_client
|
||||
|
||||
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||
return await self.client.get_model_by_hash(model_hash)
|
||||
|
||||
async def get_model_versions(self, model_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Not supported: OpenModelDB models have no version history API."""
|
||||
return None
|
||||
|
||||
async def get_model_version(self, model_id: Optional[int] = None, version_id: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Not supported: OpenModelDB models have no version history API."""
|
||||
return None
|
||||
|
||||
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||
"""Not supported: OpenModelDB models have no version history API."""
|
||||
return None, "Model not found"
|
||||
|
||||
async def get_user_models(self, username: str, cursor: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Not supported by the OpenModelDB provider."""
|
||||
return None
|
||||
|
||||
class FallbackMetadataProvider(ModelMetadataProvider):
|
||||
"""Try providers in order, return first successful result.
|
||||
|
||||
@@ -750,6 +783,32 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
||||
def _iter_providers(self):
|
||||
return zip(self.providers, self._provider_labels)
|
||||
|
||||
def excluding(self, labels: "frozenset[str] | set[str]") -> "FallbackMetadataProvider":
|
||||
"""Return a copy of this chain without the providers named in *labels*.
|
||||
|
||||
Used by the metadata sync service to skip providers that cannot apply
|
||||
to a given model (e.g. OpenModelDB only indexes upscalers), so their
|
||||
cold-start cost (a bulk dump download) is never paid pointlessly.
|
||||
"""
|
||||
kept = [
|
||||
(label, provider)
|
||||
for provider, label in self._iter_providers()
|
||||
if label not in labels
|
||||
]
|
||||
if len(kept) == len(self.providers):
|
||||
return self
|
||||
if not kept:
|
||||
# Never produce an empty chain; the caller still needs a provider
|
||||
# that can at least report "Model not found".
|
||||
return self
|
||||
return FallbackMetadataProvider(
|
||||
kept,
|
||||
rate_limit_retry_limit=self._rate_limit_retry_limit,
|
||||
rate_limit_base_delay=self._rate_limit_base_delay,
|
||||
rate_limit_max_delay=self._rate_limit_max_delay,
|
||||
rate_limit_jitter_ratio=self._rate_limit_jitter_ratio,
|
||||
)
|
||||
|
||||
async def _call_with_rate_limit(self, label: str, func, *args, **kwargs):
|
||||
return await self._rate_limit_helper.run(label, func, *args, **kwargs)
|
||||
|
||||
|
||||
@@ -432,7 +432,6 @@ class SearchStrategy:
|
||||
"tags": False,
|
||||
"recursive": True,
|
||||
"creator": False,
|
||||
"hash": False,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
@@ -495,13 +494,14 @@ class SearchStrategy:
|
||||
results.append(item)
|
||||
continue
|
||||
|
||||
# Hash search is always exact (never fuzzy): match the full
|
||||
# sha256, its autov2 prefix (first 10 chars), or the autov3 hash.
|
||||
if options.get("hash", False):
|
||||
hash_query = search_lower.strip()
|
||||
if hash_query and self._matches_hash(item, hash_query):
|
||||
results.append(item)
|
||||
continue
|
||||
# Hash/id search is always exact (never fuzzy) and always on: it
|
||||
# matches the full sha256, its autov2 prefix (first 10 chars),
|
||||
# the autov3 hash, or the Civitai model/version ids. Exact-match
|
||||
# semantics mean it adds no noise to ordinary keyword searches.
|
||||
hash_query = search_lower.strip()
|
||||
if hash_query and self._matches_hash(item, hash_query):
|
||||
results.append(item)
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
@@ -515,6 +515,18 @@ class SearchStrategy:
|
||||
autov3 = item.get("autov3")
|
||||
if isinstance(autov3, str) and autov3 and hash_query == autov3.lower():
|
||||
return True
|
||||
civitai = item.get("civitai")
|
||||
if isinstance(civitai, dict):
|
||||
# A model card corresponds to one Civitai version: `modelId` is
|
||||
# the model id (may match several cards when the library holds
|
||||
# multiple versions), `id` is the version id (unique per card).
|
||||
for key in ("modelId", "model_id", "id"):
|
||||
value = civitai.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
value_str = str(value).strip()
|
||||
if value_str and value_str != "0" and hash_query == value_str:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _matches(
|
||||
|
||||
+384
-55
@@ -4,6 +4,7 @@ import logging
|
||||
import asyncio
|
||||
import time
|
||||
import shutil
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple, Type, Union, cast
|
||||
|
||||
@@ -11,6 +12,13 @@ from ..utils.models import BaseModelMetadata, autov3_from_civitai_files
|
||||
from ..config import config
|
||||
from ..utils.file_utils import find_preview_file, get_preview_extension, calculate_sha256, calculate_autov3
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.sidecar_paths import (
|
||||
get_metadata_path,
|
||||
get_preview_dir,
|
||||
get_sidecar_dir,
|
||||
is_centralized,
|
||||
resolve_centralized_dir_for_dir,
|
||||
)
|
||||
from ..utils.civitai_utils import resolve_license_info
|
||||
from .model_cache import ModelCache
|
||||
from .model_hash_index import ModelHashIndex
|
||||
@@ -154,6 +162,14 @@ class ModelScanner:
|
||||
self._persistent_cache = get_persistent_cache()
|
||||
self._name_display_mode = self._resolve_name_display_mode()
|
||||
self._cancel_requested = False # Flag for cancellation
|
||||
self._move_locks: Dict[str, asyncio.Lock] = {} # Per-source-file move locks
|
||||
# Bulk-operation deferral: while _defer_persist_depth > 0,
|
||||
# update_single_model_cache() skips the per-call resort/persist and
|
||||
# only marks _deferred_persist_pending; the exit of the outermost
|
||||
# defer_cache_persist() context finalizes once (see
|
||||
# _finalize_deferred_cache_persist).
|
||||
self._defer_persist_depth = 0
|
||||
self._deferred_persist_pending = False
|
||||
self._autov3_backfill_scheduled = False # One-time AutoV3 backfill trigger per process
|
||||
# Guard against concurrent all-folders backfill walks (cold fallback
|
||||
# for persisted snapshots that predate folder recording).
|
||||
@@ -765,11 +781,11 @@ class ModelScanner:
|
||||
except Exception as exc:
|
||||
logger.warning("AutoV3 backfill failed: %s", exc)
|
||||
|
||||
async def _save_persistent_cache(self, scan_result: CacheBuildResult) -> None:
|
||||
async def _save_persistent_cache(self, scan_result: CacheBuildResult, *, force: bool = False) -> None:
|
||||
if not scan_result or not getattr(self, '_persistent_cache', None):
|
||||
return
|
||||
|
||||
if self.is_cancelled():
|
||||
if self.is_cancelled() and not force:
|
||||
logger.info(
|
||||
f"{self.model_type.capitalize()} Scanner: Skipping _save_persistent_cache "
|
||||
"after cancellation"
|
||||
@@ -828,7 +844,7 @@ class ModelScanner:
|
||||
bucket.append(path)
|
||||
return snapshot
|
||||
|
||||
async def _persist_current_cache(self) -> None:
|
||||
async def _persist_current_cache(self, *, force: bool = False) -> None:
|
||||
if self._cache is None or not getattr(self, '_persistent_cache', None):
|
||||
return
|
||||
|
||||
@@ -843,7 +859,7 @@ class ModelScanner:
|
||||
else None
|
||||
),
|
||||
)
|
||||
await self._save_persistent_cache(snapshot)
|
||||
await self._save_persistent_cache(snapshot, force=force)
|
||||
await self._sync_download_history(snapshot.raw_data, source='scan')
|
||||
def _count_model_files(self) -> int:
|
||||
"""Count all model files with supported extensions in all roots
|
||||
@@ -1613,6 +1629,25 @@ class ModelScanner:
|
||||
old_abs_prefix = f"{str(previous_path).replace(chr(92), '/').rstrip('/')}/"
|
||||
new_abs_prefix = f"{str(new_path).replace(chr(92), '/').rstrip('/')}/"
|
||||
|
||||
# Centralized sidecar mode: sidecars/previews live in the mirror tree,
|
||||
# not under the renamed model directory, so the mirror subtree must
|
||||
# move too and mirror-prefixed preview URLs need their own rekey.
|
||||
old_mirror_dir: Optional[str] = None
|
||||
new_mirror_dir: Optional[str] = None
|
||||
if is_centralized():
|
||||
old_mirror_dir = resolve_centralized_dir_for_dir(str(previous_path))
|
||||
new_mirror_dir = resolve_centralized_dir_for_dir(str(new_path))
|
||||
old_mirror_prefix = (
|
||||
f"{old_mirror_dir.replace(chr(92), '/').rstrip('/')}/"
|
||||
if old_mirror_dir
|
||||
else ""
|
||||
)
|
||||
new_mirror_prefix = (
|
||||
f"{new_mirror_dir.replace(chr(92), '/').rstrip('/')}/"
|
||||
if new_mirror_dir
|
||||
else ""
|
||||
)
|
||||
|
||||
cache = self._cache
|
||||
if cache is None:
|
||||
return False
|
||||
@@ -1670,8 +1705,24 @@ class ModelScanner:
|
||||
item["preview_url"] = self._rekey_path(
|
||||
item["preview_url"], old_abs_prefix, new_abs_prefix
|
||||
)
|
||||
if old_mirror_prefix:
|
||||
item["preview_url"] = self._rekey_path(
|
||||
item["preview_url"], old_mirror_prefix, new_mirror_prefix
|
||||
)
|
||||
touched.append(item)
|
||||
|
||||
if old_mirror_dir and new_mirror_dir and os.path.isdir(old_mirror_dir):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(new_mirror_dir), exist_ok=True)
|
||||
shutil.move(old_mirror_dir, new_mirror_dir)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning(
|
||||
"Failed to move centralized sidecar mirror %s -> %s: %s",
|
||||
old_mirror_dir,
|
||||
new_mirror_dir,
|
||||
exc,
|
||||
)
|
||||
|
||||
if touched:
|
||||
changed = True
|
||||
await self._rewrite_sidecar_paths(touched)
|
||||
@@ -1700,7 +1751,9 @@ class ModelScanner:
|
||||
async def _rewrite_sidecar_paths(self, entries: List[Dict[str, Any]]) -> None:
|
||||
"""Point each model's metadata sidecar at its new location.
|
||||
|
||||
Sidecars travel with the renamed directory, so only the recorded
|
||||
In alongside mode sidecars travel with the renamed directory; in
|
||||
centralized mode the mirror subtree has already been moved by the
|
||||
caller (:meth:`rename_known_folder`). Either way only the recorded
|
||||
``file_path``/``preview_url`` inside them need rewriting. Failures are
|
||||
logged and skipped — a stale sidecar is repaired by the next metadata
|
||||
refresh, and must not abort the rename.
|
||||
@@ -1709,7 +1762,7 @@ class ModelScanner:
|
||||
file_path = item.get("file_path")
|
||||
if not file_path:
|
||||
continue
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
if not os.path.exists(metadata_path):
|
||||
continue
|
||||
try:
|
||||
@@ -1719,6 +1772,94 @@ class ModelScanner:
|
||||
"Failed to rewrite metadata sidecar %s: %s", metadata_path, exc
|
||||
)
|
||||
|
||||
def _find_pending_models_in_sidecar_mirror(self) -> List[Dict[str, Any]]:
|
||||
"""Mirror-tree counterpart of the alongside pending-hash filesystem scan.
|
||||
|
||||
Centralized mode stores ``.metadata.json`` sidecars in the mirror
|
||||
tree, so walking the model folders finds nothing. Each mirror base is
|
||||
resolved from a configured model root; a sidecar's recorded
|
||||
``file_path`` locates its model, with a stem-based probe under the
|
||||
mapped model root as fallback (mirror path components are sanitized,
|
||||
so reverse mapping is best-effort). Orphan sidecars whose model file
|
||||
no longer exists are skipped, matching the alongside scan.
|
||||
"""
|
||||
|
||||
pending_models: List[Dict[str, Any]] = []
|
||||
|
||||
for root_path in self.get_model_roots():
|
||||
mirror_base = resolve_centralized_dir_for_dir(root_path)
|
||||
if not mirror_base or not os.path.isdir(mirror_base):
|
||||
continue
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(mirror_base):
|
||||
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
|
||||
for filename in filenames:
|
||||
if not filename.endswith(".metadata.json"):
|
||||
continue
|
||||
|
||||
metadata_path = os.path.join(dirpath, filename)
|
||||
try:
|
||||
with open(metadata_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Check if hash is pending
|
||||
hash_status = data.get("hash_status", "completed")
|
||||
sha256 = data.get("sha256", "")
|
||||
|
||||
if hash_status != "completed" or not sha256:
|
||||
# Find corresponding model file: prefer the
|
||||
# sidecar's recorded path, then probe by stem
|
||||
# under the mapped model root.
|
||||
model_path = None
|
||||
recorded_path = data.get("file_path")
|
||||
if (
|
||||
isinstance(recorded_path, str)
|
||||
and recorded_path
|
||||
and os.path.exists(recorded_path)
|
||||
):
|
||||
model_path = recorded_path
|
||||
else:
|
||||
model_name = filename.replace(".metadata.json", "")
|
||||
rel_dir = os.path.relpath(dirpath, mirror_base)
|
||||
candidate_dir = (
|
||||
root_path
|
||||
if rel_dir == os.curdir
|
||||
else os.path.join(root_path, rel_dir)
|
||||
)
|
||||
for ext in self.file_extensions:
|
||||
potential_path = os.path.join(
|
||||
candidate_dir, model_name + ext
|
||||
)
|
||||
if os.path.exists(potential_path):
|
||||
model_path = potential_path
|
||||
break
|
||||
|
||||
if model_path:
|
||||
pending_models.append(
|
||||
{
|
||||
"file_path": model_path.replace(os.sep, "/"),
|
||||
"hash_status": hash_status,
|
||||
"sha256": sha256,
|
||||
**{
|
||||
k: v
|
||||
for k, v in data.items()
|
||||
if k
|
||||
not in [
|
||||
"file_path",
|
||||
"hash_status",
|
||||
"sha256",
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
except (json.JSONDecodeError, Exception) as e:
|
||||
logger.debug(
|
||||
f"Error reading metadata file {metadata_path}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
return pending_models
|
||||
|
||||
def _schedule_all_folders_backfill(self) -> None:
|
||||
"""Kick off a one-shot background folder walk if none is running."""
|
||||
if self._all_folders_backfill_running:
|
||||
@@ -1889,7 +2030,7 @@ class ModelScanner:
|
||||
file_info['name'] = os.path.basename(file_path)
|
||||
|
||||
metadata = cast(Any, self.model_class).from_civitai_info(version_info, file_info, file_path)
|
||||
metadata.preview_url = find_preview_file(local_stem, os.path.dirname(file_path))
|
||||
metadata.preview_url = find_preview_file(local_stem, get_preview_dir(file_path))
|
||||
await MetadataManager.save_metadata(file_path, metadata)
|
||||
logger.info(f"Created metadata from .civitai.info for {file_path} (Reason: .civitai.info was found but .metadata.json was missing)")
|
||||
except Exception as e:
|
||||
@@ -2276,18 +2417,31 @@ class ModelScanner:
|
||||
|
||||
async def move_model(self, source_path: str, target_path: str) -> Optional[Dict[str, Any]]:
|
||||
"""Move a model and its associated files to a new location
|
||||
|
||||
|
||||
Args:
|
||||
source_path: Original file path
|
||||
target_path: Target directory path
|
||||
|
||||
|
||||
Returns:
|
||||
Optional[str]: New file path if successful, None if failed
|
||||
"""
|
||||
source_path = source_path.replace(os.sep, '/')
|
||||
target_path = target_path.replace(os.sep, '/')
|
||||
|
||||
# Serialize moves per source file: concurrent requests for the same
|
||||
# model (auto-organize racing a manual move, duplicate clicks) must
|
||||
# not interleave, or the second mover hits a missing source file.
|
||||
lock_key = os.path.normcase(os.path.abspath(os.path.normpath(source_path)))
|
||||
lock = self._move_locks.setdefault(lock_key, asyncio.Lock())
|
||||
try:
|
||||
async with lock:
|
||||
return await self._move_model_locked(source_path, target_path)
|
||||
finally:
|
||||
if not lock.locked():
|
||||
self._move_locks.pop(lock_key, None)
|
||||
|
||||
async def _move_model_locked(self, source_path: str, target_path: str) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
source_path = source_path.replace(os.sep, '/')
|
||||
target_path = target_path.replace(os.sep, '/')
|
||||
|
||||
file_ext = os.path.splitext(source_path)[1]
|
||||
|
||||
if not file_ext or file_ext.lower() not in self.file_extensions:
|
||||
@@ -2318,46 +2472,69 @@ class ModelScanner:
|
||||
if final_filename != f"{base_name}{file_ext}":
|
||||
logger.info(f"Renamed {base_name}{file_ext} to {final_filename} to avoid filename conflict")
|
||||
|
||||
real_source = os.path.realpath(source_path)
|
||||
real_target = os.path.realpath(target_file)
|
||||
|
||||
shutil.move(real_source, real_target)
|
||||
# Business paths (abspath, symlinks NOT resolved) per project
|
||||
# convention: file mutations must operate on the paths as they
|
||||
# appear under the configured model roots.
|
||||
move_source = os.path.abspath(source_path)
|
||||
move_target = os.path.abspath(target_file)
|
||||
|
||||
if not os.path.exists(move_source):
|
||||
# The source is gone — typically a previous move already
|
||||
# succeeded but the cache/metadata were left pointing at the
|
||||
# old path. Repair that state instead of failing.
|
||||
natural_target = os.path.join(target_path, f"{base_name}{file_ext}").replace(os.sep, '/')
|
||||
return await self._reconcile_already_moved(source_path, [target_file, natural_target])
|
||||
|
||||
shutil.move(move_source, move_target)
|
||||
|
||||
# Move all associated files with the same base name
|
||||
source_metadata = None
|
||||
moved_metadata_path = None
|
||||
|
||||
# Find all files with the same base name in the source directory
|
||||
|
||||
# Associated files (sidecar metadata, previews) sit next to the
|
||||
# model in alongside mode and in the mirror tree in centralized
|
||||
# mode; collect from every directory that holds them.
|
||||
source_sidecar_dir = get_sidecar_dir(source_path)
|
||||
target_sidecar_dir = get_sidecar_dir(target_file)
|
||||
associated_dirs = [(source_dir, target_path)]
|
||||
if os.path.normpath(source_sidecar_dir) != os.path.normpath(source_dir):
|
||||
associated_dirs.append((source_sidecar_dir, target_sidecar_dir))
|
||||
|
||||
# Find all files with the same base name in the source directories
|
||||
files_to_move = []
|
||||
try:
|
||||
for file in os.listdir(source_dir):
|
||||
if file.startswith(base_name + ".") and file != os.path.basename(source_path):
|
||||
source_file_path = os.path.join(source_dir, file)
|
||||
# Generate new filename with the same base name as the model file
|
||||
file_suffix = file[len(base_name):] # Get the part after base_name (e.g., ".metadata.json", ".preview.png")
|
||||
new_associated_filename = f"{final_base_name}{file_suffix}"
|
||||
target_associated_path = os.path.join(target_path, new_associated_filename)
|
||||
|
||||
# Store metadata file path for special handling
|
||||
if file == f"{base_name}.metadata.json":
|
||||
source_metadata = source_file_path
|
||||
moved_metadata_path = target_associated_path
|
||||
else:
|
||||
files_to_move.append((source_file_path, target_associated_path))
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing files in {source_dir}: {e}")
|
||||
|
||||
metadata_filename = os.path.basename(get_metadata_path(source_path))
|
||||
for assoc_source_dir, assoc_target_dir in associated_dirs:
|
||||
try:
|
||||
for file in os.listdir(assoc_source_dir):
|
||||
if file.startswith(base_name + ".") and file != os.path.basename(source_path):
|
||||
source_file_path = os.path.join(assoc_source_dir, file)
|
||||
# Generate new filename with the same base name as the model file
|
||||
file_suffix = file[len(base_name):] # Get the part after base_name (e.g., ".metadata.json", ".preview.png")
|
||||
new_associated_filename = f"{final_base_name}{file_suffix}"
|
||||
target_associated_path = os.path.join(assoc_target_dir, new_associated_filename)
|
||||
|
||||
# Store metadata file path for special handling
|
||||
if file == metadata_filename:
|
||||
source_metadata = source_file_path
|
||||
moved_metadata_path = target_associated_path
|
||||
else:
|
||||
files_to_move.append((source_file_path, target_associated_path))
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing files in {assoc_source_dir}: {e}")
|
||||
|
||||
# Move all associated files
|
||||
metadata = None
|
||||
for source_file, target_file_path in files_to_move:
|
||||
try:
|
||||
os.makedirs(os.path.dirname(target_file_path), exist_ok=True)
|
||||
shutil.move(source_file, target_file_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving associated file {source_file}: {e}")
|
||||
|
||||
|
||||
# Handle metadata file specially to update paths
|
||||
if source_metadata and moved_metadata_path and os.path.exists(source_metadata):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(moved_metadata_path), exist_ok=True)
|
||||
shutil.move(source_metadata, moved_metadata_path)
|
||||
metadata = await self._update_metadata_paths(moved_metadata_path, target_file)
|
||||
except Exception as e:
|
||||
@@ -2387,7 +2564,70 @@ class ModelScanner:
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving model: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def _reconcile_already_moved(self, source_path: str, target_candidates: List[str]) -> Optional[Dict[str, Any]]:
|
||||
"""Repair state when a move's source file is already gone.
|
||||
|
||||
A previous move may have relocated the file while the cache/metadata
|
||||
still point at the old path (crash mid-move, concurrent request, or
|
||||
external tools). If the model is found at its new location, update
|
||||
the cache and metadata to match reality instead of failing.
|
||||
"""
|
||||
candidates: List[str] = []
|
||||
source_hash = self.get_hash_by_path(source_path)
|
||||
if source_hash:
|
||||
indexed_path = self.get_path_by_hash(source_hash)
|
||||
if indexed_path:
|
||||
candidates.append(indexed_path)
|
||||
candidates.extend(target_candidates)
|
||||
|
||||
for candidate in candidates:
|
||||
if not candidate or os.path.normpath(candidate) == os.path.normpath(source_path):
|
||||
continue
|
||||
if not os.path.exists(os.path.abspath(candidate)):
|
||||
continue
|
||||
|
||||
new_path = candidate.replace(os.sep, '/')
|
||||
logger.info(
|
||||
f"Move source {source_path} no longer exists; the model is already "
|
||||
f"at {new_path}. Reconciling cache and metadata."
|
||||
)
|
||||
|
||||
cache = await self.get_cached_data()
|
||||
existing_at_target = next((item for item in cache.raw_data if item['file_path'] == new_path), None)
|
||||
if existing_at_target is not None:
|
||||
# Cache already tracks the moved file (a previous move updated
|
||||
# it); just drop the stale source entry without appending a
|
||||
# duplicate.
|
||||
await self.update_single_model_cache(source_path, new_path, None)
|
||||
return {"new_path": new_path, "cache_entry": existing_at_target}
|
||||
|
||||
metadata = None
|
||||
metadata_path = get_metadata_path(new_path)
|
||||
if os.path.exists(metadata_path):
|
||||
metadata = await self._update_metadata_paths(metadata_path, new_path)
|
||||
|
||||
if metadata is None:
|
||||
# No sidecar at the new location — reuse the stale cache entry
|
||||
# so the model card keeps its data under the corrected path.
|
||||
existing_item = next((item for item in cache.raw_data if item['file_path'] == source_path), None)
|
||||
if existing_item:
|
||||
metadata = dict(existing_item)
|
||||
metadata['file_path'] = new_path
|
||||
metadata['file_name'] = os.path.splitext(os.path.basename(new_path))[0]
|
||||
|
||||
update_result = await self.update_single_model_cache(source_path, new_path, metadata, recalculate_type=True)
|
||||
return {
|
||||
"new_path": new_path,
|
||||
"cache_entry": update_result if isinstance(update_result, dict) else None,
|
||||
}
|
||||
|
||||
logger.error(
|
||||
f"Cannot move model: source file not found: {source_path} "
|
||||
f"(already moved or deleted outside LoRA Manager?)"
|
||||
)
|
||||
return None
|
||||
|
||||
async def _update_metadata_paths(self, metadata_path: str, model_path: str) -> Optional[Dict[str, Any]]:
|
||||
"""Update file paths in metadata file"""
|
||||
try:
|
||||
@@ -2399,7 +2639,7 @@ class ModelScanner:
|
||||
metadata['file_name'] = os.path.splitext(os.path.basename(model_path))[0]
|
||||
|
||||
if 'preview_url' in metadata and metadata['preview_url']:
|
||||
preview_dir = os.path.dirname(model_path)
|
||||
preview_dir = get_preview_dir(model_path)
|
||||
# Update preview filename to match the new base name
|
||||
new_base_name = os.path.splitext(os.path.basename(model_path))[0]
|
||||
preview_ext = get_preview_extension(metadata['preview_url'])
|
||||
@@ -2414,11 +2654,85 @@ class ModelScanner:
|
||||
logger.error(f"Error updating metadata paths: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
@asynccontextmanager
|
||||
async def defer_cache_persist(self):
|
||||
"""Defer heavyweight cache maintenance for a bulk operation.
|
||||
|
||||
While at least one ``defer_cache_persist`` context is active,
|
||||
:meth:`update_single_model_cache` performs only the in-memory entry
|
||||
swap plus incremental index updates — it skips the full version-index
|
||||
rebuild, the natsort resort, and the whole-table SQLite persist plus
|
||||
download-history sync that normally run per call. When the outermost
|
||||
context exits, the pending maintenance runs **once** (resort, persist,
|
||||
download-history sync).
|
||||
|
||||
The final persist is forced: it runs even when the scanner's
|
||||
cancellation flag is set or the wrapped block raised, because callers
|
||||
use this around operations that already mutated files on disk and the
|
||||
cache must not be left diverging from reality.
|
||||
|
||||
Intended for bulk rename/move loops (e.g. the filename-template "Apply
|
||||
to Library" flow). Single-shot callers keep the immediate per-call
|
||||
behavior by not entering this context.
|
||||
"""
|
||||
self._defer_persist_depth = getattr(self, "_defer_persist_depth", 0) + 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._defer_persist_depth -= 1
|
||||
if self._defer_persist_depth == 0:
|
||||
await self._finalize_deferred_cache_persist()
|
||||
|
||||
@property
|
||||
def _cache_persist_deferred(self) -> bool:
|
||||
"""True while cache resort/persist is deferred to a bulk finalize."""
|
||||
return getattr(self, "_defer_persist_depth", 0) > 0
|
||||
|
||||
async def _finalize_deferred_cache_persist(self) -> None:
|
||||
"""Run the resort + persist deferred by ``defer_cache_persist``.
|
||||
|
||||
Best-effort: failures are logged, never raised, so an error here
|
||||
cannot mask the outcome of the bulk operation itself (including
|
||||
cancellation).
|
||||
"""
|
||||
if not getattr(self, "_deferred_persist_pending", False):
|
||||
return
|
||||
self._deferred_persist_pending = False
|
||||
if self._cache is None:
|
||||
return
|
||||
try:
|
||||
# resort() rebuilds the version index and folder list, so the
|
||||
# per-call rebuilds skipped during deferral are covered here.
|
||||
await self._cache.resort()
|
||||
await self._persist_current_cache(force=True)
|
||||
self.bump_cache_version()
|
||||
except Exception:
|
||||
logger.error(
|
||||
"%s Scanner: failed to finalize deferred cache persist",
|
||||
self.model_type.capitalize(),
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def update_single_model_cache(self, original_path: str, new_path: str, metadata: Optional[Dict[str, Any]], recalculate_type: bool = False) -> Union[bool, Dict[str, Any]]:
|
||||
"""Update cache after a model has been moved or modified"""
|
||||
"""Update cache after a model has been moved or modified.
|
||||
|
||||
Performs the full maintenance chain (version-index rebuild, resort,
|
||||
whole-table persist, download-history sync) unless the scanner is
|
||||
inside a :meth:`defer_cache_persist` context, in which case only
|
||||
the in-memory entry swap and incremental index updates run and the
|
||||
heavy chain executes once at context exit.
|
||||
"""
|
||||
deferred = self._cache_persist_deferred
|
||||
cache = await self.get_cached_data()
|
||||
|
||||
existing_item = next((item for item in cache.raw_data if item['file_path'] == original_path), None)
|
||||
existing_index: Optional[int] = None
|
||||
existing_item = None
|
||||
for idx, item in enumerate(cache.raw_data):
|
||||
if item['file_path'] == original_path:
|
||||
existing_item = item
|
||||
existing_index = idx
|
||||
break
|
||||
|
||||
if existing_item:
|
||||
cache.remove_from_version_index(existing_item)
|
||||
|
||||
@@ -2430,11 +2744,18 @@ class ModelScanner:
|
||||
del self._tags_count[tag]
|
||||
|
||||
self._hash_index.remove_by_path(original_path)
|
||||
|
||||
cache.raw_data = [
|
||||
item for item in cache.raw_data
|
||||
if item['file_path'] != original_path
|
||||
]
|
||||
|
||||
if deferred:
|
||||
# In-place swap avoids the O(n) list rebuild per renamed file;
|
||||
# indexes were already updated incrementally above/below, and the
|
||||
# folder recompute happens in the single finalize resort().
|
||||
if existing_index is not None:
|
||||
cache.raw_data.pop(existing_index)
|
||||
else:
|
||||
cache.raw_data = [
|
||||
item for item in cache.raw_data
|
||||
if item['file_path'] != original_path
|
||||
]
|
||||
|
||||
cache_modified = bool(existing_item) or bool(metadata)
|
||||
cache_entry: Optional[Dict[str, Any]] = None
|
||||
@@ -2475,8 +2796,11 @@ class ModelScanner:
|
||||
cache_entry.get('autov3') or None,
|
||||
)
|
||||
|
||||
all_folders = set(item['folder'] for item in cache.raw_data)
|
||||
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
||||
if not deferred:
|
||||
# O(n) over raw_data; the finalize resort() recomputes the
|
||||
# folder list once, so bulk callers skip it per file.
|
||||
all_folders = set(item['folder'] for item in cache.raw_data)
|
||||
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
||||
|
||||
# The move target may live in directories the last scan never saw;
|
||||
# record the destination folder (and its parents) in the known
|
||||
@@ -2491,13 +2815,18 @@ class ModelScanner:
|
||||
for tag in cache_entry.get('tags', []):
|
||||
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
|
||||
|
||||
cache.rebuild_version_index()
|
||||
if deferred:
|
||||
if cache_modified:
|
||||
self._deferred_persist_pending = True
|
||||
self.bump_cache_version()
|
||||
else:
|
||||
cache.rebuild_version_index()
|
||||
|
||||
await cache.resort()
|
||||
await cache.resort()
|
||||
|
||||
if cache_modified:
|
||||
await self._persist_current_cache()
|
||||
self.bump_cache_version()
|
||||
if cache_modified:
|
||||
await self._persist_current_cache()
|
||||
self.bump_cache_version()
|
||||
|
||||
if metadata and cache_entry is not None:
|
||||
return cache_entry
|
||||
@@ -2759,7 +3088,7 @@ class ModelScanner:
|
||||
|
||||
# Sidecar write-back: JSON null encodes the checked-unavailable
|
||||
# state. Skip silently when the sidecar does not exist.
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
if os.path.exists(metadata_path):
|
||||
with open(metadata_path, 'r', encoding='utf-8') as handle:
|
||||
payload = json.load(handle)
|
||||
@@ -2825,7 +3154,7 @@ class ModelScanner:
|
||||
if not file_path:
|
||||
return None
|
||||
|
||||
dir_path = os.path.dirname(file_path)
|
||||
dir_path = get_preview_dir(file_path)
|
||||
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
preview_path = find_preview_file(base_name, dir_path)
|
||||
if preview_path:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""External model-source providers (Hugging Face, ModelScope, TensorArt).
|
||||
"""External model-source providers (Hugging Face, ModelScope, TensorArt, OpenModelDB).
|
||||
|
||||
This package is the single abstraction over "a site that hosts models and
|
||||
a model card". See :mod:`py.services.model_sources.base` for the provider
|
||||
@@ -30,6 +30,7 @@ from .hydration import (
|
||||
resolve_site_base_model,
|
||||
)
|
||||
from .modelscope import ModelScopeIntlSource, ModelScopeSource
|
||||
from .openmodeldb import OpenModelDBSource
|
||||
from .registry import (
|
||||
LEGACY_HF_URL_FIELD,
|
||||
SOURCE_PLATFORM_FIELD,
|
||||
@@ -59,6 +60,7 @@ __all__ = [
|
||||
"HuggingFaceSource",
|
||||
"ModelScopeIntlSource",
|
||||
"ModelScopeSource",
|
||||
"OpenModelDBSource",
|
||||
"SOURCE_PLATFORM_FIELD",
|
||||
"SOURCE_URL_FIELD",
|
||||
"SourceRef",
|
||||
|
||||
@@ -47,6 +47,7 @@ GROUP_PREFIXES: dict[str, str] = {
|
||||
"modelscope": "ms",
|
||||
"modelscope-ai": "msai",
|
||||
"tensorart": "ta",
|
||||
"openmodeldb": "omdb",
|
||||
}
|
||||
|
||||
|
||||
@@ -288,6 +289,11 @@ class ModelSource:
|
||||
#: Sub-directory the "use default paths" template places downloads in.
|
||||
default_subdir: str = ""
|
||||
|
||||
#: Source id used to build the example URL shown in UI copy and error
|
||||
#: messages. ``owner/name`` suits repository sites; sites with a
|
||||
#: different identity shape override it with a real example.
|
||||
example_source_id: str = "user/repo"
|
||||
|
||||
#: Lenient pattern used to recognise URLs already stored in metadata.
|
||||
#: Captures the site-specific source id in group ``id``.
|
||||
url_pattern: re.Pattern[str] | None = None
|
||||
@@ -340,6 +346,26 @@ class ModelSource:
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
def is_valid_source_id(self, source_id: str) -> bool:
|
||||
"""Return ``True`` when *source_id* is a safe id on this site.
|
||||
|
||||
Defaults to the ``owner/name`` repository rule; sites whose ids are
|
||||
not repositories (OpenModelDB's flat model ids) override it.
|
||||
"""
|
||||
|
||||
return is_valid_source_id(source_id)
|
||||
|
||||
def default_subdir_parts(self, source_id: str) -> tuple[str, ...]:
|
||||
"""Path segments appended to the model root by "use default paths".
|
||||
|
||||
Defaults to ``<default_subdir>/<owner>/<repo>`` so downloads from
|
||||
repository sites stay namespaced by author. Sites without an
|
||||
owner/repo split override it.
|
||||
"""
|
||||
|
||||
owner, repo_name = source_id.split("/", 1)
|
||||
return (self.default_subdir, owner, repo_name)
|
||||
|
||||
def asset_base_url(self, source_id: str, revision: str = "") -> str:
|
||||
"""Base URL used to resolve repository-relative asset paths."""
|
||||
|
||||
@@ -432,6 +458,18 @@ class ModelSource:
|
||||
f"{self.label or self.platform} does not support downloads", status=400
|
||||
)
|
||||
|
||||
async def resolve_download_url(
|
||||
self, source_id: str, filename: str, revision: str = ""
|
||||
) -> str:
|
||||
"""Resolve the download URL for one file, allowing async lookups.
|
||||
|
||||
Defaults to the synchronous :meth:`file_download_url`; sites whose
|
||||
download URL is not derivable from the id alone (OpenModelDB stores
|
||||
the URL inside its catalogue entry) override this to look it up.
|
||||
"""
|
||||
|
||||
return self.file_download_url(source_id, filename, revision)
|
||||
|
||||
def resolve_revision(self, revision: str = "") -> str:
|
||||
"""Return *revision*, falling back to this site's default branch."""
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""OpenModelDB model source (upscaler catalogue).
|
||||
|
||||
OpenModelDB (https://openmodeldb.info) is a static catalogue of upscaler
|
||||
models. Unlike the repository-based sources (Hugging Face, ModelScope) a
|
||||
model id here is a flat token (``4x-UltraSharp``) that *is* the published
|
||||
model identity: there is no owner/repo split, no revision, and no README.
|
||||
Everything the source needs — the resource download URLs, sizes, sha256
|
||||
hashes, tags and example images — comes from the site's bulk JSON dumps via
|
||||
:class:`~py.services.openmodeldb_client.OpenModelDBClient`, which caches the
|
||||
catalogue on disk, so every method below is a local lookup once warmed.
|
||||
|
||||
Only PyTorch resources (``.pth`` / ``.safetensors``) are listed for download:
|
||||
``.onnx`` is not a loadable weight format for the supported model types (see
|
||||
:data:`py.utils.constants.MODEL_FILE_EXTENSIONS`). Resources can carry
|
||||
mirror URLs; only the primary URL is ever used (see
|
||||
:meth:`OpenModelDBClient.primary_url`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .base import (
|
||||
ModelCardContext,
|
||||
ModelSource,
|
||||
ModelSourceCache,
|
||||
ModelSourceError,
|
||||
filter_weight_files,
|
||||
)
|
||||
from ..openmodeldb_client import OPENMODELDB_SITE_BASE, OpenModelDBClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Model ids are flat tokens (``4x-UltraSharp``), usable as a path segment.
|
||||
_SOURCE_ID = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*$")
|
||||
|
||||
_URL_PATTERN = re.compile(
|
||||
r"https?://(?:www\.)?openmodeldb\.info/models/(?P<id>[A-Za-z0-9_][A-Za-z0-9_.\-]*)"
|
||||
)
|
||||
_STRICT_URL_PATTERN = re.compile(
|
||||
r"https?://(?:www\.)?openmodeldb\.info/models/(?P<id>[A-Za-z0-9_][A-Za-z0-9_.\-]*)/?$"
|
||||
)
|
||||
|
||||
#: Resource platforms whose files ComfyUI can load.
|
||||
_DOWNLOADABLE_PLATFORMS = frozenset({"pytorch"})
|
||||
|
||||
|
||||
class OpenModelDBSource(ModelSource):
|
||||
"""OpenModelDB (``openmodeldb.info``)."""
|
||||
|
||||
platform = "openmodeldb"
|
||||
label = "OpenModelDB"
|
||||
supports_enrichment = True
|
||||
supports_download = True
|
||||
default_revision = ""
|
||||
default_subdir = "openmodeldb"
|
||||
example_source_id = "4x-UltraSharp"
|
||||
url_pattern = _URL_PATTERN
|
||||
strict_url_pattern = _STRICT_URL_PATTERN
|
||||
|
||||
def canonical_url(self, source_id: str) -> str:
|
||||
return f"{OPENMODELDB_SITE_BASE}/models/{source_id}"
|
||||
|
||||
def is_valid_source_id(self, source_id: str) -> bool:
|
||||
"""OpenModelDB ids are flat tokens, not ``owner/name`` repositories."""
|
||||
|
||||
return bool(isinstance(source_id, str) and _SOURCE_ID.match(source_id))
|
||||
|
||||
def default_subdir_parts(self, source_id: str) -> tuple[str, ...]:
|
||||
"""Flat catalogue: there is no owner/repo split to mirror on disk."""
|
||||
|
||||
return (self.default_subdir,)
|
||||
|
||||
async def fetch_model_card_context(
|
||||
self,
|
||||
source_id: str,
|
||||
filename: str = "",
|
||||
*,
|
||||
sha256: str = "",
|
||||
cache: Optional["ModelSourceCache"] = None,
|
||||
) -> ModelCardContext:
|
||||
"""Build the card extras from the cached catalogue entry.
|
||||
|
||||
OpenModelDB has no README; the catalogue entry itself carries the
|
||||
description, license, tags and example images, so the context is the
|
||||
whole card. The catalogue is bulk-loaded and disk-cached, so no
|
||||
per-run memo is needed.
|
||||
"""
|
||||
|
||||
try:
|
||||
client = await OpenModelDBClient.get_instance()
|
||||
found = await client.get_model_entry(source_id)
|
||||
except Exception as exc: # never break enrichment on a lookup fault
|
||||
logger.debug("OpenModelDB context lookup failed for %s: %s", source_id, exc)
|
||||
return ModelCardContext()
|
||||
if found is None:
|
||||
return ModelCardContext()
|
||||
entry = found[1]
|
||||
|
||||
scale = entry.get("scale")
|
||||
arch_name = client._resolve_architecture_name(entry)
|
||||
# e.g. "ESRGAN 4x" — closest thing upscalers have to a base model,
|
||||
# recorded as a hint rather than a canonical base-model name.
|
||||
base_hint = (
|
||||
f"{arch_name} {scale}x".strip()
|
||||
if arch_name and isinstance(scale, (int, float))
|
||||
else arch_name
|
||||
)
|
||||
description = entry.get("description")
|
||||
|
||||
return ModelCardContext(
|
||||
description=description if isinstance(description, str) else "",
|
||||
model_name=entry.get("name") or source_id,
|
||||
license=entry.get("license") or "",
|
||||
model_type="Upscaler",
|
||||
base_model_aliases=[base_hint] if base_hint else [],
|
||||
official_tags=client._resolve_tags(entry),
|
||||
example_images=client.example_image_urls(entry),
|
||||
source_model_id=source_id,
|
||||
)
|
||||
|
||||
async def list_files(
|
||||
self, source_id: str, revision: str = ""
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List the entry's directly downloadable PyTorch resources.
|
||||
|
||||
Resources whose only mirrors are HTML-gateway hosts (mediafire,
|
||||
mega.nz, drive.google.com) are skipped: they serve a web page, not
|
||||
the file bytes. When every resource is mirror-only this raises a
|
||||
manual-download hint instead of returning an empty list, which the
|
||||
download dialog would otherwise misreport as "no model files".
|
||||
"""
|
||||
|
||||
client = await OpenModelDBClient.get_instance()
|
||||
entry = await self._require_entry(client, source_id)
|
||||
|
||||
saw_mirror_only = False
|
||||
entries = []
|
||||
for resource in entry.get("resources") or []:
|
||||
if not isinstance(resource, dict):
|
||||
continue
|
||||
if str(resource.get("platform") or "").lower() not in _DOWNLOADABLE_PLATFORMS:
|
||||
continue
|
||||
url = client.direct_url(resource)
|
||||
if not url:
|
||||
saw_mirror_only = True
|
||||
continue
|
||||
size = resource.get("size")
|
||||
entries.append(
|
||||
(
|
||||
client.resource_filename(source_id, resource),
|
||||
size if isinstance(size, (int, float)) else 0,
|
||||
)
|
||||
)
|
||||
|
||||
if not entries and saw_mirror_only:
|
||||
raise ModelSourceError(
|
||||
f"None of this model's mirrors support direct download; "
|
||||
f"download it manually from {self.canonical_url(source_id)}",
|
||||
status=400,
|
||||
)
|
||||
|
||||
return filter_weight_files(entries)
|
||||
|
||||
async def resolve_download_url(
|
||||
self, source_id: str, filename: str, revision: str = ""
|
||||
) -> str:
|
||||
"""Resolve the direct download URL of one resource by filename."""
|
||||
|
||||
client = await OpenModelDBClient.get_instance()
|
||||
entry = await self._require_entry(client, source_id)
|
||||
|
||||
resource = client.find_resource_by_filename(
|
||||
source_id, entry, os.path.basename(filename)
|
||||
)
|
||||
if resource is None:
|
||||
raise ModelSourceError(
|
||||
f"'{filename}' is not a downloadable resource of '{source_id}'",
|
||||
status=404,
|
||||
)
|
||||
url = client.direct_url(resource)
|
||||
if not url:
|
||||
host = urlparse(client.primary_url(resource)).netloc or "this mirror"
|
||||
raise ModelSourceError(
|
||||
f"This mirror ({host}) requires manual download from "
|
||||
f"{self.canonical_url(source_id)}",
|
||||
status=400,
|
||||
)
|
||||
return url
|
||||
|
||||
async def _require_entry(
|
||||
self, client: OpenModelDBClient, source_id: str
|
||||
) -> dict[str, Any]:
|
||||
"""Return the catalogue entry, raising a mapped error otherwise."""
|
||||
|
||||
if not await client.catalogue_ready():
|
||||
raise ModelSourceError("OpenModelDB catalogue unavailable", status=502)
|
||||
found = await client.get_model_entry(source_id)
|
||||
if found is None:
|
||||
raise ModelSourceError(
|
||||
f"Model '{source_id}' not found on OpenModelDB", status=404
|
||||
)
|
||||
return found[1]
|
||||
|
||||
|
||||
__all__ = ["OpenModelDBSource"]
|
||||
@@ -14,6 +14,7 @@ from typing import Any, Dict, Mapping, Optional
|
||||
from .base import GROUP_PREFIXES, ModelSource, SourceRef, clean_source_url
|
||||
from .huggingface import HuggingFaceSource
|
||||
from .modelscope import ModelScopeIntlSource, ModelScopeSource
|
||||
from .openmodeldb import OpenModelDBSource
|
||||
from .tensorart import TensorArtSource
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -26,6 +27,7 @@ _SOURCES: tuple[ModelSource, ...] = (
|
||||
ModelScopeSource(),
|
||||
ModelScopeIntlSource(),
|
||||
TensorArtSource(),
|
||||
OpenModelDBSource(),
|
||||
)
|
||||
|
||||
_BY_PLATFORM: Dict[str, ModelSource] = {s.platform: s for s in _SOURCES}
|
||||
|
||||
@@ -42,6 +42,7 @@ class TensorArtSource(ModelSource):
|
||||
label = "TensorArt"
|
||||
supports_enrichment = False
|
||||
supports_download = False
|
||||
example_source_id = "827823520299086029"
|
||||
url_pattern = _URL_PATTERN
|
||||
strict_url_pattern = _STRICT_URL_PATTERN
|
||||
|
||||
|
||||
@@ -0,0 +1,762 @@
|
||||
"""Client for the OpenModelDB bulk JSON API.
|
||||
|
||||
OpenModelDB (https://openmodeldb.info) is a static catalogue of upscaler
|
||||
models. It exposes no per-model or by-hash endpoint — only bulk JSON dumps
|
||||
(``/api/v1/models.json`` and friends), so this client downloads the dumps
|
||||
once, caches them on disk with a TTL, honors ETag/Last-Modified on refresh,
|
||||
and builds an in-memory SHA256 -> model index for read-only metadata lookups.
|
||||
|
||||
Every catalogue resource carries a ``sha256`` and a byte ``size``, which is
|
||||
what makes hash-based matching against local files possible. Lookups degrade
|
||||
gracefully: when the catalogue cannot be fetched (offline, upstream failure)
|
||||
the stale disk cache is used, and if there is no cache at all the lookup
|
||||
reports "not found" so the metadata fallback chain simply moves on.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .downloader import get_downloader
|
||||
from .errors import RateLimitError
|
||||
from ..utils.cache_paths import get_cache_base_dir
|
||||
from ..utils.constants import MODEL_FILE_EXTENSIONS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OPENMODELDB_API_BASE = "https://openmodeldb.info/api/v1"
|
||||
OPENMODELDB_SITE_BASE = "https://openmodeldb.info"
|
||||
|
||||
#: Value emitted as ``source`` in the synthesized version dict; the metadata
|
||||
#: sync service persists it as the model's ``metadata_source``.
|
||||
METADATA_SOURCE_VALUE = "openmodeldb"
|
||||
|
||||
#: Hosts whose URLs serve an HTML interstitial page instead of the raw file
|
||||
#: bytes. Downloading from them would silently save a web page as ``.pth``
|
||||
#: (the download flow does not verify the sha256 afterwards), so the model
|
||||
#: source layer rejects them with a manual-download hint. Kept deliberately
|
||||
#: small and explicit.
|
||||
HTML_GATEWAY_HOSTS = frozenset({"mediafire.com", "mega.nz", "drive.google.com"})
|
||||
|
||||
|
||||
def is_html_gateway_url(url: str) -> bool:
|
||||
"""Return ``True`` when *url* points at a known HTML-gateway host."""
|
||||
|
||||
if not isinstance(url, str) or not url:
|
||||
return False
|
||||
try:
|
||||
host = urlparse(url).netloc.lower()
|
||||
except ValueError:
|
||||
return False
|
||||
return any(host == g or host.endswith(f".{g}") for g in HTML_GATEWAY_HOSTS)
|
||||
|
||||
|
||||
def _is_ephemeral_viewer_url(url: str) -> bool:
|
||||
"""Return ``True`` for imgdiff.net session URLs.
|
||||
|
||||
Paired comparisons are hosted as ephemeral imgdiff viewer sessions
|
||||
(``/api/image.php?id=...``) that expire shortly after the site build;
|
||||
they 404 when used as an ``<img>`` source and must never be emitted as a
|
||||
displayable image URL.
|
||||
"""
|
||||
|
||||
return isinstance(url, str) and "imgdiff.net/api/" in url
|
||||
|
||||
#: Bulk dumps consumed by the client. Only ``models`` is strictly required;
|
||||
#: the rest resolve ids to human-readable names and degrade to raw ids.
|
||||
_DUMP_NAMES = ("models", "users", "tags", "architectures")
|
||||
|
||||
#: How long a fetched catalogue is considered fresh before a revalidation
|
||||
#: request is made. The site only changes when it is rebuilt (hours to days),
|
||||
#: so a daily TTL avoids re-downloading the ~1.4MB models dump on every
|
||||
#: lookup while still picking up new models reasonably fast.
|
||||
CACHE_TTL_SECONDS = 24 * 60 * 60
|
||||
|
||||
_META_FILENAME = "_meta.json"
|
||||
|
||||
|
||||
class OpenModelDBClient:
|
||||
"""Hash-lookup client over a locally cached OpenModelDB catalogue dump."""
|
||||
|
||||
_instance: Optional["OpenModelDBClient"] = None
|
||||
_instance_lock = asyncio.Lock()
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls) -> "OpenModelDBClient":
|
||||
"""Get the singleton instance of OpenModelDBClient."""
|
||||
async with cls._instance_lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
|
||||
# Register this client as a metadata provider (mirrors the
|
||||
# CivitAI/CivArchive client bootstrap).
|
||||
from .model_metadata_provider import (
|
||||
ModelMetadataProviderManager,
|
||||
OpenModelDBModelMetadataProvider,
|
||||
)
|
||||
|
||||
provider_manager = await ModelMetadataProviderManager.get_instance()
|
||||
provider_manager.register_provider(
|
||||
"openmodeldb",
|
||||
OpenModelDBModelMetadataProvider(cls._instance),
|
||||
False,
|
||||
)
|
||||
|
||||
return cls._instance
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cache_dir: Optional[str] = None,
|
||||
ttl_seconds: float = CACHE_TTL_SECONDS,
|
||||
) -> None:
|
||||
# Guard re-initialization for the singleton pattern.
|
||||
if hasattr(self, "_initialized"):
|
||||
return
|
||||
self._initialized = True
|
||||
|
||||
self._cache_dir_override = cache_dir
|
||||
self._ttl_seconds = ttl_seconds
|
||||
|
||||
self._models: Dict[str, Dict[str, Any]] = {}
|
||||
self._users: Dict[str, Dict[str, Any]] = {}
|
||||
self._tags: Dict[str, Dict[str, Any]] = {}
|
||||
self._architectures: Dict[str, Dict[str, Any]] = {}
|
||||
# sha256 (lowercase) -> (model_id, model entry, matching resource)
|
||||
self._index: Dict[str, Tuple[str, Dict[str, Any], Dict[str, Any]]] = {}
|
||||
self._loaded_at: float = 0.0
|
||||
self._load_lock = asyncio.Lock()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_model_by_hash(
|
||||
self, model_hash: str
|
||||
) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||
"""Find an upscaler model by SHA256 hash.
|
||||
|
||||
Returns a CivitAI-shaped version dict (same contract as the other
|
||||
metadata providers) or ``(None, reason)``.
|
||||
"""
|
||||
if not model_hash or not isinstance(model_hash, str):
|
||||
return None, "Model not found"
|
||||
|
||||
try:
|
||||
loaded = await self._ensure_loaded()
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("OpenModelDB lookup failed for %s: %s", model_hash[:10], exc)
|
||||
return None, str(exc)
|
||||
|
||||
if not loaded:
|
||||
return None, "OpenModelDB catalogue unavailable"
|
||||
|
||||
hit = self._index.get(model_hash.lower())
|
||||
if hit is None:
|
||||
return None, "Model not found"
|
||||
|
||||
model_id, model_entry, resource = hit
|
||||
return self._to_civitai_version(model_id, model_entry, resource), None
|
||||
|
||||
async def catalogue_ready(self) -> bool:
|
||||
"""Return ``True`` when the catalogue is loaded (or loadable)."""
|
||||
|
||||
try:
|
||||
return await self._ensure_loaded()
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("OpenModelDB catalogue load failed: %s", exc)
|
||||
return False
|
||||
|
||||
async def get_model_entry(
|
||||
self, model_id: str
|
||||
) -> Optional[Tuple[str, Dict[str, Any]]]:
|
||||
"""Return ``(model_id, catalogue entry)`` for *model_id*, or ``None``.
|
||||
|
||||
Loads the catalogue on first use; an unavailable catalogue and an
|
||||
unknown id both yield ``None`` (callers that need to distinguish the
|
||||
two can check :meth:`catalogue_ready` first).
|
||||
"""
|
||||
if not model_id or not isinstance(model_id, str):
|
||||
return None
|
||||
if not await self.catalogue_ready():
|
||||
return None
|
||||
entry = self._models.get(model_id)
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
return model_id, entry
|
||||
|
||||
def find_resource_by_filename(
|
||||
self, model_id: str, entry: Dict[str, Any], filename: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Match a resource by its derived filename (see :meth:`resource_filename`)."""
|
||||
target = (filename or "").strip().lower()
|
||||
if not target:
|
||||
return None
|
||||
for resource in entry.get("resources") or []:
|
||||
if not isinstance(resource, dict):
|
||||
continue
|
||||
if self.resource_filename(model_id, resource).lower() == target:
|
||||
return resource
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def resource_filename(model_id: str, resource: Dict[str, Any]) -> str:
|
||||
"""Derive the local filename for a catalogue resource.
|
||||
|
||||
The download URL's basename is not authoritative — mirrors like
|
||||
mediafire put the real filename mid-path
|
||||
(``/file/<key>/90s_Sonic_2x.pth/file``) and folder links (mega.nz)
|
||||
have no filename at all. Strategy: the first URL path segment whose
|
||||
extension is a known model format, else ``{model_id}.{type}`` (the
|
||||
catalogue's ``type`` field is authoritative).
|
||||
"""
|
||||
urls = resource.get("urls")
|
||||
for url in urls if isinstance(urls, list) else []:
|
||||
if not isinstance(url, str):
|
||||
continue
|
||||
path = url.split("?", 1)[0].split("#", 1)[0]
|
||||
for segment in path.split("/"):
|
||||
if os.path.splitext(segment)[1].lower() in MODEL_FILE_EXTENSIONS:
|
||||
return segment
|
||||
resource_type = str(resource.get("type") or "").lower()
|
||||
extension = (
|
||||
resource_type if resource_type in {"pth", "safetensors", "onnx"} else "bin"
|
||||
)
|
||||
return f"{model_id}.{extension}"
|
||||
|
||||
@staticmethod
|
||||
def primary_url(resource: Dict[str, Any]) -> str:
|
||||
"""Return the resource's primary download URL.
|
||||
|
||||
Only the first URL is used: additional entries are mirrors that may
|
||||
need site-specific handling (e.g. mega.nz) and are never tried
|
||||
automatically.
|
||||
"""
|
||||
urls = resource.get("urls")
|
||||
if isinstance(urls, list):
|
||||
for url in urls:
|
||||
if isinstance(url, str) and url.startswith("http"):
|
||||
return url
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def direct_url(resource: Dict[str, Any]) -> str:
|
||||
"""Return the first URL that serves raw bytes, or ``""``.
|
||||
|
||||
HTML-gateway hosts (mediafire, mega.nz, drive.google.com — see
|
||||
:data:`HTML_GATEWAY_HOSTS`) serve an interstitial page instead of the
|
||||
file, so they are skipped here and reported to the user instead.
|
||||
"""
|
||||
urls = resource.get("urls")
|
||||
if isinstance(urls, list):
|
||||
for url in urls:
|
||||
if (
|
||||
isinstance(url, str)
|
||||
and url.startswith("http")
|
||||
and not is_html_gateway_url(url)
|
||||
):
|
||||
return url
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _absolutize(url: str) -> str:
|
||||
"""Turn a site-relative path (``/thumbs/...``) into an absolute URL."""
|
||||
|
||||
if isinstance(url, str) and url.startswith("/"):
|
||||
return f"{OPENMODELDB_SITE_BASE}{url}"
|
||||
return url
|
||||
|
||||
def _paired_display_url(self, image: Dict[str, Any]) -> str:
|
||||
"""Return the displayable URL for a paired comparison image.
|
||||
|
||||
Prefers the site-hosted thumbnail: the ``LR``/``SR`` originals are
|
||||
frequently ephemeral imgdiff session URLs that 404 outside the
|
||||
viewer. Falls back to the SR (then LR) original only when it is not
|
||||
one of those session URLs.
|
||||
"""
|
||||
thumbnail = image.get("thumbnail")
|
||||
if isinstance(thumbnail, str) and thumbnail:
|
||||
return self._absolutize(thumbnail)
|
||||
for key in ("SR", "LR"):
|
||||
original = image.get(key)
|
||||
if (
|
||||
isinstance(original, str)
|
||||
and original
|
||||
and not _is_ephemeral_viewer_url(original)
|
||||
):
|
||||
return original
|
||||
return ""
|
||||
|
||||
def _model_preview_url(self, entry: Dict[str, Any]) -> str:
|
||||
"""Return the model-level thumbnail URL, mirroring the site's own
|
||||
``getPreviewImage`` precedence (paired → SR, standalone → url)."""
|
||||
thumbnail = entry.get("thumbnail")
|
||||
if not isinstance(thumbnail, dict):
|
||||
return ""
|
||||
if thumbnail.get("type") == "paired":
|
||||
url = thumbnail.get("SR") or thumbnail.get("LR")
|
||||
else:
|
||||
url = thumbnail.get("url")
|
||||
if isinstance(url, str) and url:
|
||||
return self._absolutize(url)
|
||||
return ""
|
||||
|
||||
def example_image_urls(self, entry: Dict[str, Any]) -> List[str]:
|
||||
"""Return displayable example-image URLs, model thumbnail first.
|
||||
|
||||
Never contains ephemeral imgdiff session URLs; standalone images keep
|
||||
their direct URLs (regular image hosts are hotlinkable).
|
||||
"""
|
||||
urls: List[str] = []
|
||||
lead = self._model_preview_url(entry)
|
||||
if lead:
|
||||
urls.append(lead)
|
||||
for image in entry.get("images") or []:
|
||||
if not isinstance(image, dict):
|
||||
continue
|
||||
if image.get("type") == "paired":
|
||||
url = self._paired_display_url(image)
|
||||
else:
|
||||
url = image.get("url")
|
||||
if isinstance(url, str) and url and url not in urls:
|
||||
urls.append(url)
|
||||
return urls
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Catalogue loading
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _ensure_loaded(self) -> bool:
|
||||
"""Ensure the in-memory index is built, refreshing stale caches."""
|
||||
async with self._load_lock:
|
||||
if self._index and (time.monotonic() - self._loaded_at) < self._ttl_seconds:
|
||||
return True
|
||||
|
||||
meta = self._read_meta()
|
||||
fetched_at = float(meta.get("fetched_at") or 0.0)
|
||||
disk_fresh = (
|
||||
fetched_at > 0
|
||||
and (time.time() - fetched_at) < self._ttl_seconds
|
||||
and all(os.path.exists(self._dump_path(name)) for name in _DUMP_NAMES)
|
||||
)
|
||||
|
||||
if disk_fresh:
|
||||
if self._load_from_disk():
|
||||
return True
|
||||
# Corrupt disk cache: fall through to a network refresh.
|
||||
|
||||
if await self._refresh_from_network(meta):
|
||||
return True
|
||||
|
||||
# Network failed or was blocked: fall back to whatever is on disk,
|
||||
# however stale — old metadata beats none.
|
||||
if fetched_at > 0 and self._load_from_disk():
|
||||
logger.info("Using stale OpenModelDB cache (network refresh failed)")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _load_from_disk(self) -> bool:
|
||||
"""Load all dumps from the disk cache and rebuild the index."""
|
||||
payloads: Dict[str, Dict[str, Any]] = {}
|
||||
for name in _DUMP_NAMES:
|
||||
path = self._dump_path(name)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except FileNotFoundError:
|
||||
if name == "models":
|
||||
return False
|
||||
data = {}
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Failed to read OpenModelDB cache %s: %s", path, exc)
|
||||
if name == "models":
|
||||
return False
|
||||
data = {}
|
||||
payloads[name] = data if isinstance(data, dict) else {}
|
||||
|
||||
if not payloads["models"]:
|
||||
return False
|
||||
|
||||
self._install_payloads(payloads)
|
||||
return True
|
||||
|
||||
async def _refresh_from_network(self, meta: Dict[str, Any]) -> bool:
|
||||
"""Revalidate cached dumps against the site and rebuild the index.
|
||||
|
||||
Honors ETag/Last-Modified via a HEAD probe: an unchanged dump keeps
|
||||
its cached body, so a TTL expiry without upstream changes costs one
|
||||
tiny request per dump instead of a full download.
|
||||
"""
|
||||
payloads: Dict[str, Dict[str, Any]] = {}
|
||||
etags: Dict[str, str] = dict(meta.get("etags") or {})
|
||||
last_modified: Dict[str, str] = dict(meta.get("last_modified") or {})
|
||||
|
||||
for name in _DUMP_NAMES:
|
||||
payload, etag, modified = await self._fetch_dump(
|
||||
name,
|
||||
known_etag=etags.get(name) or "",
|
||||
known_last_modified=last_modified.get(name) or "",
|
||||
)
|
||||
if payload is None:
|
||||
if name == "models":
|
||||
return False
|
||||
payload = {}
|
||||
payloads[name] = payload
|
||||
if etag:
|
||||
etags[name] = etag
|
||||
if modified:
|
||||
last_modified[name] = modified
|
||||
|
||||
self._install_payloads(payloads)
|
||||
self._write_cache(payloads, etags, last_modified)
|
||||
return True
|
||||
|
||||
async def _fetch_dump(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
known_etag: str,
|
||||
known_last_modified: str,
|
||||
) -> Tuple[Optional[Dict[str, Any]], str, str]:
|
||||
"""Fetch one dump, returning ``(body, etag, last_modified)``.
|
||||
|
||||
``body`` is ``None`` when the fetch failed and there is no usable
|
||||
cached copy. When the HEAD probe shows the resource unchanged, the
|
||||
cached body is returned without a full download.
|
||||
"""
|
||||
url = f"{OPENMODELDB_API_BASE}/{name}.json"
|
||||
disk_path = self._dump_path(name)
|
||||
have_cached = os.path.exists(disk_path)
|
||||
|
||||
downloader = await get_downloader()
|
||||
|
||||
head_etag = ""
|
||||
head_modified = ""
|
||||
try:
|
||||
head_ok, head_headers = await downloader.get_response_headers(url)
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
logger.debug("OpenModelDB HEAD probe failed for %s: %s", url, exc)
|
||||
head_ok, head_headers = False, {}
|
||||
|
||||
if head_ok and isinstance(head_headers, dict):
|
||||
# aiohttp headers are case-insensitive; plain dicts in tests are not.
|
||||
head_etag = str(head_headers.get("ETag") or head_headers.get("etag") or "")
|
||||
head_modified = str(
|
||||
head_headers.get("Last-Modified") or head_headers.get("last-modified") or ""
|
||||
)
|
||||
|
||||
if (
|
||||
have_cached
|
||||
and known_etag
|
||||
and head_etag
|
||||
and head_etag == known_etag
|
||||
):
|
||||
cached = self._read_dump_file(disk_path)
|
||||
if cached is not None:
|
||||
logger.debug("OpenModelDB %s unchanged (etag match); using cache", name)
|
||||
return cached, known_etag, known_last_modified or head_modified
|
||||
|
||||
success, payload = await downloader.make_request("GET", url, use_auth=False)
|
||||
if isinstance(payload, RateLimitError):
|
||||
raise payload
|
||||
if not success or not isinstance(payload, dict):
|
||||
logger.warning(
|
||||
"OpenModelDB %s fetch failed: %s",
|
||||
name,
|
||||
payload if isinstance(payload, str) else "unexpected payload",
|
||||
)
|
||||
if have_cached:
|
||||
cached = self._read_dump_file(disk_path)
|
||||
if cached is not None:
|
||||
return cached, known_etag, known_last_modified
|
||||
return None, known_etag, known_last_modified
|
||||
|
||||
return payload, head_etag or known_etag, head_modified or known_last_modified
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Index and transformation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _install_payloads(self, payloads: Dict[str, Dict[str, Any]]) -> None:
|
||||
"""Install dump payloads and rebuild the sha256 index."""
|
||||
self._models = payloads.get("models") or {}
|
||||
self._users = payloads.get("users") or {}
|
||||
self._tags = payloads.get("tags") or {}
|
||||
self._architectures = payloads.get("architectures") or {}
|
||||
self._index = self._build_index(self._models)
|
||||
self._loaded_at = time.monotonic()
|
||||
logger.debug(
|
||||
"OpenModelDB catalogue loaded: %d models, %d indexed hashes",
|
||||
len(self._models),
|
||||
len(self._index),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_index(
|
||||
models: Dict[str, Dict[str, Any]]
|
||||
) -> Dict[str, Tuple[str, Dict[str, Any], Dict[str, Any]]]:
|
||||
"""Build the sha256 -> (model_id, model entry, resource) index."""
|
||||
index: Dict[str, Tuple[str, Dict[str, Any], Dict[str, Any]]] = {}
|
||||
for model_id, entry in models.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
resources = entry.get("resources")
|
||||
if not isinstance(resources, list):
|
||||
continue
|
||||
for resource in resources:
|
||||
if not isinstance(resource, dict):
|
||||
continue
|
||||
sha256 = resource.get("sha256")
|
||||
if not isinstance(sha256, str) or not sha256:
|
||||
continue
|
||||
# First writer wins: duplicate hashes across catalogue entries
|
||||
# are ambiguous and cannot be disambiguated locally.
|
||||
index.setdefault(sha256.lower(), (model_id, entry, resource))
|
||||
return index
|
||||
|
||||
def _resolve_authors(self, entry: Dict[str, Any]) -> Tuple[str, List[str]]:
|
||||
"""Resolve the author field to a display name plus the raw user ids."""
|
||||
raw = entry.get("author")
|
||||
author_ids = raw if isinstance(raw, list) else [raw]
|
||||
ids = [str(a) for a in author_ids if isinstance(a, str) and a]
|
||||
names: List[str] = []
|
||||
for author_id in ids:
|
||||
user = self._users.get(author_id)
|
||||
name = user.get("name") if isinstance(user, dict) else None
|
||||
names.append(name if isinstance(name, str) and name else author_id)
|
||||
return ", ".join(names), ids
|
||||
|
||||
def _resolve_tags(self, entry: Dict[str, Any]) -> List[str]:
|
||||
"""Resolve tag ids to their display names."""
|
||||
raw_tags = entry.get("tags")
|
||||
if not isinstance(raw_tags, list):
|
||||
return []
|
||||
resolved: List[str] = []
|
||||
for tag_id in raw_tags:
|
||||
if not isinstance(tag_id, str) or not tag_id:
|
||||
continue
|
||||
tag = self._tags.get(tag_id)
|
||||
name = tag.get("name") if isinstance(tag, dict) else None
|
||||
resolved.append(name if isinstance(name, str) and name else tag_id)
|
||||
return resolved
|
||||
|
||||
def _resolve_architecture_name(self, entry: Dict[str, Any]) -> str:
|
||||
"""Resolve the architecture id to its display name."""
|
||||
arch_id = entry.get("architecture")
|
||||
if not isinstance(arch_id, str) or not arch_id:
|
||||
return ""
|
||||
arch = self._architectures.get(arch_id)
|
||||
if isinstance(arch, dict):
|
||||
name = arch.get("name")
|
||||
if isinstance(name, str) and name:
|
||||
return name
|
||||
return arch_id
|
||||
|
||||
@staticmethod
|
||||
def _resource_format(resource: Dict[str, Any]) -> str:
|
||||
"""Map an OpenModelDB resource type to a CivitAI file metadata format."""
|
||||
resource_type = str(resource.get("type") or "").lower()
|
||||
if resource_type == "safetensors":
|
||||
return "SafeTensor"
|
||||
if resource_type in ("pth", "pt", "ckpt"):
|
||||
return "PickleTensor"
|
||||
return "Other"
|
||||
|
||||
def _to_civitai_version(
|
||||
self,
|
||||
model_id: str,
|
||||
entry: Dict[str, Any],
|
||||
matched_resource: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Map an OpenModelDB catalogue entry to a CivitAI-shaped version dict.
|
||||
|
||||
Follows the same contract as the CivArchive/SQLite providers so the
|
||||
metadata sync service can merge it unchanged. Numeric ``id``/``modelId``
|
||||
are deliberately omitted: OpenModelDB ids are strings, and consumers
|
||||
treat a missing ``modelId`` as "not a CivitAI model" (no CivitAI page
|
||||
link, no update checks).
|
||||
"""
|
||||
author_display, author_ids = self._resolve_authors(entry)
|
||||
tags = self._resolve_tags(entry)
|
||||
architecture_id = entry.get("architecture")
|
||||
architecture_name = self._resolve_architecture_name(entry)
|
||||
description = entry.get("description")
|
||||
license_name = entry.get("license")
|
||||
page_url = f"{OPENMODELDB_SITE_BASE}/models/{model_id}"
|
||||
|
||||
files: List[Dict[str, Any]] = []
|
||||
resources = entry.get("resources")
|
||||
for resource in resources if isinstance(resources, list) else []:
|
||||
if not isinstance(resource, dict):
|
||||
continue
|
||||
# The displayable download URL prefers a direct-bytes mirror when
|
||||
# one exists; the filename is derived (never the raw URL basename,
|
||||
# which mediafire-style mirrors leave as "file").
|
||||
download_url = self.direct_url(resource) or self.primary_url(resource)
|
||||
sha256 = resource.get("sha256")
|
||||
size_bytes = resource.get("size")
|
||||
files.append(
|
||||
{
|
||||
"name": self.resource_filename(model_id, resource),
|
||||
"type": "Model",
|
||||
"sizeKB": (size_bytes / 1024.0)
|
||||
if isinstance(size_bytes, (int, float))
|
||||
else 0,
|
||||
"downloadUrl": download_url,
|
||||
"primary": resource is matched_resource,
|
||||
"hashes": {"SHA256": str(sha256).upper()} if sha256 else {},
|
||||
"metadata": {"format": self._resource_format(resource)},
|
||||
}
|
||||
)
|
||||
|
||||
images: List[Dict[str, Any]] = []
|
||||
# The model-level thumbnail is the site's own preview pick and larger
|
||||
# than the per-image small thumbs; the card preview derives from
|
||||
# images[0], so it leads the list.
|
||||
lead = self._model_preview_url(entry)
|
||||
if lead:
|
||||
images.append({"url": lead, "nsfwLevel": 1, "type": "image"})
|
||||
raw_images = entry.get("images")
|
||||
for image in raw_images if isinstance(raw_images, list) else []:
|
||||
if not isinstance(image, dict):
|
||||
continue
|
||||
paired = image.get("type") == "paired"
|
||||
# Paired entries show the upscaled (SR) result as the preview.
|
||||
url = self._paired_display_url(image) if paired else image.get("url")
|
||||
if not isinstance(url, str) or not url:
|
||||
continue
|
||||
if any(existing["url"] == url for existing in images):
|
||||
continue
|
||||
mapped: Dict[str, Any] = {"url": url, "nsfwLevel": 1, "type": "image"}
|
||||
thumbnail = image.get("thumbnail")
|
||||
if isinstance(thumbnail, str) and thumbnail:
|
||||
thumbnail_url = self._absolutize(thumbnail)
|
||||
if thumbnail_url != url:
|
||||
mapped["thumbnailUrl"] = thumbnail_url
|
||||
meta: Dict[str, Any] = {}
|
||||
if paired:
|
||||
comparison = image.get("SR") or image.get("LR")
|
||||
if (
|
||||
isinstance(comparison, str)
|
||||
and comparison
|
||||
and comparison != url
|
||||
and _is_ephemeral_viewer_url(comparison)
|
||||
):
|
||||
# Ephemeral imgdiff viewer session, kept for reference
|
||||
# only — it 404s outside the session and is never
|
||||
# displayable.
|
||||
meta["comparisonUrl"] = comparison
|
||||
caption = image.get("caption")
|
||||
if isinstance(caption, str) and caption:
|
||||
meta["caption"] = caption
|
||||
if meta:
|
||||
mapped["meta"] = meta
|
||||
images.append(mapped)
|
||||
|
||||
return {
|
||||
"name": entry.get("name") or model_id,
|
||||
# Upscalers are not tied to a diffusion base model.
|
||||
"baseModel": "Other",
|
||||
"description": description or "",
|
||||
"publishedAt": entry.get("date"),
|
||||
"trainedWords": [],
|
||||
"model": {
|
||||
"name": entry.get("name") or model_id,
|
||||
"type": "Upscaler",
|
||||
"nsfw": False,
|
||||
"description": description,
|
||||
"tags": tags,
|
||||
"license": license_name or "",
|
||||
},
|
||||
"creator": {"username": author_display, "image": None},
|
||||
"files": files,
|
||||
"images": images,
|
||||
"source": METADATA_SOURCE_VALUE,
|
||||
# OpenModelDB-native provenance, kept inside the persisted payload
|
||||
# so the UI can link to the model page in a later phase.
|
||||
"openmodeldb": {
|
||||
"id": model_id,
|
||||
"url": page_url,
|
||||
"authors": author_ids,
|
||||
"architecture": architecture_id or "",
|
||||
"architectureName": architecture_name,
|
||||
"scale": entry.get("scale"),
|
||||
"inputChannels": entry.get("inputChannels"),
|
||||
"outputChannels": entry.get("outputChannels"),
|
||||
"size": entry.get("size") or [],
|
||||
"license": license_name or "",
|
||||
"date": entry.get("date"),
|
||||
},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Disk cache
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _cache_dir(self) -> str:
|
||||
base = self._cache_dir_override or os.path.join(
|
||||
get_cache_base_dir(), "openmodeldb"
|
||||
)
|
||||
os.makedirs(base, exist_ok=True)
|
||||
return base
|
||||
|
||||
def _dump_path(self, name: str) -> str:
|
||||
return os.path.join(self._cache_dir(), f"{name}.json")
|
||||
|
||||
def _meta_path(self) -> str:
|
||||
return os.path.join(self._cache_dir(), _META_FILENAME)
|
||||
|
||||
def _read_meta(self) -> Dict[str, Any]:
|
||||
try:
|
||||
with open(self._meta_path(), "r", encoding="utf-8") as handle:
|
||||
meta = json.load(handle)
|
||||
return meta if isinstance(meta, dict) else {}
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Failed to read OpenModelDB cache meta: %s", exc)
|
||||
return {}
|
||||
|
||||
def _read_dump_file(self, path: str) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
return data if isinstance(data, dict) else None
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Failed to read OpenModelDB cache %s: %s", path, exc)
|
||||
return None
|
||||
|
||||
def _write_cache(
|
||||
self,
|
||||
payloads: Dict[str, Dict[str, Any]],
|
||||
etags: Dict[str, str],
|
||||
last_modified: Dict[str, str],
|
||||
) -> None:
|
||||
for name, payload in payloads.items():
|
||||
path = self._dump_path(name)
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle)
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to write OpenModelDB cache %s: %s", path, exc)
|
||||
|
||||
meta = {
|
||||
"fetched_at": time.time(),
|
||||
"etags": etags,
|
||||
"last_modified": last_modified,
|
||||
}
|
||||
try:
|
||||
with open(self._meta_path(), "w", encoding="utf-8") as handle:
|
||||
json.dump(meta, handle, indent=2)
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to write OpenModelDB cache meta: %s", exc)
|
||||
@@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional
|
||||
from ..utils.models import OtherModelMetadata
|
||||
from ..utils.file_utils import find_preview_file, normalize_path, calculate_autov3
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.sidecar_paths import get_preview_dir, is_centralized
|
||||
from ..config import config
|
||||
from .model_scanner import ModelScanner, _is_excluded_dir
|
||||
from .model_hash_index import ModelHashIndex
|
||||
@@ -72,10 +73,9 @@ class OtherScanner(ModelScanner):
|
||||
return None
|
||||
|
||||
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
dir_path = os.path.dirname(file_path)
|
||||
|
||||
# Find preview image
|
||||
preview_url = find_preview_file(base_name, dir_path)
|
||||
preview_url = find_preview_file(base_name, get_preview_dir(file_path))
|
||||
|
||||
# AutoV3 reads only the safetensors header, so it is cheap even for
|
||||
# large files; record the checked state at creation time ("" =
|
||||
@@ -333,6 +333,11 @@ class OtherScanner(ModelScanner):
|
||||
|
||||
async def _find_pending_models_from_filesystem(self) -> List[Dict[str, Any]]:
|
||||
"""Scan filesystem for other-model metadata files with pending hash status."""
|
||||
# Centralized mode stores sidecars in the mirror tree, not next to the
|
||||
# models; walk the mirror instead of the model folders.
|
||||
if is_centralized():
|
||||
return self._find_pending_models_in_sidecar_mirror()
|
||||
|
||||
pending_models = []
|
||||
|
||||
for root_path in self.get_model_roots():
|
||||
|
||||
@@ -38,6 +38,7 @@ from typing import (
|
||||
)
|
||||
|
||||
from ..utils.constants import PREVIEW_EXTENSIONS
|
||||
from ..utils.sidecar_paths import get_metadata_path, get_sidecar_dir
|
||||
from ..utils import settings_paths
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -669,13 +670,22 @@ class PendingDeleteService:
|
||||
"""Enumerate existing artifacts exactly like delete_model_artifacts."""
|
||||
main_extension = ".safetensors" if main_extension is None else main_extension
|
||||
main_file = f"{file_name}{main_extension}" if main_extension else file_name
|
||||
patterns = [main_file, f"{file_name}.metadata.json"]
|
||||
model_path = os.path.join(target_dir, main_file)
|
||||
|
||||
artifacts: List[str] = []
|
||||
main_path = os.path.abspath(model_path)
|
||||
if os.path.exists(main_path):
|
||||
artifacts.append(main_path)
|
||||
|
||||
# Sidecars/previews live in the sidecar dir (the model's own dir in
|
||||
# alongside mode, the centralized mirror otherwise).
|
||||
sidecar_dir = get_sidecar_dir(model_path)
|
||||
patterns = [os.path.basename(get_metadata_path(model_path))]
|
||||
for ext in PREVIEW_EXTENSIONS:
|
||||
patterns.append(f"{file_name}{ext}")
|
||||
|
||||
artifacts: List[str] = []
|
||||
for pattern in patterns:
|
||||
path = os.path.abspath(os.path.join(target_dir, pattern))
|
||||
path = os.path.abspath(os.path.join(sidecar_dir, pattern))
|
||||
if os.path.exists(path):
|
||||
artifacts.append(path)
|
||||
return artifacts
|
||||
@@ -694,7 +704,9 @@ class PendingDeleteService:
|
||||
"""
|
||||
for original_path in artifacts:
|
||||
staged_path = os.path.join(batch_dir, os.path.basename(original_path))
|
||||
os.rename(original_path, staged_path)
|
||||
# EXDEV-tolerant: centralized sidecars may live on a different
|
||||
# filesystem than the staging batch dir under the model root.
|
||||
self._restore_file(original_path, staged_path)
|
||||
staged_pairs.append(
|
||||
{
|
||||
"staged": os.path.abspath(staged_path),
|
||||
@@ -736,13 +748,14 @@ class PendingDeleteService:
|
||||
return staged_pairs
|
||||
|
||||
def _restore_file(self, staged_path: str, original_path: str) -> None:
|
||||
"""Restore a staged file to its original path, tolerating EXDEV.
|
||||
"""Move a file between staging and library paths, tolerating EXDEV.
|
||||
|
||||
``os.rename`` is atomic and preferred (model staging and most recipe
|
||||
restores are same-volume). Recipe staging copies into the settings-dir
|
||||
staging parent, which may live on a DIFFERENT filesystem than the
|
||||
recipes dir; rename then raises EXDEV. Fall back to ``shutil.copy2`` +
|
||||
``os.remove`` so the bytes are restored and the staged copy removed.
|
||||
staging parent, and centralized sidecars live under the configured
|
||||
sidecar root; both may live on a DIFFERENT filesystem than the target
|
||||
dir, so rename can raise EXDEV. Fall back to ``shutil.copy2`` +
|
||||
``os.remove`` so the bytes are moved and the source copy removed.
|
||||
"""
|
||||
try:
|
||||
os.rename(staged_path, original_path)
|
||||
@@ -764,7 +777,10 @@ class PendingDeleteService:
|
||||
if not os.path.exists(staged_path):
|
||||
continue
|
||||
try:
|
||||
os.rename(staged_path, original_path)
|
||||
# EXDEV-tolerant: centralized sidecars may have been copied
|
||||
# across filesystems into staging, so plain os.rename would
|
||||
# fail here and strand the only copy.
|
||||
self._restore_file(staged_path, original_path)
|
||||
except OSError as exc: # pragma: no cover - best-effort rollback
|
||||
logger.warning(
|
||||
"Failed to roll back staged file %s -> %s: %s",
|
||||
|
||||
@@ -10,6 +10,7 @@ from urllib.parse import urlparse
|
||||
from ..utils.constants import CARD_PREVIEW_WIDTH, PREVIEW_EXTENSIONS
|
||||
from ..utils.civitai_utils import rewrite_preview_url
|
||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||
from ..utils.sidecar_paths import get_metadata_path, get_preview_dir
|
||||
from .settings_manager import get_settings_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -63,6 +64,9 @@ class PreviewAssetService:
|
||||
|
||||
base_name = os.path.splitext(os.path.splitext(os.path.basename(metadata_path))[0])[0]
|
||||
preview_dir = os.path.dirname(metadata_path)
|
||||
# Centralized mirrors may not exist yet (unlike the model's own
|
||||
# directory in alongside mode).
|
||||
os.makedirs(preview_dir, exist_ok=True)
|
||||
is_video = first_preview.get("type") == "video"
|
||||
preview_url = first_preview.get("url")
|
||||
|
||||
@@ -159,7 +163,10 @@ class PreviewAssetService:
|
||||
"""Replace an existing preview asset for a model."""
|
||||
|
||||
base_name = os.path.splitext(os.path.basename(model_path))[0]
|
||||
folder = os.path.dirname(model_path)
|
||||
folder = get_preview_dir(model_path)
|
||||
# Centralized mirrors may not exist yet (unlike the model's own
|
||||
# directory in alongside mode).
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
|
||||
extension, optimized_data = await self._convert_preview(
|
||||
preview_data, content_type, original_filename
|
||||
@@ -179,7 +186,7 @@ class PreviewAssetService:
|
||||
with open(preview_path, "wb") as handle:
|
||||
handle.write(optimized_data)
|
||||
|
||||
metadata_path = os.path.splitext(model_path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(model_path)
|
||||
metadata = await metadata_loader(metadata_path)
|
||||
metadata["preview_url"] = preview_path
|
||||
metadata["preview_nsfw_level"] = nsfw_level
|
||||
|
||||
@@ -4580,14 +4580,64 @@ class RecipeScanner:
|
||||
|
||||
return syntax_parts
|
||||
|
||||
async def build_lora_hash_index(self) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Build a one-shot lowercase-LoRA-hash → recipes index.
|
||||
|
||||
Scans the recipe cache exactly once (O(recipes × loras)) and returns
|
||||
a mapping of lowercase lora ``hash`` to the list of recipe dicts
|
||||
containing it. Bulk rename loops pass this index to
|
||||
:meth:`update_lora_filename_by_hash` so per-file lookups are O(1)
|
||||
instead of rescanning every recipe for each renamed LoRA.
|
||||
"""
|
||||
cache = await self.get_cached_data()
|
||||
index: Dict[str, List[Dict[str, Any]]] = {}
|
||||
if not cache or not cache.raw_data:
|
||||
return index
|
||||
|
||||
for recipe in cache.raw_data:
|
||||
loras = recipe.get("loras", [])
|
||||
if not isinstance(loras, list):
|
||||
continue
|
||||
for lora in loras:
|
||||
if not isinstance(lora, dict):
|
||||
continue
|
||||
hash_value = (lora.get("hash") or "").lower()
|
||||
if hash_value:
|
||||
index.setdefault(hash_value, []).append(recipe)
|
||||
return index
|
||||
|
||||
async def finalize_bulk_filename_updates(self) -> None:
|
||||
"""Run once after a bulk rename session that deferred maintenance.
|
||||
|
||||
Refreshes folder metadata and schedules a single re-sort. Filename-only
|
||||
renames never change recipe folders, so the deferred refresh is
|
||||
redundant but cheap; skipping it per file is what makes bulk renames
|
||||
O(1)-per-file.
|
||||
"""
|
||||
if self._cache is None:
|
||||
return
|
||||
self._schedule_resort()
|
||||
|
||||
async def update_lora_filename_by_hash(
|
||||
self, hash_value: str, new_file_name: str
|
||||
self,
|
||||
hash_value: str,
|
||||
new_file_name: str,
|
||||
*,
|
||||
hash_index: Optional[Dict[str, List[Dict[str, Any]]]] = None,
|
||||
defer_maintenance: bool = False,
|
||||
) -> Tuple[int, int]:
|
||||
"""Update file_name in all recipes that contain a LoRA with the specified hash.
|
||||
|
||||
Args:
|
||||
hash_value: The SHA256 hash value of the LoRA
|
||||
new_file_name: The new file_name to set
|
||||
hash_index: Optional prebuilt index from
|
||||
:meth:`build_lora_hash_index`. When given, the O(recipes)
|
||||
cache scan (and its folder-metadata walk) is skipped and the
|
||||
affected recipes are looked up directly — the bulk rename path.
|
||||
defer_maintenance: When True, skip the folder-metadata refresh and
|
||||
resort scheduling. The caller MUST run
|
||||
:meth:`finalize_bulk_filename_updates` exactly once afterwards.
|
||||
|
||||
Returns:
|
||||
Tuple[int, int]: (number of recipes updated in files, number of recipes updated in cache)
|
||||
@@ -4598,17 +4648,21 @@ class RecipeScanner:
|
||||
# Always use lowercase hash for consistency
|
||||
hash_value = hash_value.lower()
|
||||
|
||||
# Get cache
|
||||
cache = await self.get_cached_data()
|
||||
if not cache or not cache.raw_data:
|
||||
return 0, 0
|
||||
if hash_index is not None:
|
||||
candidate_recipes = hash_index.get(hash_value, [])
|
||||
else:
|
||||
# Get cache
|
||||
cache = await self.get_cached_data()
|
||||
if not cache or not cache.raw_data:
|
||||
return 0, 0
|
||||
candidate_recipes = cache.raw_data
|
||||
|
||||
file_updated_count = 0
|
||||
cache_updated_count = 0
|
||||
|
||||
# Find recipes that need updating from the cache
|
||||
# Find recipes that need updating
|
||||
recipes_to_update = []
|
||||
for recipe in cache.raw_data:
|
||||
for recipe in candidate_recipes:
|
||||
loras = recipe.get("loras", [])
|
||||
if not isinstance(loras, list):
|
||||
continue
|
||||
@@ -4654,7 +4708,9 @@ class RecipeScanner:
|
||||
# We don't necessarily need to resort because LoRA file_name isn't a sort key,
|
||||
# but we might want to schedule a resort if we're paranoid or if searching relies on sorted state.
|
||||
# Given it's a rename of a dependency, search results might change if searching by LoRA name.
|
||||
self._schedule_resort()
|
||||
# Bulk callers defer this to a single finalize_bulk_filename_updates() call.
|
||||
if not defer_maintenance:
|
||||
self._schedule_resort()
|
||||
|
||||
return file_updated_count, cache_updated_count
|
||||
|
||||
|
||||
@@ -117,6 +117,10 @@ class RecipeAnalysisService:
|
||||
image_info: Optional[dict[str, Any]] = None
|
||||
is_video = False
|
||||
extension = ".jpg" # Default
|
||||
# Workflow recovered from the image. CivitAI's optimized renditions are
|
||||
# re-encoded and carry no metadata, so for those the workflow only
|
||||
# exists in the original rendition, fetched below for EXIF extraction.
|
||||
recovered_workflow: Optional[str] = None
|
||||
# Diagnostics collected during analysis; surfaced in the payload so
|
||||
# callers can persist an import_info block explaining empty LoRA lists.
|
||||
diagnostics: dict[str, Any] = {"channel": "url"}
|
||||
@@ -238,6 +242,9 @@ class RecipeAnalysisService:
|
||||
exif_metadata = await asyncio.to_thread(
|
||||
self._exif_utils.extract_image_metadata, temp_path
|
||||
)
|
||||
recovered_workflow = await asyncio.to_thread(
|
||||
self._read_embedded_workflow, temp_path
|
||||
)
|
||||
|
||||
# Fallback: try the original (non-optimized) image for EXIF data
|
||||
if not exif_metadata and civitai_image_id and image_info:
|
||||
@@ -255,6 +262,16 @@ class RecipeAnalysisService:
|
||||
self._exif_utils.extract_image_metadata,
|
||||
orig_temp_path,
|
||||
)
|
||||
# The original is also the only place a ComfyUI
|
||||
# workflow survives; carry it so the save step can
|
||||
# embed it even though the stored preview stays the
|
||||
# small, metadata-free optimized rendition.
|
||||
recovered_workflow = (
|
||||
await asyncio.to_thread(
|
||||
self._read_embedded_workflow, orig_temp_path
|
||||
)
|
||||
or recovered_workflow
|
||||
)
|
||||
finally:
|
||||
self._safe_cleanup(orig_temp_path)
|
||||
|
||||
@@ -358,6 +375,8 @@ class RecipeAnalysisService:
|
||||
|
||||
diagnostics["is_video"] = is_video
|
||||
result.payload["diagnostics"] = diagnostics
|
||||
if recovered_workflow:
|
||||
result.payload["workflow"] = recovered_workflow
|
||||
return result
|
||||
finally:
|
||||
if temp_path:
|
||||
@@ -545,6 +564,25 @@ class RecipeAnalysisService:
|
||||
if not success:
|
||||
raise RecipeDownloadError(f"Failed to download image from URL: {result}")
|
||||
|
||||
def _read_embedded_workflow(self, image_path: Optional[str]) -> Optional[str]:
|
||||
"""Return a ComfyUI workflow embedded in ``image_path``, if any.
|
||||
|
||||
The raw metadata string extractor stops at the generation parameters
|
||||
(``prompt``/``parameters``), so the UI-format workflow has to be read
|
||||
through the structured metadata reader. Failures map to ``None``.
|
||||
"""
|
||||
if not image_path or not os.path.exists(image_path):
|
||||
return None
|
||||
try:
|
||||
metadata = self._exif_utils._load_structured_metadata(image_path)
|
||||
except Exception as exc:
|
||||
self._logger.debug(
|
||||
"Failed to read embedded workflow from %s: %s", image_path, exc
|
||||
)
|
||||
return None
|
||||
workflow = metadata.get("workflow") if isinstance(metadata, dict) else None
|
||||
return workflow if isinstance(workflow, str) and workflow else None
|
||||
|
||||
def _metadata_not_found_response(self, path: str) -> AnalysisResult:
|
||||
payload: dict[str, Any] = {
|
||||
"error": "No metadata found in this image",
|
||||
|
||||
@@ -18,6 +18,7 @@ from ...utils.base_model import (
|
||||
RELATION_INCOMPATIBLE,
|
||||
base_model_relation,
|
||||
)
|
||||
from ...utils.constants import MAX_WORKFLOW_EMBED_BYTES
|
||||
from ...utils.utils import calculate_recipe_fingerprint
|
||||
from ..pending_delete_service import get_pending_delete_service
|
||||
from .errors import RecipeNotFoundError, RecipeValidationError
|
||||
@@ -73,6 +74,11 @@ class RecipePersistenceService:
|
||||
byte-level EXIF update that leaves the pixels untouched). Used
|
||||
by local re-import, where the source is the recipe's own
|
||||
already-optimized preview image.
|
||||
|
||||
``metadata`` may carry a ``workflow`` entry (JSON string, dict or
|
||||
list) recovered from the source's original rendition; it is embedded
|
||||
into the stored image so the recipe reports ``has_workflow`` and can
|
||||
send the workflow back to ComfyUI.
|
||||
"""
|
||||
|
||||
missing_fields = []
|
||||
@@ -87,6 +93,13 @@ class RecipePersistenceService:
|
||||
|
||||
assert metadata is not None
|
||||
|
||||
# A workflow recovered from a higher-fidelity source (CivitAI's
|
||||
# original rendition — its optimized preview is re-encoded and carries
|
||||
# no metadata) travels as data instead of as image bytes. It is
|
||||
# embedded below so ``has_workflow`` and the "send workflow to ComfyUI"
|
||||
# action work for imports whose preview pixels are metadata-free.
|
||||
workflow = metadata.get("workflow")
|
||||
|
||||
resolved_image_bytes = self._resolve_image_bytes(image_bytes, image_base64)
|
||||
recipes_dir = target_dir or recipe_scanner.recipes_dir
|
||||
os.makedirs(recipes_dir, exist_ok=True)
|
||||
@@ -108,6 +121,7 @@ class RecipePersistenceService:
|
||||
format="webp",
|
||||
quality=85,
|
||||
preserve_metadata=True,
|
||||
workflow=workflow,
|
||||
)
|
||||
|
||||
image_filename = f"{recipe_id}{extension}"
|
||||
@@ -116,6 +130,12 @@ class RecipePersistenceService:
|
||||
with open(normalized_image_path, "wb") as file_obj:
|
||||
file_obj.write(optimized_image)
|
||||
|
||||
# The optimization branch above embeds the workflow while re-encoding;
|
||||
# the verbatim (skip_optimize) branch still needs it added, and this is
|
||||
# also the safety net when re-encoding dropped it.
|
||||
if workflow and not is_video:
|
||||
self._exif_utils.embed_workflow(normalized_image_path, workflow)
|
||||
|
||||
current_time = time.time()
|
||||
loras_data = [self._normalise_lora_entry(lora) for lora in (metadata.get("loras") or [])]
|
||||
checkpoint_entry = self._sanitize_checkpoint_entry(self._extract_checkpoint_entry(metadata))
|
||||
@@ -855,8 +875,15 @@ class RecipePersistenceService:
|
||||
recipe_scanner,
|
||||
metadata: dict[str, Any],
|
||||
image_bytes: bytes,
|
||||
workflow: Any = None,
|
||||
) -> PersistenceResult:
|
||||
"""Save a recipe constructed from widget metadata."""
|
||||
"""Save a recipe constructed from widget metadata.
|
||||
|
||||
``workflow`` is the caller's ComfyUI graph (UI or API format) to embed
|
||||
in the stored preview. Embedding is opt-in because the graph is by far
|
||||
the largest metadata field and its widget values may contain sensitive
|
||||
data; an oversized graph is dropped rather than inflating the preview.
|
||||
"""
|
||||
|
||||
if not metadata:
|
||||
raise RecipeValidationError("No generation metadata found")
|
||||
@@ -865,12 +892,25 @@ class RecipePersistenceService:
|
||||
os.makedirs(recipes_dir, exist_ok=True)
|
||||
|
||||
recipe_id = str(uuid.uuid4())
|
||||
|
||||
workflow_json = self._exif_utils.normalise_workflow(workflow)
|
||||
workflow_skipped: Optional[str] = None
|
||||
if workflow_json and len(workflow_json.encode("utf-8")) > MAX_WORKFLOW_EMBED_BYTES:
|
||||
self._logger.warning(
|
||||
"Widget workflow is %d bytes (limit %d); saving recipe without it",
|
||||
len(workflow_json),
|
||||
MAX_WORKFLOW_EMBED_BYTES,
|
||||
)
|
||||
workflow_json = None
|
||||
workflow_skipped = "too_large"
|
||||
|
||||
optimized_image, extension = self._exif_utils.optimize_image(
|
||||
image_data=image_bytes,
|
||||
target_width=self._card_preview_width,
|
||||
format="webp",
|
||||
quality=85,
|
||||
preserve_metadata=True,
|
||||
workflow=workflow_json,
|
||||
)
|
||||
image_filename = f"{recipe_id}{extension}"
|
||||
image_path = os.path.join(recipes_dir, image_filename)
|
||||
@@ -924,9 +964,9 @@ class RecipePersistenceService:
|
||||
if key not in ["checkpoint", "loras"]
|
||||
},
|
||||
"loras_stack": lora_stack,
|
||||
# Widget saves re-encode an in-memory tensor to PNG/WebP with no
|
||||
# embedded metadata chunks, so a workflow can never be present.
|
||||
"has_workflow": False,
|
||||
# Set by detection below: the workflow is embedded during
|
||||
# re-encoding only when the caller opted in and it fit the cap.
|
||||
"has_workflow": self._detect_has_workflow(image_path),
|
||||
# Widget saves read LoRAs straight from the current workflow; an
|
||||
# empty list means the workflow used no LoRAs.
|
||||
"import_info": build_import_info(CHANNEL_WIDGET, None, loras_data),
|
||||
@@ -942,15 +982,17 @@ class RecipePersistenceService:
|
||||
self._exif_utils.append_recipe_metadata(image_path, recipe_data)
|
||||
await recipe_scanner.add_recipe(recipe_data)
|
||||
|
||||
return PersistenceResult(
|
||||
{
|
||||
"success": True,
|
||||
"recipe_id": recipe_id,
|
||||
"image_path": image_path,
|
||||
"json_path": json_path,
|
||||
"recipe_name": recipe_name,
|
||||
}
|
||||
)
|
||||
payload: dict[str, Any] = {
|
||||
"success": True,
|
||||
"recipe_id": recipe_id,
|
||||
"image_path": image_path,
|
||||
"json_path": json_path,
|
||||
"recipe_name": recipe_name,
|
||||
"has_workflow": recipe_data["has_workflow"],
|
||||
}
|
||||
if workflow_skipped:
|
||||
payload["workflow_skipped"] = workflow_skipped
|
||||
return PersistenceResult(payload)
|
||||
|
||||
# Helper methods ---------------------------------------------------
|
||||
|
||||
|
||||
@@ -251,7 +251,28 @@ class ServiceRegistry:
|
||||
cls._services[service_name] = client
|
||||
logger.debug(f"Created and registered {service_name}")
|
||||
return client
|
||||
|
||||
|
||||
@classmethod
|
||||
async def get_openmodeldb_client(cls):
|
||||
"""Get or create OpenModelDB client instance"""
|
||||
service_name = "openmodeldb_client"
|
||||
|
||||
if service_name in cls._services:
|
||||
return cls._services[service_name]
|
||||
|
||||
async with cls._get_lock(service_name):
|
||||
# Double-check after acquiring lock
|
||||
if service_name in cls._services:
|
||||
return cls._services[service_name]
|
||||
|
||||
# Import here to avoid circular imports
|
||||
from .openmodeldb_client import OpenModelDBClient
|
||||
|
||||
client = await OpenModelDBClient.get_instance()
|
||||
cls._services[service_name] = client
|
||||
logger.debug(f"Created and registered {service_name}")
|
||||
return client
|
||||
|
||||
@classmethod
|
||||
async def get_download_manager(cls):
|
||||
"""Get or create Download manager instance"""
|
||||
|
||||
@@ -79,6 +79,9 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"dismissed_banners": [],
|
||||
"enable_metadata_archive_db": False,
|
||||
"enable_civarchive_api": True,
|
||||
# OpenModelDB supplies read-only metadata for upscaler models (the "other"
|
||||
# page's upscaler sub_type) via hash matching against its bulk catalogue.
|
||||
"enable_openmodeldb_api": True,
|
||||
"metadata_provider_order": "civitai_archive_sqlite",
|
||||
"rate_limit_gate_enabled": True,
|
||||
"rate_limit_max_wait_seconds": 300,
|
||||
@@ -99,6 +102,8 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"enable_other_models": False,
|
||||
"enabled_other_sub_types": list(DEFAULT_ENABLED_OTHER_SUB_TYPES),
|
||||
"recipes_path": "",
|
||||
"sidecar_storage_mode": "alongside",
|
||||
"sidecar_storage_path": "",
|
||||
"base_model_path_mappings": {},
|
||||
"download_path_templates": {},
|
||||
"download_filename_templates": {},
|
||||
@@ -116,6 +121,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"display_density": "default",
|
||||
"recipes_layout": "grid",
|
||||
"card_info_display": "always",
|
||||
"showcase_layout": "gallery",
|
||||
"include_trigger_words": False,
|
||||
"compact_mode": False,
|
||||
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),
|
||||
@@ -128,6 +134,9 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"metadata_refresh_skip_paths": [],
|
||||
"skip_previously_downloaded_model_versions": False,
|
||||
"download_skip_base_models": [],
|
||||
# Routing target for checkpoint downloads whose baseModel is neither a
|
||||
# known diffusion model nor a known full checkpoint (CHECKPOINT_BASE_MODELS).
|
||||
"unknown_base_model_routing": "diffusion_model",
|
||||
"backup_auto_enabled": True,
|
||||
"backup_retention_count": 5,
|
||||
"use_new_license_icons": True,
|
||||
@@ -1616,9 +1625,40 @@ class SettingsManager:
|
||||
|
||||
return os.path.abspath(os.path.normpath(os.path.expanduser(stripped)))
|
||||
|
||||
@staticmethod
|
||||
def _normalize_sidecar_storage_mode(value: Any) -> str:
|
||||
"""Return a valid sidecar storage mode, falling back to ``alongside``."""
|
||||
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in ("alongside", "centralized"):
|
||||
return normalized
|
||||
return "alongside"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_unknown_base_model_routing(value: Any) -> str:
|
||||
"""Return a valid unknown-base-model routing target, falling back to ``diffusion_model``."""
|
||||
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in ("diffusion_model", "checkpoint"):
|
||||
return normalized
|
||||
return "diffusion_model"
|
||||
|
||||
def _refresh_sidecar_storage_config(self) -> None:
|
||||
"""Rebuild dependent config state after sidecar storage settings change."""
|
||||
|
||||
try:
|
||||
from ..config import config # Local import to avoid circular dependency
|
||||
|
||||
config.refresh_preview_roots()
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.debug(
|
||||
"Failed to refresh config after sidecar storage change: %s", exc
|
||||
)
|
||||
|
||||
def _get_effective_recipes_dir(self, recipes_path: Optional[str] = None) -> str:
|
||||
"""Resolve the effective recipes directory for the active library."""
|
||||
|
||||
normalized_custom = self._normalize_recipes_path_value(
|
||||
self.settings.get("recipes_path", "")
|
||||
if recipes_path is None
|
||||
@@ -1815,6 +1855,12 @@ class SettingsManager:
|
||||
target_recipes_dir = self._get_effective_recipes_dir(value)
|
||||
self._validate_recipes_storage_path(target_recipes_dir)
|
||||
self._migrate_recipes_directory(current_recipes_dir, target_recipes_dir)
|
||||
elif key == "sidecar_storage_mode":
|
||||
value = self._normalize_sidecar_storage_mode(value)
|
||||
elif key == "unknown_base_model_routing":
|
||||
value = self._normalize_unknown_base_model_routing(value)
|
||||
elif key == "sidecar_storage_path":
|
||||
value = self._normalize_recipes_path_value(value)
|
||||
self.settings[key] = value
|
||||
portable_switch_pending = False
|
||||
if key == "use_portable_settings" and isinstance(value, bool):
|
||||
@@ -1845,6 +1891,8 @@ class SettingsManager:
|
||||
self._save_settings()
|
||||
if key == "recipes_path":
|
||||
self._notify_library_change(self.get_active_library_name())
|
||||
if key in ("sidecar_storage_mode", "sidecar_storage_path"):
|
||||
self._refresh_sidecar_storage_config()
|
||||
if key in ("enable_other_models", "enabled_other_sub_types"):
|
||||
self._apply_other_model_settings_change()
|
||||
if portable_switch_pending:
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from typing import Awaitable, Callable, Dict, List, Sequence, Tuple
|
||||
|
||||
from ..utils.sidecar_paths import get_metadata_path
|
||||
from .auto_tag_service import extract_auto_tags
|
||||
|
||||
|
||||
@@ -24,8 +23,7 @@ class TagUpdateService:
|
||||
update_cache: Callable[[str, str, Dict[str, object]], Awaitable[bool]],
|
||||
) -> Tuple[List[str], List[str]]:
|
||||
"""Add tags to a metadata entry and return updated tags and auto_tags."""
|
||||
base, _ = os.path.splitext(file_path)
|
||||
metadata_path = f"{base}.metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
metadata = await metadata_loader(metadata_path)
|
||||
|
||||
raw_tags = metadata.get("tags", [])
|
||||
|
||||
@@ -21,6 +21,10 @@ from .example_images import (
|
||||
ImportExampleImagesValidationError,
|
||||
)
|
||||
from .filename_template_use_case import FilenameTemplateUseCase
|
||||
from .sidecar_migration_use_case import (
|
||||
SidecarMigrationProgressReporter,
|
||||
SidecarMigrationUseCase,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AutoOrganizeInProgressError",
|
||||
@@ -36,4 +40,6 @@ __all__ = [
|
||||
"ImportExampleImagesUseCase",
|
||||
"ImportExampleImagesValidationError",
|
||||
"FilenameTemplateUseCase",
|
||||
"SidecarMigrationProgressReporter",
|
||||
"SidecarMigrationUseCase",
|
||||
]
|
||||
|
||||
@@ -29,6 +29,7 @@ class ImportExampleImagesUseCase:
|
||||
|
||||
async def execute(self, request: web.Request) -> Dict[str, Any]:
|
||||
model_hash: str | None = None
|
||||
model_path: str | None = None
|
||||
files_to_import: List[str] = []
|
||||
temp_files: List[str] = []
|
||||
|
||||
@@ -40,6 +41,8 @@ class ImportExampleImagesUseCase:
|
||||
first_field = cast(BodyPartReader, first_field_raw) if first_field_raw is not None else None
|
||||
if first_field and first_field.name == "model_hash":
|
||||
model_hash = await first_field.text()
|
||||
elif first_field and first_field.name == "model_path":
|
||||
model_path = await first_field.text()
|
||||
else:
|
||||
# Support clients that send files first and hash later
|
||||
if first_field is not None:
|
||||
@@ -49,13 +52,22 @@ class ImportExampleImagesUseCase:
|
||||
field = cast(BodyPartReader, raw_field)
|
||||
if field.name == "model_hash" and not model_hash:
|
||||
model_hash = await field.text()
|
||||
elif field.name == "model_path" and not model_path:
|
||||
model_path = await field.text()
|
||||
elif field.name == "files":
|
||||
await self._collect_upload_file(field, files_to_import, temp_files)
|
||||
else:
|
||||
data = await request.json()
|
||||
model_hash = data.get("model_hash")
|
||||
model_path = data.get("model_path")
|
||||
files_to_import = list(data.get("file_paths", []))
|
||||
|
||||
# Models with a deferred hash (checkpoints, Other) send an empty
|
||||
# model_hash; locate them by file path and compute the hash on
|
||||
# demand, since example-image folders are keyed by hash.
|
||||
if not model_hash and model_path:
|
||||
model_hash = await self._processor.resolve_hash_for_file_path(model_path)
|
||||
|
||||
if not model_hash:
|
||||
raise ImportExampleImagesValidationError("Missing model_hash parameter")
|
||||
result = await self._processor.import_images(model_hash, files_to_import)
|
||||
|
||||
@@ -12,6 +12,7 @@ import os
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence
|
||||
|
||||
from ...utils.constants import AUTO_ORGANIZE_BATCH_SIZE
|
||||
from ...utils.sidecar_paths import get_metadata_path
|
||||
from ...utils.utils import calculate_filename_for_model
|
||||
from ..model_file_service import AutoOrganizeResult, ProgressCallback
|
||||
from ..model_lifecycle_service import ModelLifecycleService, load_local_metadata
|
||||
@@ -32,7 +33,9 @@ class FilenameTemplateUseCase:
|
||||
An empty template restores the recorded original filename instead of
|
||||
rendering a template. Shares the auto-organize lock (and its in-progress
|
||||
error) so a bulk rename never runs concurrently with an auto-organize
|
||||
operation.
|
||||
operation. The whole loop runs inside a bulk rename session so cache
|
||||
persist/resort and recipe maintenance happen once at the end instead of
|
||||
per renamed file.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -105,23 +108,24 @@ class FilenameTemplateUseCase:
|
||||
|
||||
await self._emit_progress(progress_callback, result, "started")
|
||||
|
||||
for index in range(0, result.total, AUTO_ORGANIZE_BATCH_SIZE):
|
||||
if self._scanner.is_cancelled():
|
||||
logger.info(
|
||||
"Filename template apply cancelled for %s", self._model_type
|
||||
)
|
||||
break
|
||||
|
||||
batch = models[index : index + AUTO_ORGANIZE_BATCH_SIZE]
|
||||
for model in batch:
|
||||
async with self._lifecycle_service.bulk_rename_session() as bulk_context:
|
||||
for index in range(0, result.total, AUTO_ORGANIZE_BATCH_SIZE):
|
||||
if self._scanner.is_cancelled():
|
||||
logger.info(
|
||||
"Filename template apply cancelled for %s", self._model_type
|
||||
)
|
||||
break
|
||||
await self._process_model(model, template, result)
|
||||
result.processed += 1
|
||||
|
||||
await self._emit_progress(progress_callback, result, "processing")
|
||||
# Yield between batches so the server stays responsive.
|
||||
await asyncio.sleep(0.1)
|
||||
batch = models[index : index + AUTO_ORGANIZE_BATCH_SIZE]
|
||||
for model in batch:
|
||||
if self._scanner.is_cancelled():
|
||||
break
|
||||
await self._process_model(model, template, result, bulk_context)
|
||||
result.processed += 1
|
||||
|
||||
await self._emit_progress(progress_callback, result, "processing")
|
||||
# Yield between batches so the server stays responsive.
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
if self._scanner.is_cancelled():
|
||||
result.status = "cancelled"
|
||||
@@ -149,6 +153,7 @@ class FilenameTemplateUseCase:
|
||||
model: Dict[str, Any],
|
||||
template: str,
|
||||
result: AutoOrganizeResult,
|
||||
bulk_context: Any = None,
|
||||
) -> None:
|
||||
model_name = model.get("model_name", "Unknown")
|
||||
try:
|
||||
@@ -176,7 +181,7 @@ class FilenameTemplateUseCase:
|
||||
return
|
||||
|
||||
await self._lifecycle_service.rename_model(
|
||||
file_path=file_path, new_file_name=new_stem
|
||||
file_path=file_path, new_file_name=new_stem, bulk_context=bulk_context
|
||||
)
|
||||
result.success_count += 1
|
||||
|
||||
@@ -200,7 +205,7 @@ class FilenameTemplateUseCase:
|
||||
sidecar or no ``original_file_name`` entry exists (models never
|
||||
renamed, or renamed before the recording shipped).
|
||||
"""
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
metadata = await self._metadata_loader(metadata_path)
|
||||
original = metadata.get("original_file_name")
|
||||
if not isinstance(original, str):
|
||||
|
||||
@@ -0,0 +1,734 @@
|
||||
"""Use case migrating sidecar metadata and previews between storage layouts.
|
||||
|
||||
Two storage layouts exist (see :mod:`py.utils.sidecar_paths`):
|
||||
|
||||
- ``alongside``: ``<model_dir>/<name>.metadata.json`` and preview files live
|
||||
next to the model file.
|
||||
- ``centralized``: the same files live under the configured sidecar root,
|
||||
mirroring each model root's directory structure under a per-root identity
|
||||
component (see :func:`py.utils.sidecar_paths.root_mirror_component`).
|
||||
|
||||
This use case moves the ``.metadata.json`` sidecar and preview files for every
|
||||
known model from one layout to the other. Model files themselves NEVER move.
|
||||
Paths inside the moved sidecar (``file_path``, ``file_name``, ``preview_url``)
|
||||
are rewritten the same way :meth:`ModelScanner._update_metadata_paths` does.
|
||||
After the move, scanner caches are reconciled so the list API immediately
|
||||
serves the new preview locations instead of stale pre-migration URLs.
|
||||
|
||||
Intended flow (settings-first):
|
||||
|
||||
1. The user switches ``sidecar_storage_mode`` (and optionally
|
||||
``sidecar_storage_path``) in settings.
|
||||
2. The migration runs in the direction of the NEW mode with ``force=True``.
|
||||
After the switch, files in the OLD layout are the source of truth; the
|
||||
guard below would otherwise refuse to run because the active mode already
|
||||
matches the migration target.
|
||||
|
||||
Both orderings work because all path computations are mode-independent: the
|
||||
alongside location is derived from the model path directly, and the mirror
|
||||
location is resolved via ``get_configured_sidecar_root()``, which ignores the
|
||||
active mode.
|
||||
|
||||
Guards (pass ``force=True`` to bypass):
|
||||
|
||||
- ``migrate_to_centralized`` refuses when centralized storage is already the
|
||||
active, resolvable mode.
|
||||
- ``migrate_to_alongside`` refuses when the active mode is ``alongside``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Protocol, Sequence, Tuple
|
||||
|
||||
from ..service_registry import ServiceRegistry
|
||||
from ..settings_manager import get_settings_manager
|
||||
from ...utils.constants import PREVIEW_EXTENSIONS
|
||||
from ...utils.file_utils import find_preview_file, get_preview_extension
|
||||
from ...utils.metadata_manager import MetadataManager
|
||||
from ...utils.sidecar_paths import (
|
||||
METADATA_SUFFIX,
|
||||
ROOT_MAP_FILENAME,
|
||||
STORAGE_MODE_CENTRALIZED,
|
||||
get_configured_sidecar_root,
|
||||
get_sidecar_root,
|
||||
get_storage_mode,
|
||||
relocate_root_map,
|
||||
resolve_centralized_dir_for_dir,
|
||||
)
|
||||
|
||||
|
||||
class SidecarMigrationProgressReporter(Protocol):
|
||||
"""Protocol for progress reporters used during sidecar migration."""
|
||||
|
||||
async def on_progress(self, payload: Dict[str, Any]) -> None:
|
||||
"""Handle a sidecar migration progress update."""
|
||||
|
||||
|
||||
ScannerFactory = Callable[[], Awaitable[Any]]
|
||||
|
||||
DIRECTION_TO_CENTRALIZED = "to_centralized"
|
||||
DIRECTION_TO_ALONGSIDE = "to_alongside"
|
||||
DIRECTION_RELOCATE_ROOT = "relocate_root"
|
||||
|
||||
# Same candidate set find_preview_file recognizes: every PREVIEW_EXTENSIONS
|
||||
# suffix plus the legacy ".example.0.jpeg" (issue #225).
|
||||
_PREVIEW_CANDIDATE_EXTENSIONS = tuple(PREVIEW_EXTENSIONS) + (".example.0.jpeg",)
|
||||
|
||||
|
||||
def _enumerate_preview_names(directory: str, stem: str) -> List[str]:
|
||||
"""Return preview filenames for ``stem`` present in ``directory``.
|
||||
|
||||
Case-insensitive full-name match against the preview candidate set, so
|
||||
files like ``model.WEBP`` or ``model.Png`` placed by external tools are
|
||||
migrated along with the exact-case variants.
|
||||
"""
|
||||
|
||||
targets = {f"{stem.lower()}{ext}" for ext in _PREVIEW_CANDIDATE_EXTENSIONS}
|
||||
try:
|
||||
entries = os.listdir(directory)
|
||||
except OSError:
|
||||
return []
|
||||
return [entry for entry in entries if entry.lower() in targets]
|
||||
|
||||
|
||||
class SidecarMigrationUseCase:
|
||||
"""Move sidecars and previews between alongside and centralized layouts."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
scanner_factories: Sequence[Tuple[str, ScannerFactory]] | None = None,
|
||||
settings_service=None,
|
||||
logger: Optional[logging.Logger] = None,
|
||||
) -> None:
|
||||
self._settings = settings_service or get_settings_manager()
|
||||
self._scanner_factories: Tuple[Tuple[str, ScannerFactory], ...] = tuple(
|
||||
scanner_factories
|
||||
or (
|
||||
("lora", ServiceRegistry.get_lora_scanner),
|
||||
("checkpoint", ServiceRegistry.get_checkpoint_scanner),
|
||||
("embedding", ServiceRegistry.get_embedding_scanner),
|
||||
("other", ServiceRegistry.get_other_scanner),
|
||||
)
|
||||
)
|
||||
self._logger = logger or logging.getLogger(__name__)
|
||||
|
||||
async def migrate_to_centralized(
|
||||
self,
|
||||
progress_cb: Optional[SidecarMigrationProgressReporter] = None,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Move sidecars/previews from alongside the models into the mirror root."""
|
||||
|
||||
if (
|
||||
not force
|
||||
and get_storage_mode() == STORAGE_MODE_CENTRALIZED
|
||||
and get_sidecar_root()
|
||||
):
|
||||
return self._refusal(
|
||||
DIRECTION_TO_CENTRALIZED,
|
||||
"sidecar storage is already centralized; pass force=true to migrate anyway",
|
||||
)
|
||||
return await self._migrate(
|
||||
direction=DIRECTION_TO_CENTRALIZED,
|
||||
to_centralized=True,
|
||||
progress_cb=progress_cb,
|
||||
)
|
||||
|
||||
async def migrate_to_alongside(
|
||||
self,
|
||||
progress_cb: Optional[SidecarMigrationProgressReporter] = None,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Move sidecars/previews from the mirror root back next to the models."""
|
||||
|
||||
if not force and get_storage_mode() != STORAGE_MODE_CENTRALIZED:
|
||||
return self._refusal(
|
||||
DIRECTION_TO_ALONGSIDE,
|
||||
"sidecar storage is already alongside; pass force=true to migrate anyway",
|
||||
)
|
||||
return await self._migrate(
|
||||
direction=DIRECTION_TO_ALONGSIDE,
|
||||
to_centralized=False,
|
||||
progress_cb=progress_cb,
|
||||
)
|
||||
|
||||
async def migrate_root(
|
||||
self,
|
||||
old_root: str,
|
||||
progress_cb: Optional[SidecarMigrationProgressReporter] = None,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Relocate the whole mirror tree from a previous root to the configured one.
|
||||
|
||||
Used after ``sidecar_storage_path`` changes while centralized storage
|
||||
is active: without it, every asset under the old root would silently
|
||||
disappear from the application. Moves every file keeping the
|
||||
root-relative structure, rewrites the ``preview_url`` prefix inside
|
||||
moved sidecars, reconciles scanner caches, and prunes the emptied old
|
||||
tree. Keep-newer conflict resolution matches :meth:`_transfer`.
|
||||
"""
|
||||
|
||||
if not force and get_storage_mode() != STORAGE_MODE_CENTRALIZED:
|
||||
return self._refusal(
|
||||
DIRECTION_RELOCATE_ROOT,
|
||||
"sidecar storage is not centralized; pass force=true to relocate anyway",
|
||||
)
|
||||
new_root = get_configured_sidecar_root()
|
||||
if not new_root:
|
||||
return self._refusal(
|
||||
DIRECTION_RELOCATE_ROOT,
|
||||
"cannot resolve the centralized sidecar root",
|
||||
)
|
||||
old = (
|
||||
os.path.abspath(os.path.expanduser(old_root.strip()))
|
||||
if isinstance(old_root, str) and old_root.strip()
|
||||
else ""
|
||||
)
|
||||
if not old:
|
||||
return self._refusal(DIRECTION_RELOCATE_ROOT, "old_root is required")
|
||||
if os.path.normpath(old) == os.path.normpath(new_root):
|
||||
return self._refusal(
|
||||
DIRECTION_RELOCATE_ROOT,
|
||||
"old_root matches the configured sidecar root",
|
||||
)
|
||||
|
||||
files: List[Tuple[str, str]] = []
|
||||
source_map_path = os.path.join(old, ROOT_MAP_FILENAME)
|
||||
if os.path.isdir(old):
|
||||
for dirpath, _dirnames, filenames in os.walk(old):
|
||||
rel = os.path.relpath(dirpath, old)
|
||||
target_dir = new_root if rel == os.curdir else os.path.join(new_root, rel)
|
||||
for filename in filenames:
|
||||
source = os.path.join(dirpath, filename)
|
||||
# The identity map is handled by relocate_root_map below:
|
||||
# _transfer's keep-newer rule would let a destination map
|
||||
# written before the relocation displace it.
|
||||
if source == source_map_path:
|
||||
continue
|
||||
files.append((source, os.path.join(target_dir, filename)))
|
||||
|
||||
errors: List[Dict[str, str]] = []
|
||||
counters: Dict[str, Any] = {"moved": 0, "conflicts": 0}
|
||||
moved_sidecars: List[str] = []
|
||||
|
||||
async def emit(status: str, **extra: Any) -> None:
|
||||
if progress_cb is None:
|
||||
return
|
||||
payload: Dict[str, Any] = {
|
||||
"type": "sidecar_migration_progress",
|
||||
"status": status,
|
||||
"direction": DIRECTION_RELOCATE_ROOT,
|
||||
"total": len(files),
|
||||
"processed": extra.pop("processed", 0),
|
||||
"moved": counters["moved"],
|
||||
"skipped": 0,
|
||||
"conflicts": counters["conflicts"],
|
||||
"errors": len(errors),
|
||||
}
|
||||
payload.update(extra)
|
||||
await progress_cb.on_progress(payload)
|
||||
|
||||
await emit("started")
|
||||
|
||||
for index, (src, dst) in enumerate(files, start=1):
|
||||
try:
|
||||
if self._transfer(src, dst, counters) and src.endswith(METADATA_SUFFIX):
|
||||
moved_sidecars.append(dst)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Sidecar root relocation failed for %s: %s", src, exc, exc_info=True
|
||||
)
|
||||
errors.append({"model": os.path.basename(src), "error": str(exc)})
|
||||
await emit("processing", processed=index, current=os.path.basename(src))
|
||||
|
||||
# The identity map names the directories just moved, so it travels with
|
||||
# them and wins over any map the destination acquired beforehand.
|
||||
# A failure here strands the moved metadata, so it is a real error.
|
||||
try:
|
||||
if not relocate_root_map(old, new_root):
|
||||
errors.append(
|
||||
{
|
||||
"model": ROOT_MAP_FILENAME,
|
||||
"error": "sidecar root map could not be written to the new root",
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Sidecar root relocation failed for the root map: %s", exc, exc_info=True
|
||||
)
|
||||
errors.append({"model": ROOT_MAP_FILENAME, "error": str(exc)})
|
||||
|
||||
old_prefix = old.replace(os.sep, "/").rstrip("/") + "/"
|
||||
new_prefix = new_root.replace(os.sep, "/").rstrip("/") + "/"
|
||||
for sidecar in moved_sidecars:
|
||||
self._rewrite_root_prefix(sidecar, old_prefix, new_prefix)
|
||||
await self._reconcile_root_prefix(old_prefix, new_prefix)
|
||||
|
||||
# Prune the emptied old tree, best-effort.
|
||||
if os.path.isdir(old):
|
||||
for dirpath, dirnames, filenames in os.walk(old, topdown=False):
|
||||
if filenames:
|
||||
continue
|
||||
for dirname in dirnames:
|
||||
try:
|
||||
os.rmdir(os.path.join(dirpath, dirname))
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.rmdir(dirpath)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
await emit("completed")
|
||||
|
||||
return {
|
||||
"success": not errors,
|
||||
"direction": DIRECTION_RELOCATE_ROOT,
|
||||
"models_total": len(files),
|
||||
"models_processed": len(files),
|
||||
"models_moved": 0,
|
||||
"moved": counters["moved"],
|
||||
"skipped": 0,
|
||||
"conflicts": counters["conflicts"],
|
||||
"errors": errors,
|
||||
"error_count": len(errors),
|
||||
"sidecar_root": new_root,
|
||||
}
|
||||
|
||||
def _rewrite_root_prefix(
|
||||
self, sidecar_path: str, old_prefix: str, new_prefix: str
|
||||
) -> None:
|
||||
"""Repoint preview_url inside a relocated sidecar from old to new root."""
|
||||
|
||||
try:
|
||||
with open(sidecar_path, "r", encoding="utf-8") as handle:
|
||||
metadata = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
self._logger.warning(
|
||||
"Sidecar root relocation: cannot read %s: %s", sidecar_path, exc
|
||||
)
|
||||
return
|
||||
|
||||
preview_url = metadata.get("preview_url")
|
||||
if not isinstance(preview_url, str) or not preview_url.startswith(old_prefix):
|
||||
return
|
||||
metadata["preview_url"] = new_prefix + preview_url[len(old_prefix):]
|
||||
try:
|
||||
with open(sidecar_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(metadata, handle, ensure_ascii=False, indent=2)
|
||||
except OSError as exc:
|
||||
self._logger.warning(
|
||||
"Sidecar root relocation: cannot rewrite %s: %s", sidecar_path, exc
|
||||
)
|
||||
|
||||
async def _reconcile_root_prefix(self, old_prefix: str, new_prefix: str) -> None:
|
||||
"""Rewrite old-root preview URLs in every scanner cache after relocation."""
|
||||
|
||||
for model_type, factory in self._active_scanner_factories():
|
||||
try:
|
||||
scanner = await factory()
|
||||
cache = await scanner.get_cached_data()
|
||||
changed = False
|
||||
for item in cache.raw_data:
|
||||
preview_url = item.get("preview_url")
|
||||
if (
|
||||
isinstance(preview_url, str)
|
||||
and preview_url.startswith(old_prefix)
|
||||
):
|
||||
item["preview_url"] = new_prefix + preview_url[len(old_prefix):]
|
||||
changed = True
|
||||
if changed and hasattr(scanner, "_persist_current_cache"):
|
||||
await scanner._persist_current_cache()
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Sidecar root relocation: failed to reconcile %s cache: %s",
|
||||
model_type,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _refusal(direction: str, message: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"success": False,
|
||||
"error": message,
|
||||
"direction": direction,
|
||||
"models_total": 0,
|
||||
"models_processed": 0,
|
||||
"models_moved": 0,
|
||||
"moved": 0,
|
||||
"skipped": 0,
|
||||
"conflicts": 0,
|
||||
"errors": [],
|
||||
"error_count": 0,
|
||||
"sidecar_root": get_configured_sidecar_root() or "",
|
||||
}
|
||||
|
||||
def _active_scanner_factories(self) -> Tuple[Tuple[str, ScannerFactory], ...]:
|
||||
"""Drop the opt-in other scanner while Other Models is disabled."""
|
||||
|
||||
if self._settings.is_other_models_enabled():
|
||||
return self._scanner_factories
|
||||
return tuple(entry for entry in self._scanner_factories if entry[0] != "other")
|
||||
|
||||
async def _collect_model_paths(
|
||||
self, errors: List[Dict[str, str]]
|
||||
) -> List[Tuple[Any, List[str]]]:
|
||||
"""Enumerate model file paths grouped by the scanner that owns them.
|
||||
|
||||
Excluded models are included: they are absent from the cache but still
|
||||
on disk, and leaving their sidecars behind would strand the metadata
|
||||
if the user later un-excludes them (the scanner would then look the
|
||||
sidecar up in the NEW layout and find nothing).
|
||||
"""
|
||||
|
||||
groups: List[Tuple[Any, List[str]]] = []
|
||||
for model_type, factory in self._active_scanner_factories():
|
||||
try:
|
||||
scanner = await factory()
|
||||
cache = await scanner.get_cached_data()
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Sidecar migration: failed to enumerate %s models: %s",
|
||||
model_type,
|
||||
exc,
|
||||
)
|
||||
errors.append({"model": model_type, "error": f"enumeration failed: {exc}"})
|
||||
continue
|
||||
paths = [
|
||||
entry["file_path"]
|
||||
for entry in cache.raw_data
|
||||
if entry.get("file_path")
|
||||
]
|
||||
get_excluded = getattr(scanner, "get_excluded_models", None)
|
||||
if callable(get_excluded):
|
||||
try:
|
||||
known = set(paths)
|
||||
paths.extend(
|
||||
path for path in get_excluded() if path and path not in known
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Sidecar migration: failed to enumerate excluded %s models: %s",
|
||||
model_type,
|
||||
exc,
|
||||
)
|
||||
groups.append((scanner, paths))
|
||||
return groups
|
||||
|
||||
@staticmethod
|
||||
def _move_file(src: str, dst: str) -> None:
|
||||
"""Move a file, tolerating EXDEV when the layouts span filesystems."""
|
||||
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
try:
|
||||
os.rename(src, dst)
|
||||
except OSError as exc:
|
||||
if exc.errno != errno.EXDEV:
|
||||
raise
|
||||
shutil.copy2(src, dst)
|
||||
os.remove(src)
|
||||
|
||||
async def _migrate(
|
||||
self,
|
||||
*,
|
||||
direction: str,
|
||||
to_centralized: bool,
|
||||
progress_cb: Optional[SidecarMigrationProgressReporter],
|
||||
) -> Dict[str, Any]:
|
||||
root = get_configured_sidecar_root()
|
||||
if not root:
|
||||
return self._refusal(
|
||||
direction,
|
||||
"cannot resolve the centralized sidecar root",
|
||||
)
|
||||
|
||||
errors: List[Dict[str, str]] = []
|
||||
scanner_groups = await self._collect_model_paths(errors)
|
||||
|
||||
total = sum(len(paths) for _, paths in scanner_groups)
|
||||
processed = 0
|
||||
models_moved = 0
|
||||
moved = 0
|
||||
skipped = 0
|
||||
conflicts = 0
|
||||
# (file_path, final preview path at the destination layout), grouped
|
||||
# by scanner so caches can be reconciled after the move.
|
||||
preview_updates: List[Tuple[Any, List[Tuple[str, str]]]] = []
|
||||
|
||||
async def emit(status: str, **extra: Any) -> None:
|
||||
if progress_cb is None:
|
||||
return
|
||||
payload: Dict[str, Any] = {
|
||||
"type": "sidecar_migration_progress",
|
||||
"status": status,
|
||||
"direction": direction,
|
||||
"total": total,
|
||||
"processed": processed,
|
||||
"moved": moved,
|
||||
"skipped": skipped,
|
||||
"conflicts": conflicts,
|
||||
"errors": len(errors),
|
||||
}
|
||||
payload.update(extra)
|
||||
await progress_cb.on_progress(payload)
|
||||
|
||||
await emit("started")
|
||||
|
||||
for scanner, model_paths in scanner_groups:
|
||||
updates: List[Tuple[str, str]] = []
|
||||
for model_path in model_paths:
|
||||
processed += 1
|
||||
current = os.path.basename(model_path)
|
||||
try:
|
||||
result = await self._migrate_model(
|
||||
model_path,
|
||||
root=root,
|
||||
to_centralized=to_centralized,
|
||||
)
|
||||
moved += result["moved"]
|
||||
conflicts += result["conflicts"]
|
||||
if result["skipped"]:
|
||||
skipped += 1
|
||||
else:
|
||||
updates.append((model_path, result["preview_url"]))
|
||||
if result["moved"]:
|
||||
models_moved += 1
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Sidecar migration failed for %s: %s", model_path, exc, exc_info=True
|
||||
)
|
||||
errors.append({"model": current, "error": str(exc)})
|
||||
await emit("processing", current=current)
|
||||
preview_updates.append((scanner, updates))
|
||||
|
||||
await self._reconcile_scanner_caches(preview_updates)
|
||||
|
||||
await emit("completed")
|
||||
|
||||
return {
|
||||
"success": not errors,
|
||||
"direction": direction,
|
||||
"models_total": total,
|
||||
"models_processed": processed,
|
||||
"models_moved": models_moved,
|
||||
"moved": moved,
|
||||
"skipped": skipped,
|
||||
"conflicts": conflicts,
|
||||
"errors": errors,
|
||||
"error_count": len(errors),
|
||||
# Effective centralized root, so the UI can show/offer to open the
|
||||
# destination (or, for to_alongside, the source) after the run.
|
||||
"sidecar_root": root,
|
||||
}
|
||||
|
||||
async def _migrate_model(
|
||||
self,
|
||||
model_path: str,
|
||||
*,
|
||||
root: str,
|
||||
to_centralized: bool,
|
||||
) -> Dict[str, Any]:
|
||||
"""Migrate one model's sidecar + previews; return per-model counters.
|
||||
|
||||
``preview_url`` in the result is the model's final preview path in the
|
||||
destination layout ("" when none), used to reconcile scanner caches.
|
||||
"""
|
||||
|
||||
result: Dict[str, Any] = {"moved": 0, "conflicts": 0, "skipped": 0, "preview_url": ""}
|
||||
|
||||
model_path = os.path.abspath(model_path)
|
||||
if not os.path.exists(model_path):
|
||||
self._logger.warning(
|
||||
"Sidecar migration: model file missing, skipping: %s", model_path
|
||||
)
|
||||
result["skipped"] = 1
|
||||
return result
|
||||
|
||||
model_dir = os.path.dirname(model_path)
|
||||
mirror_dir = resolve_centralized_dir_for_dir(model_dir, sidecar_root=root)
|
||||
if mirror_dir is None:
|
||||
self._logger.warning(
|
||||
"Sidecar migration: %s is outside configured model roots, skipping",
|
||||
model_path,
|
||||
)
|
||||
result["skipped"] = 1
|
||||
return result
|
||||
|
||||
if to_centralized:
|
||||
src_dir, dst_dir = model_dir, mirror_dir
|
||||
else:
|
||||
src_dir, dst_dir = mirror_dir, model_dir
|
||||
|
||||
if os.path.normpath(src_dir) == os.path.normpath(dst_dir):
|
||||
result["skipped"] = 1
|
||||
return result
|
||||
|
||||
stem = os.path.splitext(os.path.basename(model_path))[0]
|
||||
sidecar_name = stem + METADATA_SUFFIX
|
||||
|
||||
moved_previews: List[str] = []
|
||||
for preview_name in _enumerate_preview_names(src_dir, stem):
|
||||
src = os.path.join(src_dir, preview_name)
|
||||
dst = os.path.join(dst_dir, preview_name)
|
||||
if self._transfer(src, dst, result):
|
||||
moved_previews.append(dst)
|
||||
|
||||
sidecar_src = os.path.join(src_dir, sidecar_name)
|
||||
sidecar_moved = False
|
||||
sidecar_dst = os.path.join(dst_dir, sidecar_name)
|
||||
if os.path.exists(sidecar_src):
|
||||
sidecar_moved = self._transfer(sidecar_src, sidecar_dst, result)
|
||||
|
||||
if sidecar_moved:
|
||||
await self._rewrite_sidecar_paths(sidecar_dst, model_path, moved_previews)
|
||||
|
||||
# Ground truth from the destination directory: covers conflict-keep
|
||||
# and partial moves, not just the previews transferred in this run.
|
||||
final_preview = find_preview_file(stem, dst_dir)
|
||||
if final_preview:
|
||||
result["preview_url"] = final_preview.replace(os.sep, "/")
|
||||
|
||||
return result
|
||||
|
||||
async def _reconcile_scanner_caches(
|
||||
self, preview_updates: List[Tuple[Any, List[Tuple[str, str]]]]
|
||||
) -> None:
|
||||
"""Point scanner cache entries at the post-migration preview locations.
|
||||
|
||||
Without this the list API keeps serving pre-migration ``preview_url``
|
||||
values whose files no longer exist; hitting one triggers the preview
|
||||
route's stale-URL cleanup, which would wipe the reference for good.
|
||||
A failing scanner is logged and skipped — the on-disk migration has
|
||||
already succeeded, and a full rescan repairs the cache.
|
||||
"""
|
||||
|
||||
for scanner, updates in preview_updates:
|
||||
if not updates:
|
||||
continue
|
||||
try:
|
||||
cache = await scanner.get_cached_data()
|
||||
changed = False
|
||||
for file_path, preview_url in updates:
|
||||
entry = next(
|
||||
(item for item in cache.raw_data if item.get("file_path") == file_path),
|
||||
None,
|
||||
)
|
||||
if entry is None:
|
||||
continue
|
||||
if entry.get("preview_url", "") == preview_url:
|
||||
continue
|
||||
if hasattr(cache, "update_preview_url"):
|
||||
await cache.update_preview_url(
|
||||
file_path,
|
||||
preview_url,
|
||||
entry.get("preview_nsfw_level", 0),
|
||||
)
|
||||
else: # pragma: no cover - minimal cache doubles
|
||||
entry["preview_url"] = preview_url
|
||||
changed = True
|
||||
if changed and hasattr(scanner, "_persist_current_cache"):
|
||||
await scanner._persist_current_cache()
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Sidecar migration: failed to reconcile scanner cache: %s",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _transfer(self, src: str, dst: str, result: Dict[str, Any]) -> bool:
|
||||
"""Move ``src`` to ``dst`` with keep-newer conflict resolution.
|
||||
|
||||
Returns True when the file was actually moved to the destination. On a
|
||||
conflict the newer file wins: a newer source replaces the destination;
|
||||
a newer (or equal) destination is kept and the source is deleted.
|
||||
"""
|
||||
|
||||
if os.path.exists(dst):
|
||||
result["conflicts"] += 1
|
||||
if os.path.getmtime(src) > os.path.getmtime(dst):
|
||||
self._logger.info(
|
||||
"Sidecar migration: conflict at %s; source is newer, replacing", dst
|
||||
)
|
||||
os.remove(dst)
|
||||
else:
|
||||
self._logger.info(
|
||||
"Sidecar migration: conflict at %s; destination is newer, keeping it",
|
||||
dst,
|
||||
)
|
||||
os.remove(src)
|
||||
return False
|
||||
self._move_file(src, dst)
|
||||
result["moved"] += 1
|
||||
return True
|
||||
|
||||
async def _rewrite_sidecar_paths(
|
||||
self,
|
||||
sidecar_path: str,
|
||||
model_path: str,
|
||||
moved_previews: List[str],
|
||||
) -> None:
|
||||
"""Update path fields inside a moved sidecar, mirroring ModelScanner."""
|
||||
|
||||
with open(sidecar_path, "r", encoding="utf-8") as handle:
|
||||
metadata = json.load(handle)
|
||||
|
||||
stem = os.path.splitext(os.path.basename(model_path))[0]
|
||||
metadata["file_path"] = model_path.replace(os.sep, "/")
|
||||
metadata["file_name"] = stem
|
||||
|
||||
if moved_previews and metadata.get("preview_url"):
|
||||
recorded_ext = get_preview_extension(metadata["preview_url"])
|
||||
chosen = next(
|
||||
(
|
||||
path
|
||||
for path in moved_previews
|
||||
if get_preview_extension(path) == recorded_ext
|
||||
),
|
||||
moved_previews[0],
|
||||
)
|
||||
metadata["preview_url"] = chosen.replace(os.sep, "/")
|
||||
|
||||
await MetadataManager.save_metadata(sidecar_path, metadata)
|
||||
|
||||
async def execute_with_error_handling(
|
||||
self,
|
||||
*,
|
||||
direction: str,
|
||||
progress_cb: Optional[SidecarMigrationProgressReporter] = None,
|
||||
force: bool = False,
|
||||
old_root: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Wrapper providing progress notification on unexpected failures."""
|
||||
|
||||
try:
|
||||
if direction == DIRECTION_TO_CENTRALIZED:
|
||||
return await self.migrate_to_centralized(progress_cb, force=force)
|
||||
if direction == DIRECTION_TO_ALONGSIDE:
|
||||
return await self.migrate_to_alongside(progress_cb, force=force)
|
||||
if direction == DIRECTION_RELOCATE_ROOT:
|
||||
return await self.migrate_root(old_root or "", progress_cb, force=force)
|
||||
raise ValueError(
|
||||
f"direction must be {DIRECTION_TO_CENTRALIZED!r}, "
|
||||
f"{DIRECTION_TO_ALONGSIDE!r} or {DIRECTION_RELOCATE_ROOT!r}"
|
||||
)
|
||||
except Exception as exc:
|
||||
if progress_cb is not None:
|
||||
await progress_cb.on_progress(
|
||||
{
|
||||
"type": "sidecar_migration_progress",
|
||||
"status": "error",
|
||||
"direction": direction,
|
||||
"error": str(exc),
|
||||
}
|
||||
)
|
||||
raise
|
||||
@@ -41,6 +41,14 @@ PREVIEW_EXTENSIONS = [
|
||||
# Card preview image width
|
||||
CARD_PREVIEW_WIDTH = 480
|
||||
|
||||
# Upper bound for a ComfyUI workflow embedded into a recipe preview on the
|
||||
# opt-in widget save path. The workflow is by far the largest metadata field
|
||||
# (tens of KB for a simple graph), so an anomalous graph — e.g. one carrying
|
||||
# base64 blobs in widget values — is skipped instead of inflating the preview.
|
||||
# Imports are deliberately not capped: their workflow comes from an image the
|
||||
# user already chose, and preserving it is the point.
|
||||
MAX_WORKFLOW_EMBED_BYTES = 256 * 1024
|
||||
|
||||
# Width for optimized example images
|
||||
EXAMPLE_IMAGE_WIDTH = 832
|
||||
|
||||
@@ -344,12 +352,15 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
||||
"PixArt E",
|
||||
# Video diffusion models
|
||||
"CogVideoX",
|
||||
"Flux 3 Video",
|
||||
"Hunyuan Video",
|
||||
"LTXV",
|
||||
"LTXV2",
|
||||
"LTXV 2.3",
|
||||
"LTXV 2.5",
|
||||
"Mochi",
|
||||
"SVD",
|
||||
"SVD XT",
|
||||
"Wan Video",
|
||||
"Wan Video 1.3B t2v",
|
||||
"Wan Video 14B t2v",
|
||||
@@ -360,11 +371,19 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
||||
"Wan Video 2.2 T2V-A14B",
|
||||
"Wan Video 2.5 T2V",
|
||||
"Wan Video 2.5 I2V",
|
||||
"Wan Video 2.7",
|
||||
"Wan Video 3.0",
|
||||
# Other diffusion models
|
||||
"Boogu",
|
||||
"Ernie",
|
||||
"Ernie Turbo",
|
||||
"HiDream-O1",
|
||||
"Ming Image Design 0.1",
|
||||
"Ming Image Design Layer 0.1",
|
||||
"MiniMax H3",
|
||||
"Nucleus",
|
||||
"Qwen",
|
||||
"Wan Image 2.7",
|
||||
"ZImageBase",
|
||||
"ZImageTurbo",
|
||||
# Krea 2 — loaded via UNETLoader in ComfyUI
|
||||
@@ -372,6 +391,53 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
||||
]
|
||||
)
|
||||
|
||||
# baseModel values from CivitAI that are true full checkpoints (loaded via
|
||||
# CheckpointLoaderSimple in ComfyUI). New DiT families appear on CivitAI all
|
||||
# the time, so download routing inverts the fallback: anything NOT in this
|
||||
# closed set (and not a known diffusion model) is treated as a diffusion
|
||||
# model by default (see py/services/download_routing.py). Cross-checked
|
||||
# against CivitAI's official baseModelRecords (packages/civitai-shared
|
||||
# src/basemodel.constants.ts), not only the download skip list.
|
||||
# "Pony V7" is deliberately excluded: verified via the live API (model
|
||||
# 1901521) to be AuraFlow-architecture shipping .gguf variants (UNETLoader),
|
||||
# so it follows the unknown-base-model default (diffusion).
|
||||
CHECKPOINT_BASE_MODELS = frozenset(
|
||||
[
|
||||
# Stable Diffusion 1.x
|
||||
"SD 1.4",
|
||||
"SD 1.5",
|
||||
"SD 1.5 LCM",
|
||||
"SD 1.5 Hyper",
|
||||
# Stable Diffusion 2.x
|
||||
"SD 2.0",
|
||||
"SD 2.0 768",
|
||||
"SD 2.1",
|
||||
"SD 2.1 768",
|
||||
"SD 2.1 Unclip",
|
||||
# Stable Diffusion 3.x
|
||||
"SD 3",
|
||||
"SD 3.5",
|
||||
"SD 3.5 Medium",
|
||||
"SD 3.5 Large",
|
||||
"SD 3.5 Large Turbo",
|
||||
# SDXL and its full-checkpoint derivatives
|
||||
"SDXL 0.9",
|
||||
"SDXL 1.0",
|
||||
"SDXL 1.0 LCM",
|
||||
"SDXL Lightning",
|
||||
"SDXL Hyper",
|
||||
"SDXL Turbo",
|
||||
"SDXL Distilled",
|
||||
"Pony",
|
||||
"Illustrious",
|
||||
"NoobAI",
|
||||
# Other full-checkpoint families
|
||||
"Playground v2",
|
||||
# Stable Cascade is unCLIP-style but loads via CheckpointLoader
|
||||
"Stable Cascade",
|
||||
]
|
||||
)
|
||||
|
||||
# Supported baseModel values for download exclusion settings.
|
||||
# Keep this aligned with static/js/utils/constants.js, excluding the generic "Other" value.
|
||||
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS = frozenset(
|
||||
|
||||
@@ -26,6 +26,42 @@ class ExampleImagesValidationError(ExampleImagesImportError):
|
||||
class ExampleImagesProcessor:
|
||||
"""Processes and manipulates example images"""
|
||||
|
||||
@staticmethod
|
||||
async def _model_scanners() -> list:
|
||||
"""Return every scanner whose models can carry example images."""
|
||||
return [
|
||||
await ServiceRegistry.get_lora_scanner(),
|
||||
await ServiceRegistry.get_checkpoint_scanner(),
|
||||
await ServiceRegistry.get_embedding_scanner(),
|
||||
await ServiceRegistry.get_other_scanner(),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def resolve_hash_for_file_path(file_path: str) -> str:
|
||||
"""Return the SHA256 for a cached model file, computing it on demand.
|
||||
|
||||
Checkpoint and Other scanners record ``hash_status="pending"`` with an
|
||||
empty sha256 until something needs the hash; importing example images
|
||||
is such a moment because example folders are keyed by hash. Returns
|
||||
``''`` when the file is unknown to every scanner or hashing failed.
|
||||
"""
|
||||
if not file_path:
|
||||
return ''
|
||||
normalized = file_path.replace(os.sep, '/')
|
||||
for scanner in await ExampleImagesProcessor._model_scanners():
|
||||
cache = await scanner.get_cached_data()
|
||||
for item in cache.raw_data:
|
||||
if item.get('file_path') != normalized:
|
||||
continue
|
||||
sha256 = (item.get('sha256') or '').strip()
|
||||
if sha256 and item.get('hash_status', 'completed') == 'completed':
|
||||
return sha256
|
||||
calculate = getattr(scanner, 'calculate_hash_for_model', None)
|
||||
if calculate is None:
|
||||
return sha256
|
||||
return (await calculate(normalized)) or ''
|
||||
return ''
|
||||
|
||||
@staticmethod
|
||||
def generate_short_id(length=8):
|
||||
"""Generate a short random alphanumeric identifier"""
|
||||
@@ -450,15 +486,11 @@ class ExampleImagesProcessor:
|
||||
raise ExampleImagesValidationError('No example images path configured')
|
||||
|
||||
# Find the model and get current metadata
|
||||
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
|
||||
model_data = None
|
||||
scanner = None
|
||||
|
||||
# Check both scanners to find the model
|
||||
for scan_obj in [lora_scanner, checkpoint_scanner, embedding_scanner]:
|
||||
# Check every scanner to find the model
|
||||
for scan_obj in await ExampleImagesProcessor._model_scanners():
|
||||
cache = await scan_obj.get_cached_data()
|
||||
for item in cache.raw_data:
|
||||
if item.get('sha256') == model_hash:
|
||||
@@ -536,6 +568,7 @@ class ExampleImagesProcessor:
|
||||
'errors': errors,
|
||||
'regular_images': regular_images,
|
||||
'custom_images': custom_images,
|
||||
'model_hash': model_hash,
|
||||
"model_file_path": model_data.get('file_path', ''),
|
||||
}
|
||||
|
||||
@@ -577,15 +610,11 @@ class ExampleImagesProcessor:
|
||||
}, status=400)
|
||||
|
||||
# Find the model and get current metadata
|
||||
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
|
||||
model_data = None
|
||||
scanner = None
|
||||
|
||||
# Check both scanners to find the model
|
||||
for scan_obj in [lora_scanner, checkpoint_scanner, embedding_scanner]:
|
||||
|
||||
# Check every scanner to find the model
|
||||
for scan_obj in await ExampleImagesProcessor._model_scanners():
|
||||
if scan_obj.has_hash(model_hash):
|
||||
cache = await scan_obj.get_cached_data()
|
||||
for item in cache.raw_data:
|
||||
@@ -595,13 +624,13 @@ class ExampleImagesProcessor:
|
||||
break
|
||||
if model_data:
|
||||
break
|
||||
|
||||
|
||||
if not model_data:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': f"Model with hash {model_hash} not found in cache"
|
||||
}, status=404)
|
||||
|
||||
|
||||
await MetadataManager.hydrate_model_data(model_data)
|
||||
civitai_data = model_data.setdefault('civitai', {})
|
||||
custom_images = civitai_data.get('customImages')
|
||||
@@ -737,14 +766,10 @@ class ExampleImagesProcessor:
|
||||
)
|
||||
|
||||
try:
|
||||
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
|
||||
model_data = None
|
||||
scanner = None
|
||||
|
||||
for scan_obj in [lora_scanner, checkpoint_scanner, embedding_scanner]:
|
||||
for scan_obj in await ExampleImagesProcessor._model_scanners():
|
||||
if scan_obj.has_hash(model_hash):
|
||||
cache = await scan_obj.get_cached_data()
|
||||
for item in cache.raw_data:
|
||||
|
||||
+134
-23
@@ -341,29 +341,125 @@ class ExifUtils:
|
||||
|
||||
metadata_fields = ExifUtils._load_structured_metadata(image_path)
|
||||
metadata_fields["parameters"] = metadata
|
||||
|
||||
with Image.open(image_path) as img:
|
||||
img_format = img.format
|
||||
|
||||
if img_format == "PNG":
|
||||
png_info = ExifUtils._build_pnginfo(img, metadata_fields)
|
||||
img.save(image_path, format="PNG", pnginfo=png_info)
|
||||
return image_path
|
||||
|
||||
exif_bytes = ExifUtils._build_exif_bytes(
|
||||
metadata_fields, img.info.get("exif")
|
||||
)
|
||||
save_kwargs: dict[str, Any] = {"exif": exif_bytes}
|
||||
if img_format == "WEBP":
|
||||
save_kwargs["quality"] = 85
|
||||
|
||||
img.save(image_path, format=img_format, **save_kwargs)
|
||||
|
||||
return image_path
|
||||
return ExifUtils._write_structured_metadata(image_path, metadata_fields)
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating metadata in {image_path}: {e}")
|
||||
return image_path
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _write_structured_metadata(
|
||||
image_path: str, metadata_fields: dict[str, Optional[str]]
|
||||
) -> str:
|
||||
"""Write structured metadata fields back into an image.
|
||||
|
||||
PNG keeps them as text chunks (``parameters``/``prompt``/``workflow``);
|
||||
every other supported container stores them in EXIF, where the workflow
|
||||
travels in ``ImageDescription`` behind a ``Workflow:`` prefix (see
|
||||
:meth:`_build_exif_bytes`).
|
||||
"""
|
||||
with Image.open(image_path) as img:
|
||||
img_format = img.format
|
||||
|
||||
if img_format == "PNG":
|
||||
png_info = ExifUtils._build_pnginfo(img, metadata_fields)
|
||||
img.save(image_path, format="PNG", pnginfo=png_info)
|
||||
return image_path
|
||||
|
||||
exif_bytes = ExifUtils._build_exif_bytes(
|
||||
metadata_fields, img.info.get("exif")
|
||||
)
|
||||
save_kwargs: dict[str, Any] = {"exif": exif_bytes}
|
||||
if img_format == "WEBP":
|
||||
save_kwargs["quality"] = 85
|
||||
|
||||
img.save(image_path, format=img_format, **save_kwargs)
|
||||
|
||||
return image_path
|
||||
|
||||
@staticmethod
|
||||
def normalise_workflow(workflow: Any) -> Optional[str]:
|
||||
"""Coerce a workflow payload into the JSON string metadata form.
|
||||
|
||||
Accepts the string form stored in image chunks as well as already
|
||||
decoded dict/list payloads; anything else yields ``None``.
|
||||
"""
|
||||
if isinstance(workflow, str):
|
||||
return workflow or None
|
||||
if isinstance(workflow, (dict, list)):
|
||||
try:
|
||||
return json.dumps(workflow)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _merge_workflow(
|
||||
metadata_fields: Optional[dict[str, Optional[str]]], workflow: Any
|
||||
) -> Optional[dict[str, Optional[str]]]:
|
||||
"""Add a caller-supplied workflow to extracted metadata fields.
|
||||
|
||||
Returns ``metadata_fields`` untouched when there is nothing to add, and
|
||||
never overwrites a workflow the source image already carries.
|
||||
"""
|
||||
workflow_json = ExifUtils.normalise_workflow(workflow)
|
||||
if not workflow_json:
|
||||
return metadata_fields
|
||||
if metadata_fields is None:
|
||||
metadata_fields = {
|
||||
"parameters": None,
|
||||
"prompt": None,
|
||||
"workflow": None,
|
||||
"comment": None,
|
||||
}
|
||||
if not metadata_fields.get("workflow"):
|
||||
metadata_fields["workflow"] = workflow_json
|
||||
return metadata_fields
|
||||
|
||||
@staticmethod
|
||||
def embed_workflow(image_path: str, workflow: Any) -> str:
|
||||
"""Embed a ComfyUI workflow into an image that does not carry one.
|
||||
|
||||
Recipe imports recover the workflow from the source's original
|
||||
rendition (CivitAI's optimized preview is re-encoded and metadata-free)
|
||||
and hand it over as data rather than as image bytes. Images that
|
||||
already embed a workflow are left untouched.
|
||||
|
||||
WebP files are patched at the byte level so preview pixels are not
|
||||
re-encoded a second time.
|
||||
"""
|
||||
workflow_json = ExifUtils.normalise_workflow(workflow)
|
||||
if not image_path or not workflow_json:
|
||||
return image_path
|
||||
|
||||
ext = os.path.splitext(image_path)[1].lower()
|
||||
if ext in ['.mp4', '.webm', '.avif', '.jxl']:
|
||||
return image_path
|
||||
|
||||
try:
|
||||
metadata_fields = ExifUtils._load_structured_metadata(image_path)
|
||||
if metadata_fields.get("workflow"):
|
||||
return image_path
|
||||
metadata_fields["workflow"] = workflow_json
|
||||
|
||||
if ext == '.webp':
|
||||
try:
|
||||
exif_bytes = ExifUtils._build_exif_bytes(metadata_fields)
|
||||
with open(image_path, "rb") as file_obj:
|
||||
image_bytes = file_obj.read()
|
||||
updated = ExifUtils._replace_webp_exif(image_bytes, exif_bytes)
|
||||
with open(image_path, "wb") as file_obj:
|
||||
file_obj.write(updated)
|
||||
return image_path
|
||||
except ValueError:
|
||||
# Container without an EXIF chunk: fall through to a full
|
||||
# rewrite so the workflow is still embedded.
|
||||
pass
|
||||
|
||||
return ExifUtils._write_structured_metadata(image_path, metadata_fields)
|
||||
except Exception as e:
|
||||
logger.error(f"Error embedding workflow in {image_path}: {e}")
|
||||
return image_path
|
||||
|
||||
@staticmethod
|
||||
def append_recipe_metadata(image_path, recipe_data, pixel_preserving=False) -> str:
|
||||
"""Append recipe metadata to an image's EXIF data
|
||||
@@ -550,7 +646,7 @@ class ExifUtils:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def optimize_image(image_data, target_width=250, format='webp', quality=85, preserve_metadata=False):
|
||||
def optimize_image(image_data, target_width=250, format='webp', quality=85, preserve_metadata=False, workflow=None):
|
||||
"""
|
||||
Optimize an image by resizing and converting to WebP format
|
||||
|
||||
@@ -560,10 +656,19 @@ class ExifUtils:
|
||||
format: Output format (default: webp)
|
||||
quality: Output quality (0-100)
|
||||
preserve_metadata: Whether to preserve EXIF metadata
|
||||
workflow: Optional ComfyUI workflow (JSON string, dict or list) to
|
||||
embed when the source image does not carry one. Used by import
|
||||
paths that recover the workflow from a higher-fidelity source
|
||||
(e.g. CivitAI's original rendition) while the preview pixels
|
||||
come from a metadata-free optimized rendition.
|
||||
|
||||
Returns:
|
||||
Tuple of (optimized_image_data, extension)
|
||||
"""
|
||||
# A supplied workflow can only survive when metadata is embedded, so
|
||||
# treat it as an implicit request for preservation.
|
||||
if workflow is not None:
|
||||
preserve_metadata = True
|
||||
try:
|
||||
if isinstance(image_data, str) and os.path.exists(image_data):
|
||||
ext = os.path.splitext(image_data)[1].lower()
|
||||
@@ -627,6 +732,12 @@ class ExifUtils:
|
||||
logger.warning(f"Failed to extract metadata, continuing without it: {e}")
|
||||
# Continue without metadata
|
||||
|
||||
# Merge in a workflow recovered elsewhere (e.g. from CivitAI's
|
||||
# original rendition). The source image wins when it already has
|
||||
# one, and this is what lets the metadata-free optimized preview
|
||||
# still end up with the workflow embedded.
|
||||
metadata_fields = ExifUtils._merge_workflow(metadata_fields, workflow)
|
||||
|
||||
# Calculate new height to maintain aspect ratio
|
||||
width, height = img.size
|
||||
new_height = int(height * (target_width / width))
|
||||
@@ -686,8 +797,8 @@ class ExifUtils:
|
||||
temp_file.write(optimized_data)
|
||||
|
||||
try:
|
||||
ExifUtils.update_image_metadata(
|
||||
temp_path, metadata_fields.get("parameters") or ""
|
||||
ExifUtils._write_structured_metadata(
|
||||
temp_path, metadata_fields
|
||||
)
|
||||
# Read back the file
|
||||
with open(temp_path, 'rb') as f:
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any, Dict, Optional, Type, Union, cast
|
||||
from .models import BaseModelMetadata, CheckpointMetadata, EmbeddingMetadata, LoraMetadata
|
||||
from .file_utils import normalize_path, find_preview_file, calculate_sha256, calculate_autov3
|
||||
from .lora_metadata import extract_lora_metadata, extract_checkpoint_metadata
|
||||
from .sidecar_paths import get_metadata_path, get_preview_dir, resolve_metadata_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,7 +33,7 @@ class MetadataManager:
|
||||
- metadata: BaseModelMetadata instance or None
|
||||
- should_skip: True if corrupted metadata file exists and model should be skipped
|
||||
"""
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
|
||||
# Check if metadata file exists
|
||||
if not os.path.exists(metadata_path):
|
||||
@@ -98,11 +99,7 @@ class MetadataManager:
|
||||
payload.update(unknown_fields)
|
||||
else:
|
||||
if not should_skip:
|
||||
metadata_path = (
|
||||
file_path
|
||||
if file_path.endswith(".metadata.json")
|
||||
else f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
)
|
||||
metadata_path = resolve_metadata_path(file_path)
|
||||
if os.path.exists(metadata_path):
|
||||
try:
|
||||
with open(metadata_path, "r", encoding="utf-8") as handle:
|
||||
@@ -150,7 +147,7 @@ class MetadataManager:
|
||||
return model_data
|
||||
|
||||
folder = model_data.get("folder")
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
sidecar_exists = os.path.exists(metadata_path)
|
||||
cached = model_data.copy()
|
||||
payload = await MetadataManager.load_metadata_payload(file_path)
|
||||
@@ -188,15 +185,14 @@ class MetadataManager:
|
||||
bool: Success or failure
|
||||
"""
|
||||
# Determine if the input is a metadata path or a model file path
|
||||
if path.endswith('.metadata.json'):
|
||||
metadata_path = path
|
||||
else:
|
||||
# Use existing logic for model file paths
|
||||
file_path = path
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
metadata_path = resolve_metadata_path(path)
|
||||
temp_path = f"{metadata_path}.tmp"
|
||||
|
||||
try:
|
||||
# Centralized sidecar mirrors may not exist yet (unlike the model's
|
||||
# own directory in alongside mode, which always does).
|
||||
os.makedirs(os.path.dirname(metadata_path), exist_ok=True)
|
||||
|
||||
# Convert to dict if needed
|
||||
if isinstance(metadata, BaseModelMetadata):
|
||||
metadata_dict = metadata.to_dict()
|
||||
@@ -259,10 +255,9 @@ class MetadataManager:
|
||||
|
||||
try:
|
||||
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
dir_path = os.path.dirname(file_path)
|
||||
|
||||
|
||||
# Find preview image
|
||||
preview_url = find_preview_file(base_name, dir_path)
|
||||
preview_url = find_preview_file(base_name, get_preview_dir(file_path))
|
||||
|
||||
# Calculate file hash
|
||||
start_hash_time = time.perf_counter()
|
||||
@@ -386,15 +381,16 @@ class MetadataManager:
|
||||
# Check if preview exists at the current location
|
||||
preview_url = metadata.preview_url
|
||||
if preview_url:
|
||||
# Get directory parts of both paths
|
||||
file_dir = os.path.dirname(file_path)
|
||||
# Get directory parts of both paths; the preview directory is the
|
||||
# sidecar/preview dir (the model's own dir in alongside mode, the
|
||||
# centralized mirror otherwise).
|
||||
file_dir = get_preview_dir(file_path)
|
||||
preview_dir = os.path.dirname(preview_url)
|
||||
|
||||
# Update preview if it doesn't exist OR if model and preview are in different directories
|
||||
if not os.path.exists(preview_url) or file_dir != preview_dir:
|
||||
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
dir_path = os.path.dirname(file_path)
|
||||
new_preview_url = find_preview_file(base_name, dir_path)
|
||||
new_preview_url = find_preview_file(base_name, file_dir)
|
||||
if new_preview_url:
|
||||
metadata.preview_url = normalize_path(new_preview_url)
|
||||
need_update = True
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -81,7 +81,7 @@ body {
|
||||
--badge-skip-refresh-glow: var(--color-skip-refresh-glow);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
:root[data-theme="dark"] {
|
||||
--bg-color: var(--bg-base);
|
||||
--text-color: var(--text-primary);
|
||||
--text-muted: var(--text-secondary);
|
||||
|
||||
@@ -572,7 +572,7 @@
|
||||
}
|
||||
|
||||
/* Dark mode: use each preset's dark-mode accent lightness for visibility.
|
||||
These match the --color-accent-l values from [data-theme="dark"][data-theme-preset="..."]
|
||||
These match the --color-accent-l values from :root[data-theme="dark"][data-theme-preset="..."]
|
||||
in tokens/colors.css so the swatch accurately previews what the theme looks like. */
|
||||
|
||||
[data-theme="dark"] .preset-swatch-default {
|
||||
|
||||
@@ -292,6 +292,15 @@
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
/* Civitai model/version ids — pushed to the right end of the footnote line */
|
||||
.hash-footnote .civitai-id-group {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.hash-footnote .hash-copy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -297,6 +297,64 @@
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Segmented layout toggle (gallery / vertical list), shown in the collapsed
|
||||
indicator bar and in each layout's toolbar */
|
||||
.showcase-layout-toggle {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.showcase-layout-toggle .layout-toggle-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 10px;
|
||||
background: var(--card-bg);
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
font-size: 0.9em;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.showcase-layout-toggle .layout-toggle-btn + .layout-toggle-btn {
|
||||
border-left: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.showcase-layout-toggle .layout-toggle-btn:hover {
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.showcase-layout-toggle .layout-toggle-btn.active {
|
||||
background: var(--lora-accent);
|
||||
color: var(--lora-text);
|
||||
}
|
||||
|
||||
/* Vertical list layout (showcase_layout = vertical): every example stacked
|
||||
full-width. Each .media-wrapper carries an inline padding-bottom aspect
|
||||
ratio, so the column is stable before any media loads; the media is
|
||||
absolutely positioned into that box (revived legacy carousel styles,
|
||||
scoped under .showcase-vertical to avoid clashing with the gallery) */
|
||||
.showcase-vertical .vertical-list-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.showcase-vertical .media-wrapper img,
|
||||
.showcase-vertical .media-wrapper video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.media-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
@@ -42,6 +42,18 @@
|
||||
animation: modalFadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* The folder delete confirm button is held disabled while the folder contents
|
||||
are checked against the backend, so it must not look clickable. */
|
||||
#deleteFolderModal .delete-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
#deleteFolderModal .delete-btn:disabled:hover {
|
||||
background: var(--lora-error);
|
||||
}
|
||||
|
||||
#resolveFilenameConflictsModal .confirmation-message {
|
||||
color: var(--text-color);
|
||||
margin: var(--space-2) 0;
|
||||
|
||||
@@ -1088,3 +1088,56 @@
|
||||
[data-theme="dark"] .batch-preview-group-body {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
|
||||
/* Routing override segmented toggle (Checkpoint / Diffusion Model) shown on
|
||||
the checkpoints page of the download modal when auto-routing can misdetect. */
|
||||
.routing-override-group {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.routing-override-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.routing-override-toggle {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.routing-override-option {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
opacity: 0.75;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-base);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.routing-override-option:first-child {
|
||||
border-radius: var(--border-radius-xs) 0 0 var(--border-radius-xs);
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.routing-override-option:last-child {
|
||||
border-radius: 0 var(--border-radius-xs) var(--border-radius-xs) 0;
|
||||
}
|
||||
|
||||
.routing-override-option:not(.active):hover,
|
||||
.routing-override-option:not(.active):focus-visible {
|
||||
border-color: var(--lora-accent);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.routing-override-option.active {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
border-color: var(--lora-accent);
|
||||
opacity: 1;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/* Sidecar Migration Summary Modal — component styles only.
|
||||
Stat cards, summary header and failure table styles are shared with the
|
||||
metadata refresh result modal (metadata-refresh-result.css) and the batch
|
||||
download summary modal (download-batch-summary.css); not redefined here. */
|
||||
|
||||
.sidecar-migration-summary-modal {
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.sidecar-migration-location {
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
@@ -43,6 +43,7 @@
|
||||
@import 'components/media-viewer.css';
|
||||
@import 'components/metadata-refresh-result.css';
|
||||
@import 'components/download-batch-summary.css';
|
||||
@import 'components/sidecar-migration-summary.css';
|
||||
|
||||
.initialization-notice {
|
||||
display: flex;
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
--favorite-glow: oklch(65% 0.15 85 / 0.5);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
:root[data-theme="dark"] {
|
||||
--bg-base: #1a1a1a;
|
||||
--bg-elevated: oklch(25% 0.02 256 / 0.98);
|
||||
--bg-overlay: oklch(0% 0 0 / 0.75);
|
||||
@@ -118,7 +118,7 @@
|
||||
|
||||
/* ── Preset: Nord ──────────────────────────────────────────── */
|
||||
|
||||
[data-theme-preset="nord"] {
|
||||
:root[data-theme-preset="nord"] {
|
||||
--color-accent-h: 213;
|
||||
--color-accent-c: 0.18;
|
||||
--color-accent-l: 62%;
|
||||
@@ -152,7 +152,7 @@
|
||||
--favorite-glow: oklch(72% 0.14 85 / 0.5);
|
||||
}
|
||||
|
||||
[data-theme="dark"][data-theme-preset="nord"] {
|
||||
:root[data-theme="dark"][data-theme-preset="nord"] {
|
||||
--color-accent-h: 213;
|
||||
--color-accent-c: 0.18;
|
||||
--color-accent-l: 68%;
|
||||
@@ -188,7 +188,7 @@
|
||||
|
||||
/* ── Preset: Midnight ───────────────────────────────────────── */
|
||||
|
||||
[data-theme-preset="midnight"] {
|
||||
:root[data-theme-preset="midnight"] {
|
||||
--color-accent-h: 300;
|
||||
--color-accent-c: 0.15;
|
||||
--color-accent-l: 52%;
|
||||
@@ -222,7 +222,7 @@
|
||||
--favorite-glow: oklch(72% 0.16 85 / 0.5);
|
||||
}
|
||||
|
||||
[data-theme="dark"][data-theme-preset="midnight"] {
|
||||
:root[data-theme="dark"][data-theme-preset="midnight"] {
|
||||
--color-accent-h: 300;
|
||||
--color-accent-c: 0.14;
|
||||
--color-accent-l: 68%;
|
||||
@@ -258,7 +258,7 @@
|
||||
|
||||
/* ── Preset: Monokai ───────────────────────────────────────── */
|
||||
|
||||
[data-theme-preset="monokai"] {
|
||||
:root[data-theme-preset="monokai"] {
|
||||
--color-accent-h: 190;
|
||||
--color-accent-c: 0.24;
|
||||
--color-accent-l: 72%;
|
||||
@@ -291,7 +291,7 @@
|
||||
--favorite-glow: oklch(72% 0.16 85 / 0.5);
|
||||
}
|
||||
|
||||
[data-theme="dark"][data-theme-preset="monokai"] {
|
||||
:root[data-theme="dark"][data-theme-preset="monokai"] {
|
||||
--color-accent-h: 190;
|
||||
--color-accent-c: 0.24;
|
||||
--color-accent-l: 72%;
|
||||
@@ -326,7 +326,7 @@
|
||||
|
||||
/* ── Preset: Dracula ───────────────────────────────────────── */
|
||||
|
||||
[data-theme-preset="dracula"] {
|
||||
:root[data-theme-preset="dracula"] {
|
||||
--color-accent-h: 265;
|
||||
--color-accent-c: 0.24;
|
||||
--color-accent-l: 68%;
|
||||
@@ -359,7 +359,7 @@
|
||||
--favorite-glow: oklch(72% 0.16 85 / 0.5);
|
||||
}
|
||||
|
||||
[data-theme="dark"][data-theme-preset="dracula"] {
|
||||
:root[data-theme="dark"][data-theme-preset="dracula"] {
|
||||
--color-accent-h: 265;
|
||||
--color-accent-c: 0.24;
|
||||
--color-accent-l: 72%;
|
||||
@@ -394,7 +394,7 @@
|
||||
|
||||
/* ── Preset: Solarized ─────────────────────────────────────── */
|
||||
|
||||
[data-theme-preset="solarized"] {
|
||||
:root[data-theme-preset="solarized"] {
|
||||
--color-accent-h: 175;
|
||||
--color-accent-c: 0.18;
|
||||
--color-accent-l: 55%;
|
||||
@@ -429,7 +429,7 @@
|
||||
--favorite-glow: oklch(68% 0.16 75 / 0.5);
|
||||
}
|
||||
|
||||
[data-theme="dark"][data-theme-preset="solarized"] {
|
||||
:root[data-theme="dark"][data-theme-preset="solarized"] {
|
||||
--color-accent-h: 175;
|
||||
--color-accent-c: 0.18;
|
||||
--color-accent-l: 60%;
|
||||
|
||||
@@ -1566,9 +1566,6 @@ export class BaseModelApiClient {
|
||||
if (pageState.searchOptions.creator !== undefined) {
|
||||
params.append('search_creator', pageState.searchOptions.creator.toString());
|
||||
}
|
||||
if (pageState.searchOptions.hash !== undefined) {
|
||||
params.append('search_hash', pageState.searchOptions.hash.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,9 @@ export class SidebarManager {
|
||||
this._renameFolderNode = null;
|
||||
this._pendingDeleteFolderPath = null;
|
||||
this._deleteFolderModalWired = false;
|
||||
// Bumped on every modal open/close so a late dry-run answer can never
|
||||
// repaint a modal the user has already dismissed or retargeted.
|
||||
this._deleteFolderProbeToken = 0;
|
||||
|
||||
// Bind methods
|
||||
this.handleTreeClick = this.handleTreeClick.bind(this);
|
||||
@@ -982,42 +985,71 @@ export class SidebarManager {
|
||||
/**
|
||||
* Open the folder delete modal for *path*.
|
||||
*
|
||||
* The tree already knows whether the subtree holds models (the same
|
||||
* models-only set that dims empty nodes), so the modal opens in one of two
|
||||
* states without a round trip: a confirmation for a model-free folder, or
|
||||
* an explanation when models would have to be cascaded over — a
|
||||
* folder-level cascade would bypass the per-model lifecycle bookkeeping,
|
||||
* so the backend refuses it and the UI says why.
|
||||
* The models-only set that dims empty nodes is only a prediction: it is
|
||||
* built from the scanned, non-excluded models, while the delete guard walks
|
||||
* the folder on disk and refuses on any weight file — excluded ones
|
||||
* included. So the modal opens on the prediction for an instant answer and
|
||||
* is then corrected by a dry run of the very delete the user is about to
|
||||
* confirm, which is the only way the button can never contradict the
|
||||
* backend (see `_verifyFolderContents`).
|
||||
*/
|
||||
showDeleteFolderModal(path) {
|
||||
async showDeleteFolderModal(path) {
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
if (!modal) return;
|
||||
|
||||
// Defensive: the modal may have been absent when listeners were wired.
|
||||
this._wireDeleteFolderModal();
|
||||
|
||||
// Opening the modal — or targeting another folder — retires any
|
||||
// in-flight check from a previous open.
|
||||
const token = ++this._deleteFolderProbeToken;
|
||||
|
||||
const holdsModels = this.nonEmptyFolders ? this.nonEmptyFolders.has(path) : false;
|
||||
const prediction = holdsModels
|
||||
? {
|
||||
state: 'blocked',
|
||||
messageKey: 'sidebar.deleteFolderModal.notEmptyMessage',
|
||||
messageFallback: 'This folder still contains models. Delete or move them first.',
|
||||
}
|
||||
: { state: 'confirm' };
|
||||
|
||||
const probePending = this._supportsFolderManagement()
|
||||
&& typeof this.apiClient.deleteFolder === 'function';
|
||||
|
||||
this._renderDeleteFolderModal(path, prediction.state, {
|
||||
...prediction,
|
||||
checking: probePending,
|
||||
});
|
||||
|
||||
modalManager.showModal('deleteFolderModal');
|
||||
|
||||
if (!probePending) return;
|
||||
|
||||
await this._verifyFolderContents(path, token, prediction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paint one state of the folder delete modal.
|
||||
*
|
||||
* `state` is 'confirm' (deletion may proceed), 'blocked' (models would be
|
||||
* cascaded over, which the backend refuses) or 'busy' (a staged delete is
|
||||
* still pending inside the folder). `checking` keeps the confirm button
|
||||
* disabled while the authoritative server-side check runs.
|
||||
*/
|
||||
_renderDeleteFolderModal(path, state, options = {}) {
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
if (!modal) return;
|
||||
|
||||
const title = modal.querySelector('[data-role="title"]');
|
||||
const message = modal.querySelector('[data-role="message"]');
|
||||
const info = modal.querySelector('[data-role="info"]');
|
||||
const confirmBtn = modal.querySelector('[data-action="confirm-delete-folder"]');
|
||||
|
||||
const holdsModels = this.nonEmptyFolders ? this.nonEmptyFolders.has(path) : false;
|
||||
const checking = Boolean(options.checking);
|
||||
|
||||
const pathLine = `<strong>${escapeHtml(translate('sidebar.deleteFolderModal.folderLabel', {}, 'Folder'))}:</strong> ${escapeHtml(path)}`;
|
||||
const extraLines = [];
|
||||
|
||||
if (holdsModels) {
|
||||
this._pendingDeleteFolderPath = null;
|
||||
title.textContent = translate(
|
||||
'sidebar.deleteFolderModal.notEmptyTitle', {}, 'Folder is not empty'
|
||||
);
|
||||
message.textContent = translate(
|
||||
'sidebar.deleteFolderModal.notEmptyMessage', {},
|
||||
'This folder still contains models. Delete or move them first.'
|
||||
);
|
||||
info.innerHTML = pathLine;
|
||||
confirmBtn.style.display = 'none';
|
||||
modal.dataset.state = 'blocked';
|
||||
} else {
|
||||
if (state === 'confirm') {
|
||||
this._pendingDeleteFolderPath = path;
|
||||
title.textContent = translate(
|
||||
'sidebar.deleteFolderModal.title', {}, 'Delete folder?'
|
||||
@@ -1026,18 +1058,146 @@ export class SidebarManager {
|
||||
'sidebar.deleteFolderModal.message', {},
|
||||
'The folder and everything inside it will be permanently removed from disk.'
|
||||
);
|
||||
info.innerHTML = `${pathLine}<br>${escapeHtml(translate(
|
||||
'sidebar.deleteFolderModal.emptyNote', {}, 'This folder contains no models.'
|
||||
))}`;
|
||||
if (!checking) {
|
||||
// While the check runs the "no models" claim is still only the
|
||||
// sidebar's prediction, so it is not repeated as a fact.
|
||||
extraLines.push(escapeHtml(translate(
|
||||
'sidebar.deleteFolderModal.emptyNote', {}, 'This folder contains no models.'
|
||||
)));
|
||||
}
|
||||
confirmBtn.style.display = '';
|
||||
modal.dataset.state = 'confirm';
|
||||
confirmBtn.disabled = checking;
|
||||
} else {
|
||||
this._pendingDeleteFolderPath = null;
|
||||
if (state === 'busy') {
|
||||
title.textContent = translate(
|
||||
'sidebar.deleteFolderModal.busyTitle', {}, 'A deletion is still pending'
|
||||
);
|
||||
message.textContent = translate(
|
||||
'sidebar.deleteFolderResult.busy', {},
|
||||
'A deletion is still pending inside this folder. Wait for the undo window to expire.'
|
||||
);
|
||||
} else {
|
||||
title.textContent = translate(
|
||||
'sidebar.deleteFolderModal.notEmptyTitle', {}, 'Folder is not empty'
|
||||
);
|
||||
message.textContent = translate(
|
||||
options.messageKey || 'sidebar.deleteFolderModal.notEmptyMessage',
|
||||
options.messageParams || {},
|
||||
options.messageFallback
|
||||
|| 'This folder still contains models. Delete or move them first.'
|
||||
);
|
||||
}
|
||||
confirmBtn.style.display = 'none';
|
||||
confirmBtn.disabled = true;
|
||||
}
|
||||
|
||||
modalManager.showModal('deleteFolderModal');
|
||||
if (checking) {
|
||||
extraLines.push(escapeHtml(translate(
|
||||
'sidebar.deleteFolderModal.checking', {}, 'Checking the folder contents...'
|
||||
)));
|
||||
}
|
||||
|
||||
info.innerHTML = [pathLine, ...extraLines].join('<br>');
|
||||
modal.dataset.state = state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the backend what deleting *relativePath* would actually remove.
|
||||
*
|
||||
* The dry run is authoritative: it walks the folder on disk and applies the
|
||||
* same guard the real delete uses, so it catches everything the sidebar
|
||||
* prediction cannot know — excluded models, weight files no scanner indexes
|
||||
* (a lora folder holding only a `.gguf`, say) and files added after the
|
||||
* last scan. A check that fails for any other reason falls back to the
|
||||
* prediction, leaving the real delete to report its own error.
|
||||
*/
|
||||
async _verifyFolderContents(relativePath, token, prediction) {
|
||||
let resolved = null;
|
||||
try {
|
||||
resolved = await this._resolveFolderAbsolutePath(relativePath);
|
||||
} catch (error) {
|
||||
console.error('[SidebarManager] Failed to resolve the folder path:', error);
|
||||
}
|
||||
|
||||
if (token !== this._deleteFolderProbeToken) return;
|
||||
|
||||
if (!resolved) {
|
||||
this._renderDeleteFolderModal(relativePath, prediction.state, prediction);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.apiClient.deleteFolder(resolved.absolutePath, { dryRun: true });
|
||||
if (token !== this._deleteFolderProbeToken) return;
|
||||
this._renderDeleteFolderModal(relativePath, 'confirm');
|
||||
} catch (error) {
|
||||
if (token !== this._deleteFolderProbeToken) return;
|
||||
if (error?.code === 'not_empty') {
|
||||
this._renderDeleteFolderModal(
|
||||
relativePath, 'blocked', this._notEmptyBlocker(error?.manifest)
|
||||
);
|
||||
} else if (error?.code === 'busy') {
|
||||
this._renderDeleteFolderModal(relativePath, 'busy');
|
||||
} else {
|
||||
this._renderDeleteFolderModal(relativePath, prediction.state, prediction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Message for a refused delete, split by whether the blocking models are
|
||||
* excluded from the library — the case where the sidebar legitimately shows
|
||||
* the folder as empty, which is exactly what used to be unexplained.
|
||||
*/
|
||||
_notEmptyBlocker(manifest) {
|
||||
const modelCount = Number(manifest?.model_count) || 0;
|
||||
const excludedCount = Number(manifest?.excluded_model_count) || 0;
|
||||
|
||||
if (modelCount > 0 && excludedCount > 0) {
|
||||
return {
|
||||
messageKey: 'sidebar.deleteFolderModal.notEmptyMessageExcluded',
|
||||
messageParams: { count: modelCount, excluded: excludedCount },
|
||||
messageFallback: `This folder still contains ${modelCount} model file(s), `
|
||||
+ `${excludedCount} of them excluded from the library. Un-exclude and `
|
||||
+ 'delete them first — deleting a folder never cascades over model files.',
|
||||
};
|
||||
}
|
||||
if (modelCount > 0) {
|
||||
return {
|
||||
messageKey: 'sidebar.deleteFolderModal.notEmptyMessageCount',
|
||||
messageParams: { count: modelCount },
|
||||
messageFallback: `This folder still contains ${modelCount} model file(s). `
|
||||
+ 'Delete or move them first — deleting a folder never cascades over model files.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
messageKey: 'sidebar.deleteFolderModal.notEmptyMessage',
|
||||
messageFallback: 'This folder still contains models. Delete or move them first.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a tree-relative folder path to the absolute business path the
|
||||
* folder APIs expect, or null when no model root is configured.
|
||||
*/
|
||||
async _resolveFolderAbsolutePath(relativePath) {
|
||||
const rootsData = await this.apiClient.fetchModelRoots();
|
||||
const roots = rootsData?.roots || [];
|
||||
const root = this._resolveDefaultRoot(roots);
|
||||
if (!root) return null;
|
||||
|
||||
return {
|
||||
root,
|
||||
absolutePath: this.combineRootAndRelativePath(root, relativePath),
|
||||
};
|
||||
}
|
||||
|
||||
hideDeleteFolderModal() {
|
||||
this._pendingDeleteFolderPath = null;
|
||||
// Retire any in-flight check so a late answer cannot repaint a modal
|
||||
// the user already dismissed.
|
||||
this._deleteFolderProbeToken += 1;
|
||||
modalManager.closeModal('deleteFolderModal');
|
||||
}
|
||||
|
||||
@@ -1057,16 +1217,13 @@ export class SidebarManager {
|
||||
}
|
||||
|
||||
try {
|
||||
const rootsData = await this.apiClient.fetchModelRoots();
|
||||
const roots = rootsData?.roots || [];
|
||||
const root = this._resolveDefaultRoot(roots);
|
||||
if (!root) {
|
||||
const resolved = await this._resolveFolderAbsolutePath(relativePath);
|
||||
if (!resolved) {
|
||||
showToast('sidebar.deleteFolderResult.noRoot', {}, 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
const absolutePath = this.combineRootAndRelativePath(root, relativePath);
|
||||
const result = await this.apiClient.deleteFolder(absolutePath);
|
||||
const result = await this.apiClient.deleteFolder(resolved.absolutePath);
|
||||
|
||||
// Drop the node (and its subtree) from the persisted expand state
|
||||
// before refreshing, otherwise stale keys accumulate forever. A
|
||||
@@ -1083,7 +1240,7 @@ export class SidebarManager {
|
||||
// the same 20s undo affordance the model delete flow uses.
|
||||
showActionToast('sidebar.deleteFolderResult.success', { name }, 'success', {
|
||||
actionText: translate('toast.undo.action', {}, 'Undo'),
|
||||
onAction: () => this._restoreDeletedFolder(absolutePath, relativePath),
|
||||
onAction: () => this._restoreDeletedFolder(resolved.absolutePath, relativePath),
|
||||
});
|
||||
} else {
|
||||
showToast(
|
||||
@@ -1097,7 +1254,18 @@ export class SidebarManager {
|
||||
} catch (error) {
|
||||
console.error('[SidebarManager] Error deleting folder:', error);
|
||||
if (error?.code === 'not_empty') {
|
||||
showToast('sidebar.deleteFolderResult.notEmpty', {}, 'warning');
|
||||
// The dry run normally catches this before the user can
|
||||
// confirm; reaching here means the folder changed in between.
|
||||
const modelCount = Number(error?.manifest?.model_count) || 0;
|
||||
if (modelCount > 0) {
|
||||
showToast(
|
||||
'sidebar.deleteFolderResult.notEmptyWithCount',
|
||||
{ count: modelCount },
|
||||
'warning'
|
||||
);
|
||||
} else {
|
||||
showToast('sidebar.deleteFolderResult.notEmpty', {}, 'warning');
|
||||
}
|
||||
} else if (error?.code === 'busy') {
|
||||
showToast('sidebar.deleteFolderResult.busy', {}, 'warning');
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
|
||||
/**
|
||||
* Escape HTML entities in a string to prevent injection when interpolating into innerHTML.
|
||||
* @param {string} str - The string to escape
|
||||
* @returns {string} - The escaped string
|
||||
*/
|
||||
function _escapeHtml(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = String(str);
|
||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the sidecar migration summary modal after a migration completes.
|
||||
* Mirrors the Metadata Fetch Summary / Batch Download Summary lifecycle: the
|
||||
* modal element is appended directly to document.body and removed on close;
|
||||
* it is not registered with ModalManager.
|
||||
* @param {Object} options - Summary options
|
||||
* @param {Object} options.result - Migration result payload from /api/lm/sidecars/migrate
|
||||
* @param {Function} options.onClose - Callback invoked after the modal is dismissed
|
||||
* @param {Function} [options.onOpenLocation] - Callback for the "Open Folder" action; the modal stays open
|
||||
*/
|
||||
export function showSidecarMigrationSummary({ result, onClose, onOpenLocation }) {
|
||||
const errorCount = result.error_count || 0;
|
||||
const errors = Array.isArray(result.errors) ? result.errors : [];
|
||||
const moved = result.moved || 0;
|
||||
const modelsMoved = result.models_moved || 0;
|
||||
const modelsTotal = result.models_total || 0;
|
||||
const skipped = result.skipped || 0;
|
||||
const conflicts = result.conflicts || 0;
|
||||
|
||||
const showLocation = result.direction !== 'to_alongside' && !!result.sidecar_root;
|
||||
|
||||
// 3-state summary header semantics (mirrors DownloadBatchSummaryModal)
|
||||
let headerState;
|
||||
let headerIcon;
|
||||
let headerText;
|
||||
if (errorCount > 0) {
|
||||
headerState = 'warning';
|
||||
headerIcon = 'fa-exclamation-circle';
|
||||
headerText = translate('modals.sidecarMigrationResult.completedWithErrors', { count: errorCount }, 'Completed with ' + errorCount + ' error(s)');
|
||||
} else {
|
||||
headerState = 'success';
|
||||
headerIcon = 'fa-check-circle';
|
||||
headerText = translate('modals.sidecarMigrationResult.completedSuccessfully', {}, 'Migration completed successfully');
|
||||
}
|
||||
|
||||
const failureRows = errors.map((entry, i) => {
|
||||
const name = entry?.model || 'Unknown';
|
||||
const error = entry?.error ? String(entry.error) : 'Unknown error';
|
||||
return `<tr>
|
||||
<td class="failure-index">${i + 1}</td>
|
||||
<td class="failure-name" title="${_escapeHtml(name)}">${_escapeHtml(name)}</td>
|
||||
<td class="failure-error" title="${_escapeHtml(error)}">${_escapeHtml(error)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
const modalHtml = `
|
||||
<div id="sidecarMigrationSummaryModal" class="modal" style="display: block;">
|
||||
<div class="modal-content sidecar-migration-summary-modal">
|
||||
<button class="close" data-action="close-modal">×</button>
|
||||
|
||||
<h2>${translate('modals.sidecarMigrationResult.title', {}, 'Sidecar Migration Summary')}</h2>
|
||||
|
||||
<div class="summary-header ${headerState}">
|
||||
<i class="fas ${headerIcon}"></i>
|
||||
<span class="summary-title">${headerText}</span>
|
||||
<span class="summary-hint">${modelsMoved}/${modelsTotal}</span>
|
||||
</div>
|
||||
|
||||
<div class="refresh-summary-stats">
|
||||
<div class="stat-card stat-card-success">
|
||||
<div class="stat-card-body">
|
||||
<span class="stat-card-label">${translate('modals.sidecarMigrationResult.statMoved', {}, 'Moved Files')}</span>
|
||||
<span class="stat-card-value">${moved}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card-total">
|
||||
<div class="stat-card-body">
|
||||
<span class="stat-card-label">${translate('modals.sidecarMigrationResult.statModels', {}, 'Models')}</span>
|
||||
<span class="stat-card-value">${modelsMoved}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card-skipped">
|
||||
<div class="stat-card-body">
|
||||
<span class="stat-card-label">${translate('modals.sidecarMigrationResult.statSkipped', {}, 'Skipped')}</span>
|
||||
<span class="stat-card-value">${skipped}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card-time">
|
||||
<div class="stat-card-body">
|
||||
<span class="stat-card-label">${translate('modals.sidecarMigrationResult.statConflicts', {}, 'Conflicts Resolved')}</span>
|
||||
<span class="stat-card-value">${conflicts}</span>
|
||||
</div>
|
||||
</div>
|
||||
${errorCount > 0 ? `
|
||||
<div class="stat-card stat-card-failure">
|
||||
<div class="stat-card-body">
|
||||
<span class="stat-card-label">${translate('modals.sidecarMigrationResult.statErrors', {}, 'Errors')}</span>
|
||||
<span class="stat-card-value">${errorCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
${errorCount > 0 ? `
|
||||
<div class="refresh-failures-section">
|
||||
<h4><i class="fas fa-exclamation-triangle"></i> ${translate('modals.sidecarMigrationResult.failedItems', { count: errorCount }, 'Failed Items (' + errorCount + ')')}</h4>
|
||||
<div class="failure-table-wrapper">
|
||||
<table class="failure-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>${translate('modals.sidecarMigrationResult.columnModel', {}, 'Model')}</th>
|
||||
<th>${translate('modals.sidecarMigrationResult.columnError', {}, 'Error')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${failureRows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
` : `
|
||||
<div class="refresh-success-message">
|
||||
<i class="fas fa-check-circle"></i> ${translate('modals.sidecarMigrationResult.successMessage', { moved: moved, models: modelsMoved }, 'Moved ' + moved + ' files for ' + modelsMoved + ' models')}
|
||||
</div>
|
||||
`}
|
||||
|
||||
${showLocation ? `
|
||||
<p class="sidecar-migration-location">
|
||||
${translate('modals.sidecarMigrationResult.location', { path: result.sidecar_root }, 'Storage location: ' + result.sidecar_root)}
|
||||
</p>
|
||||
` : ''}
|
||||
|
||||
<div class="modal-actions">
|
||||
${showLocation ? `
|
||||
<button class="secondary-btn" data-action="open-sidecar-location"><i class="fas fa-folder-open"></i> ${translate('settings.sidecarStorage.openFolderButton', {}, 'Open Folder')}</button>
|
||||
` : ''}
|
||||
<button class="cancel-btn" data-action="close-modal">${translate('common.actions.close', {}, 'Close')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const existing = document.getElementById('sidecarMigrationSummaryModal');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = modalHtml;
|
||||
const modal = container.firstElementChild;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const close = () => {
|
||||
document.removeEventListener('keydown', handleEscape, true);
|
||||
modal.remove();
|
||||
if (typeof onClose === 'function') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Capture phase + stopPropagation so ESC never reaches the settings
|
||||
// modal's own ESC handler underneath.
|
||||
const handleEscape = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleEscape, true);
|
||||
|
||||
modal.addEventListener('click', (e) => {
|
||||
const actionEl = e.target.closest('[data-action]');
|
||||
const action = actionEl?.dataset.action;
|
||||
if (!action) return;
|
||||
e.preventDefault();
|
||||
|
||||
switch (action) {
|
||||
case 'close-modal':
|
||||
close();
|
||||
break;
|
||||
case 'open-sidecar-location':
|
||||
// Keep the modal open; only trigger the file-manager action.
|
||||
if (typeof onOpenLocation === 'function') {
|
||||
onOpenLocation();
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return modal;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { showToast, openCivitai, sendLoraToWorkflow, sendEmbeddingToWorkflow, sendModelPathToWorkflow, buildLoraSyntax, copyToClipboard } from '../../utils/uiHelpers.js';
|
||||
import { getModelSourceInfo, getModelSourceGroupKey, getModelSourceViewTitle, openModelSource } from '../../utils/modelSourceHelpers.js';
|
||||
import { getModelSource, getModelSourceInfo, getModelSourceGroupKey, getModelSourceViewTitle, openModelSource } from '../../utils/modelSourceHelpers.js';
|
||||
import { modalManager } from '../../managers/ModalManager.js';
|
||||
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
||||
import {
|
||||
@@ -32,6 +32,18 @@ function getModalFilePath(fallback = '') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source descriptor for a model that was hash-enriched from the OpenModelDB
|
||||
* catalogue: it carries no `source_url`, so `getModelSourceInfo` finds
|
||||
* nothing and the page link lives in the civitai payload instead.
|
||||
*/
|
||||
function getOpenModelDBSourceInfo(model) {
|
||||
const url = model?.civitai?.openmodeldb?.url;
|
||||
if (typeof url !== 'string' || !url) return null;
|
||||
const descriptor = getModelSource('openmodeldb');
|
||||
return descriptor ? { ...descriptor, sourceId: '', url } : null;
|
||||
}
|
||||
|
||||
const COMMERCIAL_ICON_CONFIG = [
|
||||
{
|
||||
key: 'image',
|
||||
@@ -355,7 +367,9 @@ export async function showModelModal(model, modelType) {
|
||||
const escapedFolderPath = escapeHtml((modelWithFullData.file_path || '').replace(/[^/]+$/, '') || 'N/A');
|
||||
// De-emphasized hash display: a borderless full-width footnote line below
|
||||
// the info grid — sha256 middle-truncated (first 10 + last 6), autov3 in
|
||||
// full (12 chars); the full value is copied via data-hash.
|
||||
// full (12 chars); the full value is copied via data-hash. Civitai model /
|
||||
// version ids join the same line on the right when the model comes from
|
||||
// the Civitai ecosystem (a model card corresponds to one Civitai version).
|
||||
const modelSha256 = modelWithFullData.sha256 || '';
|
||||
const modelAutov3 = modelWithFullData.autov3 || '';
|
||||
const truncatedSha256 = modelSha256.length > 16
|
||||
@@ -383,8 +397,47 @@ export async function showModelModal(model, modelType) {
|
||||
</button>
|
||||
</span>`);
|
||||
}
|
||||
const hashesMarkup = modelSha256 && hashEntries.length ? `
|
||||
<div class="hash-footnote" aria-label="${translate('modals.model.metadata.hashes', {}, 'Hashes')}">${hashEntries.join('<span class="hash-sep">·</span>')}
|
||||
const normalizeCivitaiId = (value) => {
|
||||
if (value === undefined || value === null) return '';
|
||||
const normalized = String(value).trim();
|
||||
// "0" is used as a placeholder for unknown ids in some metadata sources
|
||||
return normalized && normalized !== '0' ? normalized : '';
|
||||
};
|
||||
const civitaiInfo = modelWithFullData.civitai || {};
|
||||
const footnoteModelId = normalizeCivitaiId(civitaiInfo.modelId ?? civitaiInfo.model_id);
|
||||
const footnoteVersionId = normalizeCivitaiId(civitaiInfo.id);
|
||||
const copyCivitaiIdTitle = translate('modals.model.actions.copyCivitaiId', {}, 'Copy Civitai ID');
|
||||
const civitaiIdCopiedToast = escapeAttribute(translate('modals.model.actions.civitaiIdCopied', {}, 'Civitai ID copied to clipboard'));
|
||||
const civitaiIdEntries = [];
|
||||
if (footnoteModelId) {
|
||||
civitaiIdEntries.push(`
|
||||
<span class="hash-entry civitai-id-entry">
|
||||
<span class="hash-kind">${translate('modals.model.metadata.civitaiModelId', {}, 'Model ID')}</span>
|
||||
<span class="model-hash-value" title="${escapeAttribute(footnoteModelId)}">${escapeHtml(footnoteModelId)}</span>
|
||||
<button class="hash-copy-btn" data-action="copy-hash" data-hash="${escapeAttribute(footnoteModelId)}" data-toast="${civitaiIdCopiedToast}" title="${copyCivitaiIdTitle}">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</span>`);
|
||||
}
|
||||
if (footnoteVersionId) {
|
||||
civitaiIdEntries.push(`
|
||||
<span class="hash-entry civitai-id-entry">
|
||||
<span class="hash-kind">${translate('modals.model.metadata.civitaiVersionId', {}, 'Version ID')}</span>
|
||||
<span class="model-hash-value" title="${escapeAttribute(footnoteVersionId)}">${escapeHtml(footnoteVersionId)}</span>
|
||||
<button class="hash-copy-btn" data-action="copy-hash" data-hash="${escapeAttribute(footnoteVersionId)}" data-toast="${civitaiIdCopiedToast}" title="${copyCivitaiIdTitle}">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</span>`);
|
||||
}
|
||||
const footnoteParts = [];
|
||||
if (hashEntries.length) {
|
||||
footnoteParts.push(hashEntries.join('<span class="hash-sep">·</span>'));
|
||||
}
|
||||
if (civitaiIdEntries.length) {
|
||||
footnoteParts.push(`<span class="civitai-id-group">${civitaiIdEntries.join('<span class="hash-sep">·</span>')}</span>`);
|
||||
}
|
||||
const hashesMarkup = footnoteParts.length ? `
|
||||
<div class="hash-footnote" aria-label="${translate('modals.model.metadata.hashes', {}, 'Hashes')}">${footnoteParts.join('')}
|
||||
</div>` : '';
|
||||
const useNewIcons = state.global.settings.use_new_license_icons !== false;
|
||||
const licenseIcons = useNewIcons
|
||||
@@ -398,7 +451,7 @@ export async function showModelModal(model, modelType) {
|
||||
<div class="civitai-view" title="${translate('modals.model.actions.viewOnCivitai', {}, 'View on Civitai')}" data-action="view-civitai" data-filepath="${escapedFilePathAttr}">
|
||||
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')}
|
||||
</div>`.trim() : '';
|
||||
const sourceInfo = getModelSourceInfo(modelWithFullData);
|
||||
const sourceInfo = getModelSourceInfo(modelWithFullData) || getOpenModelDBSourceInfo(modelWithFullData);
|
||||
const escapedSourceUrl = sourceInfo?.url ? escapeAttribute(sourceInfo.url) : '';
|
||||
const isHuggingFaceSource = sourceInfo?.platform === 'huggingface';
|
||||
const sourceTitle = sourceInfo ? getModelSourceViewTitle(sourceInfo) : '';
|
||||
@@ -975,7 +1028,7 @@ function setupEventHandlers(filePath, modelType) {
|
||||
break;
|
||||
case 'copy-hash':
|
||||
if (target.dataset.hash) {
|
||||
copyToClipboard(target.dataset.hash, 'Hash copied to clipboard');
|
||||
copyToClipboard(target.dataset.hash, target.dataset.toast || 'Hash copied to clipboard');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
* MediaRenderers.js
|
||||
* HTML generators for media items (images/videos) in the showcase
|
||||
*/
|
||||
import { state } from '../../../state/index.js';
|
||||
import { translate } from '../../../utils/i18nHelpers.js';
|
||||
import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js';
|
||||
import { generateMetadataPanel } from './MetadataPanel.js';
|
||||
import { getDisplayUrl, getShowcaseUrl } from '../../../utils/civitaiUtils.js';
|
||||
|
||||
/**
|
||||
* Generate video wrapper HTML. The wrapper fills its container (the gallery's
|
||||
@@ -13,13 +18,16 @@
|
||||
* @param {string} localUrl - Local file URL
|
||||
* @param {string} remoteUrl - Remote file URL
|
||||
* @param {string} mediaControlsHtml - HTML for media control buttons
|
||||
* @param {number|null} heightPercent - Vertical-list aspect ratio (padding-bottom
|
||||
* percent) sizing the wrapper before the media loads; null in gallery mode
|
||||
* @returns {string} HTML content
|
||||
*/
|
||||
export function generateVideoWrapper(media, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
|
||||
export function generateVideoWrapper(media, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '', heightPercent = null) {
|
||||
const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0;
|
||||
const wrapperStyle = heightPercent !== null ? ` style="padding-bottom: ${heightPercent}%"` : '';
|
||||
|
||||
return `
|
||||
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
|
||||
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}"${wrapperStyle} data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
|
||||
${shouldBlur ? `
|
||||
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
|
||||
<i class="fas fa-eye"></i>
|
||||
@@ -57,27 +65,30 @@ export function generateVideoWrapper(media, shouldBlur, nsfwText, metadataPanel,
|
||||
* @param {string} localUrl - Local file URL
|
||||
* @param {string} remoteUrl - Remote file URL
|
||||
* @param {string} mediaControlsHtml - HTML for media control buttons
|
||||
* @param {number|null} heightPercent - Vertical-list aspect ratio (padding-bottom
|
||||
* percent) sizing the wrapper before the media loads; null in gallery mode
|
||||
* @returns {string} HTML content
|
||||
*/
|
||||
export function generateImageWrapper(media, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
|
||||
export function generateImageWrapper(media, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '', heightPercent = null) {
|
||||
const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0;
|
||||
const wrapperStyle = heightPercent !== null ? ` style="padding-bottom: ${heightPercent}%"` : '';
|
||||
|
||||
return `
|
||||
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
|
||||
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}"${wrapperStyle} data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
|
||||
${shouldBlur ? `
|
||||
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
${mediaControlsHtml}
|
||||
<img data-local-src="${localUrl || ''}"
|
||||
<img data-local-src="${localUrl || ''}"
|
||||
data-remote-src="${remoteUrl}"
|
||||
data-nsfw-level="${nsfwLevel}"
|
||||
alt="Preview"
|
||||
alt="Preview"
|
||||
width="${media.width}"
|
||||
height="${media.height}"
|
||||
fetchpriority="high"
|
||||
class="lazy ${shouldBlur ? 'blurred' : ''}">
|
||||
${heightPercent === null ? 'fetchpriority="high"' : ''}
|
||||
class="lazy ${shouldBlur ? 'blurred' : ''}">
|
||||
${shouldBlur ? `
|
||||
<div class="nsfw-overlay">
|
||||
<div class="nsfw-warning">
|
||||
@@ -89,4 +100,162 @@ export function generateImageWrapper(media, shouldBlur, nsfwText, metadataPanel,
|
||||
${metadataPanel}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the matching local file for an image
|
||||
* @param {Object} img - Image metadata
|
||||
* @param {number} index - Image index
|
||||
* @param {Array} exampleFiles - Array of local files
|
||||
* @returns {Object|null} Matching local file or null
|
||||
*/
|
||||
export function findLocalFile(img, index, exampleFiles) {
|
||||
if (!exampleFiles || exampleFiles.length === 0) return null;
|
||||
|
||||
let localFile = null;
|
||||
|
||||
if (typeof img.id === 'string' && img.id) {
|
||||
// This is a custom image, find by custom_<id>
|
||||
const customPrefix = `custom_${img.id}`;
|
||||
localFile = exampleFiles.find(file => file.name.startsWith(customPrefix));
|
||||
} else {
|
||||
// This is a regular image from civitai, find by index
|
||||
localFile = exampleFiles.find(file => {
|
||||
const match = file.name.match(/image_(\d+)\./);
|
||||
return match && parseInt(match[1]) === index;
|
||||
});
|
||||
}
|
||||
|
||||
return localFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single showcase media item (image or video) with its metadata
|
||||
* panel and media controls. Shared by the gallery's main viewer and the
|
||||
* vertical list layout.
|
||||
* @param {Object} img - Image/video metadata
|
||||
* @param {number} index - Index in the array
|
||||
* @param {Array} exampleFiles - Local files
|
||||
* @param {Object} options - Rendering options
|
||||
* @param {number|null} options.heightPercent - Vertical-list aspect ratio
|
||||
* (padding-bottom percent) for the wrapper; null sizes the wrapper from its
|
||||
* container (gallery main viewer)
|
||||
* @param {boolean} options.fullQuality - True to use the full-quality showcase
|
||||
* URL (vertical list), false for the display-optimized URL (gallery viewer)
|
||||
* @returns {string} HTML for the media item
|
||||
*/
|
||||
export function renderShowcaseMediaItem(img, index, exampleFiles, { heightPercent = null, fullQuality = false } = {}) {
|
||||
// Find matching file in our list of actual files
|
||||
let localFile = findLocalFile(img, index, exampleFiles);
|
||||
|
||||
// Get original remote URL
|
||||
const originalRemoteUrl = img.url || '';
|
||||
|
||||
// Determine media type for optimization
|
||||
const isVideo = localFile ? localFile.is_video :
|
||||
originalRemoteUrl.endsWith('.mp4') || originalRemoteUrl.endsWith('.webm');
|
||||
const mediaType = isVideo ? 'video' : 'image';
|
||||
|
||||
// Gallery main viewer: CivitAI URLs capped at width=2400 (the full-size
|
||||
// media viewer uses getShowcaseUrl separately). Vertical list: full
|
||||
// quality, matching the legacy behavior.
|
||||
const remoteUrl = fullQuality
|
||||
? getShowcaseUrl(originalRemoteUrl, mediaType)
|
||||
: getDisplayUrl(originalRemoteUrl, mediaType);
|
||||
|
||||
const localUrl = localFile ? localFile.path : '';
|
||||
|
||||
// Extract CivitAI image ID from CDN URL for import status check
|
||||
const cdnImageId = (img.url || '').match(/\/(\d+)\.(?:jpeg|jpg|png|webp|gif)(?:\?|#|$)/)?.[1] || '';
|
||||
|
||||
// Check if media should be blurred
|
||||
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
|
||||
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
|
||||
|
||||
// Determine NSFW warning text based on level
|
||||
let nsfwText = translate('modals.model.showcase.nsfwMature', {}, 'Mature Content');
|
||||
if (nsfwLevel >= NSFW_LEVELS.XXX) {
|
||||
nsfwText = translate('modals.model.showcase.nsfwXxx', {}, 'XXX-rated Content');
|
||||
} else if (nsfwLevel >= NSFW_LEVELS.X) {
|
||||
nsfwText = translate('modals.model.showcase.nsfwX', {}, 'X-rated Content');
|
||||
} else if (nsfwLevel >= NSFW_LEVELS.R) {
|
||||
nsfwText = translate('modals.model.showcase.nsfwR', {}, 'R-rated Content');
|
||||
}
|
||||
|
||||
// Extract metadata from the image
|
||||
const meta = img.meta || {};
|
||||
const prompt = meta.prompt || '';
|
||||
const negativePrompt = meta.negative_prompt || meta.negativePrompt || '';
|
||||
const size = meta.Size || `${img.width}x${img.height}`;
|
||||
const seed = meta.seed || '';
|
||||
const model = meta.Model || '';
|
||||
const steps = meta.steps || '';
|
||||
const sampler = meta.sampler || '';
|
||||
const cfgScale = meta.cfg_scale || meta.cfgScale || '';
|
||||
const clipSkip = meta.clip_skip || meta.clipSkip || '';
|
||||
|
||||
// Check if we have any meaningful generation parameters
|
||||
const hasParams = seed || model || steps || sampler || cfgScale || clipSkip;
|
||||
const hasPrompts = prompt || negativePrompt;
|
||||
|
||||
// Create metadata panel content
|
||||
const metadataPanel = generateMetadataPanel(
|
||||
hasParams, hasPrompts,
|
||||
prompt, negativePrompt,
|
||||
size, seed, model, steps, sampler, cfgScale, clipSkip
|
||||
);
|
||||
|
||||
// Determine if this is a custom image (has id property)
|
||||
const isCustomImage = Boolean(typeof img.id === 'string' && img.id);
|
||||
|
||||
const hasGenMeta = img.hasMeta || (img.meta && (img.meta.prompt || img.meta.seed || img.meta.resources));
|
||||
|
||||
// Create the media control buttons HTML
|
||||
const mediaControlsHtml = `
|
||||
<div class="media-controls">
|
||||
<button class="media-control-btn set-preview-btn" title="Set as preview">
|
||||
<i class="fas fa-image"></i>
|
||||
</button>
|
||||
${hasGenMeta ? `
|
||||
<button class="media-control-btn create-recipe-btn"
|
||||
title="Create As Recipe"
|
||||
data-image-meta="${encodeURIComponent(JSON.stringify(img.meta || {}))}"
|
||||
data-image-url="${img.url || ''}"
|
||||
data-image-nsfw="${img.nsfwLevel ?? ''}"
|
||||
data-image-id="${cdnImageId}"
|
||||
data-img-id="${img.id || ''}"
|
||||
data-local-path="${localFile ? localFile.path : ''}">
|
||||
<i class="fas fa-book-open"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
<button class="media-control-btn set-nsfw-btn"
|
||||
title="Set content rating"
|
||||
data-media-index="${index}"
|
||||
data-media-source="${isCustomImage ? 'custom' : 'civitai'}"
|
||||
data-media-id="${img.id || ''}">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
</button>
|
||||
<button class="media-control-btn example-delete-btn ${!isCustomImage ? 'disabled' : ''}"
|
||||
title="${isCustomImage ? 'Delete this example' : 'Only custom images can be deleted'}"
|
||||
data-short-id="${img.id || ''}"
|
||||
${!isCustomImage ? 'aria-disabled="true"' : ''}>
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
<i class="fas fa-check confirm-icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Generate the appropriate wrapper based on media type
|
||||
if (isVideo) {
|
||||
return generateVideoWrapper(
|
||||
img, shouldBlur, nsfwText, metadataPanel,
|
||||
localUrl, remoteUrl, mediaControlsHtml, heightPercent
|
||||
);
|
||||
}
|
||||
|
||||
return generateImageWrapper(
|
||||
img, shouldBlur, nsfwText, metadataPanel,
|
||||
localUrl, remoteUrl, mediaControlsHtml, heightPercent
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
*
|
||||
* The showcase starts collapsed as a slim indicator bar ("Show N examples"),
|
||||
* so opening the modal never triggers remote image fetches. Expanding reveals
|
||||
* a gallery: a single main viewer with prev/next controls, a horizontal
|
||||
* thumbnail strip for overview/random access, and an always-visible import
|
||||
* entry — no scrolling through a vertical stack of full-width examples.
|
||||
* the layout chosen by the `showcase_layout` setting: either a gallery (a
|
||||
* single main viewer with prev/next controls, a horizontal thumbnail strip
|
||||
* for overview/random access, and an always-visible import entry) or the
|
||||
* classic vertical list (every example stacked full-width, see
|
||||
* VerticalListView.js). A segmented toggle in the toolbar switches layouts.
|
||||
*/
|
||||
import { showToast } from '../../../utils/uiHelpers.js';
|
||||
import { state } from '../../../state/index.js';
|
||||
@@ -20,8 +22,8 @@ import {
|
||||
initMediaControlHandlers,
|
||||
positionAllMediaControls
|
||||
} from './MediaUtils.js';
|
||||
import { generateMetadataPanel } from './MetadataPanel.js';
|
||||
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
|
||||
import { renderShowcaseMediaItem, findLocalFile } from './MediaRenderers.js';
|
||||
import { renderVerticalList, initVerticalList } from './VerticalListView.js';
|
||||
import { getShowcaseUrl, getDisplayUrl, getGalleryThumbnailUrl } from '../../../utils/civitaiUtils.js';
|
||||
import { openMediaViewer, isMediaViewerOpen } from '../MediaViewer.js';
|
||||
import { escapeAttribute } from '../utils.js';
|
||||
@@ -118,12 +120,45 @@ function renderPreviewThumb(previewUrl) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render showcase content: collapsed indicator bar by default, gallery
|
||||
* (main viewer + thumbnail strip + import entry) when expanded
|
||||
* Active showcase layout ('gallery' | 'vertical') from the persisted setting;
|
||||
* unknown/missing values fall back to the gallery
|
||||
* @returns {string}
|
||||
*/
|
||||
function getShowcaseLayout() {
|
||||
return state.settings.showcase_layout === 'vertical' ? 'vertical' : 'gallery';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the segmented gallery/list layout toggle
|
||||
* @param {string} layout - Active layout ('gallery' | 'vertical')
|
||||
* @returns {string} HTML for the toggle
|
||||
*/
|
||||
function renderLayoutToggle(layout) {
|
||||
const galleryLabel = translate('modals.model.showcase.layoutGallery', {}, 'Gallery view');
|
||||
const listLabel = translate('modals.model.showcase.layoutList', {}, 'List view');
|
||||
return `
|
||||
<div class="showcase-layout-toggle" role="group">
|
||||
<button type="button" class="layout-toggle-btn${layout === 'gallery' ? ' active' : ''}"
|
||||
data-showcase-layout="gallery" title="${galleryLabel}"
|
||||
aria-pressed="${layout === 'gallery'}">
|
||||
<i class="fas fa-th-large"></i>
|
||||
</button>
|
||||
<button type="button" class="layout-toggle-btn${layout === 'vertical' ? ' active' : ''}"
|
||||
data-showcase-layout="vertical" title="${listLabel}"
|
||||
aria-pressed="${layout === 'vertical'}">
|
||||
<i class="fas fa-list"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render showcase content: collapsed indicator bar by default, the active
|
||||
* layout (gallery or vertical list) when expanded
|
||||
* @param {Array} images - Array of images/videos to show
|
||||
* @param {Array} exampleFiles - Local example files
|
||||
* @param {string} previewUrl - Model preview URL for the collapsed indicator bar
|
||||
* @param {boolean} expanded - Whether to render the full gallery (loads remote media)
|
||||
* @param {boolean} expanded - Whether to render the full showcase (loads remote media)
|
||||
* @returns {string} HTML content
|
||||
*/
|
||||
export function renderShowcaseContent(images, exampleFiles = [], previewUrl = '', expanded = false) {
|
||||
@@ -189,6 +224,8 @@ export function renderShowcaseContent(images, exampleFiles = [], previewUrl = ''
|
||||
${renderImportInterface(false)}
|
||||
</div>` : '';
|
||||
|
||||
const layout = getShowcaseLayout();
|
||||
|
||||
// Collapsed resting state: a slim indicator bar only — remote examples are
|
||||
// not rendered (and therefore not fetched) until the user expands.
|
||||
if (!expanded) {
|
||||
@@ -201,6 +238,7 @@ export function renderShowcaseContent(images, exampleFiles = [], previewUrl = ''
|
||||
<i class="fas fa-chevron-down"></i> ${translate('modals.model.showcase.showCount', { count }, `${showText} (${count})`)}
|
||||
</button>
|
||||
${hiddenNotification}
|
||||
${renderLayoutToggle(layout)}
|
||||
<button class="gallery-import-btn" id="galleryImportBtn" title="${translate('modals.model.showcase.addExamples', {}, 'Add examples')}">
|
||||
<i class="fas fa-plus"></i> ${translate('modals.model.showcase.addExamples', {}, 'Add examples')}
|
||||
</button>
|
||||
@@ -210,6 +248,26 @@ export function renderShowcaseContent(images, exampleFiles = [], previewUrl = ''
|
||||
`;
|
||||
}
|
||||
|
||||
// Vertical list layout: every example stacked full-width
|
||||
if (layout === 'vertical') {
|
||||
return `
|
||||
<div class="showcase-gallery showcase-vertical">
|
||||
<div class="gallery-toolbar">
|
||||
${hiddenNotification}
|
||||
<button class="gallery-show-btn" id="galleryShowBtn">
|
||||
<i class="fas fa-chevron-up"></i> ${translate('modals.model.showcase.hideExamples', {}, 'Hide examples')}
|
||||
</button>
|
||||
${renderLayoutToggle(layout)}
|
||||
<button class="gallery-import-btn" id="galleryImportBtn" title="${translate('modals.model.showcase.addExamples', {}, 'Add examples')}">
|
||||
<i class="fas fa-plus"></i> ${translate('modals.model.showcase.addExamples', {}, 'Add examples')}
|
||||
</button>
|
||||
</div>
|
||||
${renderVerticalList(filteredImages, exampleFiles)}
|
||||
${importZone}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const showNav = count > 1;
|
||||
const positionText = `${galleryState.activeIndex + 1} / ${count}`;
|
||||
const activeImg = filteredImages[galleryState.activeIndex];
|
||||
@@ -222,6 +280,7 @@ export function renderShowcaseContent(images, exampleFiles = [], previewUrl = ''
|
||||
<button class="gallery-show-btn" id="galleryShowBtn">
|
||||
<i class="fas fa-chevron-up"></i> ${translate('modals.model.showcase.hideExamples', {}, 'Hide examples')}
|
||||
</button>
|
||||
${renderLayoutToggle(layout)}
|
||||
<button class="gallery-import-btn" id="galleryImportBtn" title="${translate('modals.model.showcase.addExamples', {}, 'Add examples')}">
|
||||
<i class="fas fa-plus"></i> ${translate('modals.model.showcase.addExamples', {}, 'Add examples')}
|
||||
</button>
|
||||
@@ -300,150 +359,15 @@ function renderThumbnail(img, index, exampleFiles) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the active media item in the main viewer
|
||||
* Render the active media item in the main viewer (display-optimized URL,
|
||||
* wrapper sized by its container — see MediaRenderers.js)
|
||||
* @param {Object} img - Image/video metadata
|
||||
* @param {number} index - Index in the array
|
||||
* @param {Array} exampleFiles - Local files
|
||||
* @returns {string} HTML for the media item
|
||||
*/
|
||||
function renderMediaItem(img, index, exampleFiles) {
|
||||
// Find matching file in our list of actual files
|
||||
let localFile = findLocalFile(img, index, exampleFiles);
|
||||
|
||||
// Get original remote URL
|
||||
const originalRemoteUrl = img.url || '';
|
||||
|
||||
// Determine media type for optimization
|
||||
const isVideo = localFile ? localFile.is_video :
|
||||
originalRemoteUrl.endsWith('.mp4') || originalRemoteUrl.endsWith('.webm');
|
||||
const mediaType = isVideo ? 'video' : 'image';
|
||||
|
||||
// Optimize CivitAI URLs for in-modal display (images capped at width=2400;
|
||||
// the full-size media viewer uses getShowcaseUrl separately)
|
||||
const remoteUrl = getDisplayUrl(originalRemoteUrl, mediaType);
|
||||
|
||||
const localUrl = localFile ? localFile.path : '';
|
||||
|
||||
// Extract CivitAI image ID from CDN URL for import status check
|
||||
const cdnImageId = (img.url || '').match(/\/(\d+)\.(?:jpeg|jpg|png|webp|gif)(?:\?|#|$)/)?.[1] || '';
|
||||
|
||||
// Check if media should be blurred
|
||||
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
|
||||
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
|
||||
|
||||
// Determine NSFW warning text based on level
|
||||
let nsfwText = translate('modals.model.showcase.nsfwMature', {}, 'Mature Content');
|
||||
if (nsfwLevel >= NSFW_LEVELS.XXX) {
|
||||
nsfwText = translate('modals.model.showcase.nsfwXxx', {}, 'XXX-rated Content');
|
||||
} else if (nsfwLevel >= NSFW_LEVELS.X) {
|
||||
nsfwText = translate('modals.model.showcase.nsfwX', {}, 'X-rated Content');
|
||||
} else if (nsfwLevel >= NSFW_LEVELS.R) {
|
||||
nsfwText = translate('modals.model.showcase.nsfwR', {}, 'R-rated Content');
|
||||
}
|
||||
|
||||
// Extract metadata from the image
|
||||
const meta = img.meta || {};
|
||||
const prompt = meta.prompt || '';
|
||||
const negativePrompt = meta.negative_prompt || meta.negativePrompt || '';
|
||||
const size = meta.Size || `${img.width}x${img.height}`;
|
||||
const seed = meta.seed || '';
|
||||
const model = meta.Model || '';
|
||||
const steps = meta.steps || '';
|
||||
const sampler = meta.sampler || '';
|
||||
const cfgScale = meta.cfg_scale || meta.cfgScale || '';
|
||||
const clipSkip = meta.clip_skip || meta.clipSkip || '';
|
||||
|
||||
// Check if we have any meaningful generation parameters
|
||||
const hasParams = seed || model || steps || sampler || cfgScale || clipSkip;
|
||||
const hasPrompts = prompt || negativePrompt;
|
||||
|
||||
// Create metadata panel content
|
||||
const metadataPanel = generateMetadataPanel(
|
||||
hasParams, hasPrompts,
|
||||
prompt, negativePrompt,
|
||||
size, seed, model, steps, sampler, cfgScale, clipSkip
|
||||
);
|
||||
|
||||
// Determine if this is a custom image (has id property)
|
||||
const isCustomImage = Boolean(typeof img.id === 'string' && img.id);
|
||||
|
||||
const hasGenMeta = img.hasMeta || (img.meta && (img.meta.prompt || img.meta.seed || img.meta.resources));
|
||||
|
||||
// Create the media control buttons HTML
|
||||
const mediaControlsHtml = `
|
||||
<div class="media-controls">
|
||||
<button class="media-control-btn set-preview-btn" title="Set as preview">
|
||||
<i class="fas fa-image"></i>
|
||||
</button>
|
||||
${hasGenMeta ? `
|
||||
<button class="media-control-btn create-recipe-btn"
|
||||
title="Create As Recipe"
|
||||
data-image-meta="${encodeURIComponent(JSON.stringify(img.meta || {}))}"
|
||||
data-image-url="${img.url || ''}"
|
||||
data-image-nsfw="${img.nsfwLevel ?? ''}"
|
||||
data-image-id="${cdnImageId}"
|
||||
data-img-id="${img.id || ''}"
|
||||
data-local-path="${localFile ? localFile.path : ''}">
|
||||
<i class="fas fa-book-open"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
<button class="media-control-btn set-nsfw-btn"
|
||||
title="Set content rating"
|
||||
data-media-index="${index}"
|
||||
data-media-source="${isCustomImage ? 'custom' : 'civitai'}"
|
||||
data-media-id="${img.id || ''}">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
</button>
|
||||
<button class="media-control-btn example-delete-btn ${!isCustomImage ? 'disabled' : ''}"
|
||||
title="${isCustomImage ? 'Delete this example' : 'Only custom images can be deleted'}"
|
||||
data-short-id="${img.id || ''}"
|
||||
${!isCustomImage ? 'aria-disabled="true"' : ''}>
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
<i class="fas fa-check confirm-icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Generate the appropriate wrapper based on media type
|
||||
if (isVideo) {
|
||||
return generateVideoWrapper(
|
||||
img, shouldBlur, nsfwText, metadataPanel,
|
||||
localUrl, remoteUrl, mediaControlsHtml
|
||||
);
|
||||
}
|
||||
|
||||
return generateImageWrapper(
|
||||
img, shouldBlur, nsfwText, metadataPanel,
|
||||
localUrl, remoteUrl, mediaControlsHtml
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the matching local file for an image
|
||||
* @param {Object} img - Image metadata
|
||||
* @param {number} index - Image index
|
||||
* @param {Array} exampleFiles - Array of local files
|
||||
* @returns {Object|null} Matching local file or null
|
||||
*/
|
||||
function findLocalFile(img, index, exampleFiles) {
|
||||
if (!exampleFiles || exampleFiles.length === 0) return null;
|
||||
|
||||
let localFile = null;
|
||||
|
||||
if (typeof img.id === 'string' && img.id) {
|
||||
// This is a custom image, find by custom_<id>
|
||||
const customPrefix = `custom_${img.id}`;
|
||||
localFile = exampleFiles.find(file => file.name.startsWith(customPrefix));
|
||||
} else {
|
||||
// This is a regular image from civitai, find by index
|
||||
localFile = exampleFiles.find(file => {
|
||||
const match = file.name.match(/image_(\d+)\./);
|
||||
return match && parseInt(match[1]) === index;
|
||||
});
|
||||
}
|
||||
|
||||
return localFile;
|
||||
return renderShowcaseMediaItem(img, index, exampleFiles);
|
||||
}
|
||||
|
||||
// URLs already warmed in the HTTP cache, so repeat navigations and re-renders
|
||||
@@ -845,10 +769,12 @@ function isTypingTarget(target) {
|
||||
}
|
||||
|
||||
// '[' / ']' switch examples while the gallery is expanded. ArrowLeft/Right
|
||||
// stay reserved for model-level navigation (ModelModal), and the full-size
|
||||
// media viewer owns its keys while open.
|
||||
// stay reserved for model-level navigation (ModelModal), the full-size
|
||||
// media viewer owns its keys while open, and the vertical list has no
|
||||
// active-item concept to navigate.
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key !== '[' && event.key !== ']') return;
|
||||
if (getShowcaseLayout() !== 'gallery') return;
|
||||
if (!galleryState.expanded || galleryState.images.length < 2) return;
|
||||
if (isTypingTarget(event.target)) return;
|
||||
if (isMediaViewerOpen()) return;
|
||||
@@ -892,6 +818,32 @@ function initStripVideoLazyLoading(gallery) {
|
||||
videos.forEach(video => observer.observe(video));
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the showcase layout (gallery | vertical list), persist the setting
|
||||
* immediately and re-render in place
|
||||
* @param {string} layout - Target layout
|
||||
*/
|
||||
async function setShowcaseLayout(layout) {
|
||||
if (layout !== 'gallery' && layout !== 'vertical') return;
|
||||
if (layout === getShowcaseLayout()) return;
|
||||
|
||||
// Components reach the settings manager through the global set by
|
||||
// core.js (importing it here would create a module cycle)
|
||||
const sm = window.settingsManager;
|
||||
if (sm) {
|
||||
try {
|
||||
await sm.saveSetting('showcase_layout', layout);
|
||||
} catch (error) {
|
||||
console.error('Failed to save showcase layout setting:', error);
|
||||
return; // Keep the current layout when the save fails
|
||||
}
|
||||
} else {
|
||||
state.settings.showcase_layout = layout;
|
||||
}
|
||||
|
||||
rerenderGallery(galleryState.expanded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all gallery interactions
|
||||
* @param {HTMLElement} gallery - The .showcase-gallery element
|
||||
@@ -899,16 +851,25 @@ function initStripVideoLazyLoading(gallery) {
|
||||
export function initShowcaseContent(gallery) {
|
||||
if (!gallery) return;
|
||||
|
||||
// While expanded the thumbnail strip occupies the modal's bottom-right
|
||||
// corner; hide the back-to-top button there (Hide examples is the
|
||||
// equivalent "return to top" affordance)
|
||||
gallery.closest('.modal-content')?.classList.toggle('showcase-expanded', galleryState.expanded);
|
||||
// Gallery layout only: while expanded the thumbnail strip occupies the
|
||||
// modal's bottom-right corner; hide the back-to-top button there (Hide
|
||||
// examples is the equivalent "return to top" affordance). The vertical
|
||||
// list has no strip and is exactly where the button is needed most.
|
||||
const hideBackToTop = galleryState.expanded && getShowcaseLayout() === 'gallery';
|
||||
gallery.closest('.modal-content')?.classList.toggle('showcase-expanded', hideBackToTop);
|
||||
|
||||
// Toolbar: show/hide toggle (expanding renders the gallery and starts remote loads)
|
||||
gallery.querySelector('#galleryShowBtn')?.addEventListener('click', () => {
|
||||
rerenderGallery(!galleryState.expanded);
|
||||
});
|
||||
|
||||
// Segmented layout toggle (gallery / vertical list)
|
||||
gallery.querySelectorAll('[data-showcase-layout]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
setShowcaseLayout(btn.dataset.showcaseLayout);
|
||||
});
|
||||
});
|
||||
|
||||
// Same expansion via mouse wheel near the bottom of the modal
|
||||
setupScrollToExpand(gallery);
|
||||
|
||||
@@ -947,7 +908,7 @@ export function initShowcaseContent(gallery) {
|
||||
handleExampleDeleted(e.detail?.shortId);
|
||||
});
|
||||
|
||||
// Main viewer interactions (only exists in the expanded state)
|
||||
// Main viewer interactions (only exists in the expanded gallery layout)
|
||||
const container = gallery.querySelector('.main-media-container');
|
||||
if (container && galleryState.expanded) {
|
||||
initMainMediaInteractions(container);
|
||||
@@ -960,6 +921,11 @@ export function initShowcaseContent(gallery) {
|
||||
initStripVideoLazyLoading(gallery);
|
||||
}
|
||||
|
||||
// Vertical list interactions (only exists in the expanded vertical layout)
|
||||
if (gallery.querySelector('.vertical-list-container') && galleryState.expanded) {
|
||||
initVerticalList(gallery, galleryState.images, galleryState.exampleFiles);
|
||||
}
|
||||
|
||||
// Reposition controls on window resize
|
||||
resizeBoundGallery = gallery;
|
||||
}
|
||||
@@ -1162,10 +1128,20 @@ async function handleImportFiles(files, modelHash, importContainer) {
|
||||
let successCount = 0;
|
||||
const errors = [];
|
||||
|
||||
// The showcase section carries the freshest hash (an import may have
|
||||
// resolved a deferred hash) plus the file path the backend needs to
|
||||
// locate models whose hash is still pending.
|
||||
const showcaseSection = document.querySelector('.showcase-section');
|
||||
const modelPath = showcaseSection?.dataset.filepath || '';
|
||||
const currentHash = showcaseSection?.dataset.modelHash || modelHash;
|
||||
|
||||
for (const file of validFiles) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('model_hash', modelHash);
|
||||
formData.append('model_hash', currentHash);
|
||||
if (modelPath) {
|
||||
formData.append('model_path', modelPath);
|
||||
}
|
||||
formData.append('files', file);
|
||||
|
||||
const response = await fetch('/api/lm/import-example-images', {
|
||||
@@ -1192,8 +1168,17 @@ async function handleImportFiles(files, modelHash, importContainer) {
|
||||
|
||||
const result = lastSuccessResult;
|
||||
|
||||
// A model with a deferred hash (checkpoint / Other) is imported via
|
||||
// model_path; the backend resolves and returns the real hash. Persist
|
||||
// it so every follow-up (file list, NSFW toggle, delete, re-render)
|
||||
// targets the hash-keyed example folder.
|
||||
const effectiveHash = result.model_hash || currentHash;
|
||||
if (showcaseSection && effectiveHash) {
|
||||
showcaseSection.dataset.modelHash = effectiveHash;
|
||||
}
|
||||
|
||||
// Get updated local files
|
||||
const updatedFilesResponse = await fetch(`/api/lm/example-image-files?model_hash=${modelHash}`);
|
||||
const updatedFilesResponse = await fetch(`/api/lm/example-image-files?model_hash=${effectiveHash}`);
|
||||
const updatedFilesResult = await updatedFilesResponse.json();
|
||||
|
||||
if (!updatedFilesResult.success) {
|
||||
@@ -1221,7 +1206,7 @@ async function handleImportFiles(files, modelHash, importContainer) {
|
||||
}
|
||||
|
||||
// Initialize the import UI for the new content
|
||||
initExampleImport(modelHash, showcaseTab);
|
||||
initExampleImport(effectiveHash, showcaseTab);
|
||||
|
||||
if (errors.length > 0) {
|
||||
showToast('toast.import.imagesPartial', { success: successCount, failed: errors.length }, 'warning');
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* VerticalListView.js
|
||||
* Classic vertical example list for the model modal showcase: every example
|
||||
* rendered full-width and stacked, each with its own metadata panel and media
|
||||
* controls. Revived from the pre-gallery implementation (removed in v1.2.2),
|
||||
* adapted to the current shared showcase infrastructure (MediaRenderers,
|
||||
* MediaUtils) — no legacy scroll/metrics machinery.
|
||||
*/
|
||||
import {
|
||||
initLazyLoading,
|
||||
initNsfwBlurHandlers,
|
||||
initMetadataPanelHandlers,
|
||||
initMediaControlHandlers,
|
||||
positionAllMediaControls
|
||||
} from './MediaUtils.js';
|
||||
import { renderShowcaseMediaItem, findLocalFile } from './MediaRenderers.js';
|
||||
import { getShowcaseUrl } from '../../../utils/civitaiUtils.js';
|
||||
import { openMediaViewer } from '../MediaViewer.js';
|
||||
|
||||
/**
|
||||
* Render the vertical list: all examples stacked in a column
|
||||
* @param {Array} images - Filtered images/videos to show
|
||||
* @param {Array} exampleFiles - Local example files
|
||||
* @returns {string} HTML content
|
||||
*/
|
||||
export function renderVerticalList(images, exampleFiles = []) {
|
||||
return `
|
||||
<div class="vertical-list-container">
|
||||
${images.map((img, index) => renderVerticalMediaItem(img, index, exampleFiles)).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single vertical-list item. The wrapper is sized by the
|
||||
* padding-bottom aspect-ratio hack so the column layout is stable before any
|
||||
* media loads (and lazy loading has a box to intersect).
|
||||
* @param {Object} img - Image/video metadata
|
||||
* @param {number} index - Index in the array
|
||||
* @param {Array} exampleFiles - Local files
|
||||
* @returns {string} HTML for the media item
|
||||
*/
|
||||
function renderVerticalMediaItem(img, index, exampleFiles) {
|
||||
return renderShowcaseMediaItem(img, index, exampleFiles, {
|
||||
heightPercent: computeHeightPercent(img),
|
||||
fullQuality: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the wrapper height as a padding-bottom percent of the modal width,
|
||||
* clamped so very tall or very wide media still gets a reasonable box
|
||||
* (defensive 4:3 fallback for missing dimensions prevents NaN layout)
|
||||
* @param {Object} img - Image/video metadata
|
||||
* @returns {number} padding-bottom percent
|
||||
*/
|
||||
function computeHeightPercent(img) {
|
||||
const safeW = img.width || 4;
|
||||
const safeH = img.height || 3;
|
||||
const aspectRatio = (safeH / safeW) * 100;
|
||||
const containerWidth = 800; // modal content maximum width
|
||||
const minHeightPercent = 40;
|
||||
const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100;
|
||||
return Math.max(minHeightPercent, Math.min(maxHeightPercent, aspectRatio));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the item list for the full-size media viewer from the rendered items
|
||||
* @param {Array} images - Rendered images/videos
|
||||
* @param {Array} exampleFiles - Local example files
|
||||
* @returns {Array<{url: string, type: string}>}
|
||||
*/
|
||||
function buildViewerItems(images, exampleFiles) {
|
||||
return images.map((img, index) => {
|
||||
const localFile = findLocalFile(img, index, exampleFiles);
|
||||
const originalRemoteUrl = img.url || '';
|
||||
const isVideo = localFile ? localFile.is_video :
|
||||
originalRemoteUrl.endsWith('.mp4') || originalRemoteUrl.endsWith('.webm');
|
||||
return {
|
||||
url: localFile?.path || getShowcaseUrl(originalRemoteUrl, isVideo ? 'video' : 'image'),
|
||||
type: isVideo ? 'video' : 'image'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all vertical-list interactions: lazy loading, NSFW blur,
|
||||
* per-item metadata panels and media controls, click-to-view
|
||||
* @param {HTMLElement} container - The .showcase-vertical root element
|
||||
* @param {Array} images - Rendered images/videos (aligned with DOM order)
|
||||
* @param {Array} exampleFiles - Local example files
|
||||
*/
|
||||
export function initVerticalList(container, images, exampleFiles = []) {
|
||||
if (!container) return;
|
||||
|
||||
initLazyLoading(container);
|
||||
initNsfwBlurHandlers(container);
|
||||
initMetadataPanelHandlers(container);
|
||||
initMediaControlHandlers(container);
|
||||
positionAllMediaControls(container);
|
||||
|
||||
// Click-to-view: open the full-size media viewer at the clicked item
|
||||
const viewerItems = buildViewerItems(images, exampleFiles);
|
||||
container.querySelectorAll('.vertical-list-container .media-wrapper').forEach((wrapper, index) => {
|
||||
const mediaEl = wrapper.querySelector('img, video');
|
||||
if (!mediaEl) return;
|
||||
mediaEl.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
openMediaViewer(viewerItems, index);
|
||||
});
|
||||
});
|
||||
|
||||
// Reposition controls once media dimensions are known
|
||||
container.querySelectorAll('.vertical-list-container img, .vertical-list-container video').forEach(media => {
|
||||
media.addEventListener('load', () => positionAllMediaControls(container));
|
||||
if (media.tagName === 'VIDEO') {
|
||||
media.addEventListener('loadedmetadata', () => positionAllMediaControls(container));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
detectModelSourceDownloadUrl,
|
||||
getModelSource,
|
||||
isExternalModelSource,
|
||||
isValidRepoId,
|
||||
isValidSourceId,
|
||||
} from '../utils/modelSourceHelpers.js';
|
||||
|
||||
export class DownloadManager {
|
||||
@@ -34,6 +34,9 @@ export class DownloadManager {
|
||||
this.selectedFolder = '';
|
||||
this.apiClient = null;
|
||||
this.useDefaultPath = false;
|
||||
// Manual checkpoint/diffusion root override on the location step:
|
||||
// null follows the auto classification, true/false is user-picked.
|
||||
this._routingOverride = null;
|
||||
|
||||
// Multi-file selection state: selectedFile stays the first selected
|
||||
// file for backward compatibility with single-file flows (#1058).
|
||||
@@ -127,6 +130,11 @@ export class DownloadManager {
|
||||
// Default path toggle handler
|
||||
document.getElementById('useDefaultPath').addEventListener('change', this.handleToggleDefaultPath);
|
||||
|
||||
// Checkpoint/diffusion routing override toggle (checkpoints page only)
|
||||
document.querySelectorAll('#routingOverrideGroup .routing-override-option').forEach(btn => {
|
||||
btn.addEventListener('click', () => this.handleRoutingOverrideChange(btn.dataset.routing === 'diffusion'));
|
||||
});
|
||||
|
||||
// Auto-append newline after pasting a URL so users can paste multiple URLs in succession
|
||||
setupAutoNewlineOnPaste('modelUrl');
|
||||
}
|
||||
@@ -180,6 +188,7 @@ export class DownloadManager {
|
||||
this.selectedFiles = [];
|
||||
this._lastDownloadError = null;
|
||||
this._isDiffusionModel = false;
|
||||
this._routingOverride = null;
|
||||
|
||||
this.selectedFolder = '';
|
||||
this.batchModels = [];
|
||||
@@ -525,7 +534,7 @@ export class DownloadManager {
|
||||
const sourceInfo = detectModelSourceDownloadUrl(trimmed);
|
||||
if (sourceInfo) {
|
||||
// Reject path-traversal patterns like "../.." or "user/.."
|
||||
if (!isValidRepoId(sourceInfo.repo)) {
|
||||
if (!isValidSourceId(sourceInfo.platform, sourceInfo.repo)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
@@ -974,48 +983,12 @@ export class DownloadManager {
|
||||
try {
|
||||
this._isDiffusionModel = await this._resolveIsDiffusionModel();
|
||||
this._otherSubType = await this._resolveOtherSubType();
|
||||
// Each location-step entry starts from the auto classification;
|
||||
// the user can override it via the checkpoint/diffusion toggle.
|
||||
this._routingOverride = null;
|
||||
this._syncRoutingOverrideToggle();
|
||||
|
||||
let rootsData;
|
||||
if (this._isDiffusionModel && this.apiClient.modelType === 'checkpoints') {
|
||||
rootsData = await this.apiClient.fetchModelRoots('diffusion_model');
|
||||
} else if (this.apiClient.modelType === 'other' && this._otherSubType) {
|
||||
rootsData = await this.apiClient.fetchModelRoots(this._otherSubType);
|
||||
} else {
|
||||
// An undecidable other sub_type (null) intentionally lands
|
||||
// here: fetchModelRoots() lists all other roots so the user
|
||||
// can pick manually.
|
||||
rootsData = await this.apiClient.fetchModelRoots();
|
||||
}
|
||||
const modelRoot = document.getElementById('modelRoot');
|
||||
modelRoot.innerHTML = rootsData.roots.map(root =>
|
||||
`<option value="${root}">${root}</option>`
|
||||
).join('');
|
||||
|
||||
let defaultRoot;
|
||||
let subtypeDisplay;
|
||||
if (this.apiClient.modelType === 'other') {
|
||||
const otherDefaultRoots = state.global.settings.default_other_roots || {};
|
||||
defaultRoot = this._otherSubType ? (otherDefaultRoots[this._otherSubType] || '') : '';
|
||||
subtypeDisplay = this._otherSubType
|
||||
? (MODEL_SUBTYPE_DISPLAY_NAMES[this._otherSubType] || this._otherSubType)
|
||||
: this.apiClient.apiConfig.config.displayName;
|
||||
} else {
|
||||
const singularType = this._isDiffusionModel
|
||||
? 'unet'
|
||||
: this.apiClient.modelType.replace(/s$/, '');
|
||||
const defaultRootKey = `default_${singularType}_root`;
|
||||
defaultRoot = state.global.settings[defaultRootKey];
|
||||
subtypeDisplay = this._isDiffusionModel ? 'Diffusion Model' : this.apiClient.apiConfig.config.displayName;
|
||||
}
|
||||
console.log('Default root:', defaultRoot);
|
||||
console.log('Available roots:', rootsData.roots);
|
||||
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
||||
console.log(`Setting default root: ${defaultRoot}`);
|
||||
modelRoot.value = defaultRoot;
|
||||
}
|
||||
|
||||
document.getElementById('modelRootLabel').textContent =
|
||||
translate('modals.download.selectTypeRoot', { type: subtypeDisplay });
|
||||
await this._populateModelRoots();
|
||||
|
||||
// Set autocomplete="off" on folderPath input
|
||||
const folderPathInput = document.getElementById('folderPath');
|
||||
@@ -1035,6 +1008,7 @@ export class DownloadManager {
|
||||
});
|
||||
|
||||
// Setup model root change handler
|
||||
const modelRoot = document.getElementById('modelRoot');
|
||||
modelRoot.addEventListener('change', async () => {
|
||||
await this.initializeFolderTree();
|
||||
this.updateTargetPath();
|
||||
@@ -1049,6 +1023,116 @@ export class DownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective checkpoint/diffusion routing for the location step: the
|
||||
* user's manual override when set, otherwise the auto classification.
|
||||
*/
|
||||
_getEffectiveIsDiffusionModel() {
|
||||
return this._routingOverride ?? this._isDiffusionModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the root list for the effective routing group and repopulate
|
||||
* the root dropdown, preselecting the matching default root and
|
||||
* updating the root label.
|
||||
*/
|
||||
async _populateModelRoots() {
|
||||
const isDiffusion = this._getEffectiveIsDiffusionModel();
|
||||
|
||||
let rootsData;
|
||||
if (isDiffusion && this.apiClient.modelType === 'checkpoints') {
|
||||
rootsData = await this.apiClient.fetchModelRoots('diffusion_model');
|
||||
} else if (this.apiClient.modelType === 'other' && this._otherSubType) {
|
||||
rootsData = await this.apiClient.fetchModelRoots(this._otherSubType);
|
||||
} else {
|
||||
// An undecidable other sub_type (null) intentionally lands
|
||||
// here: fetchModelRoots() lists all other roots so the user
|
||||
// can pick manually.
|
||||
rootsData = await this.apiClient.fetchModelRoots();
|
||||
}
|
||||
const modelRoot = document.getElementById('modelRoot');
|
||||
modelRoot.innerHTML = rootsData.roots.map(root =>
|
||||
`<option value="${root}">${root}</option>`
|
||||
).join('');
|
||||
|
||||
let defaultRoot;
|
||||
let subtypeDisplay;
|
||||
if (this.apiClient.modelType === 'other') {
|
||||
const otherDefaultRoots = state.global.settings.default_other_roots || {};
|
||||
defaultRoot = this._otherSubType ? (otherDefaultRoots[this._otherSubType] || '') : '';
|
||||
subtypeDisplay = this._otherSubType
|
||||
? (MODEL_SUBTYPE_DISPLAY_NAMES[this._otherSubType] || this._otherSubType)
|
||||
: this.apiClient.apiConfig.config.displayName;
|
||||
} else {
|
||||
const singularType = isDiffusion
|
||||
? 'unet'
|
||||
: this.apiClient.modelType.replace(/s$/, '');
|
||||
const defaultRootKey = `default_${singularType}_root`;
|
||||
defaultRoot = state.global.settings[defaultRootKey];
|
||||
subtypeDisplay = isDiffusion ? 'Diffusion Model' : this.apiClient.apiConfig.config.displayName;
|
||||
}
|
||||
console.log('Default root:', defaultRoot);
|
||||
console.log('Available roots:', rootsData.roots);
|
||||
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
||||
console.log(`Setting default root: ${defaultRoot}`);
|
||||
modelRoot.value = defaultRoot;
|
||||
}
|
||||
|
||||
document.getElementById('modelRootLabel').textContent =
|
||||
translate('modals.download.selectTypeRoot', { type: subtypeDisplay });
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the checkpoint/diffusion override toggle on the checkpoints page
|
||||
* (hidden elsewhere) and reflect the effective routing on its options.
|
||||
*/
|
||||
_syncRoutingOverrideToggle() {
|
||||
const group = document.getElementById('routingOverrideGroup');
|
||||
if (!group) return;
|
||||
const isCheckpoints = this.apiClient.modelType === 'checkpoints';
|
||||
group.style.display = isCheckpoints ? '' : 'none';
|
||||
if (!isCheckpoints) return;
|
||||
|
||||
const effective = this._getEffectiveIsDiffusionModel();
|
||||
group.querySelectorAll('.routing-override-option').forEach(btn => {
|
||||
const active = (btn.dataset.routing === 'diffusion') === effective;
|
||||
btn.classList.toggle('active', active);
|
||||
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the user switching the checkpoint/diffusion routing toggle.
|
||||
* The override must never be silently ignored: the backend re-derives
|
||||
* the routing group only under "use default path", so overriding turns
|
||||
* that off for this session (the localStorage preference is left
|
||||
* untouched) to guarantee the picked root is the actual destination.
|
||||
*/
|
||||
async handleRoutingOverrideChange(isDiffusion) {
|
||||
if (this.apiClient.modelType !== 'checkpoints') return;
|
||||
|
||||
const overridden = isDiffusion !== this._isDiffusionModel;
|
||||
this._routingOverride = overridden ? isDiffusion : null;
|
||||
|
||||
if (overridden && this.useDefaultPath) {
|
||||
this.useDefaultPath = false;
|
||||
const toggleInput = document.getElementById('useDefaultPath');
|
||||
if (toggleInput) {
|
||||
toggleInput.checked = false;
|
||||
}
|
||||
}
|
||||
|
||||
this._syncRoutingOverrideToggle();
|
||||
|
||||
try {
|
||||
await this._populateModelRoots();
|
||||
await this.initializeFolderTree();
|
||||
this.updatePathSelectionUI();
|
||||
} catch (error) {
|
||||
showToast('toast.downloads.loadError', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether this download routes to the diffusion model (unet)
|
||||
* roots rather than the checkpoint roots. The backend owns the routing
|
||||
@@ -2554,13 +2638,13 @@ export class DownloadManager {
|
||||
const modelRoot = document.getElementById('modelRoot').value;
|
||||
const config = this.apiClient.apiConfig.config;
|
||||
|
||||
const subtypeDisplay = this._isDiffusionModel ? 'Diffusion Model' : config.displayName;
|
||||
const subtypeDisplay = this._getEffectiveIsDiffusionModel() ? 'Diffusion Model' : config.displayName;
|
||||
let fullPath = modelRoot || translate('modals.download.selectTypeRoot', { type: subtypeDisplay });
|
||||
|
||||
if (modelRoot) {
|
||||
if (this.useDefaultPath) {
|
||||
try {
|
||||
const singularType = this._isDiffusionModel
|
||||
const singularType = this._getEffectiveIsDiffusionModel()
|
||||
? 'unet'
|
||||
: this.apiClient.modelType.replace(/s$/, '');
|
||||
const templates = state.global?.settings?.download_path_templates;
|
||||
|
||||
@@ -304,7 +304,6 @@ export class SearchManager {
|
||||
pageState.searchOptions.modelname = options.modelname || false;
|
||||
pageState.searchOptions.tags = options.tags || false;
|
||||
pageState.searchOptions.creator = options.creator || false;
|
||||
pageState.searchOptions.hash = options.hash || false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { configureModelCardVideo } from '../components/shared/ModelCard.js';
|
||||
import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePriorityTagSuggestionsCache } from '../utils/priorityTagHelpers.js';
|
||||
import { bannerService } from './BannerService.js';
|
||||
import { directoryPickerModal } from '../components/DirectoryPickerModal.js';
|
||||
import { showSidecarMigrationSummary } from '../components/SidecarMigrationSummaryModal.js';
|
||||
|
||||
const VALID_MATURE_BLUR_LEVELS = new Set(['PG13', 'R', 'X', 'XXX']);
|
||||
|
||||
@@ -1052,6 +1053,11 @@ export class SettingsManager {
|
||||
downloadBackendSelect.value = state.global.settings.download_backend || 'python';
|
||||
}
|
||||
|
||||
const unknownBaseModelRoutingSelect = document.getElementById('unknownBaseModelRouting');
|
||||
if (unknownBaseModelRoutingSelect) {
|
||||
unknownBaseModelRoutingSelect.value = state.global.settings.unknown_base_model_routing || 'diffusion_model';
|
||||
}
|
||||
|
||||
const aria2cPathInput = document.getElementById('aria2cPath');
|
||||
if (aria2cPathInput) {
|
||||
aria2cPathInput.value = state.global.settings.aria2c_path || '';
|
||||
@@ -1110,6 +1116,12 @@ export class SettingsManager {
|
||||
cardInfoDisplaySelect.value = state.global.settings.card_info_display || 'always';
|
||||
}
|
||||
|
||||
// Set showcase layout setting
|
||||
const showcaseLayoutSelect = document.getElementById('showcaseLayout');
|
||||
if (showcaseLayoutSelect) {
|
||||
showcaseLayoutSelect.value = state.global.settings.showcase_layout || 'gallery';
|
||||
}
|
||||
|
||||
// Set model card footer action
|
||||
const modelCardFooterActionSelect = document.getElementById('modelCardFooterAction');
|
||||
if (modelCardFooterActionSelect) {
|
||||
@@ -1192,6 +1204,9 @@ export class SettingsManager {
|
||||
|
||||
this.updateExampleImagesOpenSettingsVisibility();
|
||||
|
||||
// Load sidecar storage settings
|
||||
this.loadSidecarStorageSettings();
|
||||
|
||||
// Load download path templates
|
||||
this.loadDownloadPathTemplates();
|
||||
|
||||
@@ -1280,6 +1295,9 @@ export class SettingsManager {
|
||||
this.attachPathField('exampleImagesLocalRoot', {
|
||||
onAfterSelect: () => this.saveInputSetting('exampleImagesLocalRoot', 'example_images_local_root'),
|
||||
});
|
||||
this.attachPathField('sidecarStoragePath', {
|
||||
onAfterSelect: () => this.handleSidecarStoragePathChange(),
|
||||
});
|
||||
}
|
||||
|
||||
loadDownloadBackendSettings() {
|
||||
@@ -3379,6 +3397,313 @@ export class SettingsManager {
|
||||
this.updateExampleImagesOpenSettingsVisibility();
|
||||
}
|
||||
|
||||
loadSidecarStorageSettings() {
|
||||
const currentMode = state.global.settings.sidecar_storage_mode === 'centralized'
|
||||
? 'centralized'
|
||||
: 'alongside';
|
||||
|
||||
const modeSelect = document.getElementById('sidecarStorageMode');
|
||||
if (modeSelect) {
|
||||
modeSelect.value = currentMode;
|
||||
}
|
||||
// Baseline used to detect a mode change in handleSidecarStorageModeChange
|
||||
this._loadedSidecarStorageMode = currentMode;
|
||||
// Baseline used to detect a root change in handleSidecarStoragePathChange
|
||||
this._loadedSidecarStoragePath = state.global.settings.sidecar_storage_path || '';
|
||||
|
||||
const pathInput = document.getElementById('sidecarStoragePath');
|
||||
if (pathInput) {
|
||||
pathInput.value = state.global.settings.sidecar_storage_path || '';
|
||||
}
|
||||
|
||||
this.renderSidecarStorageInfo();
|
||||
this.updateSidecarStorageVisibility();
|
||||
}
|
||||
|
||||
// Show the backend-resolved storage root (covers the default location,
|
||||
// which the path input leaves blank) plus the portable-mode repo warning.
|
||||
renderSidecarStorageInfo() {
|
||||
const resolvedEl = document.getElementById('sidecarStorageResolvedPath');
|
||||
if (resolvedEl) {
|
||||
resolvedEl.textContent = state.global.settings.sidecar_storage_root || '';
|
||||
}
|
||||
const warningEl = document.getElementById('sidecarStorageRepoWarning');
|
||||
if (warningEl) {
|
||||
warningEl.style.display = state.global.settings.sidecar_storage_root_in_repo
|
||||
? 'block'
|
||||
: 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Re-pull just the derived sidecar fields after a path save: the resolved
|
||||
// root is computed server-side (default location, absolutization).
|
||||
async refreshSidecarStorageInfo() {
|
||||
try {
|
||||
const response = await fetch('/api/lm/settings');
|
||||
const data = await response.json();
|
||||
if (data.success && data.settings) {
|
||||
state.global.settings.sidecar_storage_root = data.settings.sidecar_storage_root;
|
||||
state.global.settings.sidecar_storage_root_is_default = data.settings.sidecar_storage_root_is_default;
|
||||
state.global.settings.sidecar_storage_root_in_repo = data.settings.sidecar_storage_root_in_repo;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to refresh sidecar storage info:', error);
|
||||
}
|
||||
this.renderSidecarStorageInfo();
|
||||
}
|
||||
|
||||
async openSidecarStorageLocation() {
|
||||
try {
|
||||
const response = await fetch('/api/lm/sidecars/open-location', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.mode === 'clipboard' && data.path) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(data.path);
|
||||
showToast('settings.sidecarStorage.openLocationCopied', { path: data.path }, 'success');
|
||||
} catch (clipboardErr) {
|
||||
console.warn('Clipboard API not available:', clipboardErr);
|
||||
showToast('settings.sidecarStorage.openLocationClipboardFallback', { path: data.path }, 'info');
|
||||
}
|
||||
} else {
|
||||
showToast('settings.sidecarStorage.openLocationSuccess', {}, 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to open sidecar storage location:', error);
|
||||
showToast('settings.sidecarStorage.openLocationFailed', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
updateSidecarStorageVisibility() {
|
||||
const modeSelect = document.getElementById('sidecarStorageMode');
|
||||
const pathSetting = document.getElementById('sidecarStoragePathSetting');
|
||||
if (!pathSetting) return;
|
||||
|
||||
const mode = modeSelect ? modeSelect.value : state.global.settings.sidecar_storage_mode;
|
||||
pathSetting.style.display = mode === 'centralized' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
async handleSidecarStorageModeChange() {
|
||||
const modeSelect = document.getElementById('sidecarStorageMode');
|
||||
if (!modeSelect) return;
|
||||
|
||||
const previousMode = this._loadedSidecarStorageMode || 'alongside';
|
||||
|
||||
await this.saveSelectSetting('sidecarStorageMode', 'sidecar_storage_mode');
|
||||
this.updateSidecarStorageVisibility();
|
||||
|
||||
const newMode = modeSelect.value;
|
||||
this._loadedSidecarStorageMode = newMode;
|
||||
|
||||
// Existing sidecars are not moved automatically; offer to migrate them.
|
||||
if (newMode !== previousMode) {
|
||||
const direction = newMode === 'centralized' ? 'to_centralized' : 'to_alongside';
|
||||
const confirmed = await this.confirmSidecarMigration(direction);
|
||||
if (confirmed) {
|
||||
await this.migrateSidecars(direction);
|
||||
} else {
|
||||
showToast('settings.sidecarStorage.migrationDeferred', {}, 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Path change while centralized storage is active: the assets under the
|
||||
// previous root do not move by themselves, so offer a root relocation.
|
||||
async handleSidecarStoragePathChange() {
|
||||
const pathInput = document.getElementById('sidecarStoragePath');
|
||||
if (!pathInput) return;
|
||||
|
||||
const previousPath = this._loadedSidecarStoragePath || '';
|
||||
|
||||
await this.saveInputSetting('sidecarStoragePath', 'sidecar_storage_path');
|
||||
|
||||
const newPath = pathInput.value.trim();
|
||||
this._loadedSidecarStoragePath = newPath;
|
||||
|
||||
// The resolved root is server-side; refresh before any relocate
|
||||
// confirm so the dialog can name the real destination.
|
||||
await this.refreshSidecarStorageInfo();
|
||||
|
||||
const centralized = state.global.settings.sidecar_storage_mode === 'centralized';
|
||||
if (centralized && previousPath && previousPath !== newPath) {
|
||||
const confirmed = await this.confirmSidecarMigration('relocate_root');
|
||||
if (confirmed) {
|
||||
await this.migrateSidecars('relocate_root', { old_root: previousPath });
|
||||
} else {
|
||||
showToast('settings.sidecarStorage.migrationDeferred', {}, 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Entry point for the "Migrate Sidecars Now" button: the direction follows
|
||||
// the currently saved storage mode.
|
||||
async confirmAndMigrateSidecars() {
|
||||
const direction = state.global.settings.sidecar_storage_mode === 'centralized'
|
||||
? 'to_centralized'
|
||||
: 'to_alongside';
|
||||
const confirmed = await this.confirmSidecarMigration(direction);
|
||||
if (confirmed) {
|
||||
await this.migrateSidecars(direction);
|
||||
}
|
||||
}
|
||||
|
||||
confirmSidecarMigration(direction) {
|
||||
const modalElement = document.getElementById('sidecarMigrationConfirmModal');
|
||||
if (!modalElement) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
const isToCentralized = direction === 'to_centralized';
|
||||
const isRelocate = direction === 'relocate_root';
|
||||
|
||||
const titleElement = modalElement.querySelector('[data-role="title"]');
|
||||
if (titleElement) {
|
||||
titleElement.textContent = isRelocate
|
||||
? translate('modals.sidecarMigrationConfirm.titleRelocateRoot', {}, 'Move sidecars to the new storage directory?')
|
||||
: isToCentralized
|
||||
? translate('modals.sidecarMigrationConfirm.titleToCentralized', {}, 'Move sidecars to centralized storage?')
|
||||
: translate('modals.sidecarMigrationConfirm.titleToAlongside', {}, 'Move sidecars back next to model files?');
|
||||
}
|
||||
|
||||
const messageElement = modalElement.querySelector('[data-role="message"]');
|
||||
if (messageElement) {
|
||||
messageElement.textContent = isRelocate
|
||||
? translate('settings.sidecarStorage.confirmRelocateRoot', {}, 'The centralized storage directory changed, but existing sidecars and preview images are still in the previous directory. Move them to the new directory now?')
|
||||
: isToCentralized
|
||||
? translate('settings.sidecarStorage.confirmToCentralized', {}, 'The storage mode changed, but existing .metadata.json sidecars and preview images are not moved automatically. Move them into the centralized storage directory now? You can also do this later with the "Migrate Sidecars Now" button.')
|
||||
: translate('settings.sidecarStorage.confirmToAlongside', {}, 'The storage mode changed, but existing .metadata.json sidecars and preview images are not moved automatically. Move them back next to their model files now? You can also do this later with the "Migrate Sidecars Now" button.');
|
||||
}
|
||||
|
||||
// Name the destination so users know where the files are going.
|
||||
const destinationElement = modalElement.querySelector('[data-role="destination"]');
|
||||
if (destinationElement) {
|
||||
const resolvedRoot = state.global.settings.sidecar_storage_root || '';
|
||||
if (!isToCentralized && !isRelocate || !resolvedRoot) {
|
||||
destinationElement.style.display = 'none';
|
||||
} else {
|
||||
destinationElement.textContent = translate(
|
||||
'modals.sidecarMigrationConfirm.destination', { path: resolvedRoot }, `Destination: ${resolvedRoot}`
|
||||
);
|
||||
destinationElement.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
const confirmButton = modalElement.querySelector('[data-action="confirm-sidecar-migration"]');
|
||||
const cancelButton = modalElement.querySelector('[data-action="cancel-sidecar-migration"]');
|
||||
if (!confirmButton || !cancelButton) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
confirmButton.textContent = translate('modals.sidecarMigrationConfirm.confirmButton', {}, 'Migrate Now');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
|
||||
const cleanup = () => {
|
||||
confirmButton.removeEventListener('click', handleConfirm);
|
||||
cancelButton.removeEventListener('click', handleCancel);
|
||||
document.removeEventListener('keydown', handleEscape, true);
|
||||
};
|
||||
|
||||
const finalize = (proceed) => {
|
||||
if (resolved) {
|
||||
return;
|
||||
}
|
||||
resolved = true;
|
||||
cleanup();
|
||||
modalElement.classList.remove('show');
|
||||
// Keep body.modal-open: the settings modal underneath is still open.
|
||||
resolve(proceed);
|
||||
};
|
||||
|
||||
const handleConfirm = (event) => {
|
||||
event.preventDefault();
|
||||
finalize(true);
|
||||
};
|
||||
|
||||
const handleCancel = (event) => {
|
||||
event.preventDefault();
|
||||
finalize(false);
|
||||
};
|
||||
|
||||
// Capture phase + stopPropagation so ESC never reaches the
|
||||
// settings modal's own ESC handler underneath.
|
||||
const handleEscape = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
finalize(false);
|
||||
}
|
||||
};
|
||||
|
||||
confirmButton.addEventListener('click', handleConfirm);
|
||||
cancelButton.addEventListener('click', handleCancel);
|
||||
document.addEventListener('keydown', handleEscape, true);
|
||||
|
||||
modalElement.classList.add('show');
|
||||
cancelButton.focus();
|
||||
});
|
||||
}
|
||||
|
||||
async migrateSidecars(direction, extraBody = {}) {
|
||||
const migrateBtn = document.getElementById('migrateSidecarsBtn');
|
||||
try {
|
||||
if (migrateBtn) {
|
||||
migrateBtn.disabled = true;
|
||||
migrateBtn.textContent = translate('settings.sidecarStorage.migratingButton', {}, 'Migrating...');
|
||||
}
|
||||
|
||||
state.loadingManager?.showSimpleLoading(
|
||||
translate('settings.sidecarStorage.migrating', {}, 'Migrating sidecars...')
|
||||
);
|
||||
|
||||
const response = await fetch('/api/lm/sidecars/migrate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
// The new mode/path is already saved by the time migration runs,
|
||||
// so the backend guard requires force=true to confirm the
|
||||
// "switch first, then migrate" flow.
|
||||
body: JSON.stringify({ direction, force: true, ...extraBody }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || data.success === false) {
|
||||
throw new Error(data.error || 'Migration failed');
|
||||
}
|
||||
|
||||
state.loadingManager?.hide();
|
||||
this.showSidecarMigrationResult(data);
|
||||
} catch (error) {
|
||||
console.error('Error migrating sidecars:', error);
|
||||
state.loadingManager?.hide();
|
||||
showToast('settings.sidecarStorage.migrateFailed', { message: error.message }, 'error');
|
||||
} finally {
|
||||
if (migrateBtn) {
|
||||
migrateBtn.disabled = false;
|
||||
migrateBtn.textContent = translate('settings.sidecarStorage.migrateButton', {}, 'Migrate Sidecars Now');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post-migration summary: counters + storage location, with an "open
|
||||
// folder" shortcut. Closing reloads so cards pick up the new paths.
|
||||
showSidecarMigrationResult(result) {
|
||||
showSidecarMigrationSummary({
|
||||
result,
|
||||
// Reload so cards pick up metadata/preview paths from the new location
|
||||
onClose: () => resetAndReload(true),
|
||||
// Opening the folder keeps the result modal open; the reload
|
||||
// happens when the user closes it.
|
||||
onOpenLocation: () => this.openSidecarStorageLocation(),
|
||||
});
|
||||
}
|
||||
|
||||
async loadMetadataArchiveSettings() {
|
||||
try {
|
||||
// Load current settings from state
|
||||
@@ -3392,6 +3717,11 @@ export class SettingsManager {
|
||||
enableCivarchiveApiCheckbox.checked = state.global.settings.enable_civarchive_api ?? true;
|
||||
}
|
||||
|
||||
const enableOpenmodeldbApiCheckbox = document.getElementById('enableOpenmodeldbApi');
|
||||
if (enableOpenmodeldbApiCheckbox) {
|
||||
enableOpenmodeldbApiCheckbox.checked = state.global.settings.enable_openmodeldb_api ?? true;
|
||||
}
|
||||
|
||||
const metadataProviderOrderSelect = document.getElementById('metadataProviderOrder');
|
||||
if (metadataProviderOrderSelect) {
|
||||
metadataProviderOrderSelect.value = state.global.settings.metadata_provider_order || 'civitai_archive_sqlite';
|
||||
|
||||
@@ -72,6 +72,15 @@ export class DownloadManager {
|
||||
completeMetadata.diagnostics = diagnostics;
|
||||
}
|
||||
|
||||
// A ComfyUI workflow recovered from the source's original
|
||||
// rendition: CivitAI's optimized preview is re-encoded and
|
||||
// metadata-free, so the workflow travels as data and the
|
||||
// backend embeds it into the stored image.
|
||||
const workflow = this.importManager.recipeData.workflow;
|
||||
if (workflow) {
|
||||
completeMetadata.workflow = workflow;
|
||||
}
|
||||
|
||||
// Preserve preview_nsfw_level from analysis so the saved
|
||||
// recipe applies the correct NSFW blur on the preview image.
|
||||
const nsfwLevel = this.importManager.recipeData.preview_nsfw_level;
|
||||
|
||||
@@ -16,6 +16,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
show_only_sfw: false,
|
||||
enable_metadata_archive_db: false,
|
||||
enable_civarchive_api: true,
|
||||
enable_openmodeldb_api: true,
|
||||
metadata_provider_order: 'civitai_archive_sqlite',
|
||||
proxy_enabled: false,
|
||||
proxy_type: 'http',
|
||||
@@ -46,6 +47,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
display_density: 'default',
|
||||
recipes_layout: 'grid',
|
||||
card_info_display: 'always',
|
||||
showcase_layout: 'gallery',
|
||||
model_name_display: 'model_name',
|
||||
lora_syntax_format: 'legacy',
|
||||
model_card_footer_action: 'example_images',
|
||||
@@ -60,8 +62,11 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
metadata_refresh_skip_paths: [],
|
||||
skip_previously_downloaded_model_versions: false,
|
||||
download_skip_base_models: [],
|
||||
unknown_base_model_routing: 'diffusion_model',
|
||||
backup_auto_enabled: true,
|
||||
backup_retention_count: 5,
|
||||
sidecar_storage_mode: 'alongside',
|
||||
sidecar_storage_path: '',
|
||||
strip_lora_on_copy: false,
|
||||
use_new_license_icons: true,
|
||||
group_by_model: false,
|
||||
@@ -120,7 +125,6 @@ export const state = {
|
||||
modelname: true,
|
||||
tags: false,
|
||||
creator: false,
|
||||
hash: false,
|
||||
recursive: getStorageItem(`${MODEL_TYPES.LORA}_recursiveSearch`, true),
|
||||
},
|
||||
filters: {
|
||||
@@ -187,7 +191,6 @@ export const state = {
|
||||
filename: true,
|
||||
modelname: true,
|
||||
creator: false,
|
||||
hash: false,
|
||||
recursive: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_recursiveSearch`, true),
|
||||
},
|
||||
filters: {
|
||||
@@ -227,7 +230,6 @@ export const state = {
|
||||
modelname: true,
|
||||
tags: false,
|
||||
creator: false,
|
||||
hash: false,
|
||||
recursive: getStorageItem(`${MODEL_TYPES.EMBEDDING}_recursiveSearch`, true),
|
||||
},
|
||||
filters: {
|
||||
@@ -265,7 +267,6 @@ export const state = {
|
||||
modelname: true,
|
||||
tags: false,
|
||||
creator: false,
|
||||
hash: false,
|
||||
recursive: getStorageItem(`${MODEL_TYPES.OTHER}_recursiveSearch`, true),
|
||||
},
|
||||
filters: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* External model source helpers (Hugging Face / ModelScope / TensorArt).
|
||||
* External model source helpers (Hugging Face / ModelScope / TensorArt / OpenModelDB).
|
||||
*
|
||||
* Mirrors `py/services/model_sources/registry.py` so the frontend and the
|
||||
* backend agree on URL recognition, version-group keys, and which sites
|
||||
@@ -92,6 +92,26 @@ export const MODEL_SOURCES = [
|
||||
canonical: (id) => `https://tensor.art/models/${id}`,
|
||||
filePage: null,
|
||||
},
|
||||
{
|
||||
// Flat catalogue of upscalers: the id IS the published-model identity
|
||||
// (no owner/repo split, no revisions, no per-file page).
|
||||
platform: 'openmodeldb',
|
||||
label: 'OpenModelDB',
|
||||
groupPrefix: 'omdb',
|
||||
groupKey: 'repo',
|
||||
supportsEnrichment: true,
|
||||
supportsDownload: true,
|
||||
defaultRevision: '',
|
||||
defaultSubdir: 'openmodeldb',
|
||||
exampleUrl: 'https://openmodeldb.info/models/4x-UltraSharp',
|
||||
placeholder: 'https://openmodeldb.info/models/4x-UltraSharp',
|
||||
pattern: /^https?:\/\/(?:www\.)?openmodeldb\.info\/models\/([A-Za-z0-9_][A-Za-z0-9_.-]*)/i,
|
||||
filePattern: null,
|
||||
// Flat model ids (no owner/name split).
|
||||
flatId: true,
|
||||
canonical: (id) => `https://openmodeldb.info/models/${id}`,
|
||||
filePage: null,
|
||||
},
|
||||
];
|
||||
|
||||
/** Return the source descriptor for a platform id, or null. */
|
||||
@@ -257,6 +277,18 @@ export function isValidRepoId(repo) {
|
||||
.every((part) => part && part !== '.' && part !== '..' && /^[A-Za-z0-9_][\w.-]*$/.test(part));
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-aware id validation, mirroring `ModelSource.is_valid_source_id`:
|
||||
* `owner/name` for repository sites, a flat token for OpenModelDB.
|
||||
*/
|
||||
export function isValidSourceId(platform, repo) {
|
||||
const source = getModelSource(platform);
|
||||
if (source?.flatId) {
|
||||
return typeof repo === 'string' && /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(repo);
|
||||
}
|
||||
return isValidRepoId(repo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recognise a downloadable model-source URL.
|
||||
*
|
||||
|
||||
@@ -203,20 +203,17 @@
|
||||
<div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div>
|
||||
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
|
||||
<div class="search-option-tag" data-option="creator">{{ t('header.search.filters.creator') }}</div>
|
||||
<div class="search-option-tag" data-option="hash">{{ t('header.search.filters.hash') }}</div>
|
||||
{% elif request.path == '/embeddings' or request.path == '/other' %}
|
||||
<div class="search-option-tag active" data-option="filename">{{ t('header.search.filters.filename') }}</div>
|
||||
<div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div>
|
||||
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
|
||||
<div class="search-option-tag" data-option="creator">{{ t('header.search.filters.creator') }}</div>
|
||||
<div class="search-option-tag" data-option="hash">{{ t('header.search.filters.hash') }}</div>
|
||||
{% else %}
|
||||
<!-- Default options for LoRAs page -->
|
||||
<div class="search-option-tag active" data-option="filename">{{ t('header.search.filters.filename') }}</div>
|
||||
<div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div>
|
||||
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
|
||||
<div class="search-option-tag" data-option="creator">{{ t('header.search.filters.creator') }}</div>
|
||||
<div class="search-option-tag" data-option="hash">{{ t('header.search.filters.hash') }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -96,6 +96,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidecar Migration Confirmation Modal
|
||||
Self-managed by SettingsManager (NOT registered with ModalManager): it
|
||||
stacks above the settings modal, like the directory picker. -->
|
||||
<div id="sidecarMigrationConfirmModal" class="modal delete-modal">
|
||||
<div class="modal-content delete-modal-content">
|
||||
<h2 data-role="title"></h2>
|
||||
<p class="delete-message" data-role="message"></p>
|
||||
<p class="delete-message sidecar-migration-destination" data-role="destination" style="display: none;"></p>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" data-action="cancel-sidecar-migration">{{ t('common.actions.cancel') }}</button>
|
||||
<button class="primary-btn" data-action="confirm-sidecar-migration"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar Folder Delete Confirmation Modal -->
|
||||
<!-- Shared by two states: 'confirm' (model-free folder) and 'blocked' (the
|
||||
subtree still holds models, so a cascade delete is refused). -->
|
||||
|
||||
@@ -80,6 +80,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Model Root Selection (always visible) -->
|
||||
<div class="input-group routing-override-group" id="routingOverrideGroup" style="display: none;"
|
||||
title="{{ t('modals.download.routingOverride.tooltip') }}">
|
||||
<label id="routingOverrideLabel">{{ t('modals.download.routingOverride.label') }}</label>
|
||||
<div class="routing-override-toggle" role="group" aria-label="{{ t('modals.download.routingOverride.label') }}">
|
||||
<button type="button" class="routing-override-option" data-routing="checkpoint">{{ t('checkpoints.modelTypes.checkpoint') }}</button>
|
||||
<button type="button" class="routing-override-option" data-routing="diffusion">{{ t('checkpoints.modelTypes.diffusion_model') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="modelRoot" id="modelRootLabel">{{ t('modals.download.selectModelRoot') }}</label>
|
||||
<select id="modelRoot"></select>
|
||||
|
||||
@@ -87,6 +87,11 @@
|
||||
('hover', 'settings.layoutSettings.cardInfoDisplayOptions.hover'),
|
||||
], 'settings.layoutSettings.cardInfoDisplayHelp') }}
|
||||
|
||||
{{ sm.setting_select('showcaseLayout', 'showcase_layout', 'settings.layoutSettings.showcaseLayout', [
|
||||
('gallery', 'settings.layoutSettings.showcaseLayoutOptions.gallery'),
|
||||
('vertical', 'settings.layoutSettings.showcaseLayoutOptions.vertical'),
|
||||
], 'settings.layoutSettings.showcaseLayoutHelp') }}
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
|
||||
{{ sm.setting_select('defaultUnetRoot', 'default_unet_root', 'settings.folderSettings.defaultUnetRoot', [], 'settings.folderSettings.defaultUnetRootHelp') }}
|
||||
|
||||
{{ sm.setting_select('unknownBaseModelRouting', 'unknown_base_model_routing', 'settings.unknownBaseModelRouting.label', [
|
||||
('diffusion_model', 'settings.unknownBaseModelRouting.options.diffusionModel'),
|
||||
('checkpoint', 'settings.unknownBaseModelRouting.options.checkpoint'),
|
||||
], 'settings.unknownBaseModelRouting.help') }}
|
||||
|
||||
{{ sm.setting_select('defaultEmbeddingRoot', 'default_embedding_root', 'settings.folderSettings.defaultEmbeddingRoot', [], 'settings.folderSettings.defaultEmbeddingRootHelp') }}
|
||||
|
||||
{{ sm.setting_toggle('enableOtherModels', 'enable_other_models', 'settings.folderSettings.enableOtherModels', 'settings.folderSettings.enableOtherModelsHelp') }}
|
||||
@@ -287,6 +292,9 @@
|
||||
<!-- CivArchive API provider toggle -->
|
||||
{{ sm.setting_toggle('enableCivarchiveApi', 'enable_civarchive_api', 'settings.metadataArchive.enableCivarchiveApi', 'settings.metadataArchive.enableCivarchiveApiHelp') }}
|
||||
|
||||
<!-- OpenModelDB API provider toggle -->
|
||||
{{ sm.setting_toggle('enableOpenmodeldbApi', 'enable_openmodeldb_api', 'settings.metadataArchive.enableOpenmodeldbApi', 'settings.metadataArchive.enableOpenmodeldbApiHelp') }}
|
||||
|
||||
<!-- Metadata Archive DB -->
|
||||
{{ sm.setting_toggle('enableMetadataArchive', 'enable_metadata_archive_db', 'settings.metadataArchive.enableArchiveDb', 'settings.metadataArchive.enableArchiveDbHelp') }}
|
||||
|
||||
@@ -321,4 +329,77 @@
|
||||
('civitai_sqlite_archive', 'settings.metadataArchive.providerOrderCivitaiSqliteArchive'),
|
||||
], 'settings.metadataArchive.providerOrderHelp') }}
|
||||
</div>
|
||||
|
||||
<!-- Sidecar Storage -->
|
||||
<div class="settings-subsection">
|
||||
{{ sm.subsection_header('settings.sections.sidecarStorage') }}
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="sidecarStorageMode">
|
||||
{{ t('settings.sidecarStorage.mode') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.sidecarStorage.modeHelp') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="sidecarStorageMode" onchange="settingsManager.handleSidecarStorageModeChange()">
|
||||
<option value="alongside">{{ t('settings.sidecarStorage.modeOptions.alongside') }}</option>
|
||||
<option value="centralized">{{ t('settings.sidecarStorage.modeOptions.centralized') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" id="sidecarStoragePathSetting" style="display: none;">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="sidecarStoragePath">
|
||||
{{ t('settings.sidecarStorage.path') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.sidecarStorage.pathHelp') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<div class="text-input-wrapper">
|
||||
<input type="text" id="sidecarStoragePath"
|
||||
placeholder="{{ t('settings.sidecarStorage.pathPlaceholder') }}"
|
||||
onblur="settingsManager.handleSidecarStoragePathChange()"
|
||||
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<div class="input-help sidecar-storage-location">
|
||||
{{ t('settings.sidecarStorage.effectivePathLabel') }}
|
||||
<code id="sidecarStorageResolvedPath" class="backup-location-path"></code>
|
||||
</div>
|
||||
<div class="input-help sidecar-storage-repo-warning" id="sidecarStorageRepoWarning" style="display: none;">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
{{ t('settings.sidecarStorage.repoWarning') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<button type="button" class="secondary-btn" id="sidecarStorageOpenBtn" onclick="settingsManager.openSidecarStorageLocation()">
|
||||
{{ t('settings.sidecarStorage.openFolderButton') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label>
|
||||
{{ t('settings.sidecarStorage.management') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.sidecarStorage.managementHelp') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<button type="button" id="migrateSidecarsBtn" class="primary-btn" onclick="settingsManager.confirmAndMigrateSidecars()">
|
||||
{{ t('settings.sidecarStorage.migrateButton') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+6
-1
@@ -3,9 +3,10 @@ import importlib.util
|
||||
import inspect
|
||||
import sys
|
||||
import types
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
from typing import Any, AsyncIterator, Dict, List, Optional, Sequence
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
@@ -177,6 +178,10 @@ class MockScanner:
|
||||
def reset_cancellation(self) -> None:
|
||||
self._cancelled = False
|
||||
|
||||
@asynccontextmanager
|
||||
async def defer_cache_persist(self) -> AsyncIterator[None]:
|
||||
yield None
|
||||
|
||||
async def get_cached_data(self, force_refresh: bool = False):
|
||||
return self._cache
|
||||
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
BASE_MODEL_API_MODULE,
|
||||
STATE_MODULE,
|
||||
UI_HELPERS_MODULE,
|
||||
I18N_MODULE,
|
||||
STORAGE_MODULE,
|
||||
API_CONFIG_MODULE,
|
||||
API_FACTORY_MODULE,
|
||||
SIDEBAR_MANAGER_MODULE,
|
||||
} = vi.hoisted(() => ({
|
||||
BASE_MODEL_API_MODULE: new URL('../../../static/js/api/baseModelApi.js', import.meta.url).pathname,
|
||||
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||
STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
|
||||
API_CONFIG_MODULE: new URL('../../../static/js/api/apiConfig.js', import.meta.url).pathname,
|
||||
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||
SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
vi.mock(STATE_MODULE, () => ({
|
||||
state: {
|
||||
global: { settings: {} },
|
||||
},
|
||||
getCurrentPageState: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(I18N_MODULE, () => ({
|
||||
translate: vi.fn((key) => key),
|
||||
}));
|
||||
|
||||
vi.mock(STORAGE_MODULE, () => ({
|
||||
getStorageItem: vi.fn(),
|
||||
getSessionItem: vi.fn(() => null),
|
||||
removeSessionItem: vi.fn(),
|
||||
saveMapToStorage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(API_CONFIG_MODULE, () => ({
|
||||
getCompleteApiConfig: vi.fn(() => ({
|
||||
endpoints: {},
|
||||
config: { displayName: 'LoRA', singularName: 'LoRA', supportsLetterFilter: false },
|
||||
})),
|
||||
getCurrentModelType: vi.fn(() => 'loras'),
|
||||
isValidModelType: vi.fn(() => true),
|
||||
DOWNLOAD_ENDPOINTS: {},
|
||||
HF_ENDPOINTS: {},
|
||||
WS_ENDPOINTS: {},
|
||||
}));
|
||||
|
||||
vi.mock(API_FACTORY_MODULE, () => ({
|
||||
resetAndReload: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(SIDEBAR_MANAGER_MODULE, () => ({
|
||||
sidebarManager: { refresh: vi.fn() },
|
||||
}));
|
||||
|
||||
async function createClient() {
|
||||
const { BaseModelApiClient } = await import(BASE_MODEL_API_MODULE);
|
||||
class TestClient extends BaseModelApiClient {}
|
||||
return new TestClient('loras');
|
||||
}
|
||||
|
||||
function makePageState(searchOptions) {
|
||||
return {
|
||||
viewMode: 'active',
|
||||
activeFolder: null,
|
||||
showFavoritesOnly: false,
|
||||
showUpdateAvailableOnly: false,
|
||||
filters: { search: 'abc123' },
|
||||
searchOptions: {
|
||||
filename: true,
|
||||
modelname: true,
|
||||
tags: false,
|
||||
creator: false,
|
||||
recursive: true,
|
||||
...searchOptions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('BaseModelApiClient._buildQueryParams hash search option', () => {
|
||||
it('appends search_hash=true when the hash option is enabled', async () => {
|
||||
const client = await createClient();
|
||||
const params = client._buildQueryParams({}, makePageState({ hash: true }));
|
||||
|
||||
expect(params.get('search_hash')).toBe('true');
|
||||
expect(params.get('search')).toBe('abc123');
|
||||
});
|
||||
|
||||
it('appends search_hash=false when the hash option is disabled', async () => {
|
||||
const client = await createClient();
|
||||
const params = client._buildQueryParams({}, makePageState({ hash: false }));
|
||||
|
||||
expect(params.get('search_hash')).toBe('false');
|
||||
});
|
||||
|
||||
it('omits search_hash when the option is absent (backend defaults to false)', async () => {
|
||||
const client = await createClient();
|
||||
const params = client._buildQueryParams({}, makePageState({}));
|
||||
|
||||
expect(params.get('search_hash')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not send search_hash without an active search term', async () => {
|
||||
const client = await createClient();
|
||||
const pageState = makePageState({ hash: true });
|
||||
pageState.filters.search = '';
|
||||
const params = client._buildQueryParams({}, pageState);
|
||||
|
||||
expect(params.get('search_hash')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
|
||||
const { EVENTS_MODULE, API_MODULE, APP_MODULE, COMPONENTS_MODULE, UTILS_MODULE } =
|
||||
vi.hoisted(() => ({
|
||||
EVENTS_MODULE: new URL('../../../web/comfyui/loras_widget_events.js', import.meta.url)
|
||||
.pathname,
|
||||
API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname,
|
||||
APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname,
|
||||
COMPONENTS_MODULE: new URL('../../../web/comfyui/loras_widget_components.js', import.meta.url)
|
||||
.pathname,
|
||||
UTILS_MODULE: new URL('../../../web/comfyui/loras_widget_utils.js', import.meta.url)
|
||||
.pathname,
|
||||
}));
|
||||
|
||||
const saveRecipeDirectly = vi.fn();
|
||||
|
||||
vi.mock(API_MODULE, () => ({ api: {} }));
|
||||
vi.mock(APP_MODULE, () => ({ app: {} }));
|
||||
|
||||
vi.mock(COMPONENTS_MODULE, () => ({
|
||||
createMenuItem: (text, icon, onClick) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'lm-lora-menu-item';
|
||||
el.textContent = text;
|
||||
if (onClick) el.addEventListener('click', onClick);
|
||||
return el;
|
||||
},
|
||||
createDropIndicator: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(UTILS_MODULE, () => ({
|
||||
parseLoraValue: vi.fn(() => []),
|
||||
formatLoraValue: vi.fn((value) => value),
|
||||
syncClipStrengthIfCollapsed: vi.fn(),
|
||||
saveRecipeDirectly,
|
||||
copyToClipboard: vi.fn(),
|
||||
showToast: vi.fn(),
|
||||
moveLoraByDirection: vi.fn(),
|
||||
getDropTargetIndex: vi.fn(),
|
||||
getLoraStrengthRange: vi.fn(),
|
||||
applyStrengthRangeCue: vi.fn(),
|
||||
}));
|
||||
|
||||
function findMenuItem(label) {
|
||||
return Array.from(document.querySelectorAll('.lm-lora-menu-item')).find(
|
||||
(item) => item.textContent === label
|
||||
);
|
||||
}
|
||||
|
||||
describe('LoRA widget context menu save options', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('offers workflow embedding as a separate, opt-in action', async () => {
|
||||
const { createContextMenu } = await import(EVENTS_MODULE);
|
||||
const widget = { value: [], callback: vi.fn() };
|
||||
|
||||
createContextMenu(10, 10, 'lora-a', widget, null, vi.fn());
|
||||
|
||||
const plain = findMenuItem('Save Recipe');
|
||||
const withWorkflow = findMenuItem('Save Recipe with Workflow');
|
||||
expect(plain).toBeTruthy();
|
||||
expect(withWorkflow).toBeTruthy();
|
||||
|
||||
plain.click();
|
||||
expect(saveRecipeDirectly).toHaveBeenLastCalledWith();
|
||||
|
||||
// Re-open: the first click removed the menu.
|
||||
createContextMenu(10, 10, 'lora-a', widget, null, vi.fn());
|
||||
findMenuItem('Save Recipe with Workflow').click();
|
||||
expect(saveRecipeDirectly).toHaveBeenLastCalledWith({ embedWorkflow: true });
|
||||
});
|
||||
});
|
||||
@@ -165,12 +165,76 @@ describe('Model modal hash rendering', () => {
|
||||
expect(rows[0].querySelector('.hash-kind').textContent).toBe('SHA256');
|
||||
});
|
||||
|
||||
it('hides the hashes item entirely when sha256 is empty', async () => {
|
||||
it('renders autov3 alone when sha256 is empty', async () => {
|
||||
await renderModal(makeModel({ sha256: '', autov3: AUTOV3 }));
|
||||
|
||||
const rows = document.querySelectorAll('.hash-footnote .hash-entry');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].querySelector('.hash-kind').textContent).toBe('AutoV3');
|
||||
});
|
||||
|
||||
it('hides the footnote entirely when there are no hashes and no civitai ids', async () => {
|
||||
await renderModal(makeModel({ sha256: '', autov3: '', civitai: {} }));
|
||||
|
||||
expect(document.querySelector('.hash-footnote')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders civitai model and version ids with copy buttons', async () => {
|
||||
await renderModal(makeModel({ civitai: { modelId: 12345, id: 67890 } }));
|
||||
|
||||
const group = document.querySelector('.hash-footnote .civitai-id-group');
|
||||
expect(group).not.toBeNull();
|
||||
|
||||
const entries = group.querySelectorAll('.civitai-id-entry');
|
||||
expect(entries).toHaveLength(2);
|
||||
|
||||
expect(entries[0].querySelector('.hash-kind').textContent).toBe('Model ID');
|
||||
expect(entries[0].querySelector('.model-hash-value').textContent).toBe('12345');
|
||||
expect(entries[0].querySelector('[data-action="copy-hash"]').dataset.hash).toBe('12345');
|
||||
|
||||
expect(entries[1].querySelector('.hash-kind').textContent).toBe('Version ID');
|
||||
expect(entries[1].querySelector('.model-hash-value').textContent).toBe('67890');
|
||||
expect(entries[1].querySelector('[data-action="copy-hash"]').dataset.hash).toBe('67890');
|
||||
});
|
||||
|
||||
it('hides civitai ids for models without civitai data', async () => {
|
||||
await renderModal(makeModel({ civitai: {} }));
|
||||
|
||||
expect(document.querySelector('.hash-footnote .civitai-id-group')).toBeNull();
|
||||
});
|
||||
|
||||
it.each([0, '0', null, undefined, ''])('treats civitai id %s as missing', async (missingId) => {
|
||||
await renderModal(makeModel({ civitai: { modelId: missingId, id: missingId } }));
|
||||
|
||||
expect(document.querySelector('.hash-footnote .civitai-id-group')).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to snake_case model_id', async () => {
|
||||
await renderModal(makeModel({ civitai: { model_id: 777, id: 888 } }));
|
||||
|
||||
const group = document.querySelector('.hash-footnote .civitai-id-group');
|
||||
const entries = group.querySelectorAll('.civitai-id-entry');
|
||||
expect(entries[0].querySelector('.model-hash-value').textContent).toBe('777');
|
||||
expect(entries[1].querySelector('.model-hash-value').textContent).toBe('888');
|
||||
});
|
||||
|
||||
it('still renders the footnote when only civitai ids exist', async () => {
|
||||
await renderModal(makeModel({ sha256: '', autov3: '', civitai: { modelId: 12345, id: 67890 } }));
|
||||
|
||||
const footnote = document.querySelector('.hash-footnote');
|
||||
expect(footnote).not.toBeNull();
|
||||
expect(footnote.querySelectorAll('.civitai-id-entry')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('copies the version id with the civitai toast when its copy button is clicked', async () => {
|
||||
await renderModal(makeModel({ civitai: { modelId: 12345, id: 67890 } }));
|
||||
|
||||
const entries = document.querySelectorAll('.civitai-id-entry [data-action="copy-hash"]');
|
||||
entries[1].click();
|
||||
|
||||
expect(copyToClipboard).toHaveBeenCalledWith('67890', 'Civitai ID copied to clipboard');
|
||||
});
|
||||
|
||||
it('copies the full hash when the copy button is clicked', async () => {
|
||||
await renderModal(makeModel());
|
||||
|
||||
|
||||
@@ -188,4 +188,41 @@ describe('Model modal source links (#1094)', () => {
|
||||
expect(civitaiLink()).toBeNull();
|
||||
expect(hfLink()).toBeNull();
|
||||
});
|
||||
|
||||
it('renders an OpenModelDB link from the enriched civitai payload', async () => {
|
||||
// Hash-enriched upscalers carry no `source_url`; the page link lives in
|
||||
// `civitai.openmodeldb.url` instead.
|
||||
await renderModal(
|
||||
makeModel({
|
||||
from_civitai: false,
|
||||
civitai: {
|
||||
source: 'openmodeldb',
|
||||
openmodeldb: { id: '4x-UltraSharp', url: 'https://openmodeldb.info/models/4x-UltraSharp' },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const link = document.querySelector('[data-action="view-model-source"]');
|
||||
expect(link).not.toBeNull();
|
||||
expect(link.dataset.sourceUrl).toBe('https://openmodeldb.info/models/4x-UltraSharp');
|
||||
expect(civitaiLink()).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers source_url over the openmodeldb payload fallback', async () => {
|
||||
await renderModal(
|
||||
makeModel({
|
||||
from_civitai: false,
|
||||
source_url: 'https://openmodeldb.info/models/1x-DeJPG',
|
||||
source_platform: 'openmodeldb',
|
||||
civitai: {
|
||||
source: 'openmodeldb',
|
||||
openmodeldb: { id: '4x-UltraSharp', url: 'https://openmodeldb.info/models/4x-UltraSharp' },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const links = document.querySelectorAll('[data-action="view-model-source"]');
|
||||
expect(links.length).toBe(1);
|
||||
expect(links[0].dataset.sourceUrl).toBe('https://openmodeldb.info/models/1x-DeJPG');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { describe, it, beforeEach, afterEach, expect } from 'vitest';
|
||||
|
||||
const { SHOWCASE_MODULE, VERTICAL_MODULE, MEDIA_UTILS_MODULE, MEDIA_VIEWER_MODULE } = vi.hoisted(() => ({
|
||||
SHOWCASE_MODULE: new URL('../../../static/js/components/shared/showcase/ShowcaseView.js', import.meta.url).pathname,
|
||||
VERTICAL_MODULE: new URL('../../../static/js/components/shared/showcase/VerticalListView.js', import.meta.url).pathname,
|
||||
MEDIA_UTILS_MODULE: new URL('../../../static/js/components/shared/showcase/MediaUtils.js', import.meta.url).pathname,
|
||||
MEDIA_VIEWER_MODULE: new URL('../../../static/js/components/shared/MediaViewer.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
vi.mock(MEDIA_UTILS_MODULE, () => ({
|
||||
initLazyLoading: vi.fn(),
|
||||
initNsfwBlurHandlers: vi.fn(),
|
||||
initMetadataPanelHandlers: vi.fn(),
|
||||
initMediaControlHandlers: vi.fn(),
|
||||
positionAllMediaControls: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(MEDIA_VIEWER_MODULE, () => ({
|
||||
openMediaViewer: vi.fn(),
|
||||
isMediaViewerOpen: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
const PREVIEW_URL = '/loras_static/preview/abc.png';
|
||||
|
||||
const IMAGES = [
|
||||
{ url: 'https://image.civitai.com/vl/111.jpeg', width: 512, height: 768, nsfwLevel: 0 },
|
||||
{ url: 'https://image.civitai.com/vl/222.jpeg', width: 768, height: 512, nsfwLevel: 0 },
|
||||
{ url: 'https://image.civitai.com/vl/333.mp4', width: 512, height: 512, nsfwLevel: 0 },
|
||||
];
|
||||
|
||||
describe('Showcase vertical list layout', () => {
|
||||
let state;
|
||||
|
||||
beforeEach(async () => {
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
const stateModule = await import('../../../static/js/state/index.js');
|
||||
state = stateModule.state;
|
||||
state.settings.show_only_sfw = false;
|
||||
state.settings.blur_mature_content = false;
|
||||
state.settings.showcase_layout = 'vertical';
|
||||
state.global.settings.example_images_path = '/tmp/examples';
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
delete window.settingsManager;
|
||||
state.settings.showcase_layout = 'gallery';
|
||||
});
|
||||
|
||||
it('defaults the showcase_layout setting to gallery', async () => {
|
||||
const { createDefaultSettings } = await import('../../../static/js/state/index.js');
|
||||
// DEFAULT_SETTINGS_BASE feeds both the frontend state and the backend key set
|
||||
expect(createDefaultSettings().showcase_layout).toBe('gallery');
|
||||
});
|
||||
|
||||
it('renders every example stacked full-width with its own panel and controls', async () => {
|
||||
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||
|
||||
const html = renderShowcaseContent(IMAGES, [], PREVIEW_URL, true);
|
||||
const host = document.createElement('div');
|
||||
host.innerHTML = html;
|
||||
|
||||
expect(host.querySelector('.showcase-gallery.showcase-vertical')).toBeTruthy();
|
||||
const list = host.querySelector('.vertical-list-container');
|
||||
expect(list).toBeTruthy();
|
||||
const wrappers = list.querySelectorAll('.media-wrapper');
|
||||
expect(wrappers).toHaveLength(3);
|
||||
// Every item keeps its aspect ratio via the padding-bottom hack and gets
|
||||
// its own metadata panel + media controls
|
||||
wrappers.forEach(wrapper => {
|
||||
expect(wrapper.getAttribute('style')).toContain('padding-bottom');
|
||||
expect(wrapper.querySelector('.image-metadata-panel')).toBeTruthy();
|
||||
expect(wrapper.querySelector('.media-controls')).toBeTruthy();
|
||||
});
|
||||
// Gallery-only chrome is absent
|
||||
expect(host.querySelector('.gallery-main')).toBeNull();
|
||||
expect(host.querySelector('.gallery-strip')).toBeNull();
|
||||
expect(host.querySelector('#galleryPrevBtn')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders media lazy: data-* sources only, no real src until observed', async () => {
|
||||
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||
|
||||
const html = renderShowcaseContent(IMAGES, [], PREVIEW_URL, true);
|
||||
const host = document.createElement('div');
|
||||
host.innerHTML = html;
|
||||
|
||||
const media = host.querySelectorAll('.vertical-list-container .media-wrapper img, .vertical-list-container .media-wrapper video');
|
||||
expect(media).toHaveLength(3);
|
||||
media.forEach(el => {
|
||||
expect(el.classList.contains('lazy')).toBe(true);
|
||||
expect(el.dataset.remoteSrc).toBeTruthy();
|
||||
expect(el.hasAttribute('src')).toBe(false);
|
||||
});
|
||||
// The third example is a video
|
||||
expect(host.querySelectorAll('.vertical-list-container video')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('filters NSFW examples and reports the hidden count', async () => {
|
||||
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||
state.settings.show_only_sfw = true;
|
||||
|
||||
const images = [
|
||||
IMAGES[0],
|
||||
{ url: 'https://image.civitai.com/vl/444.jpeg', width: 10, height: 10, nsfwLevel: 32 },
|
||||
];
|
||||
const html = renderShowcaseContent(images, [], '', true);
|
||||
const host = document.createElement('div');
|
||||
host.innerHTML = html;
|
||||
|
||||
expect(host.querySelectorAll('.vertical-list-container .media-wrapper')).toHaveLength(1);
|
||||
expect(host.querySelector('.nsfw-filter-notification')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('toggles between the collapsed indicator bar and the expanded list', async () => {
|
||||
const { renderShowcaseContent, initShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||
|
||||
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL)}</div>`;
|
||||
|
||||
// Collapsed: indicator bar only, no media rendered even in vertical mode
|
||||
expect(document.querySelector('.gallery-indicator-bar')).toBeTruthy();
|
||||
expect(document.querySelector('.vertical-list-container')).toBeNull();
|
||||
|
||||
initShowcaseContent(document.querySelector('.showcase-gallery'));
|
||||
|
||||
// Expand → vertical list
|
||||
document.querySelector('#galleryShowBtn').click();
|
||||
expect(document.querySelector('.showcase-vertical')).toBeTruthy();
|
||||
expect(document.querySelectorAll('.vertical-list-container .media-wrapper')).toHaveLength(3);
|
||||
|
||||
// Collapse back to the indicator bar
|
||||
document.querySelector('#galleryShowBtn').click();
|
||||
expect(document.querySelector('.vertical-list-container')).toBeNull();
|
||||
expect(document.querySelector('.gallery-indicator-bar')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps the back-to-top button available in vertical mode (gallery hides it)', async () => {
|
||||
const { renderShowcaseContent, initShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||
|
||||
// Expanded vertical list: no thumbnail strip in the corner, so the modal
|
||||
// must NOT get the showcase-expanded class that hides back-to-top
|
||||
document.body.innerHTML = `<div class="modal-content"><div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div></div>`;
|
||||
initShowcaseContent(document.querySelector('.showcase-gallery'));
|
||||
expect(document.querySelector('.modal-content').classList.contains('showcase-expanded')).toBe(false);
|
||||
|
||||
// Expanded gallery: the strip occupies the corner, button hidden as before
|
||||
state.settings.showcase_layout = 'gallery';
|
||||
document.body.innerHTML = `<div class="modal-content"><div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div></div>`;
|
||||
initShowcaseContent(document.querySelector('.showcase-gallery'));
|
||||
expect(document.querySelector('.modal-content').classList.contains('showcase-expanded')).toBe(true);
|
||||
});
|
||||
|
||||
it('switches between gallery and vertical renderers via the segmented toggle', async () => {
|
||||
const { renderShowcaseContent, initShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||
|
||||
// Stub the settings manager global (normally set by core.js); it persists
|
||||
// into state like the real saveSetting does
|
||||
const saveSetting = vi.fn(async (key, value) => { state.global.settings[key] = value; });
|
||||
window.settingsManager = { saveSetting };
|
||||
|
||||
state.settings.showcase_layout = 'gallery';
|
||||
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
|
||||
initShowcaseContent(document.querySelector('.showcase-gallery'));
|
||||
|
||||
// Gallery renders with the gallery segment active
|
||||
expect(document.querySelector('.gallery-strip')).toBeTruthy();
|
||||
expect(document.querySelector('[data-showcase-layout="gallery"]').classList.contains('active')).toBe(true);
|
||||
|
||||
// Switch to the vertical list
|
||||
document.querySelector('[data-showcase-layout="vertical"]').click();
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('.showcase-vertical')).toBeTruthy();
|
||||
});
|
||||
expect(saveSetting).toHaveBeenCalledWith('showcase_layout', 'vertical');
|
||||
expect(state.settings.showcase_layout).toBe('vertical');
|
||||
expect(document.querySelectorAll('.vertical-list-container .media-wrapper')).toHaveLength(3);
|
||||
expect(document.querySelector('[data-showcase-layout="vertical"]').classList.contains('active')).toBe(true);
|
||||
|
||||
// initShowcaseContent was re-run by the re-render; bind again and switch back
|
||||
document.querySelector('[data-showcase-layout="gallery"]').click();
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('.gallery-strip')).toBeTruthy();
|
||||
});
|
||||
expect(saveSetting).toHaveBeenLastCalledWith('showcase_layout', 'gallery');
|
||||
expect(document.querySelector('.vertical-list-container')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the collapsed indicator bar shared across layouts (toggle included)', async () => {
|
||||
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||
|
||||
const html = renderShowcaseContent(IMAGES, [], PREVIEW_URL);
|
||||
const host = document.createElement('div');
|
||||
host.innerHTML = html;
|
||||
|
||||
const bar = host.querySelector('.gallery-indicator-bar');
|
||||
expect(bar).toBeTruthy();
|
||||
expect(bar.querySelector('[data-showcase-layout="vertical"]')).toBeTruthy();
|
||||
expect(bar.querySelector('[data-showcase-layout="gallery"]')).toBeTruthy();
|
||||
// Vertical layout active in the collapsed bar too
|
||||
expect(bar.querySelector('[data-showcase-layout="vertical"]').classList.contains('active')).toBe(true);
|
||||
});
|
||||
|
||||
it('initializes shared media handlers for every stacked item', async () => {
|
||||
const { initLazyLoading, initNsfwBlurHandlers, initMetadataPanelHandlers, initMediaControlHandlers } = await import(MEDIA_UTILS_MODULE);
|
||||
const { renderVerticalList, initVerticalList } = await import(VERTICAL_MODULE);
|
||||
|
||||
document.body.innerHTML = `<div class="showcase-gallery showcase-vertical">${renderVerticalList(IMAGES, [])}</div>`;
|
||||
initVerticalList(document.querySelector('.showcase-vertical'), IMAGES, []);
|
||||
|
||||
expect(initLazyLoading).toHaveBeenCalled();
|
||||
expect(initNsfwBlurHandlers).toHaveBeenCalled();
|
||||
expect(initMetadataPanelHandlers).toHaveBeenCalled();
|
||||
expect(initMediaControlHandlers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens the full-size media viewer at the clicked item', async () => {
|
||||
const { openMediaViewer } = await import(MEDIA_VIEWER_MODULE);
|
||||
const { renderVerticalList, initVerticalList } = await import(VERTICAL_MODULE);
|
||||
|
||||
document.body.innerHTML = `<div class="showcase-gallery showcase-vertical">${renderVerticalList(IMAGES, [])}</div>`;
|
||||
initVerticalList(document.querySelector('.showcase-vertical'), IMAGES, []);
|
||||
|
||||
const wrappers = document.querySelectorAll('.vertical-list-container .media-wrapper');
|
||||
wrappers[1].querySelector('img').click();
|
||||
|
||||
expect(openMediaViewer).toHaveBeenCalledTimes(1);
|
||||
const [items, index] = openMediaViewer.mock.calls[0];
|
||||
expect(index).toBe(1);
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items[2].type).toBe('video');
|
||||
});
|
||||
});
|
||||
@@ -631,24 +631,38 @@ describe('SidebarManager folder deletion', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('opens the confirm state for a folder whose subtree holds no models', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
it('opens the confirm state for a folder whose subtree holds no models', async () => {
|
||||
const apiClient = createApiClient();
|
||||
const manager = createManager(apiClient);
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
manager.showDeleteFolderModal('empty');
|
||||
await manager.showDeleteFolderModal('empty');
|
||||
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
expect(modal.dataset.state).toBe('confirm');
|
||||
expect(confirmBtn().style.display).toBe('');
|
||||
expect(confirmBtn().disabled).toBe(false);
|
||||
expect(manager._pendingDeleteFolderPath).toBe('empty');
|
||||
expect(modalManager.showModal).toHaveBeenCalledWith('deleteFolderModal');
|
||||
// The prediction is confirmed against the real guard before the user can
|
||||
// act on it.
|
||||
expect(apiClient.deleteFolder).toHaveBeenCalledWith(
|
||||
'/models/loras/empty', { dryRun: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('explains the refusal when the subtree still holds models', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
it('explains the refusal when the subtree still holds models', async () => {
|
||||
const conflict = Object.assign(new Error('still contains models'), {
|
||||
code: 'not_empty',
|
||||
manifest: { model_count: 2, excluded_model_count: 0 },
|
||||
});
|
||||
const apiClient = createApiClient({
|
||||
deleteFolder: vi.fn().mockRejectedValue(conflict),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
manager.showDeleteFolderModal('full');
|
||||
await manager.showDeleteFolderModal('full');
|
||||
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
expect(modal.dataset.state).toBe('blocked');
|
||||
@@ -656,17 +670,134 @@ describe('SidebarManager folder deletion', () => {
|
||||
expect(manager._pendingDeleteFolderPath).toBeNull();
|
||||
});
|
||||
|
||||
it('treats an unknown folder as model-free when the models-only set is missing', () => {
|
||||
// nonEmptyFolders is null outside the include-empty tree; the server still
|
||||
// refuses a non-empty folder, so the client falls back to the confirm state.
|
||||
it('treats an unknown folder as model-free when the models-only set is missing', async () => {
|
||||
// nonEmptyFolders is null outside the include-empty tree; the dry run is
|
||||
// what actually decides, so the prediction is only a starting point.
|
||||
const manager = createManager(createApiClient());
|
||||
manager.nonEmptyFolders = null;
|
||||
|
||||
manager.showDeleteFolderModal('empty');
|
||||
await manager.showDeleteFolderModal('empty');
|
||||
|
||||
expect(document.getElementById('deleteFolderModal').dataset.state).toBe('confirm');
|
||||
});
|
||||
|
||||
it('blocks a folder the tree shows as empty when only excluded models live there', async () => {
|
||||
// The reported mismatch: excluded models are absent from the models-only
|
||||
// set (so the node dims as empty), yet they are real weight files on disk
|
||||
// and the delete guard refuses to cascade over them.
|
||||
const conflict = Object.assign(
|
||||
new Error('Folder still contains 3 model file(s), all excluded from the library'),
|
||||
{ code: 'not_empty', manifest: { model_count: 3, excluded_model_count: 3 } }
|
||||
);
|
||||
const apiClient = createApiClient({
|
||||
deleteFolder: vi.fn().mockRejectedValue(conflict),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
await manager.showDeleteFolderModal('Flux.1 D/test');
|
||||
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
expect(modal.dataset.state).toBe('blocked');
|
||||
expect(confirmBtn().style.display).toBe('none');
|
||||
expect(manager._pendingDeleteFolderPath).toBeNull();
|
||||
// The message names the excluded models instead of contradicting the tree.
|
||||
expect(modal.querySelector('[data-role="message"]').textContent)
|
||||
.toContain('excluded from the library');
|
||||
expect(apiClient.deleteFolder).toHaveBeenCalledWith(
|
||||
'/models/loras/Flux.1 D/test', { dryRun: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('reports how many model files block the delete when some are excluded', async () => {
|
||||
const conflict = Object.assign(new Error('still contains models'), {
|
||||
code: 'not_empty',
|
||||
manifest: { model_count: 4, excluded_model_count: 1 },
|
||||
});
|
||||
const apiClient = createApiClient({
|
||||
deleteFolder: vi.fn().mockRejectedValue(conflict),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
await manager.showDeleteFolderModal('mixed');
|
||||
|
||||
expect(
|
||||
document.getElementById('deleteFolderModal')
|
||||
.querySelector('[data-role="message"]').textContent
|
||||
).toContain('4 model file(s)');
|
||||
});
|
||||
|
||||
it('blocks the delete while a staged delete is still pending', async () => {
|
||||
const busy = Object.assign(new Error('staged delete pending'), { code: 'busy' });
|
||||
const apiClient = createApiClient({
|
||||
deleteFolder: vi.fn().mockRejectedValue(busy),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
await manager.showDeleteFolderModal('empty');
|
||||
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
expect(modal.dataset.state).toBe('busy');
|
||||
expect(confirmBtn().style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('keeps the confirm button disabled until the check settles', async () => {
|
||||
let release;
|
||||
const apiClient = createApiClient({
|
||||
fetchModelRoots: vi.fn(() => new Promise((resolve) => { release = resolve; })),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
const pending = manager.showDeleteFolderModal('empty');
|
||||
expect(confirmBtn().disabled).toBe(true);
|
||||
|
||||
release({ roots: ['/models/loras'] });
|
||||
await pending;
|
||||
|
||||
expect(confirmBtn().disabled).toBe(false);
|
||||
expect(document.getElementById('deleteFolderModal').dataset.state).toBe('confirm');
|
||||
});
|
||||
|
||||
it('ignores a dry-run answer that lands after the modal was dismissed', async () => {
|
||||
let rejectProbe;
|
||||
const apiClient = createApiClient({
|
||||
deleteFolder: vi.fn(() => new Promise((_resolve, reject) => { rejectProbe = reject; })),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
const pending = manager.showDeleteFolderModal('empty');
|
||||
expect(document.getElementById('deleteFolderModal').dataset.state).toBe('confirm');
|
||||
|
||||
await vi.waitFor(() => expect(rejectProbe).toBeTypeOf('function'));
|
||||
|
||||
manager.hideDeleteFolderModal();
|
||||
rejectProbe(Object.assign(new Error('still contains models'), {
|
||||
code: 'not_empty',
|
||||
manifest: { model_count: 1, excluded_model_count: 0 },
|
||||
}));
|
||||
await pending;
|
||||
|
||||
expect(document.getElementById('deleteFolderModal').dataset.state).toBe('confirm');
|
||||
});
|
||||
|
||||
it('falls back to the tree prediction when the check fails for another reason', async () => {
|
||||
const apiClient = createApiClient({
|
||||
deleteFolder: vi.fn().mockRejectedValue(new Error('network down')),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
await manager.showDeleteFolderModal('empty');
|
||||
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
expect(modal.dataset.state).toBe('confirm');
|
||||
expect(confirmBtn().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('deletes the folder and offers the undo affordance for an empty one', async () => {
|
||||
const apiClient = createApiClient();
|
||||
const manager = createManager(apiClient);
|
||||
@@ -732,6 +863,25 @@ describe('SidebarManager folder deletion', () => {
|
||||
expect(manager.refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('includes the model count in the stale-tree toast when the manifest has one', async () => {
|
||||
const conflict = Object.assign(new Error('still contains models'), {
|
||||
code: 'not_empty',
|
||||
manifest: { model_count: 3, excluded_model_count: 3 },
|
||||
});
|
||||
const apiClient = createApiClient({
|
||||
deleteFolder: vi.fn().mockRejectedValue(conflict),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const success = await manager._deleteFolder('full');
|
||||
|
||||
expect(success).toBe(false);
|
||||
expect(showToast).toHaveBeenCalledWith(
|
||||
'sidebar.deleteFolderResult.notEmptyWithCount', { count: 3 }, 'warning'
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces a busy folder with a staged delete', async () => {
|
||||
const busy = Object.assign(new Error('staged delete pending'), { code: 'busy' });
|
||||
const apiClient = createApiClient({
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
DOWNLOAD_MANAGER_MODULE,
|
||||
MODAL_MANAGER_MODULE,
|
||||
UI_HELPERS_MODULE,
|
||||
STATE_MODULE,
|
||||
LOADING_MANAGER_MODULE,
|
||||
API_FACTORY_MODULE,
|
||||
STORAGE_HELPERS_MODULE,
|
||||
FOLDER_TREE_MANAGER_MODULE,
|
||||
I18N_HELPERS_MODULE,
|
||||
SUMMARY_MODULE,
|
||||
OTHER_MODELS_MODULE,
|
||||
} = vi.hoisted(() => ({
|
||||
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
|
||||
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
|
||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||
LOADING_MANAGER_MODULE: new URL('../../../static/js/managers/LoadingManager.js', import.meta.url).pathname,
|
||||
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
|
||||
FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname,
|
||||
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||
SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname,
|
||||
OTHER_MODELS_MODULE: new URL('../../../static/js/utils/otherModels.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
vi.mock(MODAL_MANAGER_MODULE, () => ({
|
||||
modalManager: { showModal: vi.fn(), closeModal: vi.fn() },
|
||||
}));
|
||||
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||
showToast: vi.fn(),
|
||||
showActionToast: vi.fn(),
|
||||
setupAutoNewlineOnPaste: vi.fn(),
|
||||
}));
|
||||
vi.mock(STATE_MODULE, () => ({
|
||||
state: { global: { settings: {} }, loadingManager: {} },
|
||||
}));
|
||||
vi.mock(LOADING_MANAGER_MODULE, () => ({
|
||||
LoadingManager: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock(API_FACTORY_MODULE, () => ({
|
||||
getModelApiClient: vi.fn(),
|
||||
resetAndReload: vi.fn(),
|
||||
}));
|
||||
vi.mock(STORAGE_HELPERS_MODULE, () => ({
|
||||
getStorageItem: vi.fn((_key, defaultValue) => defaultValue),
|
||||
setStorageItem: vi.fn(),
|
||||
}));
|
||||
vi.mock(FOLDER_TREE_MANAGER_MODULE, () => ({
|
||||
FolderTreeManager: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock(I18N_HELPERS_MODULE, () => ({
|
||||
translate: vi.fn((_key, _vars, fallback) => fallback ?? ''),
|
||||
}));
|
||||
vi.mock(SUMMARY_MODULE, () => ({
|
||||
showDownloadBatchSummary: vi.fn(),
|
||||
}));
|
||||
vi.mock(OTHER_MODELS_MODULE, () => ({
|
||||
enableOtherModels: vi.fn(),
|
||||
openOtherModelsSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
const { DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE);
|
||||
const { state } = await import(STATE_MODULE);
|
||||
const { setStorageItem } = await import(STORAGE_HELPERS_MODULE);
|
||||
|
||||
describe('DownloadManager checkpoint/diffusion routing override', () => {
|
||||
let manager;
|
||||
|
||||
function setupDom() {
|
||||
document.body.innerHTML = `
|
||||
<div id="routingOverrideGroup" style="display:none">
|
||||
<button class="routing-override-option" data-routing="checkpoint"></button>
|
||||
<button class="routing-override-option" data-routing="diffusion"></button>
|
||||
</div>
|
||||
<select id="modelRoot"></select>
|
||||
<label id="modelRootLabel"></label>
|
||||
<input id="folderPath" />
|
||||
<input type="checkbox" id="useDefaultPath" />
|
||||
<div id="manualPathSelection"></div>
|
||||
<div id="targetPathDisplay"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
function createManager(modelType, displayName = 'Model') {
|
||||
manager = new DownloadManager();
|
||||
manager.apiClient = {
|
||||
modelType,
|
||||
apiConfig: { config: { displayName } },
|
||||
fetchModelRoots: vi.fn(),
|
||||
};
|
||||
manager.selectedFile = null;
|
||||
manager.selectedFiles = [];
|
||||
manager.currentVersion = null;
|
||||
manager.initializeFolderTree = vi.fn().mockResolvedValue();
|
||||
manager.folderTreeManager = { init: vi.fn(), getSelectedPath: vi.fn(() => '') };
|
||||
manager.loadDefaultPathSetting = vi.fn();
|
||||
vi.spyOn(manager, '_resolveOtherSubType').mockResolvedValue(null);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setupDom();
|
||||
state.global.settings = {};
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('shows the toggle for diffusion-routed checkpoints and marks diffusion active', async () => {
|
||||
createManager('checkpoints', 'Checkpoint');
|
||||
vi.spyOn(manager, '_resolveIsDiffusionModel').mockResolvedValue(true);
|
||||
manager.apiClient.fetchModelRoots.mockResolvedValue({
|
||||
success: true,
|
||||
roots: ['/models/unet'],
|
||||
});
|
||||
state.global.settings.default_unet_root = '/models/unet';
|
||||
|
||||
await manager.proceedToLocationContent();
|
||||
|
||||
const group = document.getElementById('routingOverrideGroup');
|
||||
expect(group.style.display).not.toBe('none');
|
||||
const [checkpointBtn, diffusionBtn] = group.querySelectorAll('.routing-override-option');
|
||||
expect(diffusionBtn.classList.contains('active')).toBe(true);
|
||||
expect(diffusionBtn.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(checkpointBtn.classList.contains('active')).toBe(false);
|
||||
expect(manager.apiClient.fetchModelRoots).toHaveBeenCalledWith('diffusion_model');
|
||||
expect(document.getElementById('modelRoot').value).toBe('/models/unet');
|
||||
});
|
||||
|
||||
it('keeps the toggle hidden on non-checkpoints pages', async () => {
|
||||
createManager('loras', 'LoRA');
|
||||
manager.apiClient.fetchModelRoots.mockResolvedValue({
|
||||
success: true,
|
||||
roots: ['/models/loras'],
|
||||
});
|
||||
|
||||
await manager.proceedToLocationContent();
|
||||
|
||||
expect(document.getElementById('routingOverrideGroup').style.display).toBe('none');
|
||||
expect(manager.apiClient.fetchModelRoots).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('re-fetches diffusion roots and preselects the unet default when overriding to diffusion', async () => {
|
||||
createManager('checkpoints', 'Checkpoint');
|
||||
vi.spyOn(manager, '_resolveIsDiffusionModel').mockResolvedValue(false);
|
||||
manager.apiClient.fetchModelRoots
|
||||
.mockResolvedValueOnce({ success: true, roots: ['/models/checkpoints'] })
|
||||
.mockResolvedValueOnce({ success: true, roots: ['/models/unet'] });
|
||||
state.global.settings.default_unet_root = '/models/unet';
|
||||
|
||||
await manager.proceedToLocationContent();
|
||||
expect(document.getElementById('modelRoot').value).toBe('/models/checkpoints');
|
||||
|
||||
await manager.handleRoutingOverrideChange(true);
|
||||
|
||||
expect(manager.apiClient.fetchModelRoots).toHaveBeenLastCalledWith('diffusion_model');
|
||||
const modelRoot = document.getElementById('modelRoot');
|
||||
expect(Array.from(modelRoot.options).map(o => o.value)).toEqual(['/models/unet']);
|
||||
expect(modelRoot.value).toBe('/models/unet');
|
||||
const group = document.getElementById('routingOverrideGroup');
|
||||
const [checkpointBtn, diffusionBtn] = group.querySelectorAll('.routing-override-option');
|
||||
expect(checkpointBtn.classList.contains('active')).toBe(false);
|
||||
expect(diffusionBtn.classList.contains('active')).toBe(true);
|
||||
});
|
||||
|
||||
it('turns off Use Default Path for the session without persisting when overriding', async () => {
|
||||
createManager('checkpoints', 'Checkpoint');
|
||||
vi.spyOn(manager, '_resolveIsDiffusionModel').mockResolvedValue(false);
|
||||
manager.apiClient.fetchModelRoots.mockResolvedValue({
|
||||
success: true,
|
||||
roots: ['/models/checkpoints'],
|
||||
});
|
||||
|
||||
await manager.proceedToLocationContent();
|
||||
|
||||
manager.useDefaultPath = true;
|
||||
document.getElementById('useDefaultPath').checked = true;
|
||||
|
||||
await manager.handleRoutingOverrideChange(true);
|
||||
|
||||
expect(manager.useDefaultPath).toBe(false);
|
||||
expect(document.getElementById('useDefaultPath').checked).toBe(false);
|
||||
expect(setStorageItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears the override when switching back to the auto-classified side', async () => {
|
||||
createManager('checkpoints', 'Checkpoint');
|
||||
vi.spyOn(manager, '_resolveIsDiffusionModel').mockResolvedValue(false);
|
||||
manager.apiClient.fetchModelRoots.mockResolvedValue({
|
||||
success: true,
|
||||
roots: ['/models/checkpoints'],
|
||||
});
|
||||
|
||||
await manager.proceedToLocationContent();
|
||||
await manager.handleRoutingOverrideChange(true);
|
||||
expect(manager._routingOverride).toBe(true);
|
||||
|
||||
manager.apiClient.fetchModelRoots.mockClear();
|
||||
await manager.handleRoutingOverrideChange(false);
|
||||
|
||||
expect(manager._routingOverride).toBeNull();
|
||||
// Back on the checkpoint group: no routing argument
|
||||
expect(manager.apiClient.fetchModelRoots).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user