Compare commits

..
22 Commits
Author SHA1 Message Date
Will Miao 18504c712c fix(nodes): let Load Image Metadata value outputs drive dropdown widgets
model_name, sampler_name and scheduler were declared as COMBO outputs, so the
documented wiring failed at queue time with "Return type mismatch between linked
nodes". ComfyUI only accepts a COMBO output into a node that declares its
dropdown as COMBO/IO.Combo, while Load Checkpoint, KSampler and the LoRA Manager
loaders expose their options as a plain list; comfy_execution.validation rejects
any non-string input type there, and a STRING output is rejected the same way.

Declare the three sockets untyped ("*"), the type ComfyUI's own Primitive node
uses to feed widget inputs. Verified with execution.validate_inputs that they now
link into both classic list dropdowns and IO.Combo inputs.

Also corrects the wiring guide, which claimed COMBO was the supported type.
2026-09-29 10:20:05 +08:00
pixelpaws 013c378a7a Merge pull request #1131 from willmiao/fix/sidecar-root-identity
fix(sidecars): keep mirrored sidecars when a model root moves
2026-09-29 09:45:41 +08:00
Will Miao faeb66a23d feat(recipes): opt-in workflow embedding for widget recipe saves
Add a "Save Recipe with Workflow" action next to "Save Recipe" in the LoRA
widget context menu. It posts the current UI-format graph alongside the save
request so the stored preview embeds it and the recipe can send the graph back
to ComfyUI. Embedding stays opt-in rather than folded into "Save Recipe": the
workflow is by far the largest metadata field and its widget values may carry
sensitive data.

- web/comfyui: new menu entry; saveRecipeDirectly({ embedWorkflow }) posts the
  UI graph and reports the outcome (embedded / skipped) via toasts.
- save_recipe_from_widget handler: reads an optional JSON workflow field so the
  long-standing body-less POST keeps working, including from cached clients.
- RecipePersistenceService.save_recipe_from_widget: embeds the graph through
  the existing optimize_image workflow path, derives has_workflow by detection,
  and skips graphs above MAX_WORKFLOW_EMBED_BYTES with workflow_skipped.
2026-09-29 09:03:26 +08:00
Will Miao 69691b17a1 feat(recipes): preserve embedded ComfyUI workflow on remote imports
CivitAI serves a re-encoded, metadata-free optimized rendition as the recipe
preview, so the ComfyUI workflow embedded in the original image was dropped:
imported recipes reported has_workflow=false and never offered "Send Workflow
to ComfyUI" even when the source image carried one.

Recover the workflow from the original rendition and carry it to the save step
as data, so the stored preview stays the small optimized image:

- ExifUtils: embed a caller-supplied workflow during optimize_image's single
  encode pass, and add embed_workflow() to patch WebP EXIF in place (used by
  the verbatim skip_optimize branch and as a safety net).
- RecipePersistenceService.save_recipe: embed metadata["workflow"] before
  detecting has_workflow.
- analyze_remote_image: return the workflow recovered from the original
  rendition it already downloads for EXIF parsing.
- RecipeManagementHandler: add _fetch_original_media() and workflow helpers;
  _do_import_from_url reuses them, and _do_import_remote_recipe fetches the
  original only when CivitAI reports a ComfyUI payload (meta.comfy) so
  workflow-less images pay no extra bandwidth.
- Batch URL imports and the import modal forward the recovered workflow.

Verified against the reported image: has_workflow flips from false to true and
the recovered workflow matches the original (25 nodes, same graph id).
2026-09-29 07:20:12 +08:00
Will Miao b90d60f043 fix(sidecars): carry the identity map through a sidecar root move
A model root that moved earlier is mirrored under a pinned name derived
from its old path. If anything resolved against the new sidecar path before
the relocation ran, the destination got a map naming the mirror after the
*current* path. migrate_root's keep-newer transfer then dropped the source
map, so the moved metadata stayed orphaned under the pinned component while
reads followed the new name and rebuilt defaults — losing favorites, notes
and tags a second time.

The identity map is now relocated by relocate_root_map() instead of the
generic transfer: entries recorded under the old sidecar root win for the
roots they describe, destination-only entries are preserved, and the cache
is dropped so the next resolution reloads the merged map. A merge that
cannot be written is reported as a migration error rather than silently
stranding the moved metadata.

Reported by the Codex review on #1131.
2026-09-28 21:25:48 +08:00
Will Miao c3a9350155 fix(sidecars): keep mirrored sidecars when a model root moves
Centralized sidecars were addressed by a hash of the model root's absolute
path, so moving or renaming a root produced a new mirror directory. The
scanner then found no sidecar there, rebuilt default metadata, and silently
lost favorites, notes, tags and usage tips for every model under that root,
leaving the old metadata orphaned on disk.

Mirrors are now addressed by a root identity pinned in
<sidecar_root>/.lm-sidecar-roots.json. The identity starts as the existing
deterministic <basename>-<path digest> -- so pre-existing mirrors keep
resolving even if the map is lost, and a relocated sidecar root keeps its
names -- and is re-anchored to the root's new path when it moves, matched by
basename and recorded sample directories. Ambiguous matches are never
guessed: the mirror is left untouched and reported.

Also:
- drop the <library> path segment (single-library direction); a legacy
  library prefix is only recognised while adopting an existing mirror, which
  also keeps the unreleased centralized layout usable
- surface stranded mirrors in Doctor and in the log instead of silently
  rebuilding sidecars
- keep the default alongside mode untouched: the identity map is loaded and
  reconciled lazily, only while centralized storage is in use
2026-09-28 21:11:38 +08:00
Will Miao 0dd8d74032 fix(ui): stop body data-theme from shadowing theme preset tokens
applyTheme() mirrors the active mode onto <body> as data-theme="dark", but
the theme preset is only ever written to <html>. The palette token blocks in
tokens/colors.css and base.css used the bare attribute selector
[data-theme="dark"], so <body> matched them on its own and re-declared the
default dark palette (#1a1a1a / #2d2d2d / ...) directly on the body, where it
shadowed the preset values inherited from <html>. Every non-default preset
therefore painted the selected accent over the default preset's background,
surface, text and border tokens, and flipped into that state ~200ms after
load, when initTheme() first touched <body> — the accent-tinted background
flash seen on reload and nav-tab switches. Reached only in dark mode, since
light mode has no [data-theme="light"] token block.

Scope the palette token blocks to :root so a data-theme attribute on any
descendant (only <body> has one) can no longer re-declare them; descendant
rules such as [data-theme="dark"] .foo still match through <html>. Add a
regression guard that fails on bare attribute token blocks.
2026-09-27 21:48:58 +08:00
Will Miao 0a262cbe0c fix(linking): support external-source linking for Other-model roots
set_hf_url rejected files under config.other_roots (VAEs, text encoders,
upscalers, ...) with 'File is not within any configured model directory'
because neither _find_matching_root nor _infer_model_type knew about the
Other category. Include other_roots in both, so linking routes the cache
update to the Other scanner instead of falling back to the LoRA one, and
downloads into Other roots keep the lazy-hash metadata path.

Also make the root prefix match boundary-aware so /models/vae no longer
swallows a sibling like /models/vae-old.
2026-09-27 11:02:11 +08:00
pixelpaws ebd2b5b3e0 Merge pull request #1125 from willmiao/feature/sidecar-storage-ux
feat(sidecars): surface storage location, cover excluded models in migration
2026-09-27 10:12:53 +08:00
Will Miao 0f7aa85b0b i18n(sidecars): translate the sidecar storage UX strings
Fill the [TODO: Translate] placeholders for the 7 settings open*/path
keys, the confirm-dialog destination line and the 13-key migration
summary block in all 9 locales, per the sidecar storage terminology in
docs/i18n-translation-guidelines.md (new term rows added there for
storage location / Open Folder / installation folder).
2026-09-27 10:05:30 +08:00
Will Miao e7c1c07db0 feat(sidecars): restyle migration result as a summary modal
Follow the app's existing operation-summary convention (Metadata Fetch
Summary / Batch Download Summary): a self-managed modal appended to
document.body with a 3-state summary header, stat cards (moved /
models / skipped / conflicts / errors), and a failure table listing
per-model errors that were previously swallowed into a single count.

The storage location line and Open Folder action move into the modal
actions; the page reload still happens only when the modal is
dismissed. ESC is captured so it never reaches the settings modal
underneath. The obsolete migrateSuccess toast key is dropped — the
modal is the success feedback now.
2026-09-27 10:05:18 +08:00
Will Miao 485679223b fix: return clipboard mode from _open_path on headless Linux
Addresses PR review: on a native Linux/SSH session with neither DISPLAY
nor WAYLAND_DISPLAY (and not Docker/WSL), _open_path unconditionally
launched xdg-open and reported success even though no file manager can
open. Mirror open_settings_location: hand the path to the browser for
copying instead. Fixes open_backup_location, open_wildcards_location
and open_sidecar_location together.
2026-09-27 10:05:05 +08:00
Will Miao 7aee964448 feat(sidecars): surface storage location and cover excluded models in migration
After migrating to centralized sidecar storage users had no indication
where their files went, and portable-mode installs silently placed the
sidecar root inside the plugin folder where a reinstall or git clean
would delete it.

- Migration now also covers models excluded from the library view and
  returns the resolved sidecar root in its result payload
- get_settings exposes the resolved sidecar root, whether it is the
  default, and whether it lives inside the installation folder
- New POST /api/lm/sidecars/open-location endpoint opens (or copies)
  the sidecar storage folder
- Settings UI always shows the effective storage path with an
  open-folder button, and warns when the root is inside the
  installation folder (portable-mode hazard)
- Migration confirmation shows the destination; on completion a result
  dialog summarizes moved/skipped/conflict counts with the storage
  location and an open-folder action
- Ignore /sidecars/ at the repository root so portable-mode sidecars
  are never committed

Refs #1045
2026-09-26 23:08:29 +08:00
Will Miao 62c144d80a i18n(settings): translate the sidecar storage strings
Fills in the 23 keys that came with optional centralized sidecar storage
(settings.sections.sidecarStorage, the 18 settings.sidecarStorage.*
labels/help/status/confirm strings and the 4
modals.sidecarMigrationConfirm.* titles/button), which the feature PR merged
as [TODO: Translate] copies. No placeholder remains in any locale.

Each locale reuses its own storage-relocation verb rather than a
transliteration of "migration" (ja 移動, ko 이동, ru перенос, matching
settings.folderSettings.recipesPathMigrating), its existing "preview images"
and nav-path renderings, and quotes the migrate button label the way its
other UI-label references do. `.metadata.json`, `.civitai.info` and the
`(<settings dir>/sidecars)` literal stay verbatim.

docs/i18n-translation-guidelines.md gains the "Sidecar storage feature" term
table and pass note, and refreshes the leaf-key count to 2128.
2026-09-26 19:37:32 +08:00
pixelpaws d21f209bad Merge pull request #1124 from willmiao/feature/centralized-sidecar-storage
feat: optional centralized storage for sidecar metadata and previews (#1045)
2026-09-26 19:15:07 +08:00
Will Miao a6fca8612f fix: address review — injective mirror roots, root relocation, full preview coverage, EXDEV-safe rollback
Codex review on #1124:

- P1: mirror layout root component is now <basename>-<roothash>
  (sha256 of the normalized root path), so two roots sharing a basename
  no longer map to the same mirror directory and overwrite each other's
  sidecars
- P1: changing sidecar_storage_path while centralized no longer strands
  assets in the old root — new relocate_root migration direction moves
  the whole mirror tree, rewrites preview_url prefixes inside sidecars,
  reconciles scanner caches, and prunes the emptied old tree; the
  settings UI detects the path change and offers the relocation
- P2: migration enumerates the same preview candidates as
  find_preview_file — case-insensitive variants (model.WEBP) and the
  legacy .example.0.jpeg suffix — instead of exact lowercase
  PREVIEW_EXTENSIONS only
- P2: _rollback_model_staging restores staged files with the
  EXDEV-tolerant mover, so a failed undoable-delete staging no longer
  strands a cross-filesystem centralized sidecar copy

Tests: same-basename root injectivity, mixed-case/example preview
migration, relocate_root happy path + guards + route 400, frontend
relocation prompt flow. Verified end-to-end in a sandboxed standalone
server: uppercase/legacy previews migrate, root relocation moves the
tree and the list API serves the new locations immediately without a
rescan.
2026-09-26 12:24:17 +08:00
Will Miao 5e4462822d i18n(sidebar): translate the folder delete verification strings
Fills in the 9 locale renderings for the 5 keys added with the folder delete
verification (`sidebar.deleteFolderModal.notEmptyMessageCount`,
`.notEmptyMessageExcluded`, `.busyTitle`, `.checking` and
`sidebar.deleteFolderResult.notEmptyWithCount`), so no `[TODO: Translate]`
placeholder remains anywhere.

Each locale reuses its existing `notEmptyMessage` cascade clause verbatim
(punctuation included) and its help-text style for quoting the "Manage
Excluded Models" label; `{count}` / `{excluded}` match en.json. The Hebrew
model-file noun stays `קובצי מודלים` across all four keys.
docs/i18n-translation-guidelines.md records the new copy in §2 and refreshes
its stale `{count}` and leaf-key counts.
2026-09-26 12:07:21 +08:00
Will Miao 6e45ef566c fix(sidebar): verify folder deletion against the backend
The sidebar derives "empty folder" from the models-only list, which omits
models flagged `exclude: true`, while the delete guard walks the folder on
disk and refuses on any weight file. A folder whose models are all excluded
therefore looked empty, offered the confirmation, and then failed with
"still contains models".

The delete modal still opens on that prediction, but is now corrected by a
dry run of the very delete the user is about to confirm, so the button state
cannot contradict the backend. The confirm button stays disabled while the
check runs, and a late answer is discarded once the modal is dismissed or
retargeted. The dry run also covers weight files no scanner indexes (a lora
folder holding only a `.gguf`, say) and files that appeared after the last
scan.

`_collect_folder_manifest()` now reports `excluded_model_count`, and the
refusal names the excluded models, so the message explains the mismatch
instead of reading like a bug. Locale files carry the sync placeholders in
this commit; the translations follow.
2026-09-26 12:07:03 +08:00
Will Miao 16430aef21 fix: reconcile scanner caches after sidecar migration
Sandbox E2E showed that after a migration the list API kept serving
pre-migration preview_url values; the first request to a stale URL made
the preview route's stale-URL cleanup wipe the reference from the cache
entirely, recoverable only by a full rebuild rescan.

The use case now records each migrated model's final preview location
(from the destination directory, covering conflict-keep cases), updates
the owning scanner's cache entries via ModelCache.update_preview_url,
and persists the cache. Per-scanner reconcile failures are logged and
skipped; per-model migration errors no longer prevent reconciliation of
the healthy models.

Verified end-to-end in a sandboxed standalone server: after
to_centralized and to_alongside migrations the list endpoint immediately
returns the correct preview URLs with no rescan, previews serve with
HTTP 200 in both layouts, and the mirror tree is empty after migrating
back.
2026-09-26 11:32:18 +08:00
Will Miao f5e983eaaa feat: optional centralized storage for sidecar metadata and previews (#1045)
Add an opt-in 'centralized' sidecar storage mode alongside the default
'alongside' layout. In centralized mode, .metadata.json sidecars and
preview assets live under a configurable root (sidecar_storage_path,
default <settings_dir>/sidecars), mirroring the library-relative
directory structure: <root>/<library>/<root_basename>/<rel_dir>/.

Backend:
- settings: sidecar_storage_mode / sidecar_storage_path with validation;
  changing either refreshes the preview allowlist
- config: centralized root added to preview-serving allowlist
- lifecycle: delete / move / rename / folder-rename / folder-delete and
  undoable-delete staging all operate on the mirror tree in centralized
  mode (model files themselves never move); EXDEV-tolerant cross-
  filesystem moves
- scanners: pending-hash filesystem scan walks the mirror tree in
  centralized mode; preview discovery reads from the sidecar dir;
  .civitai.info stays co-located in both modes
- migration: SidecarMigrationUseCase moves sidecars+previews between
  layouts both directions (keep-newer conflict resolution, preview_url
  rewriting, WebSocket progress), exposed as POST+GET
  /api/lm/sidecars/migrate with a mode guard (force=true for the
  settings-first flow)

Frontend:
- settings modal: sidecar storage section (mode select + path input with
  browse/validation), mode-change confirmation offering immediate
  migration (force=true), and a 'Migrate Sidecars Now' action
- i18n keys synced to all locales ([TODO: Translate] placeholders)

Docs: metadata-json-schema.md gains a storage-location section;
AGENTS.md records the sidecar_paths helper convention.
2026-09-26 10:00:58 +08:00
Will Miao 297d8787bd refactor: route sidecar/preview path derivation through sidecar_paths helpers
Phase 1 of #1045 (optional centralized sidecar storage): introduce
py/utils/sidecar_paths.py as the single place that resolves .metadata.json
and preview locations, and replace all inline splitext-based derivations
across scanners, services, download manager, and route handlers.

No behavior change: the default 'alongside' storage mode resolves every
path exactly as before. .civitai.info (third-party sidecar) derivation is
intentionally left co-located.
2026-09-26 09:02:37 +08:00
willmiao 20d8c22390 docs: auto-update supporters list in README 2026-09-26 00:53:58 +00:00
83 changed files with 8650 additions and 288 deletions
+3
View File
@@ -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/
+7
View File
@@ -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
+2 -2
View File
File diff suppressed because one or more lines are too long
+94 -6
View File
@@ -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,31 @@ 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").
---
## 1. Hard rules (do not violate)
@@ -391,11 +416,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 +464,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"
+10 -5
View File
@@ -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
+27
View File
@@ -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.
+59
View File
@@ -383,6 +383,7 @@
"exampleImages": "Beispielbilder",
"autoOrganize": "Auto-Organisierung",
"metadata": "Metadaten",
"sidecarStorage": "Sidecar-Speicherung",
"proxySettings": "Proxy-Einstellungen"
},
"nav": {
@@ -789,6 +790,34 @@
"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 +1379,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 +1391,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"
@@ -1637,6 +1671,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",
@@ -2713,6 +2769,9 @@
"filename_conflicts": {
"title": "Konflikte durch doppelte Dateinamen"
},
"sidecar_mirror_orphans": {
"title": "[TODO: Translate] Centralized Sidecars"
},
"ui_version": {
"title": "UI-Version"
}
+59
View File
@@ -383,6 +383,7 @@
"exampleImages": "Example Images",
"autoOrganize": "Auto-organize",
"metadata": "Metadata",
"sidecarStorage": "Sidecar Storage",
"proxySettings": "Proxy Settings"
},
"nav": {
@@ -789,6 +790,34 @@
"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 +1379,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 +1391,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"
@@ -1637,6 +1671,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",
@@ -2713,6 +2769,9 @@
"filename_conflicts": {
"title": "Duplicate Filename Conflicts"
},
"sidecar_mirror_orphans": {
"title": "Centralized Sidecars"
},
"ui_version": {
"title": "UI Version"
}
+59
View File
@@ -383,6 +383,7 @@
"exampleImages": "Imágenes de ejemplo",
"autoOrganize": "Organización automática",
"metadata": "Metadatos",
"sidecarStorage": "Almacenamiento de archivos sidecar",
"proxySettings": "Configuración de proxy"
},
"nav": {
@@ -789,6 +790,34 @@
"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 +1379,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 +1391,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"
@@ -1637,6 +1671,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",
@@ -2713,6 +2769,9 @@
"filename_conflicts": {
"title": "Conflictos de nombres de archivo duplicados"
},
"sidecar_mirror_orphans": {
"title": "[TODO: Translate] Centralized Sidecars"
},
"ui_version": {
"title": "Versión de la interfaz"
}
+59
View File
@@ -383,6 +383,7 @@
"exampleImages": "Images d'exemple",
"autoOrganize": "Organisation automatique",
"metadata": "Métadonnées",
"sidecarStorage": "Stockage des fichiers sidecar",
"proxySettings": "Paramètres du proxy"
},
"nav": {
@@ -789,6 +790,34 @@
"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 +1379,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 +1391,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"
@@ -1637,6 +1671,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 à",
@@ -2713,6 +2769,9 @@
"filename_conflicts": {
"title": "Conflits de noms de fichiers en double"
},
"sidecar_mirror_orphans": {
"title": "[TODO: Translate] Centralized Sidecars"
},
"ui_version": {
"title": "Version de l'interface"
}
+59
View File
@@ -383,6 +383,7 @@
"exampleImages": "תמונות דוגמה",
"autoOrganize": "ארגון אוטומטי",
"metadata": "מטא-נתונים",
"sidecarStorage": "אחסון קובצי לוואי",
"proxySettings": "הגדרות פרוקסי"
},
"nav": {
@@ -789,6 +790,34 @@
"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 +1379,10 @@
"emptyNote": "אין מודלים בתיקייה זו. קבצים אחרים שבה יימחקו גם הם.",
"notEmptyTitle": "התיקייה אינה ריקה",
"notEmptyMessage": "בתיקייה זו עדיין יש מודלים. מחק או העבר אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
"notEmptyMessageCount": "בתיקייה זו עדיין יש {count} קובצי מודלים. מחק או העבר אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
"notEmptyMessageExcluded": "בתיקייה זו עדיין יש {count} קובצי מודלים, שמהם {excluded} מוחרגים מהספרייה. בטל את ההחרגה ב«ניהול מודלים מוחרגים» ומחק אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
"busyTitle": "מחיקה עדיין ממתינה",
"checking": "בודק את תוכן התיקייה...",
"confirm": "מחק תיקייה"
},
"deleteFolderResult": {
@@ -1358,6 +1391,7 @@
"restored": "התיקייה שוחזרה",
"failed": "מחיקת התיקייה נכשלה: {message}",
"notEmpty": "בתיקייה זו עדיין יש מודלים. רענן את סרגל הצד ונסה שוב.",
"notEmptyWithCount": "בתיקייה זו עדיין יש {count} קובצי מודלים. רענן את סרגל הצד ונסה שוב.",
"busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.",
"unsupported": "מחיקת תיקיות אינה נתמכת בדף זה",
"noRoot": "לא הוגדר שורש מודלים"
@@ -1637,6 +1671,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": "הוסף תגיות ל-",
@@ -2713,6 +2769,9 @@
"filename_conflicts": {
"title": "התנגשויות שמות קבצים כפולים"
},
"sidecar_mirror_orphans": {
"title": "[TODO: Translate] Centralized Sidecars"
},
"ui_version": {
"title": "גרסת הממשק"
}
+59
View File
@@ -383,6 +383,7 @@
"exampleImages": "例画像",
"autoOrganize": "自動整理",
"metadata": "メタデータ",
"sidecarStorage": "サイドカーファイルの保存",
"proxySettings": "プロキシ設定"
},
"nav": {
@@ -789,6 +790,34 @@
"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 +1379,10 @@
"emptyNote": "このフォルダにはモデルがありません。他のファイルもすべて削除されます。",
"notEmptyTitle": "フォルダが空ではありません",
"notEmptyMessage": "このフォルダにはまだモデルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
"notEmptyMessageCount": "このフォルダにはまだ {count} 個のモデルファイルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
"notEmptyMessageExcluded": "このフォルダにはまだ {count} 個のモデルファイルがあり、そのうち {excluded} 個はライブラリから除外されています。「除外モデルを管理」で除外を解除してから削除してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
"busyTitle": "保留中の削除があります",
"checking": "フォルダの内容を確認しています...",
"confirm": "フォルダを削除"
},
"deleteFolderResult": {
@@ -1358,6 +1391,7 @@
"restored": "フォルダを復元しました",
"failed": "フォルダの削除に失敗しました: {message}",
"notEmpty": "このフォルダにはまだモデルがあります。サイドバーを再読み込みしてからもう一度お試しください。",
"notEmptyWithCount": "このフォルダにはまだ {count} 個のモデルファイルがあります。サイドバーを再読み込みしてからもう一度お試しください。",
"busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。",
"unsupported": "このページではフォルダを削除できません",
"noRoot": "モデルルートが設定されていません"
@@ -1637,6 +1671,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": "タグを追加するモデル:",
@@ -2713,6 +2769,9 @@
"filename_conflicts": {
"title": "ファイル名重複競合"
},
"sidecar_mirror_orphans": {
"title": "[TODO: Translate] Centralized Sidecars"
},
"ui_version": {
"title": "UI バージョン"
}
+59
View File
@@ -383,6 +383,7 @@
"exampleImages": "예시 이미지",
"autoOrganize": "자동 정리",
"metadata": "메타데이터",
"sidecarStorage": "사이드카 파일 저장",
"proxySettings": "프록시 설정"
},
"nav": {
@@ -789,6 +790,34 @@
"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 +1379,10 @@
"emptyNote": "이 폴더에는 모델이 없습니다. 폴더 안의 다른 파일도 함께 삭제됩니다.",
"notEmptyTitle": "폴더가 비어 있지 않습니다",
"notEmptyMessage": "이 폴더에는 아직 모델이 있습니다. 먼저 해당 모델을 삭제하거나 이동하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
"notEmptyMessageCount": "이 폴더에는 아직 모델 파일이 {count}개 있습니다. 먼저 해당 모델을 삭제하거나 이동하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
"notEmptyMessageExcluded": "이 폴더에는 아직 모델 파일이 {count}개 있으며, 그중 {excluded}개는 라이브러리에서 제외되어 있습니다. '제외된 모델 관리'에서 제외를 해제한 뒤 먼저 삭제하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
"busyTitle": "대기 중인 삭제 작업이 있습니다",
"checking": "폴더 내용을 확인하는 중...",
"confirm": "폴더 삭제"
},
"deleteFolderResult": {
@@ -1358,6 +1391,7 @@
"restored": "폴더를 복원했습니다",
"failed": "폴더 삭제 실패: {message}",
"notEmpty": "이 폴더에는 아직 모델이 있습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
"notEmptyWithCount": "이 폴더에는 아직 모델 파일이 {count}개 있습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
"busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.",
"unsupported": "이 페이지에서는 폴더를 삭제할 수 없습니다",
"noRoot": "모델 루트가 설정되지 않았습니다"
@@ -1637,6 +1671,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": "다음에 태그를 추가합니다:",
@@ -2713,6 +2769,9 @@
"filename_conflicts": {
"title": "파일명 중복 충돌"
},
"sidecar_mirror_orphans": {
"title": "[TODO: Translate] Centralized Sidecars"
},
"ui_version": {
"title": "UI 버전"
}
+59
View File
@@ -383,6 +383,7 @@
"exampleImages": "Примеры изображений",
"autoOrganize": "Автоорганизация",
"metadata": "Метаданные",
"sidecarStorage": "Хранилище sidecar-файлов",
"proxySettings": "Настройки прокси"
},
"nav": {
@@ -789,6 +790,34 @@
"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 +1379,10 @@
"emptyNote": "В этой папке нет моделей. Остальные файлы в ней тоже будут удалены.",
"notEmptyTitle": "Папка не пуста",
"notEmptyMessage": "В этой папке ещё есть модели. Сначала удалите или переместите их — удаление папки никогда не затрагивает файлы моделей.",
"notEmptyMessageCount": "В этой папке ещё есть {count} файл(ов) модели. Сначала удалите или переместите их — удаление папки никогда не затрагивает файлы моделей.",
"notEmptyMessageExcluded": "В этой папке ещё есть {count} файл(ов) модели, из них {excluded} исключены из библиотеки. Снимите исключение в разделе «Управление исключёнными моделями» и удалите их — удаление папки никогда не затрагивает файлы моделей.",
"busyTitle": "Удаление всё ещё отложено",
"checking": "Проверка содержимого папки...",
"confirm": "Удалить папку"
},
"deleteFolderResult": {
@@ -1358,6 +1391,7 @@
"restored": "Папка восстановлена",
"failed": "Не удалось удалить папку: {message}",
"notEmpty": "В этой папке ещё есть модели. Обновите боковую панель и повторите попытку.",
"notEmptyWithCount": "В этой папке ещё есть {count} файл(ов) модели. Обновите боковую панель и повторите попытку.",
"busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.",
"unsupported": "Удаление папок не поддерживается на этой странице",
"noRoot": "Корневая папка моделей не настроена"
@@ -1637,6 +1671,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": "Добавить теги к",
@@ -2713,6 +2769,9 @@
"filename_conflicts": {
"title": "Конфликты дублирующихся имён файлов"
},
"sidecar_mirror_orphans": {
"title": "[TODO: Translate] Centralized Sidecars"
},
"ui_version": {
"title": "Версия интерфейса"
}
+59
View File
@@ -383,6 +383,7 @@
"exampleImages": "示例图片",
"autoOrganize": "自动整理",
"metadata": "元数据",
"sidecarStorage": "附属文件存储",
"proxySettings": "代理设置"
},
"nav": {
@@ -789,6 +790,34 @@
"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 +1379,10 @@
"emptyNote": "该文件夹中没有模型,其中的其他文件也会一并删除。",
"notEmptyTitle": "文件夹不为空",
"notEmptyMessage": "该文件夹中仍有模型,请先删除或移出这些模型 —— 删除文件夹不会级联删除模型文件。",
"notEmptyMessageCount": "该文件夹中仍有 {count} 个模型文件,请先删除或移出这些模型 —— 删除文件夹不会级联删除模型文件。",
"notEmptyMessageExcluded": "该文件夹中仍有 {count} 个模型文件,其中 {excluded} 个已从模型库中排除。请先在“管理已排除的模型”中取消排除并删除它们 —— 删除文件夹不会级联删除模型文件。",
"busyTitle": "仍有删除操作待处理",
"checking": "正在检查文件夹内容...",
"confirm": "删除文件夹"
},
"deleteFolderResult": {
@@ -1358,6 +1391,7 @@
"restored": "文件夹已恢复",
"failed": "删除文件夹失败: {message}",
"notEmpty": "该文件夹中仍有模型。请刷新侧边栏后重试。",
"notEmptyWithCount": "该文件夹中仍有 {count} 个模型文件。请刷新侧边栏后重试。",
"busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。",
"unsupported": "此页面不支持删除文件夹",
"noRoot": "未配置模型根目录"
@@ -1637,6 +1671,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": "为多个模型添加标签",
@@ -2713,6 +2769,9 @@
"filename_conflicts": {
"title": "文件名重复冲突"
},
"sidecar_mirror_orphans": {
"title": "[TODO: Translate] Centralized Sidecars"
},
"ui_version": {
"title": "UI 版本"
}
+59
View File
@@ -383,6 +383,7 @@
"exampleImages": "範例圖片",
"autoOrganize": "自動整理",
"metadata": "中繼資料",
"sidecarStorage": "附屬檔案儲存",
"proxySettings": "代理設定"
},
"nav": {
@@ -789,6 +790,34 @@
"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 +1379,10 @@
"emptyNote": "該資料夾中沒有模型,其中的其他檔案也會一併刪除。",
"notEmptyTitle": "資料夾不是空的",
"notEmptyMessage": "該資料夾中仍有模型,請先刪除或移出這些模型 —— 刪除資料夾不會串聯刪除模型檔案。",
"notEmptyMessageCount": "該資料夾中仍有 {count} 個模型檔案,請先刪除或移出這些模型 —— 刪除資料夾不會串聯刪除模型檔案。",
"notEmptyMessageExcluded": "該資料夾中仍有 {count} 個模型檔案,其中 {excluded} 個已從模型庫中排除。請先在「管理已排除的模型」中取消排除並刪除它們 —— 刪除資料夾不會串聯刪除模型檔案。",
"busyTitle": "仍有刪除操作待處理",
"checking": "正在檢查資料夾內容...",
"confirm": "刪除資料夾"
},
"deleteFolderResult": {
@@ -1358,6 +1391,7 @@
"restored": "資料夾已還原",
"failed": "刪除資料夾失敗: {message}",
"notEmpty": "該資料夾中仍有模型。請重新整理側邊欄後再試。",
"notEmptyWithCount": "該資料夾中仍有 {count} 個模型檔案。請重新整理側邊欄後再試。",
"busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。",
"unsupported": "此頁面不支援刪除資料夾",
"noRoot": "未設定模型根目錄"
@@ -1637,6 +1671,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": "新增標籤到",
@@ -2713,6 +2769,9 @@
"filename_conflicts": {
"title": "檔案名稱重複衝突"
},
"sidecar_mirror_orphans": {
"title": "[TODO: Translate] Centralized Sidecars"
},
"ui_version": {
"title": "UI 版本"
}
+20
View File
@@ -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.
+5 -1
View File
@@ -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()
+9 -2
View File
@@ -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",
+200 -6
View File
@@ -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
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(
@@ -3345,6 +3446,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 +3614,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 +4254,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 +4338,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 +4360,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 +4406,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 +4428,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,
+2 -1
View File
@@ -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 = (
@@ -676,7 +677,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
)
+15 -3
View File
@@ -39,7 +39,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 +83,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 +223,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
+131 -22
View File
@@ -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,20 +2425,12 @@ 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
raw_orig, original_workflow = await self._fetch_original_media(
original_image_url
)
if success:
raw_orig = await asyncio.to_thread(
ExifUtils.extract_image_metadata, orig_tmp_path
diagnostics["exif_present"] = bool(raw_orig) or bool(
diagnostics.get("exif_present")
)
diagnostics["exif_present"] = bool(raw_orig)
if raw_orig:
parser = (
self._analysis_service._recipe_parser_factory.create_parser(
@@ -2334,16 +2449,8 @@ class RecipeManagementHandler:
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)
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).
+10
View File
@@ -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"
),
+3
View File
@@ -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,
)
+3 -1
View File
@@ -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)
+5
View File
@@ -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
+7 -2
View File
@@ -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():
+26 -10
View File
@@ -38,6 +38,7 @@ 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
@@ -831,7 +832,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}")
@@ -2447,7 +2448,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 +2465,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 +2508,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 +2538,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 +2568,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 +2800,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)
@@ -3049,7 +3059,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 +3071,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
+6 -5
View File
@@ -12,6 +12,7 @@ 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
@@ -216,7 +217,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")
@@ -485,7 +486,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 +506,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 +555,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 +566,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()
+79 -6
View File
@@ -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:
+33 -21
View File
@@ -11,6 +11,7 @@ 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 +42,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 +65,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)
@@ -260,7 +267,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 +322,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
@@ -384,21 +391,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
@@ -413,9 +425,9 @@ class ModelLifecycleService:
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, "/"
)
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)
@@ -432,9 +444,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)
+157 -12
View File
@@ -11,6 +11,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
@@ -1613,6 +1620,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 +1696,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 +1742,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 +1753,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 +1763,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 +2021,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:
@@ -2327,30 +2459,42 @@ class ModelScanner:
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 = []
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(source_dir):
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(source_dir, file)
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(target_path, new_associated_filename)
target_associated_path = os.path.join(assoc_target_dir, new_associated_filename)
# Store metadata file path for special handling
if file == f"{base_name}.metadata.json":
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 {source_dir}: {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}")
@@ -2358,6 +2502,7 @@ class ModelScanner:
# 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:
@@ -2399,7 +2544,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'])
@@ -2759,7 +2904,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 +2970,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:
+7 -2
View File
@@ -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():
+25 -9
View File
@@ -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",
+9 -2
View File
@@ -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
+38
View File
@@ -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",
+49 -7
View File
@@ -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(
{
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 ---------------------------------------------------
+30 -1
View File
@@ -99,6 +99,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": {},
@@ -1616,9 +1618,30 @@ 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"
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 +1838,10 @@ 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 == "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 +1872,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 -4
View File
@@ -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", [])
+6
View File
@@ -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",
]
@@ -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
@@ -200,7 +201,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
+8
View File
@@ -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
+115 -4
View File
@@ -341,7 +341,22 @@ class ExifUtils:
metadata_fields = ExifUtils._load_structured_metadata(image_path)
metadata_fields["parameters"] = metadata
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
@@ -360,8 +375,89 @@ class ExifUtils:
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 updating metadata in {image_path}: {e}")
logger.error(f"Error embedding workflow in {image_path}: {e}")
return image_path
@staticmethod
@@ -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:
+15 -19
View File
@@ -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
View File
@@ -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);
+1 -1
View File
@@ -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 {
@@ -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;
@@ -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;
}
+1
View File
@@ -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;
+11 -11
View File
@@ -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%;
+201 -33
View File
@@ -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(
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') {
// 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, '&quot;').replace(/'/g, '&#39;');
}
/**
* 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">&times;</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;
}
+314
View File
@@ -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']);
@@ -1192,6 +1193,9 @@ export class SettingsManager {
this.updateExampleImagesOpenSettingsVisibility();
// Load sidecar storage settings
this.loadSidecarStorageSettings();
// Load download path templates
this.loadDownloadPathTemplates();
@@ -1280,6 +1284,9 @@ export class SettingsManager {
this.attachPathField('exampleImagesLocalRoot', {
onAfterSelect: () => this.saveInputSetting('exampleImagesLocalRoot', 'example_images_local_root'),
});
this.attachPathField('sidecarStoragePath', {
onAfterSelect: () => this.handleSidecarStoragePathChange(),
});
}
loadDownloadBackendSettings() {
@@ -3379,6 +3386,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
@@ -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;
+2
View File
@@ -62,6 +62,8 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
download_skip_base_models: [],
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,
@@ -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). -->
@@ -321,4 +321,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>
@@ -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 });
});
});
@@ -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,101 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const { showToastMock, translateMock } = vi.hoisted(() => ({
showToastMock: vi.fn(),
translateMock: vi.fn((key, params, fallback) =>
typeof fallback === 'string' ? fallback : key
),
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: translateMock,
}));
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
getModelApiClient: vi.fn(() => ({})),
}));
vi.mock('../../../static/js/api/apiConfig.js', () => ({
MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings' },
}));
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
getStorageItem: vi.fn(() => null),
}));
vi.mock('../../../static/js/state/index.js', () => ({
state: { virtualScroller: null },
}));
import { DownloadManager } from '../../../static/js/managers/import/DownloadManager.js';
function buildImportManager(recipeData) {
return {
recipeId: null,
recipeName: 'Test Recipe',
recipeImage: null,
recipeTags: [],
downloadableLoRAs: [],
importMode: 'url',
recipeData,
loadingManager: { showSimpleLoading: vi.fn(), hide: vi.fn() },
};
}
async function saveAndReadMetadata(recipeData) {
let capturedBody = null;
const fetchMock = vi.fn(async (url, options) => {
capturedBody = options?.body ?? null;
return { ok: true, json: async () => ({ success: true }) };
});
globalThis.fetch = fetchMock;
window.fetch = fetchMock;
const manager = new DownloadManager(buildImportManager(recipeData));
await manager.saveRecipe(true);
expect(fetchMock).toHaveBeenCalledWith('/api/lm/recipes/save', expect.anything());
expect(capturedBody).toBeInstanceOf(FormData);
return JSON.parse(capturedBody.get('metadata'));
}
describe('recipe import save payload', () => {
beforeEach(() => {
globalThis.modalManager = { closeModal: vi.fn() };
globalThis.window.recipeManager = { loadRecipes: vi.fn() };
});
afterEach(() => {
delete globalThis.modalManager;
delete globalThis.window.recipeManager;
vi.clearAllMocks();
});
it('forwards a workflow recovered from the original rendition', async () => {
const workflow = '{"nodes": [{"id": 1}]}';
const metadata = await saveAndReadMetadata({
image_base64: 'AAAA',
base_model: 'sd',
loras: [],
gen_params: {},
workflow,
});
expect(metadata.workflow).toBe(workflow);
});
it('omits the workflow key when analysis recovered none', async () => {
const metadata = await saveAndReadMetadata({
image_base64: 'AAAA',
base_model: 'sd',
loras: [],
gen_params: {},
});
expect('workflow' in metadata).toBe(false);
});
});
@@ -0,0 +1,527 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
modalManager: {
closeModal: vi.fn(),
},
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: vi.fn(),
}));
vi.mock('../../../static/js/state/index.js', () => {
const settings = {};
return {
state: {
global: {
settings,
},
loadingManager: {
showSimpleLoading: vi.fn(),
hide: vi.fn(),
},
},
createDefaultSettings: () => ({
language: 'en',
sidecar_storage_mode: 'alongside',
sidecar_storage_path: '',
}),
};
});
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
resetAndReload: vi.fn(),
getModelApiClient: vi.fn(),
}));
vi.mock('../../../static/js/utils/constants.js', () => ({
DOWNLOAD_PATH_TEMPLATES: {},
DEFAULT_PATH_TEMPLATES: {},
MAPPABLE_BASE_MODELS: [],
PATH_TEMPLATE_PLACEHOLDERS: {},
FILENAME_TEMPLATE_PLACEHOLDERS: [],
DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' },
DEFAULT_PRIORITY_TAG_CONFIG: {
lora: 'character, style',
checkpoint: 'base, guide',
embedding: 'hint',
},
getMappableBaseModelsDynamic: () => [],
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: (_key, _params, fallback) => fallback ?? '',
}));
vi.mock('../../../static/js/i18n/index.js', () => ({
i18n: {
getCurrentLocale: () => 'en',
setLanguage: vi.fn().mockResolvedValue(),
},
}));
vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
configureModelCardVideo: vi.fn(),
}));
import { SettingsManager } from '../../../static/js/managers/SettingsManager.js';
import { showToast } from '../../../static/js/utils/uiHelpers.js';
import { resetAndReload } from '../../../static/js/api/modelApiFactory.js';
import { state } from '../../../static/js/state/index.js';
const createManager = () => {
state.global.settings = {};
const initSettingsSpy = vi
.spyOn(SettingsManager.prototype, 'initializeSettings')
.mockResolvedValue();
const initializeSpy = vi
.spyOn(SettingsManager.prototype, 'initialize')
.mockImplementation(() => {});
const manager = new SettingsManager();
initSettingsSpy.mockRestore();
initializeSpy.mockRestore();
return manager;
};
const appendSidecarControls = () => {
const select = document.createElement('select');
select.id = 'sidecarStorageMode';
['alongside', 'centralized'].forEach((value) => {
const option = document.createElement('option');
option.value = value;
select.appendChild(option);
});
const pathSetting = document.createElement('div');
pathSetting.id = 'sidecarStoragePathSetting';
pathSetting.style.display = 'none';
const pathInput = document.createElement('input');
pathInput.id = 'sidecarStoragePath';
const migrateBtn = document.createElement('button');
migrateBtn.id = 'migrateSidecarsBtn';
document.body.append(select, pathSetting, pathInput, migrateBtn);
return { select, pathSetting, pathInput, migrateBtn };
};
const appendMigrationModal = () => {
const modal = document.createElement('div');
modal.id = 'sidecarMigrationConfirmModal';
modal.innerHTML = `
<h2 data-role="title"></h2>
<p data-role="message"></p>
<p data-role="destination" style="display:none"></p>
<button data-action="confirm-sidecar-migration"></button>
<button data-action="cancel-sidecar-migration"></button>`;
document.body.appendChild(modal);
return modal;
};
const mockFetchOk = (payload = { success: true }) => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(payload),
});
};
beforeEach(() => {
document.body.innerHTML = '';
vi.clearAllMocks();
});
afterEach(() => {
delete global.fetch;
});
describe('SettingsManager sidecar storage', () => {
describe('loadSidecarStorageSettings', () => {
it('loads alongside mode and hides the centralized path input', () => {
const manager = createManager();
const { select, pathSetting, pathInput } = appendSidecarControls();
state.global.settings = { sidecar_storage_mode: 'alongside', sidecar_storage_path: '/data/sidecars' };
manager.loadSidecarStorageSettings();
expect(select.value).toBe('alongside');
expect(pathInput.value).toBe('/data/sidecars');
expect(pathSetting.style.display).toBe('none');
expect(manager._loadedSidecarStorageMode).toBe('alongside');
});
it('loads centralized mode and shows the path input', () => {
const manager = createManager();
const { select, pathSetting } = appendSidecarControls();
state.global.settings = { sidecar_storage_mode: 'centralized' };
manager.loadSidecarStorageSettings();
expect(select.value).toBe('centralized');
expect(pathSetting.style.display).toBe('block');
expect(manager._loadedSidecarStorageMode).toBe('centralized');
});
it('falls back to alongside for unknown stored modes', () => {
const manager = createManager();
const { select } = appendSidecarControls();
state.global.settings = { sidecar_storage_mode: 'bogus' };
manager.loadSidecarStorageSettings();
expect(select.value).toBe('alongside');
});
});
describe('handleSidecarStorageModeChange', () => {
it('does not prompt for migration when the mode is unchanged', async () => {
const manager = createManager();
const { select } = appendSidecarControls();
appendMigrationModal();
state.global.settings = { sidecar_storage_mode: 'alongside' };
manager._loadedSidecarStorageMode = 'alongside';
select.value = 'alongside';
mockFetchOk();
await manager.handleSidecarStorageModeChange();
expect(global.fetch).not.toHaveBeenCalledWith(
'/api/lm/sidecars/migrate',
expect.anything()
);
expect(showToast).toHaveBeenCalledWith(
'toast.settings.settingsUpdated',
expect.anything(),
'success'
);
});
it('migrates to centralized after the user confirms the prompt', async () => {
const manager = createManager();
const { select, pathSetting } = appendSidecarControls();
const modal = appendMigrationModal();
state.global.settings = { sidecar_storage_mode: 'alongside' };
manager._loadedSidecarStorageMode = 'alongside';
select.value = 'centralized';
mockFetchOk();
const changePromise = manager.handleSidecarStorageModeChange();
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
modal.querySelector('[data-action="confirm-sidecar-migration"]').click();
await changePromise;
expect(state.global.settings.sidecar_storage_mode).toBe('centralized');
expect(pathSetting.style.display).toBe('block');
expect(global.fetch).toHaveBeenCalledWith('/api/lm/sidecars/migrate', expect.objectContaining({
method: 'POST',
body: JSON.stringify({ direction: 'to_centralized', force: true }),
}));
// The summary modal stacks above the settings modal; the reload
// only happens once the user dismisses it.
const summaryModal = document.getElementById('sidecarMigrationSummaryModal');
expect(summaryModal).not.toBeNull();
expect(resetAndReload).not.toHaveBeenCalled();
expect(modal.classList.contains('show')).toBe(false);
summaryModal.querySelector('[data-action="close-modal"]').click();
expect(resetAndReload).toHaveBeenCalledWith(true);
});
it('names the resolved destination in the confirm dialog', async () => {
const manager = createManager();
const { select } = appendSidecarControls();
const modal = appendMigrationModal();
state.global.settings = {
sidecar_storage_mode: 'alongside',
sidecar_storage_root: '/data/sidecars',
};
manager._loadedSidecarStorageMode = 'alongside';
select.value = 'centralized';
mockFetchOk();
const changePromise = manager.handleSidecarStorageModeChange();
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
const destination = modal.querySelector('[data-role="destination"]');
expect(destination.textContent).toContain('/data/sidecars');
expect(destination.style.display).toBe('block');
modal.querySelector('[data-action="cancel-sidecar-migration"]').click();
await changePromise;
});
it('shows a deferred notice and skips migration when the user cancels', async () => { const manager = createManager();
const { select } = appendSidecarControls();
const modal = appendMigrationModal();
state.global.settings = { sidecar_storage_mode: 'centralized' };
manager._loadedSidecarStorageMode = 'centralized';
select.value = 'alongside';
mockFetchOk();
const changePromise = manager.handleSidecarStorageModeChange();
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
modal.querySelector('[data-action="cancel-sidecar-migration"]').click();
await changePromise;
const migrateCalls = global.fetch.mock.calls.filter(([url]) => url === '/api/lm/sidecars/migrate');
expect(migrateCalls).toHaveLength(0);
expect(showToast).toHaveBeenCalledWith('settings.sidecarStorage.migrationDeferred', {}, 'info');
});
});
describe('confirmAndMigrateSidecars', () => {
it('derives the migration direction from the saved mode', async () => {
const manager = createManager();
appendSidecarControls();
const modal = appendMigrationModal();
state.global.settings = { sidecar_storage_mode: 'centralized' };
mockFetchOk();
const confirmPromise = manager.confirmAndMigrateSidecars();
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
modal.querySelector('[data-action="confirm-sidecar-migration"]').click();
await confirmPromise;
expect(global.fetch).toHaveBeenCalledWith('/api/lm/sidecars/migrate', expect.objectContaining({
body: JSON.stringify({ direction: 'to_centralized', force: true }),
}));
});
});
describe('migrateSidecars', () => {
it('surfaces backend failures as an error toast', async () => {
const manager = createManager();
const { migrateBtn } = appendSidecarControls();
mockFetchOk({ success: false, error: 'disk full' });
await manager.migrateSidecars('to_alongside');
expect(showToast).toHaveBeenCalledWith(
'settings.sidecarStorage.migrateFailed',
{ message: 'disk full' },
'error'
);
expect(resetAndReload).not.toHaveBeenCalled();
expect(migrateBtn.disabled).toBe(false);
});
});
describe('handleSidecarStoragePathChange', () => { it('offers root relocation when the path changes in centralized mode', async () => {
const manager = createManager();
const { pathInput } = appendSidecarControls();
const modal = appendMigrationModal();
state.global.settings = { sidecar_storage_mode: 'centralized', sidecar_storage_path: '/old/root' };
manager._loadedSidecarStoragePath = '/old/root';
pathInput.value = '/new/root';
mockFetchOk();
const changePromise = manager.handleSidecarStoragePathChange();
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
modal.querySelector('[data-action="confirm-sidecar-migration"]').click();
await changePromise;
expect(global.fetch).toHaveBeenCalledWith('/api/lm/sidecars/migrate', expect.objectContaining({
body: JSON.stringify({ direction: 'relocate_root', force: true, old_root: '/old/root' }),
}));
expect(manager._loadedSidecarStoragePath).toBe('/new/root');
});
it('does not prompt when the path changes in alongside mode', async () => {
const manager = createManager();
const { pathInput } = appendSidecarControls();
appendMigrationModal();
state.global.settings = { sidecar_storage_mode: 'alongside', sidecar_storage_path: '/old/root' };
manager._loadedSidecarStoragePath = '/old/root';
pathInput.value = '/new/root';
mockFetchOk();
await manager.handleSidecarStoragePathChange();
const migrateCalls = global.fetch.mock.calls.filter(([url]) => url === '/api/lm/sidecars/migrate');
expect(migrateCalls).toHaveLength(0);
});
it('shows a deferred notice when relocation is cancelled', async () => {
const manager = createManager();
const { pathInput } = appendSidecarControls();
const modal = appendMigrationModal();
state.global.settings = { sidecar_storage_mode: 'centralized', sidecar_storage_path: '/old/root' };
manager._loadedSidecarStoragePath = '/old/root';
pathInput.value = '/new/root';
mockFetchOk();
const changePromise = manager.handleSidecarStoragePathChange();
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
modal.querySelector('[data-action="cancel-sidecar-migration"]').click();
await changePromise;
const migrateCalls = global.fetch.mock.calls.filter(([url]) => url === '/api/lm/sidecars/migrate');
expect(migrateCalls).toHaveLength(0);
expect(showToast).toHaveBeenCalledWith('settings.sidecarStorage.migrationDeferred', {}, 'info');
});
});
describe('renderSidecarStorageInfo', () => {
const appendStorageInfoElements = () => {
const resolved = document.createElement('code');
resolved.id = 'sidecarStorageResolvedPath';
const warning = document.createElement('div');
warning.id = 'sidecarStorageRepoWarning';
warning.style.display = 'none';
document.body.append(resolved, warning);
return { resolved, warning };
};
it('shows the resolved root and the repo warning when inside the install folder', () => {
const manager = createManager();
const { resolved, warning } = appendStorageInfoElements();
state.global.settings = {
sidecar_storage_root: '/repo/ComfyUI-Lora-Manager/sidecars',
sidecar_storage_root_in_repo: true,
};
manager.renderSidecarStorageInfo();
expect(resolved.textContent).toBe('/repo/ComfyUI-Lora-Manager/sidecars');
expect(warning.style.display).toBe('block');
});
it('hides the repo warning when the root lives outside the install folder', () => {
const manager = createManager();
const { resolved, warning } = appendStorageInfoElements();
state.global.settings = {
sidecar_storage_root: '/data/sidecars',
sidecar_storage_root_in_repo: false,
};
manager.renderSidecarStorageInfo();
expect(resolved.textContent).toBe('/data/sidecars');
expect(warning.style.display).toBe('none');
});
});
describe('openSidecarStorageLocation', () => {
it('posts to the open-location endpoint', async () => {
const manager = createManager();
mockFetchOk({ success: true });
await manager.openSidecarStorageLocation();
expect(global.fetch).toHaveBeenCalledWith('/api/lm/sidecars/open-location', { method: 'POST' });
expect(showToast).toHaveBeenCalledWith('settings.sidecarStorage.openLocationSuccess', {}, 'success');
});
it('copies the path to the clipboard in clipboard mode', async () => {
const manager = createManager();
mockFetchOk({ success: true, mode: 'clipboard', path: '/data/sidecars' });
const writeText = vi.fn().mockResolvedValue();
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
});
await manager.openSidecarStorageLocation();
expect(writeText).toHaveBeenCalledWith('/data/sidecars');
expect(showToast).toHaveBeenCalledWith(
'settings.sidecarStorage.openLocationCopied',
{ path: '/data/sidecars' },
'success'
);
});
});
describe('showSidecarMigrationResult', () => {
const baseResult = {
success: true,
direction: 'to_centralized',
moved: 12,
models_moved: 5,
models_total: 6,
skipped: 1,
conflicts: 2,
errors: [],
error_count: 0,
sidecar_root: '/data/sidecars',
};
it('renders stat cards and location, reloads only when closed', async () => {
const manager = createManager();
mockFetchOk({ success: true });
manager.showSidecarMigrationResult(baseResult);
const modal = document.getElementById('sidecarMigrationSummaryModal');
expect(modal).not.toBeNull();
const statValues = [...modal.querySelectorAll('.stat-card-value')].map((el) => el.textContent);
expect(statValues).toEqual(['12', '5', '1', '2']);
expect(modal.querySelector('.sidecar-migration-location').textContent).toContain('/data/sidecars');
expect(modal.querySelector('[data-action="open-sidecar-location"]')).not.toBeNull();
expect(modal.querySelector('.refresh-success-message')).not.toBeNull();
expect(resetAndReload).not.toHaveBeenCalled();
// "Open Folder" keeps the summary modal open.
modal.querySelector('[data-action="open-sidecar-location"]').click();
await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledWith(
'/api/lm/sidecars/open-location',
{ method: 'POST' }
));
expect(document.getElementById('sidecarMigrationSummaryModal')).not.toBeNull();
modal.querySelector('[data-action="close-modal"]').click();
expect(document.getElementById('sidecarMigrationSummaryModal')).toBeNull();
expect(resetAndReload).toHaveBeenCalledWith(true);
});
it('hides the location line and open button when migrating back alongside', () => {
const manager = createManager();
manager.showSidecarMigrationResult({ ...baseResult, direction: 'to_alongside' });
const modal = document.getElementById('sidecarMigrationSummaryModal');
expect(modal.querySelector('.sidecar-migration-location')).toBeNull();
expect(modal.querySelector('[data-action="open-sidecar-location"]')).toBeNull();
});
it('renders the failure table when errors occurred', () => {
const manager = createManager();
manager.showSidecarMigrationResult({
...baseResult,
errors: [{ model: 'broken.safetensors', error: 'permission denied' }],
error_count: 1,
});
const modal = document.getElementById('sidecarMigrationSummaryModal');
expect(modal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
expect(modal.querySelector('.refresh-success-message')).toBeNull();
const rows = modal.querySelectorAll('.failure-table tbody tr');
expect(rows).toHaveLength(1);
expect(rows[0].textContent).toContain('broken.safetensors');
expect(rows[0].textContent).toContain('permission denied');
const statValues = [...modal.querySelectorAll('.stat-card-value')].map((el) => el.textContent);
expect(statValues).toContain('1');
});
it('closes on ESC without leaking the keydown to the settings modal', () => {
const manager = createManager();
const underlyingHandler = vi.fn();
document.addEventListener('keydown', underlyingHandler);
manager.showSidecarMigrationResult(baseResult);
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(document.getElementById('sidecarMigrationSummaryModal')).toBeNull();
expect(resetAndReload).toHaveBeenCalledWith(true);
expect(underlyingHandler).not.toHaveBeenCalled();
document.removeEventListener('keydown', underlyingHandler);
});
});
});
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import path from 'path';
// Regression guard: theme palette tokens may only be declared on the root element.
//
// `applyTheme()` (static/js/utils/uiHelpers.js, and Header.setThemeMode) mirrors
// the active mode onto <body> as `data-theme="dark"`, but the theme *preset* is
// only ever written to <html> (`data-theme-preset`). While the token blocks in
// tokens/colors.css used the bare attribute selector `[data-theme="dark"]`, the
// body matched that block on its own and re-declared the DEFAULT dark palette
// (#1a1a1a / #2d2d2d), shadowing the preset palette it inherited from <html>.
// The page painted the selected preset's accent but the default preset's
// backgrounds/surfaces/text, and flipped into that state ~200ms after load when
// initTheme() first touched <body> — the accent-tinted background flash on
// reload and nav-tab switches, visible under every preset except "default".
// Root-scoping the token blocks keeps <body>'s data-theme inert.
describe('Theme token scope', () => {
const repoRoot = path.resolve(__dirname, '../../..');
const read = (rel) => readFileSync(path.join(repoRoot, rel), 'utf-8');
const stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '');
const COLOR_TOKENS = read('static/css/tokens/colors.css');
const BASE_CSS = read('static/css/base.css');
// Selectors that declare a palette token, e.g. `--bg-base:` / `--lora-surface:`.
const tokenDeclaringSelectors = (css) => {
const selectors = [];
const ruleRe = /([^{}]+)\{([^{}]*)\}/g;
let match;
while ((match = ruleRe.exec(stripComments(css)))) {
const selector = match[1].trim();
const declaresPaletteToken = /(^|[;\s])--(?:bg|surface|text|border|color|favorite|lora|badge|card)-[\w-]+\s*:/.test(
match[2]
);
if (declaresPaletteToken) selectors.push(selector);
}
return selectors;
};
const isRootScoped = (selector) =>
selector.split(',').every((part) => /^\s*(:root|html)\b/.test(part));
it('declares every colors.css palette token on :root', () => {
const selectors = tokenDeclaringSelectors(COLOR_TOKENS);
expect(selectors.length).toBeGreaterThan(0);
expect(selectors.filter((selector) => !isRootScoped(selector))).toEqual([]);
});
it('declares every base.css palette alias on :root', () => {
const selectors = tokenDeclaringSelectors(BASE_CSS);
expect(selectors.length).toBeGreaterThan(0);
expect(selectors.filter((selector) => !isRootScoped(selector))).toEqual([]);
});
it('keeps dark/preset token blocks anchored to the root element', () => {
const css = stripComments(COLOR_TOKENS);
expect(css).toContain(':root[data-theme="dark"] {');
for (const preset of ['nord', 'midnight', 'monokai', 'dracula', 'solarized']) {
expect(css).toContain(`:root[data-theme-preset="${preset}"] {`);
expect(css).toContain(`:root[data-theme="dark"][data-theme-preset="${preset}"] {`);
}
// No bare attribute selector may open a rule: <body> carries data-theme
// without the preset, so such a block would re-declare the default palette.
expect(css).not.toMatch(/(^|\n)\s*\[data-theme/);
expect(stripComments(BASE_CSS)).not.toMatch(/(^|\n)\s*\[data-theme/);
});
});
@@ -0,0 +1,92 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const { UTILS_MODULE, APP_MODULE, API_MODULE, BASE_PATH_MODULE } = vi.hoisted(() => ({
UTILS_MODULE: new URL('../../../web/comfyui/loras_widget_utils.js', import.meta.url).pathname,
APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname,
API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname,
BASE_PATH_MODULE: new URL('../../../web/comfyui/base_path.js', import.meta.url).pathname,
}));
const toastAdd = vi.fn();
const graphToPrompt = vi.fn();
vi.mock(APP_MODULE, () => ({
app: {
graphToPrompt,
extensionManager: { toast: { add: toastAdd } },
},
}));
vi.mock(API_MODULE, () => ({
api: { fetchApi: vi.fn() },
}));
vi.mock(BASE_PATH_MODULE, () => ({
lmUrl: (path) => `/lm${path}`,
}));
async function runSave(options, responseBody) {
let captured = null;
globalThis.fetch = vi.fn(async (url, init) => {
captured = { url, init };
return { json: async () => responseBody };
});
const { saveRecipeDirectly } = await import(UTILS_MODULE);
await saveRecipeDirectly(options);
return captured;
}
describe('saveRecipeDirectly', () => {
beforeEach(() => {
graphToPrompt.mockResolvedValue({
workflow: { nodes: [{ id: 1 }], last_node_id: 1 },
output: { 1: { class_type: 'KSampler' } },
});
});
afterEach(() => {
delete globalThis.fetch;
vi.clearAllMocks();
});
it('posts no workflow by default', async () => {
const captured = await runSave(undefined, { success: true, has_workflow: false });
expect(captured.init.body).toBe('{}');
expect(JSON.parse(captured.init.body)).not.toHaveProperty('workflow');
});
it('embeds the UI-format graph when asked', async () => {
const captured = await runSave(
{ embedWorkflow: true },
{ success: true, has_workflow: true }
);
const body = JSON.parse(captured.init.body);
expect(body.workflow).toEqual({ nodes: [{ id: 1 }], last_node_id: 1 });
expect(captured.init.headers['Content-Type']).toBe('application/json');
});
it('reports a skipped oversized workflow as a warning', async () => {
await runSave(
{ embedWorkflow: true },
{ success: true, has_workflow: false, workflow_skipped: 'too_large' }
);
const lastToast = toastAdd.mock.calls.at(-1)[0];
expect(lastToast.severity).toBe('warn');
expect(lastToast.summary).toBe('Recipe Saved without Workflow');
expect(lastToast.detail).toContain('too large');
});
it('reports a successful embed distinctly from a plain save', async () => {
await runSave(
{ embedWorkflow: true },
{ success: true, has_workflow: true }
);
const lastToast = toastAdd.mock.calls.at(-1)[0];
expect(lastToast.summary).toBe('Recipe Saved with Workflow');
});
});
+16
View File
@@ -61,6 +61,22 @@ def test_full_node_contract_with_real_png_metadata(runtime):
assert LoadImageMetadataLM.INPUT_TYPES()["required"]["image"][1]["image_upload"]
def test_value_selector_outputs_stay_untyped():
"""These sockets must not be typed COMBO/STRING.
Load Checkpoint, KSampler and the LoRA Manager loaders declare their name
dropdowns as plain option lists ("classic" combos). ComfyUI's
comfy_execution.validation rejects any non-string input type for those, so a
COMBO (or STRING) output linked into them fails at queue time with
"Return type mismatch between linked nodes". Only an untyped ("*") output
works, which is also what ComfyUI's own Primitive node uses for widgets.
"""
types = LoadImageMetadataLM.RETURN_TYPES
names = LoadImageMetadataLM.RETURN_NAMES
for output in ("model_name", "sampler_name", "scheduler"):
assert types[names.index(output)] == "*", output
@pytest.mark.parametrize("extension", ["webp", "jpg"])
def test_exif_parameters_from_real_image(runtime, extension):
image_path, *_ = runtime
@@ -31,6 +31,9 @@
'language': 'en',
'llm_api_key_set': False,
'other_models_paths_available': False,
'sidecar_storage_root': '/sidecars',
'sidecar_storage_root_in_repo': False,
'sidecar_storage_root_is_default': True,
'standalone_mode': False,
'theme': 'dark',
}),
+13 -1
View File
@@ -117,8 +117,20 @@ class TestSettingsHandlerSnapshots:
"""Snapshot tests for SettingsHandler responses."""
@pytest.mark.asyncio
async def test_get_settings_response_format(self, snapshot: SnapshotAssertion):
async def test_get_settings_response_format(
self, snapshot: SnapshotAssertion, monkeypatch: pytest.MonkeyPatch
):
"""Verify get_settings response format matches snapshot."""
# Pin the resolved sidecar root: it derives from the machine-specific
# settings directory, which would make the snapshot non-deterministic.
monkeypatch.setattr(
"py.routes.handlers.misc_handlers.describe_sidecar_root",
lambda: {
"root": "/sidecars",
"is_default": True,
"inside_repo": False,
},
)
settings_service = DummySettings({
"civitai_api_key": "test-key",
"language": "en",
+308
View File
@@ -12,6 +12,7 @@ import pytest
from aiohttp import web
from py.services.model_hash_index import ModelHashIndex
from py.routes.handlers import misc_handlers
from py.routes.handlers.misc_handlers import (
BackupHandler,
DoctorHandler,
@@ -23,6 +24,7 @@ from py.routes.handlers.misc_handlers import (
NodeRegistryHandler,
ServiceRegistryAdapter,
SettingsHandler,
SidecarMigrationHandler,
_collect_comfyui_session_logs,
_is_wsl,
_wsl_to_windows_path,
@@ -208,6 +210,108 @@ async def test_doctor_handler_reports_key_cache_and_ui_issues():
assert diagnostic_map["ui_version"]["status"] == "warning"
@pytest.mark.asyncio
async def test_doctor_handler_flags_orphaned_centralized_sidecars(
monkeypatch: pytest.MonkeyPatch,
):
async def civitai_factory():
return DummyCivitaiClient()
handler = DoctorHandler(
settings_service=DummySettings({"civitai_api_key": "token"}),
civitai_client_factory=civitai_factory,
scanner_factories=(),
)
monkeypatch.setattr(misc_handlers, "get_storage_mode", lambda: "centralized")
monkeypatch.setattr(
misc_handlers,
"get_unmatched_sidecar_components",
lambda: [
{
"component": "loras-abc12345",
"basename": "loras",
"last_path": "/mnt/old/loras",
},
{"component": "loras-def67890", "basename": "loras", "last_path": ""},
],
)
response = await handler.get_doctor_diagnostics(
FakeRequest(method="GET") # pyright: ignore[reportArgumentType]
)
payload = _json_payload(response)
item = {entry["id"]: entry for entry in payload["diagnostics"]}[
"sidecar_mirror_orphans"
]
assert item["status"] == "warning"
assert "2 sidecar directories" in item["summary"]
assert any("/mnt/old/loras" in line for line in item["details"])
assert any("unknown" in line for line in item["details"])
@pytest.mark.asyncio
async def test_doctor_handler_sidecar_check_ok_when_all_linked(
monkeypatch: pytest.MonkeyPatch,
):
async def civitai_factory():
return DummyCivitaiClient()
handler = DoctorHandler(
settings_service=DummySettings({"civitai_api_key": "token"}),
civitai_client_factory=civitai_factory,
scanner_factories=(),
)
monkeypatch.setattr(misc_handlers, "get_storage_mode", lambda: "centralized")
monkeypatch.setattr(misc_handlers, "get_unmatched_sidecar_components", lambda: [])
monkeypatch.setattr(
misc_handlers, "describe_sidecar_root", lambda: {"root": "/sidecars"}
)
response = await handler.get_doctor_diagnostics(
FakeRequest(method="GET") # pyright: ignore[reportArgumentType]
)
payload = _json_payload(response)
item = {entry["id"]: entry for entry in payload["diagnostics"]}[
"sidecar_mirror_orphans"
]
assert item["status"] == "ok"
@pytest.mark.asyncio
async def test_doctor_handler_sidecar_check_skipped_alongside(
monkeypatch: pytest.MonkeyPatch,
):
async def civitai_factory():
return DummyCivitaiClient()
handler = DoctorHandler(
settings_service=DummySettings({"civitai_api_key": "token"}),
civitai_client_factory=civitai_factory,
scanner_factories=(),
)
monkeypatch.setattr(misc_handlers, "get_storage_mode", lambda: "alongside")
def _unexpected():
raise AssertionError("orphan lookup must not run in alongside mode")
monkeypatch.setattr(
misc_handlers, "get_unmatched_sidecar_components", _unexpected
)
response = await handler.get_doctor_diagnostics(
FakeRequest(method="GET") # pyright: ignore[reportArgumentType]
)
payload = _json_payload(response)
item = {entry["id"]: entry for entry in payload["diagnostics"]}[
"sidecar_mirror_orphans"
]
assert item["status"] == "ok"
assert "alongside" in item["summary"]
@pytest.mark.asyncio
async def test_doctor_handler_can_repair_cache():
scanner = DummyDoctorScanner(model_type="lora", raw_data=[])
@@ -522,6 +626,7 @@ async def test_open_backup_location_uses_settings_directory(tmp_path, monkeypatc
monkeypatch.setattr(subprocess, "Popen", fake_popen)
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_docker", lambda: False)
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_wsl", lambda: False)
monkeypatch.setenv("DISPLAY", ":0")
response = await handler.open_backup_location(FakeRequest()) # pyright: ignore[reportArgumentType]
payload = _json_payload(response)
@@ -532,6 +637,88 @@ async def test_open_backup_location_uses_settings_directory(tmp_path, monkeypatc
assert calls == [["xdg-open", str(backup_dir)]]
@pytest.mark.asyncio
async def test_open_sidecar_location_opens_configured_root(tmp_path, monkeypatch):
from py.services.settings_manager import get_settings_manager
root = tmp_path / "sidecars"
get_settings_manager().set("sidecar_storage_path", str(root))
handler = FileSystemHandler(settings_service=SimpleNamespace())
calls = []
def fake_popen(args):
calls.append(args)
return MagicMock()
monkeypatch.setattr(subprocess, "Popen", fake_popen)
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_docker", lambda: False)
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_wsl", lambda: False)
monkeypatch.setenv("DISPLAY", ":0")
response = await handler.open_sidecar_location(FakeRequest()) # pyright: ignore[reportArgumentType]
payload = _json_payload(response)
assert response.status == 200
assert payload["success"] is True
assert payload["path"] == str(root)
# Created on demand so the button works before any migration ran.
assert root.is_dir()
assert calls == [["xdg-open", str(root)]]
@pytest.mark.asyncio
async def test_open_sidecar_location_headless_returns_clipboard_mode(tmp_path, monkeypatch):
"""Without a GUI session xdg-open cannot work; the handler must hand the
path to the browser instead of reporting a success that never happened."""
from py.services.settings_manager import get_settings_manager
root = tmp_path / "sidecars"
get_settings_manager().set("sidecar_storage_path", str(root))
handler = FileSystemHandler(settings_service=SimpleNamespace())
monkeypatch.delenv("DISPLAY", raising=False)
monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_docker", lambda: False)
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_wsl", lambda: False)
popen_calls = []
monkeypatch.setattr(subprocess, "Popen", lambda *args, **kwargs: popen_calls.append(args))
response = await handler.open_sidecar_location(FakeRequest()) # pyright: ignore[reportArgumentType]
payload = _json_payload(response)
assert response.status == 200
assert payload["success"] is True
assert payload["mode"] == "clipboard"
assert payload["path"] == str(root)
assert popen_calls == []
@pytest.mark.asyncio
async def test_get_settings_includes_resolved_sidecar_root(tmp_path):
from py.services.settings_manager import get_settings_manager
root = tmp_path / "sidecars-custom"
get_settings_manager().set("sidecar_storage_path", str(root))
handler = SettingsHandler(
settings_service=DummySettings(),
metadata_provider_updater=noop_async,
downloader_factory=dummy_downloader_factory,
)
response = await handler.get_settings(FakeRequest()) # pyright: ignore[reportArgumentType]
payload = _json_payload(response)
assert payload["success"] is True
assert payload["settings"]["sidecar_storage_root"] == str(root)
assert payload["settings"]["sidecar_storage_root_is_default"] is False
assert payload["settings"]["sidecar_storage_root_in_repo"] is False
@pytest.mark.asyncio
async def test_open_settings_location_headless_returns_clipboard_mode(tmp_path, monkeypatch):
"""Without a GUI session xdg-open cannot work; the handler must hand the
@@ -603,6 +790,7 @@ async def test_open_wildcards_location_creates_and_opens_directory(tmp_path, mon
monkeypatch.setattr(subprocess, "Popen", fake_popen)
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_docker", lambda: False)
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_wsl", lambda: False)
monkeypatch.setenv("DISPLAY", ":0")
monkeypatch.setattr(
"py.services.wildcard_service.get_wildcards_dir",
lambda create=False: str(wildcards_dir.mkdir(parents=True, exist_ok=True) or wildcards_dir)
@@ -2557,3 +2745,123 @@ async def test_get_model_versions_status_supported_type_stays_interactive():
"hasBeenDownloaded": False,
}
]
class DummySidecarMigrationUseCase:
def __init__(self, result):
self.result = result
self.calls = []
async def execute_with_error_handling(self, *, direction, progress_cb=None, force=False, old_root=None):
self.calls.append({"direction": direction, "force": force, "old_root": old_root})
return self.result
def _sidecar_migration_handler(result):
use_case = DummySidecarMigrationUseCase(result)
handler = SidecarMigrationHandler(
use_case_factory=lambda: use_case,
progress_callback_factory=lambda: None,
)
return handler, use_case
@pytest.mark.asyncio
async def test_sidecar_migration_handler_runs_to_centralized():
result = {"success": True, "direction": "to_centralized", "moved": 3}
handler, use_case = _sidecar_migration_handler(result)
response = await handler.migrate_sidecars(
FakeRequest(json_data={"direction": "to_centralized", "force": True}) # pyright: ignore[reportArgumentType]
)
payload = _json_payload(response)
assert response.status == 200
assert payload["success"] is True
assert payload["moved"] == 3
assert use_case.calls == [{"direction": "to_centralized", "force": True, "old_root": ""}]
@pytest.mark.asyncio
async def test_sidecar_migration_handler_rejects_bad_direction():
handler, use_case = _sidecar_migration_handler({"success": True})
response = await handler.migrate_sidecars(
FakeRequest(json_data={"direction": "sideways"}) # pyright: ignore[reportArgumentType]
)
payload = _json_payload(response)
assert response.status == 400
assert payload["success"] is False
assert use_case.calls == []
@pytest.mark.asyncio
async def test_sidecar_migration_handler_accepts_get_query_params():
result = {"success": True, "direction": "to_alongside", "moved": 0}
handler, use_case = _sidecar_migration_handler(result)
response = await handler.migrate_sidecars(
FakeRequest( # pyright: ignore[reportArgumentType]
query={"direction": "to_alongside", "force": "true"},
method="GET",
)
)
payload = _json_payload(response)
assert response.status == 200
assert payload["success"] is True
assert use_case.calls == [{"direction": "to_alongside", "force": True, "old_root": ""}]
@pytest.mark.asyncio
async def test_sidecar_migration_handler_guard_refusal_is_400():
result = {"success": False, "error": "sidecar storage is already centralized"}
handler, use_case = _sidecar_migration_handler(result)
response = await handler.migrate_sidecars(
FakeRequest(json_data={"direction": "to_centralized"}) # pyright: ignore[reportArgumentType]
)
payload = _json_payload(response)
assert response.status == 400
assert payload["success"] is False
assert "already centralized" in payload["error"]
assert use_case.calls == [{"direction": "to_centralized", "force": False, "old_root": ""}]
@pytest.mark.asyncio
async def test_sidecar_migration_handler_relocate_root_passes_old_root():
result = {"success": True, "direction": "relocate_root", "moved": 5}
handler, use_case = _sidecar_migration_handler(result)
response = await handler.migrate_sidecars(
FakeRequest( # pyright: ignore[reportArgumentType]
json_data={
"direction": "relocate_root",
"old_root": "/old/sidecars",
"force": True,
}
)
)
payload = _json_payload(response)
assert response.status == 200
assert payload["success"] is True
assert use_case.calls == [
{"direction": "relocate_root", "force": True, "old_root": "/old/sidecars"}
]
@pytest.mark.asyncio
async def test_sidecar_migration_handler_relocate_root_requires_old_root():
handler, use_case = _sidecar_migration_handler({"success": True})
response = await handler.migrate_sidecars(
FakeRequest(json_data={"direction": "relocate_root"}) # pyright: ignore[reportArgumentType]
)
payload = _json_payload(response)
assert response.status == 400
assert "old_root" in payload["error"]
assert use_case.calls == []
+100
View File
@@ -329,6 +329,106 @@ async def test_get_model_sources_lists_capabilities():
assert all(s["example_url"] for s in sources)
# ---------------------------------------------------------------------------
# Root matching (linking must recognise every configured model category)
# ---------------------------------------------------------------------------
@pytest.fixture
def isolated_config_roots(monkeypatch, tmp_path):
"""Clear every configured root so root matching is fully test-controlled."""
from py.config import config
for attr in (
"loras_roots",
"extra_loras_roots",
"checkpoints_roots",
"extra_checkpoints_roots",
"unet_roots",
"extra_unet_roots",
"embeddings_roots",
"extra_embeddings_roots",
"other_roots",
):
monkeypatch.setattr(config, attr, [])
return config
@pytest.mark.asyncio
async def test_set_hf_url_links_model_in_other_root(
isolated_config_roots, tmp_path, monkeypatch
):
""""Other" category files live under config.other_roots; linking one to an
external source must not fail with 'not within any configured model
directory' and must update the Other scanner's cache, not the LoRA one."""
vae_root = tmp_path / "vae"
vae_root.mkdir()
isolated_config_roots.other_roots = [str(vae_root)]
model_path = vae_root / "some_vae.safetensors"
await _write_plain_model(model_path)
other_scanner = SimpleNamespace(update_single_model_cache=AsyncMock())
lora_scanner = SimpleNamespace(update_single_model_cache=AsyncMock())
monkeypatch.setattr(
ServiceRegistry, "get_other_scanner", AsyncMock(return_value=other_scanner)
)
monkeypatch.setattr(
ServiceRegistry, "get_lora_scanner", AsyncMock(return_value=lora_scanner)
)
response = await ModelSourceHandler().set_hf_url(
FakeRequest(
json_data={
"file_path": str(model_path),
"source_url": "https://huggingface.co/user/repo",
}
)
)
assert response.status == 200
payload = _json_payload(response)
assert payload["success"] is True
assert payload["source_platform"] == "huggingface"
saved = json.loads(open(_sidecar_path(model_path), encoding="utf-8").read())
assert saved["source_platform"] == "huggingface"
assert saved["hf_url"] == "https://huggingface.co/user/repo"
other_scanner.update_single_model_cache.assert_awaited_once()
lora_scanner.update_single_model_cache.assert_not_awaited()
def test_find_matching_root_includes_other_roots(isolated_config_roots, tmp_path):
vae_root = tmp_path / "vae"
isolated_config_roots.other_roots = [str(vae_root)]
assert model_source_handlers._find_matching_root(str(vae_root / "sub")) == str(
vae_root
)
def test_find_matching_root_respects_path_boundaries(isolated_config_roots, tmp_path):
"""A root must not swallow a sibling that merely shares its name prefix."""
vae_root = tmp_path / "vae"
isolated_config_roots.other_roots = [str(vae_root)]
assert (
model_source_handlers._find_matching_root(str(tmp_path / "vae-old")) is None
)
def test_infer_model_type_recognises_other_roots(isolated_config_roots, tmp_path):
from py.utils.models import OtherModelMetadata
vae_root = tmp_path / "vae"
isolated_config_roots.other_roots = [str(vae_root)]
assert model_source_handlers._infer_model_type(str(vae_root)) == (
OtherModelMetadata,
"get_other_scanner",
)
# ---------------------------------------------------------------------------
# File listing
# ---------------------------------------------------------------------------
+262
View File
@@ -0,0 +1,262 @@
"""Workflow preservation for remote recipe imports.
CivitAI serves a re-encoded, metadata-free ``optimized`` rendition as the
recipe preview, so an embedded ComfyUI workflow only exists in the
``original=true`` image. These tests pin the recovery and transport of that
workflow through the remote import paths.
"""
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
from PIL import Image, PngImagePlugin
from py.routes.handlers.recipe_handlers import RecipeManagementHandler
from py.services.recipes.persistence_service import PersistenceResult
from py.utils.exif_utils import ExifUtils
async def _noop_ensure() -> None:
return None
class CapturingPersistence:
"""Persistence service double recording the save payload."""
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
async def save_recipe(self, **kwargs: Any) -> PersistenceResult:
self.calls.append(kwargs)
return PersistenceResult({"success": True, "recipe_id": "recipe-1"})
class StubScanner:
"""Scanner double exposing only what the remote import paths touch."""
def __init__(self) -> None:
self.recipes_dir = "/tmp/recipes"
async def build_local_hash_cache(self) -> dict[str, Any]:
return {}
async def get_local_lora(self, name, base_model=None):
return None
def _make_handler(
persistence: CapturingPersistence,
*,
downloader_factory=None,
) -> RecipeManagementHandler:
async def default_downloader_factory():
class Downloader:
async def download_file(self, url, path, use_auth=False):
Path(path).write_bytes(b"downloaded")
return True, "ok"
return Downloader()
analysis_service = SimpleNamespace(
_recipe_parser_factory=SimpleNamespace(create_parser=lambda metadata: None)
)
return RecipeManagementHandler(
ensure_dependencies_ready=_noop_ensure,
recipe_scanner_getter=lambda: StubScanner(),
logger=logging.getLogger(__name__),
persistence_service=persistence, # pyright: ignore[reportArgumentType]
analysis_service=analysis_service, # pyright: ignore[reportArgumentType]
downloader_factory=downloader_factory or default_downloader_factory,
civitai_client_getter=lambda: None,
)
def _meta_with_comfy() -> dict[str, Any]:
return {
"id": 143518055,
"meta": {"prompt": "p", "comfy": '{"prompt": {"1": {"class_type": "KSampler"}}}'},
}
def test_meta_indicates_comfy_workflow() -> None:
assert RecipeManagementHandler._meta_indicates_comfy_workflow(
{"meta": {"comfy": "{}"}}
)
assert RecipeManagementHandler._meta_indicates_comfy_workflow({"comfy": "{}"})
assert not RecipeManagementHandler._meta_indicates_comfy_workflow({"meta": {}})
assert not RecipeManagementHandler._meta_indicates_comfy_workflow(
{"meta": {"comfy": None}}
)
assert not RecipeManagementHandler._meta_indicates_comfy_workflow(None)
assert not RecipeManagementHandler._meta_indicates_comfy_workflow("comfy")
@pytest.mark.asyncio
async def test_fetch_original_media_reads_workflow_and_cleans_up(tmp_path, monkeypatch):
workflow = json.dumps({"nodes": [{"id": 1}], "last_node_id": 1})
source = tmp_path / "original.png"
png_info = PngImagePlugin.PngInfo()
png_info.add_text("workflow", workflow)
png_info.add_text("prompt", '{"1": {"class_type": "KSampler"}}')
Image.new("RGB", (32, 32), color="red").save(source, pnginfo=png_info)
written: list[str] = []
async def downloader_factory():
class Downloader:
async def download_file(self, url, path, use_auth=False):
written.append(str(path))
Path(path).write_bytes(source.read_bytes())
return True, "ok"
return Downloader()
handler = _make_handler(CapturingPersistence(), downloader_factory=downloader_factory)
raw_metadata, recovered = await handler._fetch_original_media(
"https://image.civitai.com/x/original=true/x.png"
)
assert recovered == workflow
# extract_image_metadata prefers the prompt chunk over the workflow.
assert raw_metadata is not None and "class_type" in raw_metadata
assert written and not os.path.exists(written[0])
@pytest.mark.asyncio
async def test_fetch_original_media_degrades_on_download_failure():
async def downloader_factory():
class Downloader:
async def download_file(self, url, path, use_auth=False):
return False, "boom"
return Downloader()
handler = _make_handler(CapturingPersistence(), downloader_factory=downloader_factory)
assert await handler._fetch_original_media("https://image.civitai.com/x.png") == (
None,
None,
)
assert await handler._fetch_original_media(None) == (None, None)
@pytest.mark.asyncio
async def test_remote_import_transports_workflow_to_save(monkeypatch):
workflow = json.dumps({"nodes": [{"id": 4}]})
persistence = CapturingPersistence()
handler = _make_handler(persistence)
async def fake_download_remote_media(image_url):
return (
b"optimized-preview",
".jpg",
_meta_with_comfy(),
12345,
"https://image.civitai.com/x/original=true/x.png",
)
fetched: list[str] = []
async def fake_fetch_original_media(original_url):
fetched.append(original_url)
return None, workflow
handler._download_remote_media = fake_download_remote_media # type: ignore[method-assign]
handler._fetch_original_media = fake_fetch_original_media # type: ignore[method-assign]
monkeypatch.setattr(
ExifUtils, "extract_image_metadata", staticmethod(lambda path: None)
)
response = await handler._do_import_remote_recipe(
image_url="https://civitai.red/images/143518055",
name="Recipe",
lora_entries=[],
checkpoint_entry=None,
gen_params_request={},
tags=[],
base_model="Krea 2",
source_path="https://civitai.red/images/143518055",
)
assert response.status == 200
assert fetched == ["https://image.civitai.com/x/original=true/x.png"]
assert persistence.calls[0]["metadata"]["workflow"] == workflow
@pytest.mark.asyncio
async def test_remote_import_skips_original_without_comfy_meta(monkeypatch):
persistence = CapturingPersistence()
handler = _make_handler(persistence)
async def fake_download_remote_media(image_url):
return (
b"optimized-preview",
".jpg",
{"id": 1, "meta": {"prompt": "p"}},
None,
"https://image.civitai.com/x/original=true/x.png",
)
async def fail_fetch(original_url): # pragma: no cover - must not be called
raise AssertionError("original rendition should not be fetched")
handler._download_remote_media = fake_download_remote_media # type: ignore[method-assign]
handler._fetch_original_media = fail_fetch # type: ignore[method-assign]
monkeypatch.setattr(
ExifUtils, "extract_image_metadata", staticmethod(lambda path: None)
)
response = await handler._do_import_remote_recipe(
image_url="https://civitai.red/images/1",
name="Recipe",
lora_entries=[],
checkpoint_entry=None,
gen_params_request={},
tags=[],
base_model="SDXL 1.0",
source_path="https://civitai.red/images/1",
)
assert response.status == 200
assert "workflow" not in persistence.calls[0]["metadata"]
@pytest.mark.asyncio
async def test_url_import_transports_workflow_to_save(monkeypatch):
workflow = json.dumps({"nodes": [{"id": 5}]})
persistence = CapturingPersistence()
handler = _make_handler(persistence)
async def fake_download_remote_media(image_url):
return (
b"optimized-preview",
".jpg",
{"id": 9, "meta": {"prompt": "p"}},
None,
"https://image.civitai.com/x/original=true/x.png",
)
async def fake_fetch_original_media(original_url):
return None, workflow
handler._download_remote_media = fake_download_remote_media # type: ignore[method-assign]
handler._fetch_original_media = fake_fetch_original_media # type: ignore[method-assign]
monkeypatch.setattr(
ExifUtils, "extract_image_metadata", staticmethod(lambda path: None)
)
response = await handler._do_import_from_url(
"https://civitai.red/images/143518055", StubScanner()
)
assert response.status == 200
assert persistence.calls[0]["metadata"]["workflow"] == workflow
+52 -3
View File
@@ -208,6 +208,11 @@ class StubAnalysisService:
self.remote_calls: List[Optional[str]] = []
self.local_calls: List[Optional[str]] = []
self.local_ignore_recipe_metadata_calls: List[bool] = []
self.widget_analysis_calls: List[Any] = []
self.widget_result = SimpleNamespace(
payload={"metadata": {"loras": ""}, "image_bytes": b"widget-image"},
status=200,
)
self.result = SimpleNamespace(payload={"loras": []}, status=200)
self._recipe_parser_factory: Any = None
StubAnalysisService.instances.append(self)
@@ -242,7 +247,8 @@ class StubAnalysisService:
return self.result
async def analyze_widget_metadata(self, *, recipe_scanner) -> SimpleNamespace:
return SimpleNamespace(payload={"metadata": {}, "image_bytes": b""}, status=200)
self.widget_analysis_calls.append(recipe_scanner)
return self.widget_result
class StubPersistenceService:
@@ -252,6 +258,7 @@ class StubPersistenceService:
def __init__(self, **_: Any) -> None:
self.save_calls: List[Dict[str, Any]] = []
self.widget_calls: List[Dict[str, Any]] = []
self.delete_calls: List[str] = []
self.move_calls: List[Dict[str, str]] = []
self.update_calls: List[Dict[str, Any]] = []
@@ -359,9 +366,24 @@ class StubPersistenceService:
)
async def save_recipe_from_widget(
self, *, recipe_scanner, metadata: Dict[str, Any], image_bytes: bytes
self,
*,
recipe_scanner,
metadata: Dict[str, Any],
image_bytes: bytes,
workflow: Any = None,
) -> SimpleNamespace: # pragma: no cover
return SimpleNamespace(payload={"success": True}, status=200)
self.widget_calls.append(
{
"recipe_scanner": recipe_scanner,
"metadata": metadata,
"image_bytes": image_bytes,
"workflow": workflow,
}
)
return SimpleNamespace(
payload={"success": True, "has_workflow": workflow is not None}, status=200
)
class StubSharingService:
@@ -481,6 +503,33 @@ async def recipe_harness(
StubSharingService.instances.clear()
async def test_save_from_widget_forwards_workflow_body(monkeypatch, tmp_path: Path) -> None:
"""The opt-in workflow arrives through a real JSON body and is handed to
the persistence layer; the response reports whether it was embedded."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
workflow = {"nodes": [{"id": 1}], "last_node_id": 1}
response = await harness.client.post(
"/api/lm/recipes/save-from-widget", json={"workflow": workflow}
)
payload = await response.json()
assert response.status == 200
assert payload["has_workflow"] is True
assert harness.persistence.widget_calls[0]["workflow"] == workflow
async def test_save_from_widget_without_body_still_saves(monkeypatch, tmp_path: Path) -> None:
"""The long-standing body-less POST must keep working unchanged."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post("/api/lm/recipes/save-from-widget")
payload = await response.json()
assert response.status == 200
assert payload["has_workflow"] is False
assert harness.persistence.widget_calls[0]["workflow"] is None
async def test_list_recipes_provides_file_urls(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
recipe_path = harness.tmp_dir / "recipes" / "demo.png"
+129
View File
@@ -0,0 +1,129 @@
"""Handler tests for the widget "Save Recipe" endpoint.
Covers the opt-in workflow body: the endpoint historically received no body at
all, so a missing, empty or malformed body must degrade to "no workflow"
rather than failing the save.
"""
from __future__ import annotations
import json
import logging
from types import SimpleNamespace
from typing import Any
import pytest
from py.routes.handlers.recipe_handlers import RecipeManagementHandler
async def _noop_ensure() -> None:
return None
class FakeRequest:
"""Minimal request double exposing the optional-body contract."""
def __init__(
self,
*,
body: Any = None,
can_read_body: bool = True,
json_raises: bool = False,
) -> None:
self._body = body
self.can_read_body = can_read_body
self._json_raises = json_raises
async def json(self) -> Any:
if self._json_raises or self._body is None:
raise ValueError("no JSON body")
return self._body
class CapturingPersistence:
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
async def save_recipe_from_widget(self, **kwargs: Any) -> SimpleNamespace:
self.calls.append(kwargs)
return SimpleNamespace(
payload={"success": True, "has_workflow": bool(kwargs.get("workflow"))},
status=200,
)
def _make_handler(persistence: CapturingPersistence) -> RecipeManagementHandler:
analysis_service = SimpleNamespace(
analyze_widget_metadata=lambda **kwargs: _analysis_result()
)
return RecipeManagementHandler(
ensure_dependencies_ready=_noop_ensure,
recipe_scanner_getter=lambda: object(),
logger=logging.getLogger(__name__),
persistence_service=persistence, # pyright: ignore[reportArgumentType]
analysis_service=analysis_service, # pyright: ignore[reportArgumentType]
downloader_factory=lambda: None,
civitai_client_getter=lambda: None,
)
async def _analysis_result() -> SimpleNamespace:
return SimpleNamespace(
payload={"metadata": {"loras": ""}, "image_bytes": b"image"}
)
@pytest.mark.asyncio
async def test_widget_save_forwards_workflow_from_json_body():
persistence = CapturingPersistence()
handler = _make_handler(persistence)
workflow = {"nodes": [{"id": 1}]}
response = await handler.save_recipe_from_widget(
FakeRequest(body={"workflow": workflow}) # type: ignore[arg-type]
)
assert response.status == 200
assert persistence.calls[0]["workflow"] == workflow
@pytest.mark.asyncio
async def test_widget_save_without_body_passes_no_workflow():
persistence = CapturingPersistence()
handler = _make_handler(persistence)
response = await handler.save_recipe_from_widget(
FakeRequest(can_read_body=False) # type: ignore[arg-type]
)
assert response.status == 200
assert persistence.calls[0]["workflow"] is None
@pytest.mark.asyncio
async def test_widget_save_tolerates_malformed_body():
persistence = CapturingPersistence()
handler = _make_handler(persistence)
await handler.save_recipe_from_widget(
FakeRequest(json_raises=True) # type: ignore[arg-type]
)
await handler.save_recipe_from_widget(
FakeRequest(body=["not", "an", "object"]) # type: ignore[arg-type]
)
assert [call["workflow"] for call in persistence.calls] == [None, None]
@pytest.mark.asyncio
async def test_widget_save_reports_embedded_workflow_in_response():
persistence = CapturingPersistence()
handler = _make_handler(persistence)
response = await handler.save_recipe_from_widget(
FakeRequest(body={"workflow": {"nodes": []}}) # type: ignore[arg-type]
)
assert json.loads(response.text)["has_workflow"] is True
@@ -589,6 +589,49 @@ class TestBatchImportServiceEdgeCases:
assert "batch-import" in persistence_service.saved_recipes[0]["tags"]
assert "test" in persistence_service.saved_recipes[0]["tags"]
@pytest.mark.asyncio
async def test_workflow_from_analysis_is_passed_to_persistence(self, tmp_path):
"""A workflow recovered from the source's original rendition travels in
the analysis payload and must reach save_recipe as metadata."""
workflow = '{"nodes": [{"id": 1}]}'
ws_manager = MockWebSocketManager()
analysis_service = MockAnalysisService(
{
"https://civitai.red/images/1": MockAnalysisResult(
{
"loras": [{"name": "test-lora"}],
"workflow": workflow,
}
),
}
)
persistence_service = MockPersistenceService()
logger = logging.getLogger("test")
service = BatchImportService(
analysis_service=analysis_service, # pyright: ignore[reportArgumentType]
persistence_service=persistence_service, # pyright: ignore[reportArgumentType]
ws_manager=ws_manager,
logger=logger,
)
recipe_scanner_getter = lambda: SimpleNamespace(
find_recipes_by_fingerprint=lambda x: [],
)
civitai_client_getter = lambda: SimpleNamespace()
await service.start_batch_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
items=[{"source": "https://civitai.red/images/1"}],
tags=[],
)
await asyncio.sleep(0.3)
assert persistence_service.saved_recipes
assert persistence_service.saved_recipes[0]["metadata"]["workflow"] == workflow
@pytest.mark.asyncio
async def test_skip_duplicates_parameter(self, service):
recipe_scanner_getter = lambda: SimpleNamespace()
@@ -0,0 +1,371 @@
"""Centralized sidecar storage: lifecycle flows (delete/move/rename/scans)."""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any, Dict, List
from unittest.mock import AsyncMock
import pytest
from py.config import config
from py.services.checkpoint_scanner import CheckpointScanner
from py.services.model_lifecycle_service import (
ModelLifecycleService,
delete_model_artifacts,
)
from py.services.pending_delete_service import PendingDeleteService
from py.services.settings_manager import get_settings_manager
from py.utils.metadata_manager import MetadataManager
from py.utils.sidecar_paths import reset_root_map_cache, root_mirror_component
def _normalize(path) -> str:
return str(path).replace(os.sep, "/")
@pytest.fixture(autouse=True)
def _isolate_root_map():
"""Root identities are cached per sidecar root; keep tests independent."""
reset_root_map_cache()
yield
reset_root_map_cache()
@pytest.fixture
def library_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Single checkpoint root under tmp_path; every other root emptied."""
root = tmp_path / "checkpoints"
root.mkdir()
for attr, value in (
("loras_roots", []),
("base_models_roots", [str(root)]),
("checkpoints_roots", [str(root)]),
("embeddings_roots", []),
("other_roots", []),
("extra_loras_roots", []),
("extra_checkpoints_roots", []),
("extra_unet_roots", []),
("extra_embeddings_roots", []),
):
monkeypatch.setattr(config, attr, value, raising=False)
return root
@pytest.fixture
def centralized(library_root: Path, tmp_path: Path) -> Path:
"""Enable centralized mode rooted at tmp_path/sidecars."""
sidecar_root = tmp_path / "sidecars"
settings = get_settings_manager()
settings.set("sidecar_storage_mode", "centralized")
settings.set("sidecar_storage_path", str(sidecar_root))
return sidecar_root
def _mirror_dir(library_root: Path, sidecar_root: Path, *rel: str) -> Path:
"""Expected mirror directory for a root-relative path."""
component = root_mirror_component(str(library_root), sidecar_root=str(sidecar_root))
return sidecar_root.joinpath(component, *rel)
def _write_sidecar(
mirror_dir: Path,
stem: str,
*,
file_path: str,
preview_name: str | None = None,
extra: Dict[str, Any] | None = None,
) -> Path:
mirror_dir.mkdir(parents=True, exist_ok=True)
payload: Dict[str, Any] = {
"file_name": stem,
"file_path": _normalize(file_path),
}
if preview_name:
payload["preview_url"] = _normalize(mirror_dir / preview_name)
(mirror_dir / preview_name).write_bytes(b"preview")
if extra:
payload.update(extra)
metadata_path = mirror_dir / f"{stem}.metadata.json"
metadata_path.write_text(json.dumps(payload), encoding="utf-8")
return metadata_path
@pytest.mark.asyncio
async def test_delete_model_artifacts_centralized(
library_root: Path, centralized: Path
):
model = library_root / "model.safetensors"
model.write_bytes(b"weights")
mirror = _mirror_dir(library_root, centralized)
_write_sidecar(mirror, "model", file_path=model, preview_name="model.preview.webp")
deleted = await delete_model_artifacts(str(library_root), "model")
assert not model.exists()
assert not (mirror / "model.metadata.json").exists()
assert not (mirror / "model.preview.webp").exists()
assert any(path.endswith("model.safetensors") for path in deleted)
assert "model.metadata.json" in deleted
assert "model.preview.webp" in deleted
@pytest.mark.asyncio
async def test_delete_model_artifacts_alongside_still_siblings(
library_root: Path,
):
"""Alongside mode (default) keeps deleting sidecars next to the model."""
model = library_root / "model.safetensors"
model.write_bytes(b"weights")
sidecar = library_root / "model.metadata.json"
sidecar.write_text("{}", encoding="utf-8")
preview = library_root / "model.preview.webp"
preview.write_bytes(b"preview")
await delete_model_artifacts(str(library_root), "model")
assert not model.exists()
assert not sidecar.exists()
assert not preview.exists()
def test_enumerate_model_artifacts_centralized(
library_root: Path, centralized: Path
):
model = library_root / "model.safetensors"
model.write_bytes(b"weights")
mirror = _mirror_dir(library_root, centralized)
_write_sidecar(mirror, "model", file_path=model, preview_name="model.preview.png")
service = PendingDeleteService.__new__(PendingDeleteService)
artifacts = service._enumerate_model_artifacts(
str(library_root), "model", ".safetensors"
)
assert artifacts == [
os.path.abspath(str(model)),
os.path.abspath(str(mirror / "model.metadata.json")),
os.path.abspath(str(mirror / "model.preview.png")),
]
class _RecordingScanner:
def __init__(self):
self.calls: List[tuple] = []
self.model_type = "lora"
async def update_single_model_cache(self, old_path, new_path, metadata):
self.calls.append((old_path, new_path, metadata))
class _PassthroughMetadataManager:
async def save_metadata(self, path: str, metadata):
await MetadataManager.save_metadata(path, metadata)
return True
async def _json_metadata_loader(path: str) -> Dict[str, object]:
with open(path, "r", encoding="utf-8") as handle:
return json.load(handle)
@pytest.mark.asyncio
async def test_rename_model_centralized(library_root: Path, centralized: Path):
model = library_root / "model.safetensors"
model.write_bytes(b"weights")
mirror = _mirror_dir(library_root, centralized)
_write_sidecar(mirror, "model", file_path=model, preview_name="model.preview.webp")
service = ModelLifecycleService(
scanner=_RecordingScanner(),
metadata_manager=_PassthroughMetadataManager(),
metadata_loader=_json_metadata_loader,
)
result = await service.rename_model(
file_path=_normalize(model), new_file_name="renamed"
)
assert result["success"] is True
renamed = library_root / "renamed.safetensors"
assert renamed.exists()
assert not model.exists()
# Mirror sidecar/preview renamed with the model; nothing left alongside.
assert (mirror / "renamed.metadata.json").exists()
assert (mirror / "renamed.preview.webp").exists()
assert not (mirror / "model.metadata.json").exists()
assert not (mirror / "model.preview.webp").exists()
assert not (library_root / "renamed.metadata.json").exists()
assert not (library_root / "renamed.preview.webp").exists()
saved = json.loads((mirror / "renamed.metadata.json").read_text())
assert saved["file_name"] == "renamed"
assert saved["file_path"].endswith("renamed.safetensors")
assert saved["preview_url"].endswith("renamed.preview.webp")
assert str(mirror).replace(os.sep, "/") in saved["preview_url"]
@pytest.mark.asyncio
async def test_move_model_centralized(
library_root: Path, centralized: Path, monkeypatch: pytest.MonkeyPatch
):
source_dir = library_root / "old"
source_dir.mkdir()
model = source_dir / "model.safetensors"
model.write_bytes(b"weights")
target_dir = library_root / "new"
old_mirror = _mirror_dir(library_root, centralized, "old")
_write_sidecar(
old_mirror, "model", file_path=model, preview_name="model.preview.webp"
)
scanner = CheckpointScanner()
monkeypatch.setattr(
scanner, "update_single_model_cache", AsyncMock(return_value=True)
)
result = await scanner.move_model(_normalize(model), _normalize(target_dir))
assert result is not None
moved_model = target_dir / "model.safetensors"
assert moved_model.exists()
assert not model.exists()
new_mirror = _mirror_dir(library_root, centralized, "new")
assert (new_mirror / "model.metadata.json").exists()
assert (new_mirror / "model.preview.webp").exists()
assert not (old_mirror / "model.metadata.json").exists()
assert not (old_mirror / "model.preview.webp").exists()
saved = json.loads((new_mirror / "model.metadata.json").read_text())
assert saved["file_path"].endswith("new/model.safetensors")
assert saved["preview_url"].endswith("model.preview.webp")
assert str(new_mirror).replace(os.sep, "/") in saved["preview_url"]
class _FakeCache:
def __init__(self, raw_data: List[Dict[str, Any]]):
self.raw_data = raw_data
self.all_folders: List[str] = []
self.folders: List[str] = []
def remove_from_version_index(self, _item) -> None:
pass
def rebuild_version_index(self) -> None:
pass
async def resort(self) -> None:
pass
@pytest.mark.asyncio
async def test_rename_known_folder_centralized(
library_root: Path, centralized: Path, monkeypatch: pytest.MonkeyPatch
):
# The caller (ModelFileService.rename_folder) has already renamed the
# model directory on disk when the scanner is asked to re-key records.
old_dir = library_root / "oldfolder"
new_dir = library_root / "newfolder"
new_dir.mkdir()
model = new_dir / "model.safetensors"
model.write_bytes(b"weights")
old_model_path = old_dir / "model.safetensors"
old_mirror = _mirror_dir(library_root, centralized, "oldfolder")
_write_sidecar(
old_mirror, "model", file_path=old_model_path, preview_name="model.preview.webp"
)
cache_entry: Dict[str, Any] = {
"file_path": _normalize(old_model_path),
"folder": "oldfolder",
"preview_url": _normalize(old_mirror / "model.preview.webp"),
"sha256": "",
}
scanner = CheckpointScanner()
scanner._cache = _FakeCache([cache_entry])
monkeypatch.setattr(scanner, "_persist_current_cache", AsyncMock())
changed = await scanner.rename_known_folder(
"oldfolder",
"newfolder",
previous_path=str(old_dir),
new_path=str(new_dir),
)
assert changed is True
new_mirror = _mirror_dir(library_root, centralized, "newfolder")
assert (new_mirror / "model.metadata.json").exists()
assert (new_mirror / "model.preview.webp").exists()
assert not old_mirror.exists()
saved = json.loads((new_mirror / "model.metadata.json").read_text())
assert saved["file_path"] == _normalize(model)
assert saved["preview_url"] == _normalize(new_mirror / "model.preview.webp")
assert cache_entry["file_path"] == _normalize(model)
assert cache_entry["folder"] == "newfolder"
assert cache_entry["preview_url"] == _normalize(
new_mirror / "model.preview.webp"
)
@pytest.mark.asyncio
async def test_pending_models_mirror_walk(library_root: Path, centralized: Path):
model = library_root / "model.safetensors"
model.write_bytes(b"weights")
mirror = _mirror_dir(library_root, centralized)
_write_sidecar(
mirror,
"model",
file_path=model,
extra={"hash_status": "pending", "sha256": ""},
)
# Orphan sidecar: recorded model path is gone and no stem match exists.
_write_sidecar(
mirror,
"ghost",
file_path=library_root / "ghost.safetensors",
extra={"hash_status": "pending", "sha256": ""},
)
scanner = CheckpointScanner()
pending = await scanner._find_pending_models_from_filesystem()
assert len(pending) == 1
assert pending[0]["file_path"] == _normalize(model)
assert pending[0]["hash_status"] == "pending"
@pytest.mark.asyncio
async def test_pending_models_mirror_walk_uses_stem_fallback(
library_root: Path, centralized: Path
):
"""A stale recorded file_path falls back to probing by stem + extension."""
model = library_root / "model.safetensors"
model.write_bytes(b"weights")
mirror = _mirror_dir(library_root, centralized)
_write_sidecar(
mirror,
"model",
file_path=library_root / "renamed-away.safetensors",
extra={"hash_status": "pending", "sha256": ""},
)
scanner = CheckpointScanner()
pending = await scanner._find_pending_models_from_filesystem()
assert len(pending) == 1
assert pending[0]["file_path"] == _normalize(model)
+90 -1
View File
@@ -11,15 +11,19 @@ from py.services.model_file_service import ModelMoveService
class FakeScanner:
def __init__(self, roots: List[Path]) -> None:
def __init__(self, roots: List[Path], excluded: List[str] | None = None) -> None:
self._roots = [str(root) for root in roots]
self.known_folders: List[str] = []
self.removed_folders: List[str] = []
self.renamed_folders: List[tuple] = []
self._excluded = list(excluded or [])
def get_model_roots(self) -> List[str]:
return list(self._roots)
def get_excluded_models(self) -> List[str]:
return list(self._excluded)
async def add_known_folder(self, folder: str) -> None:
self.known_folders.append(folder)
@@ -30,6 +34,12 @@ class FakeScanner:
self.renamed_folders.append((previous, current, kwargs))
class ScannerWithoutExcludedAccessor(FakeScanner):
"""Scanner stand-in predating ``get_excluded_models()``."""
get_excluded_models = None # type: ignore[assignment]
@pytest.mark.asyncio
async def test_create_folder_creates_directory_and_registers_it(tmp_path: Path):
scanner = FakeScanner([tmp_path])
@@ -149,11 +159,90 @@ async def test_delete_folder_refuses_when_models_live_below(tmp_path: Path):
assert result["success"] is False
assert result["code"] == "not_empty"
assert result["manifest"]["model_count"] == 1
assert result["manifest"]["excluded_model_count"] == 0
assert target.exists()
assert model_file.exists()
assert scanner.removed_folders == []
@pytest.mark.asyncio
async def test_delete_folder_manifest_marks_models_excluded_from_the_library(tmp_path: Path):
"""Excluded models are invisible to the model lists but still block the
cascade, so the manifest has to say so — the folder sidebar otherwise shows
the folder as empty and the refusal reads as a bug."""
target = _make_nested(tmp_path)
model_file = target / "hidden.safetensors"
model_file.write_text("weights", encoding="utf-8")
scanner = FakeScanner([tmp_path], excluded=[str(model_file)])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target))
assert result["success"] is False
assert result["code"] == "not_empty"
assert result["manifest"]["model_count"] == 1
assert result["manifest"]["excluded_model_count"] == 1
assert "excluded" in result["error"]
assert model_file.exists()
@pytest.mark.asyncio
async def test_delete_folder_error_splits_excluded_from_visible_models(tmp_path: Path):
target = _make_nested(tmp_path)
visible = target / "visible.safetensors"
hidden = target / "hidden.safetensors"
visible.write_text("weights", encoding="utf-8")
hidden.write_text("weights", encoding="utf-8")
scanner = FakeScanner([tmp_path], excluded=[str(hidden)])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target))
assert result["manifest"]["model_count"] == 2
assert result["manifest"]["excluded_model_count"] == 1
assert "1 of them excluded" in result["error"]
@pytest.mark.asyncio
async def test_delete_folder_does_not_claim_excluded_for_visible_models(tmp_path: Path):
target = _make_nested(tmp_path)
(target / "model.safetensors").write_text("weights", encoding="utf-8")
# An excluded model elsewhere in the library must not be attributed here.
scanner = FakeScanner([tmp_path], excluded=[str(tmp_path / "other" / "other.safetensors")])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target))
assert result["manifest"]["excluded_model_count"] == 0
assert "excluded" not in result["error"]
@pytest.mark.asyncio
async def test_delete_folder_works_without_the_excluded_accessor(tmp_path: Path):
target = _make_nested(tmp_path)
(target / "model.safetensors").write_text("weights", encoding="utf-8")
scanner = ScannerWithoutExcludedAccessor([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target))
assert result["success"] is False
assert result["manifest"]["excluded_model_count"] == 0
@pytest.mark.asyncio
async def test_delete_folder_success_manifest_carries_excluded_model_count(tmp_path: Path):
target = _make_nested(tmp_path)
(target / "leftover.webp").write_text("preview", encoding="utf-8")
scanner = FakeScanner([tmp_path], excluded=[str(tmp_path / "elsewhere.safetensors")])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target), dry_run=True)
assert result["success"] is True
assert result["excluded_model_count"] == 0
@pytest.mark.asyncio
async def test_delete_folder_refuses_while_a_staged_delete_is_pending(tmp_path: Path):
target = _make_nested(tmp_path)
+362 -1
View File
@@ -27,14 +27,36 @@ class DummyExifUtils:
self.appended = None
self.optimized_calls = 0
self.workflow_value = None
self.optimized_workflow = None
self.embedded_workflows = []
def optimize_image(self, image_data, target_width, format, quality, preserve_metadata):
def optimize_image(
self,
image_data,
target_width,
format,
quality,
preserve_metadata,
workflow=None,
):
self.optimized_calls += 1
self.optimized_workflow = workflow
return image_data, ".webp"
def append_recipe_metadata(self, image_path, recipe_data, pixel_preserving=False):
self.appended = (image_path, recipe_data, pixel_preserving)
def embed_workflow(self, image_path, workflow):
self.embedded_workflows.append((image_path, workflow))
return image_path
def normalise_workflow(self, workflow):
if isinstance(workflow, str):
return workflow or None
if isinstance(workflow, (dict, list)):
return json.dumps(workflow)
return None
def extract_image_metadata(self, path):
return {}
@@ -131,6 +153,55 @@ async def test_save_recipe_skip_optimize_preserves_image_bytes(tmp_path):
assert exif_utils.appended[2] is True
@pytest.mark.asyncio
async def test_save_recipe_skip_optimize_still_embeds_recovered_workflow(tmp_path):
"""The verbatim branch bypasses optimize_image, so the recovered workflow
has to be embedded by the explicit safety net."""
image_buffer = BytesIO()
Image.new("RGB", (96, 48), color="olive").save(
image_buffer, format="WEBP", quality=85
)
class DummyScanner:
def __init__(self, root):
self.recipes_dir = str(root / "recipes")
async def add_recipe(self, recipe_data):
return None
async def find_recipes_by_fingerprint(self, fingerprint):
return []
service = RecipePersistenceService(
exif_utils=ExifUtils,
card_preview_width=512,
logger=logging.getLogger("test"),
)
workflow = {"nodes": [{"id": 8}]}
result = await service.save_recipe(
recipe_scanner=DummyScanner(tmp_path),
image_bytes=image_buffer.getvalue(),
image_base64=None,
name="Verbatim Workflow",
tags=[],
metadata={"base_model": "sd", "loras": [], "workflow": workflow},
extension=".webp",
skip_optimize=True,
)
image_path = Path(result.payload["image_path"])
with Image.open(image_path) as img:
assert img.size == (96, 48)
assert img.format == "WEBP"
assert ExifUtils._load_structured_metadata(str(image_path))["workflow"] == (
json.dumps(workflow)
)
stored = json.loads(Path(result.payload["json_path"]).read_text())
assert stored["has_workflow"] is True
@pytest.mark.asyncio
async def test_save_recipe_skip_optimize_default_optimizes(tmp_path):
"""Normal saves must keep optimizing; only re-import opts out."""
@@ -650,6 +721,88 @@ async def test_save_recipe_preserves_workflow_when_png_is_converted_to_webp(tmp_
assert "Recipe metadata:" in decoded_comment
@pytest.mark.asyncio
async def test_save_recipe_embeds_workflow_recovered_from_source(tmp_path):
"""Import paths hand the workflow over as metadata when their preview bytes
are metadata-free (CivitAI's optimized rendition); save_recipe must embed
it so the recipe reports has_workflow and can send it to ComfyUI."""
class DummyScanner:
def __init__(self, root):
self.recipes_dir = str(root)
async def find_recipes_by_fingerprint(self, fingerprint):
return []
async def add_recipe(self, recipe_data):
return None
image_buffer = BytesIO()
Image.new("RGB", (96, 48), color="teal").save(
image_buffer, format="WEBP", quality=85
)
service = RecipePersistenceService(
exif_utils=ExifUtils,
card_preview_width=64,
logger=logging.getLogger("test"),
)
workflow = {"nodes": [{"id": 1}], "last_node_id": 1}
result = await service.save_recipe(
recipe_scanner=DummyScanner(tmp_path),
image_bytes=image_buffer.getvalue(),
image_base64=None,
name="Recovered Workflow",
tags=["workflow"],
metadata={"base_model": "sd", "loras": [], "workflow": workflow},
extension=".webp",
)
image_path = Path(result.payload["image_path"])
assert ExifUtils._load_structured_metadata(str(image_path))["workflow"] == (
json.dumps(workflow)
)
stored = json.loads(Path(result.payload["json_path"]).read_text())
assert stored["has_workflow"] is True
@pytest.mark.asyncio
async def test_save_recipe_passes_recovered_workflow_to_optimizer(tmp_path):
"""The workflow travels through optimize_image (single encode pass) rather
than being patched in afterwards."""
exif_utils = DummyExifUtils()
class DummyScanner:
def __init__(self, root):
self.recipes_dir = str(root)
async def find_recipes_by_fingerprint(self, fingerprint):
return []
async def add_recipe(self, recipe_data):
return None
workflow = '{"nodes": [{"id": 2}]}'
service = RecipePersistenceService(
exif_utils=exif_utils,
card_preview_width=512,
logger=logging.getLogger("test"),
)
await service.save_recipe(
recipe_scanner=DummyScanner(tmp_path),
image_bytes=b"image-bytes",
image_base64=None,
name="Recovered Workflow",
tags=[],
metadata={"base_model": "sd", "loras": [], "workflow": workflow},
extension=".webp",
)
assert exif_utils.optimized_workflow == workflow
@pytest.mark.asyncio
async def test_save_recipe_strips_checkpoint_local_fields(tmp_path):
exif_utils = DummyExifUtils()
@@ -835,6 +988,137 @@ async def test_save_recipe_from_widget_enriches_checkpoint_from_local_cache(tmp_
}
@pytest.mark.asyncio
async def test_save_recipe_from_widget_embeds_opted_in_workflow(tmp_path):
"""Opt-in widget saves embed the live graph so the recipe can send its
workflow back to ComfyUI, mirroring imported recipes."""
class DummyScanner:
def __init__(self, root):
self.recipes_dir = str(root)
self.added = []
async def get_local_lora(self, name): # pragma: no cover - no loras
return None
async def add_recipe(self, recipe_data):
self.added.append(recipe_data)
image_buffer = BytesIO()
Image.new("RGB", (96, 48), color="navy").save(
image_buffer, format="PNG"
)
scanner = DummyScanner(tmp_path)
service = RecipePersistenceService(
exif_utils=ExifUtils,
card_preview_width=64,
logger=logging.getLogger("test"),
)
workflow = {"nodes": [{"id": 1}], "last_node_id": 1}
result = await service.save_recipe_from_widget(
recipe_scanner=scanner,
metadata={"loras": "", "prompt": "a calm scene"},
image_bytes=image_buffer.getvalue(),
workflow=workflow,
)
assert result.payload["has_workflow"] is True
assert "workflow_skipped" not in result.payload
stored = json.loads(Path(result.payload["json_path"]).read_text())
assert stored["has_workflow"] is True
assert ExifUtils._load_structured_metadata(result.payload["image_path"])[
"workflow"
] == json.dumps(workflow)
assert scanner.added[0]["has_workflow"] is True
@pytest.mark.asyncio
async def test_save_recipe_from_widget_without_workflow_stays_unflagged(tmp_path):
"""The default action must keep saving a workflow-free preview."""
class DummyScanner:
def __init__(self, root):
self.recipes_dir = str(root)
async def get_local_lora(self, name): # pragma: no cover - no loras
return None
async def add_recipe(self, recipe_data):
return None
image_buffer = BytesIO()
Image.new("RGB", (96, 48), color="navy").save(
image_buffer, format="PNG"
)
service = RecipePersistenceService(
exif_utils=ExifUtils,
card_preview_width=64,
logger=logging.getLogger("test"),
)
result = await service.save_recipe_from_widget(
recipe_scanner=DummyScanner(tmp_path),
metadata={"loras": "", "prompt": "a calm scene"},
image_bytes=image_buffer.getvalue(),
)
assert result.payload["has_workflow"] is False
assert ExifUtils._load_structured_metadata(result.payload["image_path"])[
"workflow"
] is None
@pytest.mark.asyncio
async def test_save_recipe_from_widget_skips_oversized_workflow(
tmp_path, monkeypatch
):
"""A pathological graph is dropped instead of inflating the preview."""
monkeypatch.setattr(
"py.services.recipes.persistence_service.MAX_WORKFLOW_EMBED_BYTES", 32
)
class DummyScanner:
def __init__(self, root):
self.recipes_dir = str(root)
self.added = []
async def get_local_lora(self, name): # pragma: no cover - no loras
return None
async def add_recipe(self, recipe_data):
self.added.append(recipe_data)
image_buffer = BytesIO()
Image.new("RGB", (96, 48), color="navy").save(
image_buffer, format="PNG"
)
scanner = DummyScanner(tmp_path)
service = RecipePersistenceService(
exif_utils=ExifUtils,
card_preview_width=64,
logger=logging.getLogger("test"),
)
workflow = {"nodes": [{"id": index} for index in range(20)]}
result = await service.save_recipe_from_widget(
recipe_scanner=scanner,
metadata={"loras": "", "prompt": "a calm scene"},
image_bytes=image_buffer.getvalue(),
workflow=workflow,
)
assert result.payload["workflow_skipped"] == "too_large"
assert result.payload["has_workflow"] is False
assert ExifUtils._load_structured_metadata(result.payload["image_path"])[
"workflow"
] is None
assert scanner.added[0]["has_workflow"] is False
@pytest.mark.asyncio
async def test_move_recipe_updates_paths(tmp_path):
exif_utils = DummyExifUtils()
@@ -1071,6 +1355,83 @@ async def test_analyze_remote_image_supports_civitai_red():
assert result.payload["loras"] == []
@pytest.mark.asyncio
async def test_analyze_remote_image_returns_workflow_from_original_rendition():
"""CivitAI's optimized rendition is re-encoded and metadata-free, so an
embedded workflow only exists in the original. Analysis must surface it so
the save step can embed it while the stored preview stays the optimized
image."""
workflow = json.dumps({"nodes": [{"id": 1}], "last_node_id": 1})
class FakeExif:
def extract_image_metadata(self, path):
# The optimized rendition carries no metadata at all.
return None
def _load_structured_metadata(self, path):
# Only the original rendition (fetched to a .png temp file)
# carries the embedded workflow.
return {
"parameters": None,
"prompt": None,
"workflow": workflow if str(path).endswith(".png") else None,
"comment": None,
}
downloaded: list[str] = []
async def downloader_factory():
class Downloader:
async def download_file(self, url, path, use_auth=False):
downloaded.append(url)
Path(path).write_bytes(b"fake-image")
return True, "success"
return Downloader()
class DummyFactory:
def create_parser(self, metadata):
async def parse_metadata(m, recipe_scanner=None, civitai_client=None):
return {"loras": [], "gen_params": {"prompt": "p"}}
return SimpleNamespace(parse_metadata=parse_metadata)
service = RecipeAnalysisService(
exif_utils=FakeExif(),
recipe_parser_factory=DummyFactory(),
downloader_factory=downloader_factory,
metadata_collector=None,
metadata_processor_cls=None,
metadata_registry_cls=None,
standalone_mode=False,
logger=logging.getLogger("test"),
)
class DummyClient:
async def get_image_info(self, image_id, source_url=None):
return {
"url": "https://image.civitai.com/x/original=true/sample.jpeg",
"type": "image",
"meta": {"prompt": "p"},
}
class DummyScanner:
async def find_recipes_by_fingerprint(self, fingerprint):
return []
result = await service.analyze_remote_image(
url="https://civitai.red/images/143518055",
recipe_scanner=DummyScanner(),
civitai_client=DummyClient(),
)
assert result.payload["workflow"] == workflow
# The optimized rendition is used as the preview, the original only as the
# metadata/workflow fallback.
assert any("width=450,optimized=true" in url for url in downloaded)
assert any("original=true" in url for url in downloaded)
def _exif_utils_returning(metadata):
class MetadataExifUtils(DummyExifUtils):
def extract_image_metadata(self, path):
@@ -0,0 +1,621 @@
"""Sidecar migration use case: layout moves, conflicts, guards."""
from __future__ import annotations
import json
import os
import shutil
from pathlib import Path
from typing import Any, Dict, List
import pytest
from py.config import config
from py.services.settings_manager import get_settings_manager
from py.services.use_cases.sidecar_migration_use_case import SidecarMigrationUseCase
from py.utils.sidecar_paths import (
get_metadata_path,
reset_root_map_cache,
root_mirror_component,
)
def _normalize(path) -> str:
return str(path).replace(os.sep, "/")
@pytest.fixture(autouse=True)
def _isolate_root_map():
"""Root identities are cached per sidecar root; keep tests independent."""
reset_root_map_cache()
yield
reset_root_map_cache()
@pytest.fixture
def library_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Single lora root under tmp_path; every other root emptied."""
root = tmp_path / "loras"
root.mkdir()
for attr, value in (
("loras_roots", [str(root)]),
("base_models_roots", []),
("checkpoints_roots", []),
("embeddings_roots", []),
("other_roots", []),
("extra_loras_roots", []),
("extra_checkpoints_roots", []),
("extra_unet_roots", []),
("extra_embeddings_roots", []),
):
monkeypatch.setattr(config, attr, value, raising=False)
return root
@pytest.fixture
def sidecar_root(tmp_path: Path) -> Path:
"""Configured centralized root (mode-independent for migration)."""
root = tmp_path / "sidecars"
get_settings_manager().set("sidecar_storage_path", str(root))
return root
def _set_mode(mode: str) -> None:
get_settings_manager().set("sidecar_storage_mode", mode)
def _mirror_dir(library_root: Path, sidecar_root: Path, *rel: str) -> Path:
"""Expected mirror directory for a root-relative path."""
component = root_mirror_component(str(library_root), sidecar_root=str(sidecar_root))
return sidecar_root.joinpath(component, *rel)
def _write_model(directory: Path, stem: str) -> Path:
directory.mkdir(parents=True, exist_ok=True)
model = directory / f"{stem}.safetensors"
model.write_bytes(b"weights")
return model
def _write_sidecar(directory: Path, stem: str, model: Path, *, preview_ext: str | None = ".preview.webp") -> Path:
directory.mkdir(parents=True, exist_ok=True)
payload: Dict[str, Any] = {
"file_name": stem,
"file_path": _normalize(model),
}
if preview_ext:
payload["preview_url"] = _normalize(directory / f"{stem}{preview_ext}")
sidecar = directory / f"{stem}.metadata.json"
sidecar.write_text(json.dumps(payload), encoding="utf-8")
return sidecar
class _FakeCache:
def __init__(self, raw_data: List[Dict[str, Any]]) -> None:
self.raw_data = raw_data
async def update_preview_url(
self, file_path: str, preview_url: str, preview_nsfw_level: int
) -> bool:
for item in self.raw_data:
if item["file_path"] == file_path:
item["preview_url"] = preview_url
item["preview_nsfw_level"] = preview_nsfw_level
return True
return False
class _FakeScanner:
def __init__(
self, raw_data: List[Dict[str, Any]], excluded: List[str] | None = None
) -> None:
self._cache = _FakeCache(raw_data)
self._excluded = list(excluded or [])
self.persist_calls = 0
async def get_cached_data(self) -> _FakeCache:
return self._cache
def get_excluded_models(self) -> List[str]:
return list(self._excluded)
async def _persist_current_cache(self) -> None:
self.persist_calls += 1
def _make_use_case(
model_paths: List[str], excluded: List[str] | None = None
) -> SidecarMigrationUseCase:
scanner = _FakeScanner(
[{"file_path": path} for path in model_paths], excluded=excluded
)
async def scanner_factory() -> _FakeScanner:
return scanner
use_case = SidecarMigrationUseCase(
scanner_factories=(("lora", scanner_factory),),
settings_service=get_settings_manager(),
)
use_case._test_scanner = scanner # expose for cache-reconcile assertions
return use_case
class _ProgressRecorder:
def __init__(self) -> None:
self.payloads: List[Dict[str, Any]] = []
async def on_progress(self, payload: Dict[str, Any]) -> None:
self.payloads.append(payload)
@pytest.mark.asyncio
async def test_migrate_to_centralized_moves_sidecar_and_previews(
library_root: Path, sidecar_root: Path
):
_set_mode("centralized")
model = _write_model(library_root / "sub", "model")
sidecar = _write_sidecar(library_root / "sub", "model", model)
preview = library_root / "sub" / "model.preview.webp"
preview.write_bytes(b"preview")
extra_preview = library_root / "sub" / "model.png"
extra_preview.write_bytes(b"extra")
recorder = _ProgressRecorder()
use_case = _make_use_case([str(model)])
summary = await use_case.migrate_to_centralized(recorder, force=True)
assert summary["success"] is True
assert summary["direction"] == "to_centralized"
assert summary["moved"] == 3
assert summary["models_moved"] == 1
assert summary["skipped"] == 0
assert summary["conflicts"] == 0
assert summary["errors"] == []
mirror = _mirror_dir(library_root, sidecar_root, "sub")
assert not sidecar.exists()
assert not preview.exists()
assert not extra_preview.exists()
moved_sidecar = mirror / "model.metadata.json"
assert moved_sidecar.exists()
assert (mirror / "model.preview.webp").exists()
assert (mirror / "model.png").exists()
# Model files never move.
assert model.exists()
metadata = json.loads(moved_sidecar.read_text(encoding="utf-8"))
assert metadata["file_path"] == _normalize(model)
assert metadata["file_name"] == "model"
# Recorded extension wins when rewriting preview_url.
assert metadata["preview_url"] == _normalize(mirror / "model.preview.webp")
statuses = [payload["status"] for payload in recorder.payloads]
assert statuses[0] == "started"
assert statuses[-1] == "completed"
assert all(p["type"] == "sidecar_migration_progress" for p in recorder.payloads)
# Scanner cache was reconciled to the mirror preview and persisted.
entry = use_case._test_scanner._cache.raw_data[0]
assert entry["preview_url"] == _normalize(mirror / "model.preview.webp")
assert use_case._test_scanner.persist_calls == 1
@pytest.mark.asyncio
async def test_migrate_to_alongside_reverses_layout(
library_root: Path, sidecar_root: Path
):
_set_mode("alongside")
model = _write_model(library_root / "sub", "model")
mirror = _mirror_dir(library_root, sidecar_root, "sub")
sidecar = _write_sidecar(mirror, "model", model)
preview = mirror / "model.preview.webp"
preview.write_bytes(b"preview")
use_case = _make_use_case([str(model)])
summary = await use_case.migrate_to_alongside(force=True)
assert summary["success"] is True
assert summary["moved"] == 2
assert not sidecar.exists()
assert not preview.exists()
moved_sidecar = library_root / "sub" / "model.metadata.json"
assert moved_sidecar.exists()
assert (library_root / "sub" / "model.preview.webp").exists()
metadata = json.loads(moved_sidecar.read_text(encoding="utf-8"))
assert metadata["file_path"] == _normalize(model)
assert metadata["preview_url"] == _normalize(
library_root / "sub" / "model.preview.webp"
)
entry = use_case._test_scanner._cache.raw_data[0]
assert entry["preview_url"] == _normalize(
library_root / "sub" / "model.preview.webp"
)
assert use_case._test_scanner.persist_calls == 1
@pytest.mark.asyncio
async def test_migrate_conflict_keeps_newer_file(
library_root: Path, sidecar_root: Path
):
_set_mode("centralized")
mirror = _mirror_dir(library_root, sidecar_root)
# Model A: destination (mirror) sidecar is newer -> destination wins.
model_a = _write_model(library_root, "model_a")
src_a = _write_sidecar(library_root, "model_a", model_a, preview_ext=None)
dst_a = _write_sidecar(mirror, "model_a", model_a, preview_ext=None)
os.utime(src_a, (1000, 1000))
os.utime(dst_a, (2000, 2000))
# Model B: source (alongside) sidecar is newer -> source replaces.
model_b = _write_model(library_root, "model_b")
src_b = _write_sidecar(library_root, "model_b", model_b, preview_ext=None)
dst_b = _write_sidecar(mirror, "model_b", model_b, preview_ext=None)
(mirror / "model_b.metadata.json").write_text(
json.dumps({"stale": True}), encoding="utf-8"
)
os.utime(src_b, (3000, 3000))
os.utime(dst_b, (2000, 2000))
use_case = _make_use_case([str(model_a), str(model_b)])
summary = await use_case.migrate_to_centralized(force=True)
assert summary["conflicts"] == 2
assert summary["moved"] == 1
# A: destination kept, source deleted, content untouched.
assert not src_a.exists()
metadata_a = json.loads(dst_a.read_text(encoding="utf-8"))
assert metadata_a["file_name"] == "model_a"
# B: newer source replaced the stale destination.
assert not src_b.exists()
metadata_b = json.loads(dst_b.read_text(encoding="utf-8"))
assert metadata_b.get("stale") is None
assert metadata_b["file_name"] == "model_b"
@pytest.mark.asyncio
async def test_migrate_missing_model_file_is_skipped(
library_root: Path, sidecar_root: Path
):
_set_mode("centralized")
missing_model = library_root / "ghost.safetensors"
sidecar = _write_sidecar(library_root, "ghost", missing_model, preview_ext=None)
use_case = _make_use_case([str(missing_model)])
summary = await use_case.migrate_to_centralized(force=True)
assert summary["success"] is True
assert summary["skipped"] == 1
assert summary["moved"] == 0
# Sidecar stays put when the model file is gone.
assert sidecar.exists()
@pytest.mark.asyncio
async def test_migrate_empty_library_is_noop(
library_root: Path, sidecar_root: Path
):
_set_mode("centralized")
recorder = _ProgressRecorder()
use_case = _make_use_case([])
summary = await use_case.migrate_to_centralized(recorder, force=True)
assert summary["success"] is True
assert summary["models_total"] == 0
assert summary["moved"] == 0
statuses = [payload["status"] for payload in recorder.payloads]
assert statuses == ["started", "completed"]
@pytest.mark.asyncio
async def test_migrate_to_centralized_refuses_when_already_centralized(
library_root: Path, sidecar_root: Path
):
_set_mode("centralized")
use_case = _make_use_case([])
summary = await use_case.migrate_to_centralized()
assert summary["success"] is False
assert "already centralized" in summary["error"]
assert summary["moved"] == 0
@pytest.mark.asyncio
async def test_migrate_to_alongside_refuses_when_already_alongside(
library_root: Path, sidecar_root: Path
):
_set_mode("alongside")
use_case = _make_use_case([])
summary = await use_case.migrate_to_alongside()
assert summary["success"] is False
assert "already alongside" in summary["error"]
assert summary["moved"] == 0
def _make_use_case_with_entries(entries: List[Dict[str, Any]]) -> SidecarMigrationUseCase:
scanner = _FakeScanner(entries)
async def scanner_factory() -> _FakeScanner:
return scanner
use_case = SidecarMigrationUseCase(
scanner_factories=(("lora", scanner_factory),),
settings_service=get_settings_manager(),
)
use_case._test_scanner = scanner
return use_case
@pytest.mark.asyncio
async def test_migrate_reconcile_clears_stale_preview_when_none_remains(
library_root: Path, sidecar_root: Path
):
_set_mode("centralized")
model = _write_model(library_root, "model")
_write_sidecar(library_root, "model", model, preview_ext=None)
stale_url = _normalize(library_root / "model.preview.webp")
entries = [
{"file_path": str(model), "preview_url": stale_url, "preview_nsfw_level": 4}
]
use_case = _make_use_case_with_entries(entries)
summary = await use_case.migrate_to_centralized(force=True)
assert summary["success"] is True
entry = use_case._test_scanner._cache.raw_data[0]
# No preview exists in either layout: the stale reference is cleared.
assert entry["preview_url"] == ""
assert use_case._test_scanner.persist_calls == 1
@pytest.mark.asyncio
async def test_migrate_reconcile_survives_per_model_errors(
library_root: Path, sidecar_root: Path, monkeypatch: pytest.MonkeyPatch
):
_set_mode("centralized")
model_ok = _write_model(library_root, "ok")
_write_sidecar(library_root, "ok", model_ok)
(library_root / "ok.preview.webp").write_bytes(b"preview")
model_bad = _write_model(library_root, "bad")
_write_sidecar(library_root, "bad", model_bad, preview_ext=None)
use_case = _make_use_case([str(model_ok), str(model_bad)])
original = use_case._migrate_model
async def failing_migrate(model_path: str, **kwargs):
if os.path.basename(model_path) == "bad.safetensors":
raise RuntimeError("boom")
return await original(model_path, **kwargs)
monkeypatch.setattr(use_case, "_migrate_model", failing_migrate)
summary = await use_case.migrate_to_centralized(force=True)
assert summary["success"] is False
assert summary["error_count"] == 1
# The healthy model's cache entry is still reconciled and persisted.
ok_entry = use_case._test_scanner._cache.raw_data[0]
assert ok_entry["preview_url"] == _normalize(
_mirror_dir(library_root, sidecar_root) / "ok.preview.webp"
)
assert use_case._test_scanner.persist_calls == 1
@pytest.mark.asyncio
async def test_migrate_covers_mixed_case_and_example_previews(
library_root: Path, sidecar_root: Path
):
"""Previews like model.WEBP / model.example.0.jpeg migrate too (#225 compat)."""
_set_mode("centralized")
model = _write_model(library_root, "model")
_write_sidecar(library_root, "model", model, preview_ext=".preview.WEBP")
(library_root / "model.preview.WEBP").write_bytes(b"preview")
(library_root / "model.example.0.jpeg").write_bytes(b"example")
use_case = _make_use_case([str(model)])
summary = await use_case.migrate_to_centralized(force=True)
assert summary["success"] is True
assert summary["moved"] == 3 # sidecar + 2 previews
mirror = _mirror_dir(library_root, sidecar_root)
assert (mirror / "model.preview.WEBP").exists()
assert (mirror / "model.example.0.jpeg").exists()
assert not (library_root / "model.preview.WEBP").exists()
assert not (library_root / "model.example.0.jpeg").exists()
metadata = json.loads((mirror / "model.metadata.json").read_text(encoding="utf-8"))
assert metadata["preview_url"] == _normalize(mirror / "model.preview.WEBP")
@pytest.mark.asyncio
async def test_migrate_root_relocates_tree_and_reconciles(
library_root: Path, sidecar_root: Path, tmp_path: Path
):
_set_mode("centralized")
library = get_settings_manager().get_active_library_name()
component = root_mirror_component(str(library_root))
# Assets under the OLD root, mirroring the layout.
old_root = tmp_path / "old_sidecars"
old_mirror = old_root / library / component / "sub"
old_mirror.mkdir(parents=True)
model = _write_model(library_root / "sub", "model")
payload = {
"file_name": "model",
"file_path": _normalize(model),
"preview_url": _normalize(old_mirror / "model.preview.png"),
}
(old_mirror / "model.metadata.json").write_text(json.dumps(payload), encoding="utf-8")
(old_mirror / "model.preview.png").write_bytes(b"preview")
entries = [
{
"file_path": str(model),
"preview_url": _normalize(old_mirror / "model.preview.png"),
"preview_nsfw_level": 2,
}
]
use_case = _make_use_case_with_entries(entries)
summary = await use_case.migrate_root(str(old_root), force=True)
assert summary["success"] is True
assert summary["moved"] == 2
new_mirror = sidecar_root / library / component / "sub"
assert (new_mirror / "model.metadata.json").exists()
assert (new_mirror / "model.preview.png").exists()
# Sidecar preview_url rewritten onto the new root.
migrated = json.loads((new_mirror / "model.metadata.json").read_text(encoding="utf-8"))
assert migrated["preview_url"] == _normalize(new_mirror / "model.preview.png")
# Model path fields untouched — model files never move.
assert migrated["file_path"] == _normalize(model)
# Scanner cache preview URLs repointed and persisted.
entry = use_case._test_scanner._cache.raw_data[0]
assert entry["preview_url"] == _normalize(new_mirror / "model.preview.png")
assert use_case._test_scanner.persist_calls == 1
# Emptied old tree pruned.
assert not old_root.exists()
@pytest.mark.asyncio
async def test_migrate_root_preserves_a_reanchored_identity(
library_root: Path, sidecar_root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
"""Relocating the sidecar root must not displace a pinned mirror name.
A root that moved earlier is mirrored under a name derived from its *old*
path. If anything resolves against the destination sidecar root before the
relocation runs, it writes a map naming the mirror after the *current*
path; the relocation must keep the pinned entry, because the directories
actually being moved are named after it.
"""
_set_mode("centralized")
first_sidecars = tmp_path / "first_sidecars"
get_settings_manager().set("sidecar_storage_path", str(first_sidecars))
reset_root_map_cache()
model = _write_model(library_root / "sub", "model")
pinned = root_mirror_component(str(library_root))
original = Path(get_metadata_path(str(model)))
original.parent.mkdir(parents=True, exist_ok=True)
original.write_text(json.dumps({"favorite": True}), encoding="utf-8")
# The model root moves; the recorded identity is re-anchored to it.
moved_root = tmp_path / "relocated" / "loras"
shutil.copytree(library_root, moved_root)
monkeypatch.setattr(config, "loras_roots", [str(moved_root)], raising=False)
reset_root_map_cache()
moved_model = moved_root / "sub" / "model.safetensors"
assert Path(get_metadata_path(str(moved_model))) == original
assert root_mirror_component(str(moved_root)) == pinned
# Settings now point at the destination and something resolves first: the
# destination map is written naming the mirror after the *current* path.
get_settings_manager().set("sidecar_storage_path", str(sidecar_root))
reset_root_map_cache()
before_move = Path(get_metadata_path(str(moved_model)))
assert before_move.parent.parent.name != pinned
assert not before_move.exists()
use_case = _make_use_case([str(moved_model)])
summary = await use_case.migrate_root(str(first_sidecars), force=True)
assert summary["success"] is True
# The metadata travelled with the tree and is still found under the
# pinned identity that names it.
relocated = sidecar_root / original.relative_to(first_sidecars)
assert relocated.exists()
assert json.loads(relocated.read_text(encoding="utf-8")) == {"favorite": True}
reset_root_map_cache()
assert Path(get_metadata_path(str(moved_model))) == relocated
@pytest.mark.asyncio
async def test_migrate_root_guards(
library_root: Path, sidecar_root: Path, tmp_path: Path
):
_set_mode("centralized")
use_case = _make_use_case([])
summary = await use_case.migrate_root("")
assert summary["success"] is False
assert "old_root is required" in summary["error"]
summary = await use_case.migrate_root(str(sidecar_root))
assert summary["success"] is False
assert "matches the configured" in summary["error"]
_set_mode("alongside")
summary = await use_case.migrate_root(str(tmp_path / "old_sidecars"))
assert summary["success"] is False
assert "not centralized" in summary["error"]
@pytest.mark.asyncio
async def test_migrate_root_missing_old_tree_is_noop(
library_root: Path, sidecar_root: Path, tmp_path: Path
):
_set_mode("centralized")
use_case = _make_use_case([])
summary = await use_case.migrate_root(str(tmp_path / "nonexistent"), force=True)
assert summary["success"] is True
assert summary["moved"] == 0
@pytest.mark.asyncio
async def test_migrate_covers_excluded_models(
library_root: Path, sidecar_root: Path
):
"""Excluded models are absent from the cache; their sidecars still move.
Otherwise un-excluding a model later would leave the scanner looking for
a sidecar in the new layout that was never migrated.
"""
_set_mode("centralized")
cached = _write_model(library_root, "cached")
_write_sidecar(library_root, "cached", cached)
(library_root / "cached.preview.webp").write_bytes(b"preview")
excluded = _write_model(library_root / "hidden", "excluded")
_write_sidecar(library_root / "hidden", "excluded", excluded, preview_ext=None)
(library_root / "hidden" / "excluded.preview.webp").write_bytes(b"preview")
use_case = _make_use_case([str(cached)], excluded=[str(excluded)])
summary = await use_case.migrate_to_centralized(force=True)
assert summary["success"] is True
assert summary["models_total"] == 2
assert summary["moved"] == 4
assert summary["sidecar_root"] == str(sidecar_root)
mirror = _mirror_dir(library_root, sidecar_root, "hidden")
assert (mirror / "excluded.metadata.json").exists()
assert (mirror / "excluded.preview.webp").exists()
assert not (library_root / "hidden" / "excluded.metadata.json").exists()
assert not (library_root / "hidden" / "excluded.preview.webp").exists()
# The excluded model is not in the cache, so cache reconciliation is a
# no-op for it and only the cached entry gets persisted.
assert use_case._test_scanner.persist_calls == 1
+98
View File
@@ -211,6 +211,104 @@ def test_update_image_metadata_preserves_png_workflow(tmp_path):
)
def test_optimize_image_embeds_supplied_workflow_when_source_has_none(tmp_path):
"""Import paths hand the workflow over as data when the preview source is
metadata-free (CivitAI's optimized rendition); optimize_image must embed
it while re-encoding, otherwise the recipe loses has_workflow."""
image_path = tmp_path / "optimized.webp"
Image.new("RGB", (64, 32), color="red").save(image_path, format="WEBP", quality=85)
workflow = {"nodes": [{"id": 1}], "last_node_id": 1}
optimized_data, extension = ExifUtils.optimize_image(
str(image_path),
target_width=32,
format="webp",
quality=85,
preserve_metadata=True,
workflow=workflow,
)
optimized_path = tmp_path / f"embedded{extension}"
optimized_path.write_bytes(optimized_data)
metadata = ExifUtils._load_structured_metadata(str(optimized_path))
assert metadata["workflow"] == json.dumps(workflow)
def test_optimize_image_keeps_source_workflow_over_supplied(tmp_path):
image_path = tmp_path / "source.png"
png_info = PngImagePlugin.PngInfo()
png_info.add_text("workflow", '{"nodes":[{"id":7}]}')
Image.new("RGB", (64, 32), color="red").save(image_path, pnginfo=png_info)
optimized_data, extension = ExifUtils.optimize_image(
str(image_path),
target_width=32,
format="webp",
quality=85,
preserve_metadata=True,
workflow={"nodes": [{"id": 1}]},
)
optimized_path = tmp_path / f"sourcewins{extension}"
optimized_path.write_bytes(optimized_data)
metadata = ExifUtils._load_structured_metadata(str(optimized_path))
assert metadata["workflow"] == '{"nodes":[{"id":7}]}'
def test_embed_workflow_adds_workflow_to_metadata_free_webp(tmp_path):
image_path = tmp_path / "preview.webp"
Image.new("RGB", (32, 32), color="blue").save(image_path, format="WEBP", quality=85)
workflow = json.dumps({"nodes": [{"id": 1}]})
returned = ExifUtils.embed_workflow(str(image_path), workflow)
assert returned == str(image_path)
metadata = ExifUtils._load_structured_metadata(str(image_path))
assert metadata["workflow"] == workflow
with Image.open(image_path) as img:
assert img.size == (32, 32)
def test_embed_workflow_adds_workflow_to_metadata_free_png(tmp_path):
image_path = tmp_path / "preview.png"
Image.new("RGB", (32, 32), color="blue").save(image_path)
workflow = {"nodes": [{"id": 3}]}
ExifUtils.embed_workflow(str(image_path), workflow)
metadata = ExifUtils._load_structured_metadata(str(image_path))
assert metadata["workflow"] == json.dumps(workflow)
def test_embed_workflow_leaves_existing_workflow_untouched(tmp_path):
image_path = tmp_path / "preview.png"
png_info = PngImagePlugin.PngInfo()
png_info.add_text("workflow", '{"nodes":[{"id":9}]}')
Image.new("RGB", (32, 32), color="green").save(image_path, pnginfo=png_info)
ExifUtils.embed_workflow(str(image_path), {"nodes": [{"id": 1}]})
with Image.open(image_path) as img:
assert img.info["workflow"] == '{"nodes":[{"id":9}]}'
def test_embed_workflow_ignores_unsupported_payloads_and_containers(tmp_path):
image_path = tmp_path / "preview.webp"
Image.new("RGB", (16, 16), color="black").save(image_path, format="WEBP")
# Nothing to embed / unsupported payload types are no-ops.
assert ExifUtils.embed_workflow(str(image_path), None) == str(image_path)
assert ExifUtils.embed_workflow(str(image_path), "") == str(image_path)
assert ExifUtils.embed_workflow(str(image_path), 123) == str(image_path)
assert ExifUtils._load_structured_metadata(str(image_path))["workflow"] is None
video_path = tmp_path / "clip.mp4"
video_path.write_bytes(b"video")
assert ExifUtils.embed_workflow(str(video_path), {"nodes": []}) == str(video_path)
# --- ISOBMFF / brotli extraction tests ---
import struct
+668
View File
@@ -0,0 +1,668 @@
"""Tests for py.utils.sidecar_paths (alongside + centralized storage modes)."""
from __future__ import annotations
import hashlib
import json
import os
import shutil
from pathlib import Path
import pytest
from py.services.settings_manager import get_settings_manager
from py.utils import sidecar_paths
from py.utils.sidecar_paths import (
METADATA_SUFFIX,
ROOT_MAP_FILENAME,
get_configured_sidecar_root,
get_metadata_path,
get_preview_dir,
get_sidecar_dir,
get_sidecar_root,
get_storage_mode,
get_unmatched_sidecar_components,
is_centralized,
is_metadata_path,
relocate_root_map,
resolve_centralized_dir,
resolve_centralized_dir_for_dir,
resolve_metadata_path,
root_mirror_component,
sanitize_path_component,
)
def _write_map(root: Path, entries: dict) -> Path:
root.mkdir(parents=True, exist_ok=True)
path = root / ROOT_MAP_FILENAME
path.write_text(
json.dumps({"version": 1, "roots": entries}), encoding="utf-8"
)
return path
def _normalize(path: Path) -> str:
return str(path).replace(os.sep, "/")
def _legacy_component(root) -> str:
"""Independent reimplementation of the pre-identity-map component name."""
normalized = os.path.normpath(os.path.abspath(str(root)))
digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:8]
return f"{sanitize_path_component(os.path.basename(normalized))}-{digest}"
@pytest.fixture(autouse=True)
def _reset_root_map_cache():
"""Identities are cached per sidecar root; keep tests order-independent."""
sidecar_paths.reset_root_map_cache()
yield
sidecar_paths.reset_root_map_cache()
@pytest.fixture
def model_roots(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict:
"""Point every config model root the sidecar module reads at tmp_path."""
from py.config import config
loras = tmp_path / "loras"
checkpoints = tmp_path / "checkpoints"
loras.mkdir()
checkpoints.mkdir()
for attr, value in (
("loras_roots", [str(loras)]),
("base_models_roots", [str(checkpoints)]),
("embeddings_roots", []),
("other_roots", []),
("extra_loras_roots", []),
("extra_checkpoints_roots", []),
("extra_unet_roots", []),
("extra_embeddings_roots", []),
):
monkeypatch.setattr(config, attr, value, raising=False)
return {"loras": loras, "checkpoints": checkpoints}
@pytest.fixture
def centralized(model_roots: dict, tmp_path: Path) -> Path:
"""Enable centralized mode rooted at tmp_path/sidecars; returns the root."""
sidecar_root = tmp_path / "sidecars"
settings = get_settings_manager()
settings.set("sidecar_storage_mode", "centralized")
settings.set("sidecar_storage_path", str(sidecar_root))
return sidecar_root
def _write_sidecar(model_path: Path, payload: dict | None = None) -> Path:
"""Create the model file and its sidecar at the module-resolved path."""
model_path.parent.mkdir(parents=True, exist_ok=True)
model_path.write_bytes(b"weights")
sidecar = Path(get_metadata_path(str(model_path)))
sidecar.parent.mkdir(parents=True, exist_ok=True)
sidecar.write_text(json.dumps(payload or {"favorite": True}), encoding="utf-8")
return sidecar
class TestAlongsideMode:
def test_default_mode_is_alongside(self):
assert get_storage_mode() == "alongside"
assert not is_centralized()
assert get_sidecar_root() == ""
def test_metadata_path_next_to_model(self, tmp_path: Path):
model = tmp_path / "sub" / "model.safetensors"
assert get_metadata_path(str(model)) == os.path.join(
str(tmp_path), "sub", "model" + METADATA_SUFFIX
)
def test_preview_and_sidecar_dir_are_model_dir(self, tmp_path: Path):
model = tmp_path / "sub" / "model.safetensors"
expected = os.path.dirname(os.path.abspath(str(model)))
assert get_sidecar_dir(str(model)) == expected
assert get_preview_dir(str(model)) == expected
def test_alongside_resolution_touches_nothing(
self, model_roots: dict, tmp_path: Path
):
"""The default branch must not load, create, or scan the mirror tree."""
sidecar_root = tmp_path / "sidecars"
settings = get_settings_manager()
settings.set("sidecar_storage_mode", "alongside")
settings.set("sidecar_storage_path", str(sidecar_root))
model = model_roots["loras"] / "sub" / "model.safetensors"
assert get_metadata_path(str(model)) == os.path.join(
str(model_roots["loras"]), "sub", "model" + METADATA_SUFFIX
)
assert get_preview_dir(str(model)) == os.path.join(
str(model_roots["loras"]), "sub"
)
assert not sidecar_root.exists()
assert sidecar_paths._ROOT_MAPS == {}
class TestPathPredicates:
def test_is_metadata_path(self):
assert is_metadata_path("/x/model.metadata.json")
assert not is_metadata_path("/x/model.safetensors")
assert not is_metadata_path("/x/model.metadata.json.bak")
def test_resolve_metadata_path_passthrough_for_sidecar(self):
sidecar = "/x/model.metadata.json"
assert resolve_metadata_path(sidecar) == sidecar
def test_resolve_metadata_path_derives_for_model(self, tmp_path: Path):
model = tmp_path / "model.safetensors"
assert resolve_metadata_path(str(model)) == get_metadata_path(str(model))
class TestSanitizePathComponent:
def test_special_characters_replaced(self):
assert sanitize_path_component("foo bar/baz:qux") == "foo_bar_baz_qux"
def test_safe_characters_kept(self):
assert sanitize_path_component("Flux-1.dev_v2") == "Flux-1.dev_v2"
def test_empty_falls_back_to_underscore(self):
assert sanitize_path_component("") == "_"
assert sanitize_path_component(None) == "_"
class TestCentralizedMode:
def test_mirror_layout(self, model_roots: dict, centralized: Path):
model = model_roots["loras"] / "styles" / "anime" / "model.safetensors"
root_component = root_mirror_component(str(model_roots["loras"]))
metadata_path = get_metadata_path(str(model))
expected = os.path.join(
str(centralized), root_component, "styles", "anime", "model" + METADATA_SUFFIX
)
assert metadata_path == expected
assert get_preview_dir(str(model)) == os.path.dirname(expected)
assert is_centralized()
def test_same_basename_roots_get_distinct_mirrors(
self, model_roots: dict, centralized: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
from py.config import config
# Two roots sharing the basename "loras" must not share a mirror dir.
other_parent = tmp_path / "elsewhere"
other_root = other_parent / "loras"
other_root.mkdir(parents=True)
monkeypatch.setattr(
config,
"loras_roots",
[str(model_roots["loras"]), str(other_root)],
raising=False,
)
model_a = model_roots["loras"] / "model.safetensors"
model_b = other_root / "model.safetensors"
dir_a = resolve_centralized_dir(str(model_a))
dir_b = resolve_centralized_dir(str(model_b))
assert dir_a is not None and dir_b is not None
assert dir_a != dir_b
assert root_mirror_component(str(model_roots["loras"])) != root_mirror_component(
str(other_root)
)
# Same root always maps to the same component (pinned identity).
assert root_mirror_component(str(model_roots["loras"])) == root_mirror_component(
str(model_roots["loras"]) + os.sep
)
def test_longest_root_wins(self, model_roots: dict, centralized: Path, monkeypatch: pytest.MonkeyPatch):
from py.config import config
nested = model_roots["loras"] / "nested"
nested.mkdir()
monkeypatch.setattr(
config,
"loras_roots",
[str(model_roots["loras"]), str(nested)],
raising=False,
)
model = nested / "model.safetensors"
assert get_metadata_path(str(model)) == os.path.join(
str(centralized), root_mirror_component(str(nested)), "model" + METADATA_SUFFIX
)
def test_outside_roots_falls_back_to_alongside(
self, model_roots: dict, centralized: Path, tmp_path: Path
):
outside = tmp_path / "elsewhere" / "model.safetensors"
assert resolve_centralized_dir(str(outside)) is None
assert get_sidecar_dir(str(outside)) == os.path.dirname(
os.path.abspath(str(outside))
)
assert get_metadata_path(str(outside)) == os.path.join(
str(tmp_path), "elsewhere", "model" + METADATA_SUFFIX
)
def test_resolve_centralized_dir_for_dir_matches_model_resolution(
self, model_roots: dict, centralized: Path
):
model_dir = model_roots["checkpoints"] / "sub"
model = model_dir / "model.safetensors"
assert resolve_centralized_dir_for_dir(str(model_dir)) == resolve_centralized_dir(
str(model)
)
def test_resolve_centralized_dir_for_dir_root_maps_to_mirror_base(
self, model_roots: dict, centralized: Path
):
assert resolve_centralized_dir_for_dir(str(model_roots["loras"])) == os.path.join(
str(centralized), root_mirror_component(str(model_roots["loras"]))
)
def test_empty_path_uses_default_sidecar_root(self, model_roots: dict, tmp_path: Path):
settings = get_settings_manager()
settings.set("sidecar_storage_mode", "centralized")
settings.set("sidecar_storage_path", "")
root = get_sidecar_root()
assert root
assert root.endswith(os.sep + "sidecars")
assert is_centralized()
class TestRootIdentityMap:
"""The root component is a remembered identity, not a path hash."""
def test_component_is_pinned_in_the_map_file(
self, model_roots: dict, centralized: Path
):
model = model_roots["loras"] / "sub" / "model.safetensors"
first = get_metadata_path(str(model))
map_path = centralized / ROOT_MAP_FILENAME
assert map_path.exists()
payload = json.loads(map_path.read_text(encoding="utf-8"))
persisted = {entry["component"] for entry in payload["roots"].values()}
assert root_mirror_component(str(model_roots["loras"])) in persisted
# Restarting the process must resolve the exact same location.
sidecar_paths.reset_root_map_cache()
assert get_metadata_path(str(model)) == first
def test_component_is_deterministic_and_survives_a_lost_map(
self, model_roots: dict, centralized: Path
):
"""Deleting the map must not strand a mirror whose root has not moved."""
model = model_roots["loras"] / "sub" / "model.safetensors"
first = _write_sidecar(model)
assert Path(get_metadata_path(str(model))) == first
(centralized / ROOT_MAP_FILENAME).unlink()
sidecar_paths.reset_root_map_cache()
assert Path(get_metadata_path(str(model))) == first
assert root_mirror_component(str(model_roots["loras"])) == _legacy_component(
model_roots["loras"]
)
def test_legacy_path_derived_component_is_adopted(
self, model_roots: dict, centralized: Path
):
"""Upgrading from the hash-named layout keeps existing mirrors usable."""
legacy = _legacy_component(model_roots["loras"])
sidecar = centralized / legacy / "sub" / ("model" + METADATA_SUFFIX)
sidecar.parent.mkdir(parents=True)
sidecar.write_text(json.dumps({"favorite": True}), encoding="utf-8")
model = model_roots["loras"] / "sub" / "model.safetensors"
assert get_metadata_path(str(model)) == str(sidecar)
assert root_mirror_component(str(model_roots["loras"])) == legacy
def test_pre_identity_map_library_nested_component_is_adopted(
self, model_roots: dict, centralized: Path
):
"""Mirrors created by the pre-identity-map build nested under a library."""
legacy = _legacy_component(model_roots["loras"])
sidecar = (
centralized / "comfyui" / legacy / "sub" / ("model" + METADATA_SUFFIX)
)
sidecar.parent.mkdir(parents=True)
sidecar.write_text(json.dumps({"favorite": True}), encoding="utf-8")
model = model_roots["loras"] / "sub" / "model.safetensors"
assert get_metadata_path(str(model)) == str(sidecar)
assert root_mirror_component(str(model_roots["loras"])) == f"comfyui/{legacy}"
# The adopted tree counts as linked, not orphaned.
assert get_unmatched_sidecar_components() == []
def test_moved_root_reuses_its_mirror(
self, model_roots: dict, centralized: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
"""A relocated (and reconfigured) root keeps its remembered identity."""
from py.config import config
model = model_roots["loras"] / "sub" / "model.safetensors"
before = _write_sidecar(model)
moved_root = tmp_path / "relocated" / "loras"
shutil.copytree(model_roots["loras"], moved_root)
monkeypatch.setattr(config, "loras_roots", [str(moved_root)], raising=False)
sidecar_paths.reset_root_map_cache()
moved_model = moved_root / "sub" / "model.safetensors"
after = Path(get_metadata_path(str(moved_model)))
assert after == before
assert json.loads(after.read_text(encoding="utf-8")) == {"favorite": True}
def test_renamed_root_reanchors_via_directory_overlap(
self, model_roots: dict, centralized: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
"""A rename that changes the basename still re-anchors when the tree matches."""
from py.config import config
model = model_roots["loras"] / "sub" / "model.safetensors"
before = _write_sidecar(model)
renamed_root = tmp_path / "brand-new-name"
shutil.copytree(model_roots["loras"], renamed_root)
monkeypatch.setattr(config, "loras_roots", [str(renamed_root)], raising=False)
sidecar_paths.reset_root_map_cache()
renamed_model = renamed_root / "sub" / "model.safetensors"
assert Path(get_metadata_path(str(renamed_model))) == before
def test_ambiguous_reanchor_reports_orphans_instead_of_guessing(
self, model_roots: dict, centralized: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
from py.config import config
model = model_roots["loras"] / "sub" / "model.safetensors"
before = _write_sidecar(model)
component = before.parent.parent.name
# Two equally plausible candidates: never silently pick one.
candidate_a = tmp_path / "a" / "loras"
candidate_b = tmp_path / "b" / "loras"
shutil.copytree(model_roots["loras"], candidate_a)
shutil.copytree(model_roots["loras"], candidate_b)
monkeypatch.setattr(
config, "loras_roots", [str(candidate_a), str(candidate_b)], raising=False
)
sidecar_paths.reset_root_map_cache()
for candidate in (candidate_a, candidate_b):
resolved = Path(
get_metadata_path(str(candidate / "sub" / "model.safetensors"))
)
assert resolved != before
assert component not in str(resolved)
orphans = get_unmatched_sidecar_components()
assert [item["component"] for item in orphans] == [component]
def test_removed_root_surfaces_its_component_as_orphan(
self, model_roots: dict, centralized: Path, monkeypatch: pytest.MonkeyPatch
):
from py.config import config
model = model_roots["loras"] / "sub" / "model.safetensors"
sidecar = _write_sidecar(model)
component = sidecar.parent.parent.name
monkeypatch.setattr(config, "loras_roots", [], raising=False)
sidecar_paths.reset_root_map_cache()
assert [item["component"] for item in get_unmatched_sidecar_components()] == [
component
]
def test_unmatched_helper_is_empty_in_alongside_mode(
self, model_roots: dict, tmp_path: Path
):
settings = get_settings_manager()
settings.set("sidecar_storage_mode", "alongside")
settings.set("sidecar_storage_path", str(tmp_path / "sidecars"))
assert get_unmatched_sidecar_components() == []
assert not (tmp_path / "sidecars").exists()
def test_relocated_sidecar_root_resolves_after_map_written_first(
self, model_roots: dict, tmp_path: Path
):
"""A resolve before the mirror tree is relocated must not rename it."""
settings = get_settings_manager()
old_root = tmp_path / "old-sidecars"
new_root = tmp_path / "new-sidecars"
settings.set("sidecar_storage_mode", "centralized")
settings.set("sidecar_storage_path", str(old_root))
model = model_roots["loras"] / "sub" / "model.safetensors"
sidecar = _write_sidecar(model)
# The path setting changes first, and something resolves against the
# new root before the user runs the relocation.
settings.set("sidecar_storage_path", str(new_root))
sidecar_paths.reset_root_map_cache()
expected = new_root / sidecar.relative_to(old_root)
assert Path(get_metadata_path(str(model))) == expected
assert not expected.exists()
# Now the mirror tree moves, exactly as migrate_root does.
for dirpath, _dirnames, filenames in os.walk(old_root):
rel = os.path.relpath(dirpath, old_root)
target_dir = new_root if rel == os.curdir else new_root / rel
target_dir.mkdir(parents=True, exist_ok=True)
for filename in filenames:
shutil.move(str(Path(dirpath) / filename), str(target_dir / filename))
sidecar_paths.reset_root_map_cache()
assert Path(get_metadata_path(str(model))) == expected
assert json.loads(expected.read_text(encoding="utf-8")) == {"favorite": True}
def test_relocate_root_map_prefers_the_source_entries(self, tmp_path: Path):
"""A map written at the destination before a relocation must not win."""
source = tmp_path / "old-sidecars"
destination = tmp_path / "new-sidecars"
_write_map(
source,
{
"aaaa1111": {
"component": "loras-pinned",
"basename": "loras",
"last_path": "/models/loras",
"sample_rel_dirs": [],
}
},
)
_write_map(
destination,
{
"bbbb2222": {
"component": "loras-recomputed",
"basename": "loras",
"last_path": "/models/loras",
"sample_rel_dirs": [],
},
"cccc3333": {
"component": "vae-other",
"basename": "vae",
"last_path": "/models/vae",
"sample_rel_dirs": [],
},
},
)
assert relocate_root_map(str(source), str(destination)) is True
merged = json.loads(
(destination / ROOT_MAP_FILENAME).read_text(encoding="utf-8")
)["roots"]
assert merged["aaaa1111"]["component"] == "loras-pinned"
assert "bbbb2222" not in merged # superseded for the same root path
assert merged["cccc3333"]["component"] == "vae-other"
assert not (source / ROOT_MAP_FILENAME).exists()
def test_relocate_root_map_without_a_source_map_is_a_noop(self, tmp_path: Path):
source = tmp_path / "old-sidecars"
destination = tmp_path / "new-sidecars"
_write_map(
destination,
{
"bbbb2222": {
"component": "loras-keep",
"basename": "loras",
"last_path": "/models/loras",
"sample_rel_dirs": [],
}
},
)
assert relocate_root_map(str(source), str(destination)) is True
kept = json.loads(
(destination / ROOT_MAP_FILENAME).read_text(encoding="utf-8")
)["roots"]
assert kept["bbbb2222"]["component"] == "loras-keep"
def test_corrupt_map_file_is_ignored_and_rebuilt(
self, model_roots: dict, centralized: Path
):
model = model_roots["loras"] / "model.safetensors"
map_path = centralized / ROOT_MAP_FILENAME
get_metadata_path(str(model))
map_path.write_text("{not json", encoding="utf-8")
sidecar_paths.reset_root_map_cache()
path_after = get_metadata_path(str(model))
assert json.loads(map_path.read_text(encoding="utf-8"))["roots"]
assert os.path.basename(os.path.dirname(path_after)).startswith("loras-")
def test_root_map_file_is_not_mistaken_for_a_component(
self, model_roots: dict, centralized: Path
):
model = model_roots["loras"] / "model.safetensors"
get_metadata_path(str(model))
assert (centralized / ROOT_MAP_FILENAME).is_file()
assert get_unmatched_sidecar_components() == []
class TestModeIndependentResolution:
"""Migration tooling resolves the mirror layout regardless of active mode."""
def test_configured_root_resolves_in_alongside_mode(self, tmp_path: Path):
sidecar_root = tmp_path / "sidecars"
settings = get_settings_manager()
settings.set("sidecar_storage_mode", "alongside")
settings.set("sidecar_storage_path", str(sidecar_root))
assert get_sidecar_root() == ""
assert get_configured_sidecar_root() == os.path.abspath(str(sidecar_root))
def test_configured_root_defaults_to_settings_dir(self):
settings = get_settings_manager()
settings.set("sidecar_storage_mode", "alongside")
settings.set("sidecar_storage_path", "")
root = get_configured_sidecar_root()
assert root
assert root.endswith(os.sep + "sidecars")
def test_resolve_centralized_dir_for_dir_with_explicit_root(
self, model_roots: dict, tmp_path: Path
):
sidecar_root = tmp_path / "sidecars"
settings = get_settings_manager()
settings.set("sidecar_storage_mode", "alongside")
settings.set("sidecar_storage_path", str(sidecar_root))
model_dir = model_roots["loras"] / "sub"
component = root_mirror_component(
str(model_roots["loras"]), sidecar_root=str(sidecar_root)
)
# Alongside mode: no root resolves without the override.
assert resolve_centralized_dir_for_dir(str(model_dir)) is None
assert resolve_centralized_dir_for_dir(
str(model_dir), sidecar_root=str(sidecar_root)
) == os.path.join(str(sidecar_root), component, "sub")
class TestSettingsValidation:
def test_invalid_mode_falls_back_to_alongside(self):
settings = get_settings_manager()
settings.set("sidecar_storage_mode", "bogus")
assert settings.get("sidecar_storage_mode") == "alongside"
def test_mode_is_normalized(self):
settings = get_settings_manager()
settings.set("sidecar_storage_mode", " Centralized ")
assert settings.get("sidecar_storage_mode") == "centralized"
def test_path_is_normalized_to_absolute(self, tmp_path: Path):
settings = get_settings_manager()
settings.set("sidecar_storage_path", str(tmp_path / "sidecars"))
assert settings.get("sidecar_storage_path") == os.path.abspath(
str(tmp_path / "sidecars")
)
def test_non_string_path_becomes_empty(self):
settings = get_settings_manager()
settings.set("sidecar_storage_path", None)
assert settings.get("sidecar_storage_path") == ""
class TestDescribeSidecarRoot:
def test_configured_root(self, tmp_path: Path):
settings = get_settings_manager()
root = tmp_path / "sidecars-custom"
settings.set("sidecar_storage_path", str(root))
info = sidecar_paths.describe_sidecar_root()
assert info["root"] == os.path.abspath(str(root))
assert info["is_default"] is False
assert info["inside_repo"] is False
def test_default_root_marks_is_default(self):
settings = get_settings_manager()
settings.set("sidecar_storage_path", "")
info = sidecar_paths.describe_sidecar_root()
assert info["root"].endswith(os.sep + "sidecars")
assert info["is_default"] is True
def test_inside_repo_detection(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
sidecar_paths, "_installation_root", lambda: str(tmp_path / "repo")
)
settings = get_settings_manager()
settings.set(
"sidecar_storage_path", str(tmp_path / "repo" / "sidecars")
)
assert sidecar_paths.describe_sidecar_root()["inside_repo"] is True
settings.set("sidecar_storage_path", str(tmp_path / "elsewhere"))
assert sidecar_paths.describe_sidecar_root()["inside_repo"] is False
+14
View File
@@ -873,6 +873,19 @@ export function createContextMenu(x, y, loraName, widget, previewTooltip, render
}
);
// Save recipe with the current graph embedded. Kept opt-in rather than
// folded into "Save Recipe": the workflow dwarfs every other metadata field
// and can carry sensitive widget values, so it stays an explicit choice.
const saveWithWorkflowOption = createMenuItem(
'Save Recipe with Workflow',
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"></circle><circle cx="6" cy="12" r="3"></circle><circle cx="18" cy="19" r="3"></circle><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line></svg>',
() => {
menu.remove();
document.removeEventListener('click', closeMenu);
saveRecipeDirectly({ embedWorkflow: true });
}
);
// Move Up option with arrow up icon
const moveUpOption = createMenuItem(
'Move Up',
@@ -982,6 +995,7 @@ export function createContextMenu(x, y, loraName, widget, previewTooltip, render
menu.appendChild(copyTriggerWordsOption);
menu.appendChild(separator2);
menu.appendChild(saveOption);
menu.appendChild(saveWithWorkflowOption);
document.body.appendChild(menu);
+36 -9
View File
@@ -460,15 +460,29 @@ export function syncClipStrengthIfCollapsed(loraData) {
}
// Function to directly save the recipe without dialog
export async function saveRecipeDirectly() {
export async function saveRecipeDirectly({ embedWorkflow = false } = {}) {
try {
const prompt = await app.graphToPrompt();
console.log('Prompt:', prompt); // for debugging purposes
// Embedding the graph is opt-in: it is by far the largest metadata field
// and its widget values can contain sensitive data (paths, API keys). The
// UI-format graph is sent rather than the API prompt so node layout and
// groups survive — that is what "Send Workflow to ComfyUI" restores.
const requestBody = {};
if (embedWorkflow) {
if (prompt && prompt.workflow) {
requestBody.workflow = prompt.workflow;
} else {
showToast('No workflow available to embed; saving the recipe without it', 'warning');
}
}
// Show loading toast
if (app && app.extensionManager && app.extensionManager.toast) {
app.extensionManager.toast.add({
severity: 'info',
summary: 'Saving Recipe',
summary: embedWorkflow ? 'Saving Recipe with Workflow' : 'Saving Recipe',
detail: 'Please wait...',
life: 2000
});
@@ -476,7 +490,9 @@ export async function saveRecipeDirectly() {
// Send the request to the backend API
const response = await fetch(lmUrl('/api/lm/recipes/save-from-widget'), {
method: 'POST'
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
const result = await response.json();
@@ -484,12 +500,23 @@ export async function saveRecipeDirectly() {
// Show result toast
if (app && app.extensionManager && app.extensionManager.toast) {
if (result.success) {
app.extensionManager.toast.add({
severity: 'success',
summary: 'Recipe Saved',
detail: 'Recipe has been saved successfully',
life: 3000
});
let severity = 'success';
let summary = embedWorkflow ? 'Recipe Saved with Workflow' : 'Recipe Saved';
let detail = embedWorkflow
? 'Recipe and the current workflow have been saved'
: 'Recipe has been saved successfully';
if (embedWorkflow && result.workflow_skipped === 'too_large') {
severity = 'warn';
summary = 'Recipe Saved without Workflow';
detail = 'The workflow is too large to embed; the recipe was saved without it';
} else if (embedWorkflow && result.has_workflow !== true) {
severity = 'warn';
summary = 'Recipe Saved without Workflow';
detail = 'The workflow could not be embedded in the recipe image';
}
app.extensionManager.toast.add({ severity, summary, detail, life: 5000 });
} else {
app.extensionManager.toast.add({
severity: 'error',