mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 11:11:26 -03:00
Compare commits
22 Commits
v1.2.3
..
521531111a
| Author | SHA1 | Date | |
|---|---|---|---|
| 521531111a | |||
| 474da1b264 | |||
| 78d38b449e | |||
| 2bc9860b24 | |||
| 327da0465b | |||
| c8c84bfc54 | |||
| 3b9e8efb3d | |||
| d45a523fb5 | |||
| 6dc9f34f7d | |||
| 5adfa3be36 | |||
| d4b82d98b2 | |||
| 8c1c1691e3 | |||
| e14a084f0d | |||
| c55c6f0a41 | |||
| 7d963b27b5 | |||
| eba03800b9 | |||
| bf497d5144 | |||
| 369613f811 | |||
| 9eeebac40b | |||
| b9a516c9f8 | |||
| ef7fa7d3dd | |||
| 9c67dbbf15 |
@@ -15,6 +15,7 @@ node_modules/
|
|||||||
coverage/
|
coverage/
|
||||||
.coverage
|
.coverage
|
||||||
model_cache/
|
model_cache/
|
||||||
|
recipe_cache/
|
||||||
|
|
||||||
# agent / dev tooling
|
# agent / dev tooling
|
||||||
.opencode/
|
.opencode/
|
||||||
|
|||||||
@@ -192,6 +192,15 @@ The system runs in two modes:
|
|||||||
- Auto-saves paths to `settings.json` in ComfyUI mode
|
- Auto-saves paths to `settings.json` in ComfyUI mode
|
||||||
- `settings.json.example` is intentionally minimal (see Important Notes); all
|
- `settings.json.example` is intentionally minimal (see Important Notes); all
|
||||||
other defaults live in `DEFAULT_SETTINGS` (`py/services/settings_manager.py`)
|
other defaults live in `DEFAULT_SETTINGS` (`py/services/settings_manager.py`)
|
||||||
|
- **`folder_paths` vs `extra_folder_paths` — different purposes, do not conflate:**
|
||||||
|
- `folder_paths` (primary model roots): in ComfyUI plugin mode these come
|
||||||
|
from the ComfyUI host; in standalone mode they are the ONLY source of
|
||||||
|
model library paths and are currently edited by hand in `settings.json`.
|
||||||
|
- `extra_folder_paths` is a **ComfyUI-plugin-mode feature**: paths visible
|
||||||
|
ONLY to LoRA Manager, not to ComfyUI. Its motivation is that a very large
|
||||||
|
model library slows ComfyUI itself down, while LoRA Manager handles large
|
||||||
|
libraries without performance issues — so users keep ComfyUI's library
|
||||||
|
small and add the bulk via `extra_folder_paths`.
|
||||||
|
|
||||||
### Frontend UI Architecture
|
### Frontend UI Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,27 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
|
|||||||
> prototyped and removed because it collided with the browser's Alt + Arrow handling and the
|
> prototyped and removed because it collided with the browser's Alt + Arrow handling and the
|
||||||
> modal's arrow-key navigation.
|
> modal's arrow-key navigation.
|
||||||
|
|
||||||
|
> **Status (2026-09, standalone no-paths guidance):** the standalone branch of the
|
||||||
|
> `other.noPaths` empty state now shows the real `settings.json` path plus an
|
||||||
|
> `other.noPaths.openSettingsFolder` button (each locale reuses its
|
||||||
|
> `settings.openSettingsFileLocation.label` rendering), and `descriptionStandalone` was
|
||||||
|
> reworded in `en.json` — from "none of the configured folders exist on disk" to "no
|
||||||
|
> other-model folders were found; add the folder keys you need to the `folder_paths`
|
||||||
|
> section" — and re-translated in all 9 locales. The `on disk` phrase now survives only in
|
||||||
|
> the ComfyUI variant (`descriptionComfyUI`).
|
||||||
|
|
||||||
|
> **Status (2026-09, settings Organization tab):** the settings modal split its overloaded
|
||||||
|
> Library tab, adding the single `settings.nav.organization` key (renderings in §2,
|
||||||
|
> "Settings Organization tab"). All 9 locales are translated, so the "no remaining
|
||||||
|
> placeholders" claim holds again.
|
||||||
|
|
||||||
|
> **Status (2026-09, filename templates):** the Filename Templates feature (per-model-type
|
||||||
|
> download filename templates + bulk "Apply to Library Now" rename, with an empty template
|
||||||
|
> restoring recorded original filenames) added 26 keys across `settings.filenameTemplates.*`,
|
||||||
|
> `loras.bulkOperations.filenameTemplateProgress.*`, `modals.filenameTemplateConfirm.*` and
|
||||||
|
> the `toast.loras.filenameTemplate*` / `toast.settings.filenameTemplates*` toasts. All 9
|
||||||
|
> locales are translated (terminology in §2, "Filename Templates feature").
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Hard rules (do not violate)
|
## 1. Hard rules (do not violate)
|
||||||
@@ -362,6 +383,44 @@ clause (and its `—`) when the copy is edited. The `{name}` / `{count}` / `{mes
|
|||||||
`sidebar.createFolderResult.*`, `sidebar.deleteFolderResult.*` and `sidebar.renameFolderResult.*`
|
`sidebar.createFolderResult.*`, `sidebar.deleteFolderResult.*` and `sidebar.renameFolderResult.*`
|
||||||
are verbatim §1-R2 placeholders; `successWithFiles` is the only key carrying `{count}`.
|
are verbatim §1-R2 placeholders; `successWithFiles` is the only key carrying `{count}`.
|
||||||
|
|
||||||
|
### Settings Organization tab
|
||||||
|
|
||||||
|
The settings modal's fourth nav tab groups everything about how files are arranged on
|
||||||
|
disk: download path templates, priority tags, and auto-organize exclusions. The label is
|
||||||
|
the **noun for arranging files**, matching each locale's existing
|
||||||
|
`settings.sections.autoOrganize` rendering minus the "auto":
|
||||||
|
|
||||||
|
| Locale | `settings.nav.organization` |
|
||||||
|
|---|---|
|
||||||
|
| fr | Organisation |
|
||||||
|
| zh-CN | 整理 |
|
||||||
|
| zh-TW | 整理 |
|
||||||
|
| ja | 整理 |
|
||||||
|
| ko | 정리 |
|
||||||
|
| de | Organisation |
|
||||||
|
| es | Organización |
|
||||||
|
| ru | Организация |
|
||||||
|
| he | ארגון |
|
||||||
|
|
||||||
|
zh-CN/zh-TW use 整理 ("tidying/arranging"), not 组织/組織 (an organization as a group).
|
||||||
|
|
||||||
|
### Filename Templates feature
|
||||||
|
|
||||||
|
Per-model-type templates that name downloaded model files; "Apply to Library Now"
|
||||||
|
bulk-renames existing files, and an **empty template restores the recorded original
|
||||||
|
filenames** (recorded in each model's metadata at its first rename). "Template" follows
|
||||||
|
each locale's existing download-path-template noun (zh-CN 模板 vs zh-TW 範本 — note the
|
||||||
|
split); progress strings mirror `loras.bulkOperations.autoOrganizeProgress` verbatim with
|
||||||
|
the locale's "moved" verb swapped for its "renamed" verb, and the toasts mirror the
|
||||||
|
`autoOrganize*` / `downloadTemplates*` toast shapes.
|
||||||
|
|
||||||
|
| Term | Rendering |
|
||||||
|
|---|---|
|
||||||
|
| filename template(s) | zh-CN 文件名模板 · zh-TW 檔案名稱範本 · ja ファイル名テンプレート · ko 파일명 템플릿 · fr modèle(s) de nom de fichier · de Dateinamen-Vorlage(n) · es plantilla(s) de nombres de archivo · ru шаблон(ы) имён файлов · he תבנית שם קובץ / תבניות שמות קבצים |
|
||||||
|
| Apply to Library Now (button) | zh-CN 立即应用到库 · zh-TW 立即套用至模型庫 · ja ライブラリに今すぐ適用 · ko 지금 라이브러리에 적용 · fr Appliquer à la bibliothèque maintenant · de Jetzt auf Bibliothek anwenden · es Aplicar a la biblioteca ahora · ru Применить к библиотеке сейчас · he החל על הספרייה כעת |
|
||||||
|
| Restore original filenames (modal title / button) | zh-CN 恢复原始文件名?/ 恢复原始文件名 · zh-TW 要還原原始檔案名稱嗎?/ 還原原始檔案名稱 · ja 元のファイル名を復元しますか?/ 元のファイル名を復元 · ko 원본 파일명을 복원하시겠습니까? / 원본 파일명 복원 · fr Restaurer les noms de fichier d'origine ? / Restaurer les noms de fichier d'origine · de Ursprüngliche Dateinamen wiederherstellen? / Ursprüngliche Dateinamen wiederherstellen · es ¿Restaurar los nombres de archivo originales? / Restaurar nombres de archivo originales · ru Восстановить исходные имена файлов? / Восстановить исходные имена файлов · he לשחזר שמות קבצים מקוריים? / שחזר שמות קבצים מקוריים |
|
||||||
|
| "renamed" (progress/toast counter) | zh-CN 已重命名 · zh-TW 已重新命名 · ja リネーム · ko 이름 변경 · fr renommés · de umbenannt · es renombrados · ru переименовано · he שונו שמותם |
|
||||||
|
|
||||||
### Chip reordering (model tags / trigger words)
|
### Chip reordering (model tags / trigger words)
|
||||||
|
|
||||||
Model tags and trigger-word chips share a single reorder affordance (drag the chip, or its
|
Model tags and trigger-word chips share a single reorder affordance (drag the chip, or its
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Plan: Filename Template Follow-ups
|
||||||
|
|
||||||
|
**Issue:** [#1071 — Lora Renaming](https://github.com/willmiao/ComfyUI-Lora-Manager/issues/1071)
|
||||||
|
**Status:** Core feature **implemented** (2026-09-19, commit `2bc9860b`,
|
||||||
|
preceded by the settings-tab split in `327da046`). Follow-ups 1 and 2 were
|
||||||
|
resolved together on 2026-09-19 by redefining the empty template as
|
||||||
|
"revert to recorded original filename" (see below). Follow-up 3 remains open.
|
||||||
|
|
||||||
|
## What shipped in `2bc9860b`
|
||||||
|
|
||||||
|
- Per-model-type `download_filename_templates` setting (empty = keep current
|
||||||
|
filename; opt-in). Placeholders: `{model_name}`, `{version_name}`,
|
||||||
|
`{base_model}`, `{author}`, `{first_tag}`, `{hash_short}`,
|
||||||
|
`{original_name}`.
|
||||||
|
- `calculate_filename_for_model()` in `py/utils/utils.py` renders the
|
||||||
|
template; templates containing path separators are rejected.
|
||||||
|
- Downloads apply the template post-download
|
||||||
|
(`DownloadManager._apply_download_filename_template`); rename conflicts
|
||||||
|
keep the original name and never fail the download.
|
||||||
|
- `ModelLifecycleService.rename_model` records `original_file_name` in the
|
||||||
|
`.metadata.json` sidecar (first rename wins via `setdefault`).
|
||||||
|
- Bulk apply: `GET|POST /api/lm/{prefix}/apply-filename-template`
|
||||||
|
(`FilenameTemplateUseCase`, shares the auto-organize lock, WS progress type
|
||||||
|
`filename_template_progress`).
|
||||||
|
- Settings UI: "Filename Templates" subsection in the new **Organization**
|
||||||
|
settings tab (`templates/components/modals/settings/organization.html`),
|
||||||
|
with validation, live preview, and per-type "Apply to Library Now".
|
||||||
|
|
||||||
|
Sandbox E2E verified: rename incl. companion files (previews, sidecars),
|
||||||
|
metadata pointer updates, `original_file_name` recording, idempotency,
|
||||||
|
conflict handling (failure counted, batch continues), empty-template no-op,
|
||||||
|
GET variant.
|
||||||
|
|
||||||
|
## Follow-ups 1 & 2 — RESOLVED: empty template = revert to recorded original
|
||||||
|
|
||||||
|
Follow-up 1 asked to reword the ambiguous "Valid (keep original filename)"
|
||||||
|
empty-template message; Follow-up 2 asked for a bulk revert to the recorded
|
||||||
|
`original_file_name`. Both were resolved by a single semantic change: **an
|
||||||
|
empty template now means "restore the recorded original filename"** instead of
|
||||||
|
"leave the current filename untouched".
|
||||||
|
|
||||||
|
Rationale: for never-renamed models a revert is a no-op (no recorded
|
||||||
|
original), for renamed models it restores the pre-rename name, and new
|
||||||
|
downloads with an empty template keep the download name as before — so the
|
||||||
|
two contexts (download path and bulk apply) share one coherent meaning, and
|
||||||
|
no separate revert feature or `{recorded_original}` placeholder is needed.
|
||||||
|
|
||||||
|
Implemented changes:
|
||||||
|
|
||||||
|
- `FilenameTemplateUseCase._process_model`: an empty template now resolves
|
||||||
|
the target name from the sidecar's `original_file_name` via the injected
|
||||||
|
`metadata_loader` (default `load_local_metadata`); models without a
|
||||||
|
recorded original or whose original matches the current name are skipped.
|
||||||
|
Cache entries do not project `original_file_name`, so the sidecar is read
|
||||||
|
per model.
|
||||||
|
- `SettingsManager.js`: removed the empty-template early return and the
|
||||||
|
apply-button disable (`updateFilenameTemplateApplyButton` deleted — the
|
||||||
|
button is now always enabled). The browser-native `confirm()` was replaced
|
||||||
|
with `filenameTemplateConfirmModal`
|
||||||
|
(`templates/components/modals/confirm_modals.html`), a **self-managed**
|
||||||
|
modal (like `DirectoryPickerModal`, NOT registered with ModalManager):
|
||||||
|
ModalManager's "close current modal on open" behavior would kill the
|
||||||
|
settings modal underneath. It stacks via `z-index: 10010`
|
||||||
|
(`delete-modal.css`), handles ESC in capture phase with
|
||||||
|
`stopPropagation`, and shows apply vs revert wording
|
||||||
|
(`modals.filenameTemplateConfirm.titleApply` / `titleRevert` /
|
||||||
|
`revertButton`; messages reuse `settings.filenameTemplates.confirmApply` /
|
||||||
|
`confirmRevert`).
|
||||||
|
- `locales/en.json`: reworded `help` / `applyHelp`, replaced
|
||||||
|
`validation.keepOriginal` with `validation.restoreOriginal`
|
||||||
|
("Valid (empty template restores original filenames)"), added
|
||||||
|
`confirmRevert`, removed the now-unused `emptyTemplateInfo`. Other locales
|
||||||
|
re-synced with `[TODO: Translate]` placeholders — retranslation waits for
|
||||||
|
the feature owner's request per `docs/i18n-translation-guidelines.md` §7.
|
||||||
|
- Tests: revert / no-record-skip / same-name-skip cases in
|
||||||
|
`tests/services/test_use_cases.py`; modal confirm-and-revert and
|
||||||
|
cancel paths in
|
||||||
|
`tests/frontend/managers/settingsManager.filenameTemplates.test.js`.
|
||||||
|
|
||||||
|
Sandbox E2E verified (standalone server, sandboxed settings + library under
|
||||||
|
`/tmp`, 2026-09-19): template apply renames and records
|
||||||
|
`original_file_name`; empty-template apply reverts to the recorded name;
|
||||||
|
revert target occupied by a newer file counts as failure and keeps the
|
||||||
|
current name; models without a recorded original are skipped;
|
||||||
|
apply → revert → re-apply cycles repeat cleanly.
|
||||||
|
|
||||||
|
Standing caveats (unchanged):
|
||||||
|
|
||||||
|
- The revert target may collide with an existing file — the existing conflict
|
||||||
|
handling (count as failure, keep current name) covers this.
|
||||||
|
- `original_file_name` only exists for models renamed after `2bc9860b`;
|
||||||
|
older renames have no recorded original and are skipped.
|
||||||
|
- `original_file_name` is kept (not cleared) after a revert, so
|
||||||
|
apply → revert → re-apply stays repeatable.
|
||||||
|
|
||||||
|
## Follow-up 3 — Cross-page refresh after bulk apply
|
||||||
|
|
||||||
|
**Problem:** the settings-modal "Apply to Library Now" button calls
|
||||||
|
`resetAndReload(true)`, which refreshes only the page type currently open.
|
||||||
|
Applying the checkpoint template while on the loras page leaves the loras
|
||||||
|
view refreshed but does not touch the checkpoints page state (same
|
||||||
|
limitation as the existing bulk auto-organize flow in
|
||||||
|
`static/js/managers/SettingsManager.js#applyFilenameTemplate`).
|
||||||
|
|
||||||
|
**Fix options:** broadcast a generic "library changed" event that every
|
||||||
|
page's state listens to, or accept the limitation (the other page reloads
|
||||||
|
its cache on next visit). Low priority.
|
||||||
+87
-4
@@ -382,7 +382,9 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"general": "Allgemein",
|
"general": "Allgemein",
|
||||||
"interface": "Oberfläche",
|
"interface": "Oberfläche",
|
||||||
"library": "Bibliothek"
|
"library": "Bibliothek",
|
||||||
|
"organization": "Organisation",
|
||||||
|
"modelPaths": "Modellpfade"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Einstellungen durchsuchen...",
|
"placeholder": "Einstellungen durchsuchen...",
|
||||||
@@ -583,6 +585,46 @@
|
|||||||
"checkpointUnetOverlapInline": "Dieser Pfad wird bereits für einen anderen Modelltyp verwendet. Bitte verwenden Sie separate Ordner für Checkpoints und Diffusionsmodelle."
|
"checkpointUnetOverlapInline": "Dieser Pfad wird bereits für einen anderen Modelltyp verwendet. Bitte verwenden Sie separate Ordner für Checkpoints und Diffusionsmodelle."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"modelPaths": {
|
||||||
|
"title": "Modellbibliothek-Pfade",
|
||||||
|
"description": "Stammordner, die LoRA Manager nach Ihren Modellen durchsucht. Dies sind die primären Modellspeicherorte, die im Standalone-Modus aus der settings.json gelesen werden.",
|
||||||
|
"restartRequired": "Neustart erforderlich, damit die Änderung wirksam wird",
|
||||||
|
"coreTypes": "Kern-Modelltypen",
|
||||||
|
"otherTypes": "Weitere Modelltypen",
|
||||||
|
"otherTypesDisabledHint": "Es sind keine weiteren Modelltypen aktiviert. Aktivieren Sie oben die benötigten Typen, um deren Ordner zu konfigurieren.",
|
||||||
|
"saveSuccessRestart": "Modellbibliothek-Pfade aktualisiert. Neustart erforderlich, um Änderungen anzuwenden.",
|
||||||
|
"pendingRestartNotice": "Pfadänderungen gespeichert. Starten Sie LoRA Manager neu, damit sie wirksam werden.",
|
||||||
|
"pendingRestartBannerTitle": "Neustart erforderlich, um Pfadänderungen anzuwenden",
|
||||||
|
"pendingRestartBannerMessage": "Die Modellbibliothek-Pfade wurden aktualisiert. Starten Sie den LoRA Manager-Server neu, um die neuen Ordner zu scannen.",
|
||||||
|
"folderKeys": {
|
||||||
|
"loras": "LoRA-Pfade",
|
||||||
|
"checkpoints": "Checkpoint-Pfade",
|
||||||
|
"unet": "Diffusionsmodell-Pfade",
|
||||||
|
"embeddings": "Embedding-Pfade",
|
||||||
|
"vae": "VAE-Pfade",
|
||||||
|
"upscale_models": "Upscaler-Pfade",
|
||||||
|
"text_encoders": "Text-Encoder-Pfade",
|
||||||
|
"clip": "CLIP-Pfade (Legacy)",
|
||||||
|
"clip_vision": "CLIP-Vision-Pfade",
|
||||||
|
"controlnet": "ControlNet-Pfade"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"directoryPicker": {
|
||||||
|
"title": "Ordner durchsuchen",
|
||||||
|
"selectFolder": "Diesen Ordner auswählen",
|
||||||
|
"goUp": "Nach oben",
|
||||||
|
"pathPlaceholder": "Pfad eingeben...",
|
||||||
|
"go": "Los",
|
||||||
|
"emptyFolder": "Keine Unterordner",
|
||||||
|
"loadError": "Verzeichnis konnte nicht geladen werden"
|
||||||
|
},
|
||||||
|
"pathValidation": {
|
||||||
|
"valid": "Pfad ist gültig",
|
||||||
|
"pathNotFound": "Pfad existiert nicht",
|
||||||
|
"notADirectory": "Kein Verzeichnis",
|
||||||
|
"notReadable": "Pfad ist nicht lesbar",
|
||||||
|
"notWritable": "Pfad ist nicht beschreibbar"
|
||||||
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
"title": "Prioritäts-Tags",
|
"title": "Prioritäts-Tags",
|
||||||
"description": "Passen Sie die Tag-Prioritätsreihenfolge für jeden Modelltyp an (z. B. character, concept, style(toon|toon_style))",
|
"description": "Passen Sie die Tag-Prioritätsreihenfolge für jeden Modelltyp an (z. B. character, concept, style(toon|toon_style))",
|
||||||
@@ -639,6 +681,22 @@
|
|||||||
"validTemplate": "Gültige Vorlage"
|
"validTemplate": "Gültige Vorlage"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"filenameTemplates": {
|
||||||
|
"title": "Dateinamen-Vorlagen",
|
||||||
|
"help": "Konfigurieren Sie Dateinamen für heruntergeladene Modelle pro Modelltyp. Leer lassen, um den ursprünglichen Dateinamen zu behalten. Der ursprüngliche Dateiname bleibt immer in den Metadaten des Modells erhalten.",
|
||||||
|
"availablePlaceholders": "Verfügbare Platzhalter:",
|
||||||
|
"templatePlaceholder": "Dateinamen-Vorlage eingeben (z.B. {base_model}-{model_name}-{version_name})",
|
||||||
|
"applyButton": "Jetzt auf Bibliothek anwenden",
|
||||||
|
"applyHelp": "Benennt alle vorhandenen Dateien dieses Modelltyps gemäß der Vorlage um. Warnung: Das Umbenennen ändert den relativen Pfad, den ComfyUI-Loader sehen; vorhandene Workflows, die den alten Dateinamen referenzieren, müssen möglicherweise aktualisiert werden. Der ursprüngliche Dateiname bleibt in den Metadaten jedes Modells erhalten.",
|
||||||
|
"confirmApply": "Alle vorhandenen Dateien dieses Modelltyps gemäß der Dateinamen-Vorlage umbenennen? Dies ändert den relativen Pfad, den ComfyUI-Loader sehen. Der ursprüngliche Dateiname bleibt in den Metadaten jedes Modells erhalten.",
|
||||||
|
"confirmRevert": "Die gespeicherten ursprünglichen Dateinamen aller zuvor umbenannten Dateien dieses Modelltyps wiederherstellen? Dies ändert den relativen Pfad, den ComfyUI-Loader sehen. Dateien ohne gespeicherten ursprünglichen Dateinamen werden übersprungen.",
|
||||||
|
"validation": {
|
||||||
|
"restoreOriginal": "Gültig (leere Vorlage stellt ursprüngliche Dateinamen wieder her)",
|
||||||
|
"invalidChars": "Ungültige Zeichen erkannt (ein Dateiname darf / \\ < > : \" | ? * nicht enthalten)",
|
||||||
|
"invalidPlaceholder": "Ungültiger Platzhalter: {placeholder}",
|
||||||
|
"validTemplate": "Gültige Vorlage"
|
||||||
|
}
|
||||||
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"downloadLocation": "Download-Speicherort",
|
"downloadLocation": "Download-Speicherort",
|
||||||
"downloadLocationPlaceholder": "Ordnerpfad für Beispielbilder eingeben",
|
"downloadLocationPlaceholder": "Ordnerpfad für Beispielbilder eingeben",
|
||||||
@@ -871,6 +929,14 @@
|
|||||||
"complete": "Automatische Organisation abgeschlossen",
|
"complete": "Automatische Organisation abgeschlossen",
|
||||||
"error": "Fehler: {error}"
|
"error": "Fehler: {error}"
|
||||||
},
|
},
|
||||||
|
"filenameTemplateProgress": {
|
||||||
|
"initializing": "Anwendung der Dateinamen-Vorlage wird initialisiert...",
|
||||||
|
"starting": "Dateinamen-Vorlage wird auf {type} angewendet...",
|
||||||
|
"processing": "Verarbeitung ({processed}/{total}) – {success} umbenannt, {skipped} übersprungen, {failures} fehlgeschlagen",
|
||||||
|
"completed": "Abgeschlossen: {success} umbenannt, {skipped} übersprungen, {failures} fehlgeschlagen",
|
||||||
|
"complete": "Anwendung der Dateinamen-Vorlage abgeschlossen",
|
||||||
|
"error": "Fehler: {error}"
|
||||||
|
},
|
||||||
"enrichHfAgent": "Metadaten mit KI anreichern"
|
"enrichHfAgent": "Metadaten mit KI anreichern"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
@@ -1241,11 +1307,13 @@
|
|||||||
},
|
},
|
||||||
"noPaths": {
|
"noPaths": {
|
||||||
"title": "Keine Ordner für weitere Modelle gefunden",
|
"title": "Keine Ordner für weitere Modelle gefunden",
|
||||||
"descriptionStandalone": "Die Verwaltung weiterer Modelle ist aktiviert, aber keiner der konfigurierten Modellordner existiert auf dem Datenträger. Fügen Sie die unten stehenden Ordnerpfade zu settings.json hinzu und starten Sie LoRA Manager neu.",
|
"descriptionStandalone": "Die Verwaltung weiterer Modelle ist aktiviert, aber es wurden keine Ordner für weitere Modelle gefunden. Fügen Sie Ihre Modellordner unter Einstellungen → Modellpfade hinzu und starten Sie LoRA Manager anschließend neu.",
|
||||||
"hintStandalone": "Nur die oben aufgeführten Ordnerschlüssel werden gescannt; nicht benötigte Schlüssel können weggelassen werden.",
|
"hintStandalone": "Es werden nur aktivierte Modelltypen gescannt. Aktivieren Sie die benötigten Typen unter Bibliothek → Standard-Roots.",
|
||||||
"descriptionComfyUI": "Die Verwaltung weiterer Modelle ist aktiviert, aber keiner der konfigurierten Modellordner existiert auf dem Datenträger. Fügen Sie die entsprechenden Modellordner zu Ihren ComfyUI-Modellpfaden hinzu und laden Sie diese Seite neu.",
|
"descriptionComfyUI": "Die Verwaltung weiterer Modelle ist aktiviert, aber keiner der konfigurierten Modellordner existiert auf dem Datenträger. Fügen Sie die entsprechenden Modellordner zu Ihren ComfyUI-Modellpfaden hinzu und laden Sie diese Seite neu.",
|
||||||
"hintComfyUI": "Weitere Modelle werden aus den Ordnern vae, upscale_models, text_encoders, clip_vision und controlnet von ComfyUI gelesen.",
|
"hintComfyUI": "Weitere Modelle werden aus den Ordnern vae, upscale_models, text_encoders, clip_vision und controlnet von ComfyUI gelesen.",
|
||||||
"openSettings": "Einstellungen öffnen"
|
"openSettings": "Einstellungen öffnen",
|
||||||
|
"openModelPaths": "Modellordner konfigurieren",
|
||||||
|
"openSettingsFolder": "Einstellungsordner öffnen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
@@ -1558,6 +1626,11 @@
|
|||||||
"tip": "Möchten Sie in Etappen prüfen? Wechseln Sie in den Massenmodus, wählen Sie die benötigten Modelle aus und nutzen Sie anschließend \"Auswahl auf Updates prüfen\".",
|
"tip": "Möchten Sie in Etappen prüfen? Wechseln Sie in den Massenmodus, wählen Sie die benötigten Modelle aus und nutzen Sie anschließend \"Auswahl auf Updates prüfen\".",
|
||||||
"action": "Alles prüfen"
|
"action": "Alles prüfen"
|
||||||
},
|
},
|
||||||
|
"filenameTemplateConfirm": {
|
||||||
|
"titleApply": "Dateinamen-Vorlage auf Bibliothek anwenden?",
|
||||||
|
"titleRevert": "Ursprüngliche Dateinamen wiederherstellen?",
|
||||||
|
"revertButton": "Ursprüngliche Dateinamen wiederherstellen"
|
||||||
|
},
|
||||||
"bulkAddTags": {
|
"bulkAddTags": {
|
||||||
"title": "Tags zu mehreren Modellen hinzufügen",
|
"title": "Tags zu mehreren Modellen hinzufügen",
|
||||||
"description": "Tags hinzufügen zu",
|
"description": "Tags hinzufügen zu",
|
||||||
@@ -2267,6 +2340,9 @@
|
|||||||
"autoOrganizeSuccess": "Automatische Organisation für {count} {type} erfolgreich abgeschlossen",
|
"autoOrganizeSuccess": "Automatische Organisation für {count} {type} erfolgreich abgeschlossen",
|
||||||
"autoOrganizePartialSuccess": "Automatische Organisation abgeschlossen: {success} verschoben, {failures} fehlgeschlagen von insgesamt {total} Modellen",
|
"autoOrganizePartialSuccess": "Automatische Organisation abgeschlossen: {success} verschoben, {failures} fehlgeschlagen von insgesamt {total} Modellen",
|
||||||
"autoOrganizeFailed": "Automatische Organisation fehlgeschlagen: {error}",
|
"autoOrganizeFailed": "Automatische Organisation fehlgeschlagen: {error}",
|
||||||
|
"filenameTemplateSuccess": "Dateinamen-Vorlage erfolgreich für {count} {type} angewendet",
|
||||||
|
"filenameTemplatePartialSuccess": "Dateinamen-Vorlage angewendet: {success} umbenannt, {failures} von {total} Modellen fehlgeschlagen",
|
||||||
|
"filenameTemplateFailed": "Anwendung der Dateinamen-Vorlage fehlgeschlagen: {error}",
|
||||||
"noModelsSelected": "Keine Modelle ausgewählt"
|
"noModelsSelected": "Keine Modelle ausgewählt"
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -2433,6 +2509,8 @@
|
|||||||
"mappingSaveFailed": "Fehler beim Speichern der Basismodell-Zuordnungen: {message}",
|
"mappingSaveFailed": "Fehler beim Speichern der Basismodell-Zuordnungen: {message}",
|
||||||
"downloadTemplatesUpdated": "Download-Pfad-Vorlagen aktualisiert",
|
"downloadTemplatesUpdated": "Download-Pfad-Vorlagen aktualisiert",
|
||||||
"downloadTemplatesFailed": "Fehler beim Speichern der Download-Pfad-Vorlagen: {message}",
|
"downloadTemplatesFailed": "Fehler beim Speichern der Download-Pfad-Vorlagen: {message}",
|
||||||
|
"filenameTemplatesUpdated": "Dateinamen-Vorlagen aktualisiert",
|
||||||
|
"filenameTemplatesFailed": "Dateinamen-Vorlagen konnten nicht gespeichert werden: {message}",
|
||||||
"recipesPathUpdated": "Rezepte-Speicherpfad aktualisiert",
|
"recipesPathUpdated": "Rezepte-Speicherpfad aktualisiert",
|
||||||
"recipesPathSaveFailed": "Fehler beim Aktualisieren des Rezepte-Speicherpfads: {message}",
|
"recipesPathSaveFailed": "Fehler beim Aktualisieren des Rezepte-Speicherpfads: {message}",
|
||||||
"settingsUpdated": "Einstellungen aktualisiert: {setting}",
|
"settingsUpdated": "Einstellungen aktualisiert: {setting}",
|
||||||
@@ -2698,6 +2776,11 @@
|
|||||||
"content": "Scannen und verwalten Sie VAE-, Upscaler-, Text-Encoder-, CLIP-Vision- und ControlNet-Dateien und laden Sie sie von CivitAI herunter, alles auf einer eigenen Seite.",
|
"content": "Scannen und verwalten Sie VAE-, Upscaler-, Text-Encoder-, CLIP-Vision- und ControlNet-Dateien und laden Sie sie von CivitAI herunter, alles auf einer eigenen Seite.",
|
||||||
"enable": "Weitere Modelle aktivieren",
|
"enable": "Weitere Modelle aktivieren",
|
||||||
"openSettings": "Einstellungen öffnen"
|
"openSettings": "Einstellungen öffnen"
|
||||||
|
},
|
||||||
|
"pager": {
|
||||||
|
"previous": "Vorherige Mitteilung",
|
||||||
|
"next": "Nächste Mitteilung",
|
||||||
|
"position": "Mitteilung {current} von {total}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+87
-4
@@ -382,7 +382,9 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"interface": "Interface",
|
"interface": "Interface",
|
||||||
"library": "Library"
|
"library": "Library",
|
||||||
|
"organization": "Organization",
|
||||||
|
"modelPaths": "Model Paths"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Search settings...",
|
"placeholder": "Search settings...",
|
||||||
@@ -583,6 +585,46 @@
|
|||||||
"checkpointUnetOverlapInline": "This path is also used for a different model type. Use separate folders for checkpoints and diffusion models."
|
"checkpointUnetOverlapInline": "This path is also used for a different model type. Use separate folders for checkpoints and diffusion models."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"modelPaths": {
|
||||||
|
"title": "Model Library Paths",
|
||||||
|
"description": "Root folders LoRA Manager scans for your models. These are the primary model locations read from settings.json in standalone mode.",
|
||||||
|
"restartRequired": "Requires restart to take effect",
|
||||||
|
"coreTypes": "Core Model Types",
|
||||||
|
"otherTypes": "Other Model Types",
|
||||||
|
"otherTypesDisabledHint": "No other model types are enabled. Turn on the types you need above to configure their folders.",
|
||||||
|
"saveSuccessRestart": "Model library paths updated. Restart required to apply changes.",
|
||||||
|
"pendingRestartNotice": "Path changes saved. Restart LoRA Manager for them to take effect.",
|
||||||
|
"pendingRestartBannerTitle": "Restart required to apply path changes",
|
||||||
|
"pendingRestartBannerMessage": "Model library paths were updated. Restart the LoRA Manager server to scan the new folders.",
|
||||||
|
"folderKeys": {
|
||||||
|
"loras": "LoRA Paths",
|
||||||
|
"checkpoints": "Checkpoint Paths",
|
||||||
|
"unet": "Diffusion Model Paths",
|
||||||
|
"embeddings": "Embedding Paths",
|
||||||
|
"vae": "VAE Paths",
|
||||||
|
"upscale_models": "Upscaler Paths",
|
||||||
|
"text_encoders": "Text Encoder Paths",
|
||||||
|
"clip": "CLIP Paths (legacy)",
|
||||||
|
"clip_vision": "CLIP Vision Paths",
|
||||||
|
"controlnet": "ControlNet Paths"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"directoryPicker": {
|
||||||
|
"title": "Browse Folders",
|
||||||
|
"selectFolder": "Select This Folder",
|
||||||
|
"goUp": "Up",
|
||||||
|
"pathPlaceholder": "Enter path...",
|
||||||
|
"go": "Go",
|
||||||
|
"emptyFolder": "No subfolders",
|
||||||
|
"loadError": "Failed to load directory"
|
||||||
|
},
|
||||||
|
"pathValidation": {
|
||||||
|
"valid": "Path is valid",
|
||||||
|
"pathNotFound": "Path does not exist",
|
||||||
|
"notADirectory": "Not a directory",
|
||||||
|
"notReadable": "Path is not readable",
|
||||||
|
"notWritable": "Path is not writable"
|
||||||
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
"title": "Priority Tags",
|
"title": "Priority Tags",
|
||||||
"description": "Customize the tag priority order for each model type (e.g., character, concept, style(toon|toon_style))",
|
"description": "Customize the tag priority order for each model type (e.g., character, concept, style(toon|toon_style))",
|
||||||
@@ -639,6 +681,22 @@
|
|||||||
"validTemplate": "Valid template"
|
"validTemplate": "Valid template"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"filenameTemplates": {
|
||||||
|
"title": "Filename Templates",
|
||||||
|
"help": "Configure filenames for downloaded models per model type. Leave empty to keep original filenames on download; applying an empty template restores the recorded original filenames of previously renamed models. The original filename is always preserved in the model's metadata.",
|
||||||
|
"availablePlaceholders": "Available placeholders:",
|
||||||
|
"templatePlaceholder": "Enter filename template (e.g., {base_model}-{model_name}-{version_name})",
|
||||||
|
"applyButton": "Apply to Library Now",
|
||||||
|
"applyHelp": "Renames all existing files of this model type according to the template; with an empty template, restores the recorded original filenames instead. Warning: renaming changes the relative path seen by ComfyUI loaders, so existing workflows referencing the old filename may need to be updated. The original filename is preserved in each model's metadata.",
|
||||||
|
"confirmApply": "Rename all existing files of this model type according to the filename template? This changes the relative path seen by ComfyUI loaders. The original filename is preserved in each model's metadata.",
|
||||||
|
"confirmRevert": "Restore the recorded original filenames of all previously renamed files of this model type? This changes the relative path seen by ComfyUI loaders. Files without a recorded original filename are skipped.",
|
||||||
|
"validation": {
|
||||||
|
"restoreOriginal": "Valid (empty template restores original filenames)",
|
||||||
|
"invalidChars": "Invalid characters detected (a filename cannot contain / \\ < > : \" | ? *)",
|
||||||
|
"invalidPlaceholder": "Invalid placeholder: {placeholder}",
|
||||||
|
"validTemplate": "Valid template"
|
||||||
|
}
|
||||||
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"downloadLocation": "Download Location",
|
"downloadLocation": "Download Location",
|
||||||
"downloadLocationPlaceholder": "Enter folder path for example images",
|
"downloadLocationPlaceholder": "Enter folder path for example images",
|
||||||
@@ -871,6 +929,14 @@
|
|||||||
"complete": "Auto-organize complete",
|
"complete": "Auto-organize complete",
|
||||||
"error": "Error: {error}"
|
"error": "Error: {error}"
|
||||||
},
|
},
|
||||||
|
"filenameTemplateProgress": {
|
||||||
|
"initializing": "Initializing filename template apply...",
|
||||||
|
"starting": "Applying filename template to {type}...",
|
||||||
|
"processing": "Processing ({processed}/{total}) - {success} renamed, {skipped} skipped, {failures} failed",
|
||||||
|
"completed": "Completed: {success} renamed, {skipped} skipped, {failures} failed",
|
||||||
|
"complete": "Filename template apply complete",
|
||||||
|
"error": "Error: {error}"
|
||||||
|
},
|
||||||
"enrichHfAgent": "Enrich Metadata with AI"
|
"enrichHfAgent": "Enrich Metadata with AI"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
@@ -1241,11 +1307,13 @@
|
|||||||
},
|
},
|
||||||
"noPaths": {
|
"noPaths": {
|
||||||
"title": "No other-model folders found",
|
"title": "No other-model folders found",
|
||||||
"descriptionStandalone": "Other Models management is on, but none of the configured model folders exist on disk. Add the folder paths below to settings.json and restart LoRA Manager.",
|
"descriptionStandalone": "Other Models management is on, but no other-model folders were found. Add your model folders under Settings → Model Paths, then restart LoRA Manager.",
|
||||||
"hintStandalone": "Only the folder keys listed above are scanned; keys you do not need can be omitted.",
|
"hintStandalone": "Only enabled model types are scanned; enable the types you need under Library → Folder Settings.",
|
||||||
"descriptionComfyUI": "Other Models management is on, but none of the configured model folders exist on disk. Add the matching model folders to your ComfyUI model paths, then reload this page.",
|
"descriptionComfyUI": "Other Models management is on, but none of the configured model folders exist on disk. Add the matching model folders to your ComfyUI model paths, then reload this page.",
|
||||||
"hintComfyUI": "Other models are read from ComfyUI's vae, upscale_models, text_encoders, clip_vision and controlnet folders.",
|
"hintComfyUI": "Other models are read from ComfyUI's vae, upscale_models, text_encoders, clip_vision and controlnet folders.",
|
||||||
"openSettings": "Open Settings"
|
"openSettings": "Open Settings",
|
||||||
|
"openModelPaths": "Configure Model Folders",
|
||||||
|
"openSettingsFolder": "Open Settings Folder"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
@@ -1558,6 +1626,11 @@
|
|||||||
"tip": "To work in smaller batches, switch to bulk mode, choose the ones you need, then use \"Check Updates for Selected\".",
|
"tip": "To work in smaller batches, switch to bulk mode, choose the ones you need, then use \"Check Updates for Selected\".",
|
||||||
"action": "Check All"
|
"action": "Check All"
|
||||||
},
|
},
|
||||||
|
"filenameTemplateConfirm": {
|
||||||
|
"titleApply": "Apply filename template to library?",
|
||||||
|
"titleRevert": "Restore original filenames?",
|
||||||
|
"revertButton": "Restore Original Filenames"
|
||||||
|
},
|
||||||
"bulkAddTags": {
|
"bulkAddTags": {
|
||||||
"title": "Add Tags to Multiple Models",
|
"title": "Add Tags to Multiple Models",
|
||||||
"description": "Add tags to",
|
"description": "Add tags to",
|
||||||
@@ -2267,6 +2340,9 @@
|
|||||||
"autoOrganizeSuccess": "Auto-organize completed successfully for {count} {type}",
|
"autoOrganizeSuccess": "Auto-organize completed successfully for {count} {type}",
|
||||||
"autoOrganizePartialSuccess": "Auto-organize completed with {success} moved, {failures} failed out of {total} models",
|
"autoOrganizePartialSuccess": "Auto-organize completed with {success} moved, {failures} failed out of {total} models",
|
||||||
"autoOrganizeFailed": "Auto-organize failed: {error}",
|
"autoOrganizeFailed": "Auto-organize failed: {error}",
|
||||||
|
"filenameTemplateSuccess": "Filename template applied successfully for {count} {type}",
|
||||||
|
"filenameTemplatePartialSuccess": "Filename template applied with {success} renamed, {failures} failed out of {total} models",
|
||||||
|
"filenameTemplateFailed": "Applying filename template failed: {error}",
|
||||||
"noModelsSelected": "No models selected"
|
"noModelsSelected": "No models selected"
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -2433,6 +2509,8 @@
|
|||||||
"mappingSaveFailed": "Failed to save base model mappings: {message}",
|
"mappingSaveFailed": "Failed to save base model mappings: {message}",
|
||||||
"downloadTemplatesUpdated": "Download path templates updated",
|
"downloadTemplatesUpdated": "Download path templates updated",
|
||||||
"downloadTemplatesFailed": "Failed to save download path templates: {message}",
|
"downloadTemplatesFailed": "Failed to save download path templates: {message}",
|
||||||
|
"filenameTemplatesUpdated": "Filename templates updated",
|
||||||
|
"filenameTemplatesFailed": "Failed to save filename templates: {message}",
|
||||||
"recipesPathUpdated": "Recipes storage path updated",
|
"recipesPathUpdated": "Recipes storage path updated",
|
||||||
"recipesPathSaveFailed": "Failed to update recipes storage path: {message}",
|
"recipesPathSaveFailed": "Failed to update recipes storage path: {message}",
|
||||||
"settingsUpdated": "Settings updated: {setting}",
|
"settingsUpdated": "Settings updated: {setting}",
|
||||||
@@ -2698,6 +2776,11 @@
|
|||||||
"content": "Scan and manage VAE, upscaler, text encoder, CLIP vision and ControlNet files — and download them from CivitAI — from one dedicated page.",
|
"content": "Scan and manage VAE, upscaler, text encoder, CLIP vision and ControlNet files — and download them from CivitAI — from one dedicated page.",
|
||||||
"enable": "Enable Other Models",
|
"enable": "Enable Other Models",
|
||||||
"openSettings": "Open Settings"
|
"openSettings": "Open Settings"
|
||||||
|
},
|
||||||
|
"pager": {
|
||||||
|
"previous": "Previous message",
|
||||||
|
"next": "Next message",
|
||||||
|
"position": "Message {current} of {total}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+87
-4
@@ -382,7 +382,9 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"interface": "Interfaz",
|
"interface": "Interfaz",
|
||||||
"library": "Biblioteca"
|
"library": "Biblioteca",
|
||||||
|
"organization": "Organización",
|
||||||
|
"modelPaths": "Rutas de modelos"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Buscar ajustes...",
|
"placeholder": "Buscar ajustes...",
|
||||||
@@ -583,6 +585,46 @@
|
|||||||
"checkpointUnetOverlapInline": "Esta ruta ya se usa para otro tipo de modelo. Use carpetas separadas para checkpoints y modelos de difusión."
|
"checkpointUnetOverlapInline": "Esta ruta ya se usa para otro tipo de modelo. Use carpetas separadas para checkpoints y modelos de difusión."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"modelPaths": {
|
||||||
|
"title": "Rutas de la biblioteca de modelos",
|
||||||
|
"description": "Carpetas raíz que LoRA Manager escanea en busca de tus modelos. Son las ubicaciones de modelos principales leídas de settings.json en modo independiente.",
|
||||||
|
"restartRequired": "Requiere reiniciar para que surta efecto",
|
||||||
|
"coreTypes": "Tipos de modelos principales",
|
||||||
|
"otherTypes": "Otros tipos de modelos",
|
||||||
|
"otherTypesDisabledHint": "No hay habilitado ningún otro tipo de modelo. Activa los tipos que necesites arriba para configurar sus carpetas.",
|
||||||
|
"saveSuccessRestart": "Rutas de la biblioteca de modelos actualizadas. Se requiere reinicio para aplicar los cambios.",
|
||||||
|
"pendingRestartNotice": "Cambios de rutas guardados. Reinicia LoRA Manager para que surtan efecto.",
|
||||||
|
"pendingRestartBannerTitle": "Se requiere reinicio para aplicar los cambios de rutas",
|
||||||
|
"pendingRestartBannerMessage": "Se actualizaron las rutas de la biblioteca de modelos. Reinicia el servidor de LoRA Manager para escanear las nuevas carpetas.",
|
||||||
|
"folderKeys": {
|
||||||
|
"loras": "Rutas de LoRA",
|
||||||
|
"checkpoints": "Rutas de Checkpoint",
|
||||||
|
"unet": "Rutas de modelo de difusión",
|
||||||
|
"embeddings": "Rutas de Embedding",
|
||||||
|
"vae": "Rutas de VAE",
|
||||||
|
"upscale_models": "Rutas de Upscaler",
|
||||||
|
"text_encoders": "Rutas de Text Encoder",
|
||||||
|
"clip": "Rutas de CLIP (heredadas)",
|
||||||
|
"clip_vision": "Rutas de CLIP Vision",
|
||||||
|
"controlnet": "Rutas de ControlNet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"directoryPicker": {
|
||||||
|
"title": "Explorar carpetas",
|
||||||
|
"selectFolder": "Seleccionar esta carpeta",
|
||||||
|
"goUp": "Subir",
|
||||||
|
"pathPlaceholder": "Introducir ruta...",
|
||||||
|
"go": "Ir",
|
||||||
|
"emptyFolder": "No hay subcarpetas",
|
||||||
|
"loadError": "Error al cargar el directorio"
|
||||||
|
},
|
||||||
|
"pathValidation": {
|
||||||
|
"valid": "La ruta es válida",
|
||||||
|
"pathNotFound": "La ruta no existe",
|
||||||
|
"notADirectory": "No es un directorio",
|
||||||
|
"notReadable": "La ruta no es legible",
|
||||||
|
"notWritable": "La ruta no es escribible"
|
||||||
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
"title": "Etiquetas prioritarias",
|
"title": "Etiquetas prioritarias",
|
||||||
"description": "Personaliza el orden de prioridad de etiquetas para cada tipo de modelo (p. ej., character, concept, style(toon|toon_style))",
|
"description": "Personaliza el orden de prioridad de etiquetas para cada tipo de modelo (p. ej., character, concept, style(toon|toon_style))",
|
||||||
@@ -639,6 +681,22 @@
|
|||||||
"validTemplate": "Plantilla válida"
|
"validTemplate": "Plantilla válida"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"filenameTemplates": {
|
||||||
|
"title": "Plantillas de nombres de archivo",
|
||||||
|
"help": "Configurar nombres de archivo de los modelos descargados por tipo de modelo. Dejar vacío para conservar los nombres de archivo originales al descargar; aplicar una plantilla vacía restaura los nombres de archivo originales registrados de los modelos renombrados previamente. El nombre de archivo original siempre se conserva en los metadatos del modelo.",
|
||||||
|
"availablePlaceholders": "Marcadores de posición disponibles:",
|
||||||
|
"templatePlaceholder": "Introduce plantilla de nombre de archivo (ej., {base_model}-{model_name}-{version_name})",
|
||||||
|
"applyButton": "Aplicar a la biblioteca ahora",
|
||||||
|
"applyHelp": "Renombra todos los archivos existentes de este tipo de modelo según la plantilla; con una plantilla vacía, restaura los nombres de archivo originales registrados. Advertencia: renombrar cambia la ruta relativa que ven los cargadores de ComfyUI, por lo que los workflows existentes que hagan referencia al nombre de archivo anterior pueden necesitar actualizarse. El nombre de archivo original se conserva en los metadatos de cada modelo.",
|
||||||
|
"confirmApply": "¿Renombrar todos los archivos existentes de este tipo de modelo según la plantilla de nombres de archivo? Esto cambia la ruta relativa que ven los cargadores de ComfyUI. El nombre de archivo original se conserva en los metadatos de cada modelo.",
|
||||||
|
"confirmRevert": "¿Restaurar los nombres de archivo originales registrados de todos los archivos renombrados previamente de este tipo de modelo? Esto cambia la ruta relativa que ven los cargadores de ComfyUI. Los archivos sin un nombre de archivo original registrado se omiten.",
|
||||||
|
"validation": {
|
||||||
|
"restoreOriginal": "Válido (la plantilla vacía restaura los nombres de archivo originales)",
|
||||||
|
"invalidChars": "Caracteres inválidos detectados (un nombre de archivo no puede contener / \\ < > : \" | ? *)",
|
||||||
|
"invalidPlaceholder": "Marcador de posición inválido: {placeholder}",
|
||||||
|
"validTemplate": "Plantilla válida"
|
||||||
|
}
|
||||||
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"downloadLocation": "Ubicación de descarga",
|
"downloadLocation": "Ubicación de descarga",
|
||||||
"downloadLocationPlaceholder": "Introduce la ruta de la carpeta para imágenes de ejemplo",
|
"downloadLocationPlaceholder": "Introduce la ruta de la carpeta para imágenes de ejemplo",
|
||||||
@@ -871,6 +929,14 @@
|
|||||||
"complete": "Auto-organización completada",
|
"complete": "Auto-organización completada",
|
||||||
"error": "Error: {error}"
|
"error": "Error: {error}"
|
||||||
},
|
},
|
||||||
|
"filenameTemplateProgress": {
|
||||||
|
"initializing": "Inicializando aplicación de plantilla de nombres de archivo...",
|
||||||
|
"starting": "Aplicando plantilla de nombres de archivo a {type}...",
|
||||||
|
"processing": "Procesando ({processed}/{total}) - {success} renombrados, {skipped} omitidos, {failures} fallidos",
|
||||||
|
"completed": "Completado: {success} renombrados, {skipped} omitidos, {failures} fallidos",
|
||||||
|
"complete": "Aplicación de plantilla de nombres de archivo completada",
|
||||||
|
"error": "Error: {error}"
|
||||||
|
},
|
||||||
"enrichHfAgent": "Enriquecer metadatos con IA"
|
"enrichHfAgent": "Enriquecer metadatos con IA"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
@@ -1241,11 +1307,13 @@
|
|||||||
},
|
},
|
||||||
"noPaths": {
|
"noPaths": {
|
||||||
"title": "No se encontraron carpetas de otros modelos",
|
"title": "No se encontraron carpetas de otros modelos",
|
||||||
"descriptionStandalone": "La gestión de otros modelos está activada, pero ninguna de las carpetas de modelos configuradas existe en el disco. Añade las rutas de carpetas de abajo a settings.json y reinicia LoRA Manager.",
|
"descriptionStandalone": "La gestión de otros modelos está activada, pero no se encontraron carpetas de otros modelos. Añade tus carpetas de modelos en Configuración → Rutas de modelos y reinicia LoRA Manager.",
|
||||||
"hintStandalone": "Solo se escanean las claves de carpeta listadas arriba; las claves que no necesites puedes omitirlas.",
|
"hintStandalone": "Solo se escanean los tipos de modelos habilitados; activa los tipos que necesites en Biblioteca → Raíces predeterminadas.",
|
||||||
"descriptionComfyUI": "La gestión de otros modelos está activada, pero ninguna de las carpetas de modelos configuradas existe en el disco. Añade las carpetas de modelos correspondientes a tus rutas de modelos de ComfyUI y recarga esta página.",
|
"descriptionComfyUI": "La gestión de otros modelos está activada, pero ninguna de las carpetas de modelos configuradas existe en el disco. Añade las carpetas de modelos correspondientes a tus rutas de modelos de ComfyUI y recarga esta página.",
|
||||||
"hintComfyUI": "Los otros modelos se leen de las carpetas vae, upscale_models, text_encoders, clip_vision y controlnet de ComfyUI.",
|
"hintComfyUI": "Los otros modelos se leen de las carpetas vae, upscale_models, text_encoders, clip_vision y controlnet de ComfyUI.",
|
||||||
"openSettings": "Abrir configuración"
|
"openSettings": "Abrir configuración",
|
||||||
|
"openModelPaths": "Configurar carpetas de modelos",
|
||||||
|
"openSettingsFolder": "Abrir carpeta de ajustes"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
@@ -1558,6 +1626,11 @@
|
|||||||
"tip": "¿Quieres hacerlo por partes? Activa el modo por lotes, selecciona los modelos que necesites y usa \"Comprobar actualizaciones para la selección\".",
|
"tip": "¿Quieres hacerlo por partes? Activa el modo por lotes, selecciona los modelos que necesites y usa \"Comprobar actualizaciones para la selección\".",
|
||||||
"action": "Comprobar todo"
|
"action": "Comprobar todo"
|
||||||
},
|
},
|
||||||
|
"filenameTemplateConfirm": {
|
||||||
|
"titleApply": "¿Aplicar la plantilla de nombres de archivo a la biblioteca?",
|
||||||
|
"titleRevert": "¿Restaurar los nombres de archivo originales?",
|
||||||
|
"revertButton": "Restaurar nombres de archivo originales"
|
||||||
|
},
|
||||||
"bulkAddTags": {
|
"bulkAddTags": {
|
||||||
"title": "Añadir etiquetas a múltiples modelos",
|
"title": "Añadir etiquetas a múltiples modelos",
|
||||||
"description": "Añadir etiquetas a",
|
"description": "Añadir etiquetas a",
|
||||||
@@ -2267,6 +2340,9 @@
|
|||||||
"autoOrganizeSuccess": "Auto-organización completada exitosamente para {count} {type}",
|
"autoOrganizeSuccess": "Auto-organización completada exitosamente para {count} {type}",
|
||||||
"autoOrganizePartialSuccess": "Auto-organización completada con {success} movidos, {failures} fallidos de un total de {total} modelos",
|
"autoOrganizePartialSuccess": "Auto-organización completada con {success} movidos, {failures} fallidos de un total de {total} modelos",
|
||||||
"autoOrganizeFailed": "Auto-organización fallida: {error}",
|
"autoOrganizeFailed": "Auto-organización fallida: {error}",
|
||||||
|
"filenameTemplateSuccess": "Plantilla de nombres de archivo aplicada exitosamente para {count} {type}",
|
||||||
|
"filenameTemplatePartialSuccess": "Plantilla de nombres de archivo aplicada con {success} renombrados, {failures} fallidos de un total de {total} modelos",
|
||||||
|
"filenameTemplateFailed": "Aplicación de la plantilla de nombres de archivo fallida: {error}",
|
||||||
"noModelsSelected": "No hay modelos seleccionados"
|
"noModelsSelected": "No hay modelos seleccionados"
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -2433,6 +2509,8 @@
|
|||||||
"mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}",
|
"mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}",
|
||||||
"downloadTemplatesUpdated": "Plantillas de rutas de descarga actualizadas",
|
"downloadTemplatesUpdated": "Plantillas de rutas de descarga actualizadas",
|
||||||
"downloadTemplatesFailed": "Error al guardar plantillas de rutas de descarga: {message}",
|
"downloadTemplatesFailed": "Error al guardar plantillas de rutas de descarga: {message}",
|
||||||
|
"filenameTemplatesUpdated": "Plantillas de nombres de archivo actualizadas",
|
||||||
|
"filenameTemplatesFailed": "Error al guardar plantillas de nombres de archivo: {message}",
|
||||||
"recipesPathUpdated": "Ruta de almacenamiento de recetas actualizada",
|
"recipesPathUpdated": "Ruta de almacenamiento de recetas actualizada",
|
||||||
"recipesPathSaveFailed": "Error al actualizar la ruta de almacenamiento de recetas: {message}",
|
"recipesPathSaveFailed": "Error al actualizar la ruta de almacenamiento de recetas: {message}",
|
||||||
"settingsUpdated": "Configuración actualizada: {setting}",
|
"settingsUpdated": "Configuración actualizada: {setting}",
|
||||||
@@ -2698,6 +2776,11 @@
|
|||||||
"content": "Escanea y gestiona archivos VAE, Upscaler, Text Encoder, CLIP Vision y ControlNet, y descárgalos desde CivitAI, todo desde una página dedicada.",
|
"content": "Escanea y gestiona archivos VAE, Upscaler, Text Encoder, CLIP Vision y ControlNet, y descárgalos desde CivitAI, todo desde una página dedicada.",
|
||||||
"enable": "Activar otros modelos",
|
"enable": "Activar otros modelos",
|
||||||
"openSettings": "Abrir configuración"
|
"openSettings": "Abrir configuración"
|
||||||
|
},
|
||||||
|
"pager": {
|
||||||
|
"previous": "Notificación anterior",
|
||||||
|
"next": "Notificación siguiente",
|
||||||
|
"position": "Notificación {current} de {total}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+87
-4
@@ -382,7 +382,9 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"general": "Général",
|
"general": "Général",
|
||||||
"interface": "Interface",
|
"interface": "Interface",
|
||||||
"library": "Bibliothèque"
|
"library": "Bibliothèque",
|
||||||
|
"organization": "Organisation",
|
||||||
|
"modelPaths": "Chemins de modèles"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Rechercher dans les paramètres...",
|
"placeholder": "Rechercher dans les paramètres...",
|
||||||
@@ -583,6 +585,46 @@
|
|||||||
"checkpointUnetOverlapInline": "Ce chemin est déjà utilisé pour un autre type de modèle. Utilisez des dossiers séparés pour les checkpoints et les modèles de diffusion."
|
"checkpointUnetOverlapInline": "Ce chemin est déjà utilisé pour un autre type de modèle. Utilisez des dossiers séparés pour les checkpoints et les modèles de diffusion."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"modelPaths": {
|
||||||
|
"title": "Chemins de la bibliothèque de modèles",
|
||||||
|
"description": "Dossiers racine que LoRA Manager analyse pour trouver vos modèles. Ce sont les emplacements de modèles principaux lus depuis settings.json en mode autonome.",
|
||||||
|
"restartRequired": "Un redémarrage est requis pour appliquer les changements",
|
||||||
|
"coreTypes": "Types de modèles principaux",
|
||||||
|
"otherTypes": "Autres types de modèles",
|
||||||
|
"otherTypesDisabledHint": "Aucun autre type de modèle n’est activé. Activez les types dont vous avez besoin ci-dessus pour configurer leurs dossiers.",
|
||||||
|
"saveSuccessRestart": "Chemins de la bibliothèque de modèles mis à jour. Redémarrage requis pour appliquer les changements.",
|
||||||
|
"pendingRestartNotice": "Changements de chemins enregistrés. Redémarrez LoRA Manager pour qu’ils prennent effet.",
|
||||||
|
"pendingRestartBannerTitle": "Redémarrage requis pour appliquer les changements de chemins",
|
||||||
|
"pendingRestartBannerMessage": "Les chemins de la bibliothèque de modèles ont été mis à jour. Redémarrez le serveur LoRA Manager pour analyser les nouveaux dossiers.",
|
||||||
|
"folderKeys": {
|
||||||
|
"loras": "Chemins LoRA",
|
||||||
|
"checkpoints": "Chemins Checkpoint",
|
||||||
|
"unet": "Chemins de modèle de diffusion",
|
||||||
|
"embeddings": "Chemins Embedding",
|
||||||
|
"vae": "Chemins VAE",
|
||||||
|
"upscale_models": "Chemins Upscaler",
|
||||||
|
"text_encoders": "Chemins Text Encoder",
|
||||||
|
"clip": "Chemins CLIP (hérité)",
|
||||||
|
"clip_vision": "Chemins CLIP Vision",
|
||||||
|
"controlnet": "Chemins ControlNet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"directoryPicker": {
|
||||||
|
"title": "Parcourir les dossiers",
|
||||||
|
"selectFolder": "Sélectionner ce dossier",
|
||||||
|
"goUp": "Remonter",
|
||||||
|
"pathPlaceholder": "Saisir un chemin...",
|
||||||
|
"go": "Aller",
|
||||||
|
"emptyFolder": "Aucun sous-dossier",
|
||||||
|
"loadError": "Échec du chargement du dossier"
|
||||||
|
},
|
||||||
|
"pathValidation": {
|
||||||
|
"valid": "Le chemin est valide",
|
||||||
|
"pathNotFound": "Le chemin n’existe pas",
|
||||||
|
"notADirectory": "N’est pas un dossier",
|
||||||
|
"notReadable": "Le chemin n’est pas lisible",
|
||||||
|
"notWritable": "Le chemin n’est pas accessible en écriture"
|
||||||
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
"title": "Tags prioritaires",
|
"title": "Tags prioritaires",
|
||||||
"description": "Personnalisez l'ordre de priorité des tags pour chaque type de modèle (par ex. : character, concept, style(toon|toon_style))",
|
"description": "Personnalisez l'ordre de priorité des tags pour chaque type de modèle (par ex. : character, concept, style(toon|toon_style))",
|
||||||
@@ -639,6 +681,22 @@
|
|||||||
"validTemplate": "Modèle valide"
|
"validTemplate": "Modèle valide"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"filenameTemplates": {
|
||||||
|
"title": "Modèles de nom de fichier",
|
||||||
|
"help": "Configurer les noms de fichier des modèles téléchargés par type de modèle. Laisser vide pour conserver le nom de fichier d'origine. Le nom de fichier d'origine est toujours conservé dans les métadonnées du modèle.",
|
||||||
|
"availablePlaceholders": "Espaces réservés disponibles :",
|
||||||
|
"templatePlaceholder": "Entrez un modèle de nom de fichier (ex: {base_model}-{model_name}-{version_name})",
|
||||||
|
"applyButton": "Appliquer à la bibliothèque maintenant",
|
||||||
|
"applyHelp": "Renomme tous les fichiers existants de ce type de modèle selon le modèle. Attention : le renommage change le chemin relatif vu par les loaders ComfyUI, les workflows existants référençant l'ancien nom de fichier peuvent donc nécessiter une mise à jour. Le nom de fichier d'origine est conservé dans les métadonnées de chaque modèle.",
|
||||||
|
"confirmApply": "Renommer tous les fichiers existants de ce type de modèle selon le modèle de nom de fichier ? Cela change le chemin relatif vu par les loaders ComfyUI. Le nom de fichier d'origine est conservé dans les métadonnées de chaque modèle.",
|
||||||
|
"confirmRevert": "Restaurer les noms de fichier d'origine enregistrés de tous les fichiers précédemment renommés de ce type de modèle ? Cela change le chemin relatif vu par les loaders ComfyUI. Les fichiers sans nom de fichier d'origine enregistré sont ignorés.",
|
||||||
|
"validation": {
|
||||||
|
"restoreOriginal": "Valide (un modèle vide restaure les noms de fichier d'origine)",
|
||||||
|
"invalidChars": "Caractères invalides détectés (un nom de fichier ne peut pas contenir / \\ < > : \" | ? *)",
|
||||||
|
"invalidPlaceholder": "Espace réservé invalide : {placeholder}",
|
||||||
|
"validTemplate": "Modèle valide"
|
||||||
|
}
|
||||||
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"downloadLocation": "Emplacement de téléchargement",
|
"downloadLocation": "Emplacement de téléchargement",
|
||||||
"downloadLocationPlaceholder": "Entrez le chemin du dossier pour les images d'exemple",
|
"downloadLocationPlaceholder": "Entrez le chemin du dossier pour les images d'exemple",
|
||||||
@@ -871,6 +929,14 @@
|
|||||||
"complete": "Auto-organisation terminée",
|
"complete": "Auto-organisation terminée",
|
||||||
"error": "Erreur : {error}"
|
"error": "Erreur : {error}"
|
||||||
},
|
},
|
||||||
|
"filenameTemplateProgress": {
|
||||||
|
"initializing": "Initialisation de l'application du modèle de nom de fichier...",
|
||||||
|
"starting": "Application du modèle de nom de fichier pour {type}...",
|
||||||
|
"processing": "Traitement ({processed}/{total}) - {success} renommés, {skipped} ignorés, {failures} échecs",
|
||||||
|
"completed": "Terminé : {success} renommés, {skipped} ignorés, {failures} échecs",
|
||||||
|
"complete": "Application du modèle de nom de fichier terminée",
|
||||||
|
"error": "Erreur : {error}"
|
||||||
|
},
|
||||||
"enrichHfAgent": "Enrichir les métadonnées avec l'IA"
|
"enrichHfAgent": "Enrichir les métadonnées avec l'IA"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
@@ -1241,11 +1307,13 @@
|
|||||||
},
|
},
|
||||||
"noPaths": {
|
"noPaths": {
|
||||||
"title": "Aucun dossier d’autres modèles trouvé",
|
"title": "Aucun dossier d’autres modèles trouvé",
|
||||||
"descriptionStandalone": "La gestion des autres modèles est activée, mais aucun des dossiers de modèles configurés n’existe sur le disque. Ajoutez les chemins de dossiers ci-dessous à settings.json, puis redémarrez LoRA Manager.",
|
"descriptionStandalone": "La gestion des autres modèles est activée, mais aucun dossier d’autres modèles n’a été trouvé. Ajoutez vos dossiers de modèles dans Paramètres → Chemins de modèles, puis redémarrez LoRA Manager.",
|
||||||
"hintStandalone": "Seules les clés de dossiers listées ci-dessus sont analysées ; les clés inutiles peuvent être omises.",
|
"hintStandalone": "Seuls les types de modèles activés sont analysés ; activez les types dont vous avez besoin dans Bibliothèque → Racines par défaut.",
|
||||||
"descriptionComfyUI": "La gestion des autres modèles est activée, mais aucun des dossiers de modèles configurés n’existe sur le disque. Ajoutez les dossiers de modèles correspondants à vos chemins de modèles ComfyUI, puis rechargez cette page.",
|
"descriptionComfyUI": "La gestion des autres modèles est activée, mais aucun des dossiers de modèles configurés n’existe sur le disque. Ajoutez les dossiers de modèles correspondants à vos chemins de modèles ComfyUI, puis rechargez cette page.",
|
||||||
"hintComfyUI": "Les autres modèles sont lus depuis les dossiers vae, upscale_models, text_encoders, clip_vision et controlnet de ComfyUI.",
|
"hintComfyUI": "Les autres modèles sont lus depuis les dossiers vae, upscale_models, text_encoders, clip_vision et controlnet de ComfyUI.",
|
||||||
"openSettings": "Ouvrir les paramètres"
|
"openSettings": "Ouvrir les paramètres",
|
||||||
|
"openModelPaths": "Configurer les dossiers de modèles",
|
||||||
|
"openSettingsFolder": "Ouvrir le dossier des paramètres"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
@@ -1558,6 +1626,11 @@
|
|||||||
"tip": "Besoin de procéder par étapes ? Passez en mode groupé, sélectionnez les modèles souhaités puis utilisez \"Vérifier les mises à jour pour la sélection\".",
|
"tip": "Besoin de procéder par étapes ? Passez en mode groupé, sélectionnez les modèles souhaités puis utilisez \"Vérifier les mises à jour pour la sélection\".",
|
||||||
"action": "Tout vérifier"
|
"action": "Tout vérifier"
|
||||||
},
|
},
|
||||||
|
"filenameTemplateConfirm": {
|
||||||
|
"titleApply": "Appliquer le modèle de nom de fichier à la bibliothèque ?",
|
||||||
|
"titleRevert": "Restaurer les noms de fichier d'origine ?",
|
||||||
|
"revertButton": "Restaurer les noms de fichier d'origine"
|
||||||
|
},
|
||||||
"bulkAddTags": {
|
"bulkAddTags": {
|
||||||
"title": "Ajouter des tags à plusieurs modèles",
|
"title": "Ajouter des tags à plusieurs modèles",
|
||||||
"description": "Ajouter des tags à",
|
"description": "Ajouter des tags à",
|
||||||
@@ -2267,6 +2340,9 @@
|
|||||||
"autoOrganizeSuccess": "Auto-organisation terminée avec succès pour {count} {type}",
|
"autoOrganizeSuccess": "Auto-organisation terminée avec succès pour {count} {type}",
|
||||||
"autoOrganizePartialSuccess": "Auto-organisation terminée avec {success} déplacés, {failures} échecs sur {total} modèles",
|
"autoOrganizePartialSuccess": "Auto-organisation terminée avec {success} déplacés, {failures} échecs sur {total} modèles",
|
||||||
"autoOrganizeFailed": "Échec de l'auto-organisation : {error}",
|
"autoOrganizeFailed": "Échec de l'auto-organisation : {error}",
|
||||||
|
"filenameTemplateSuccess": "Modèle de nom de fichier appliqué avec succès pour {count} {type}",
|
||||||
|
"filenameTemplatePartialSuccess": "Modèle de nom de fichier appliqué avec {success} renommés, {failures} échecs sur {total} modèles",
|
||||||
|
"filenameTemplateFailed": "Échec de l'application du modèle de nom de fichier : {error}",
|
||||||
"noModelsSelected": "Aucun modèle sélectionné"
|
"noModelsSelected": "Aucun modèle sélectionné"
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -2433,6 +2509,8 @@
|
|||||||
"mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}",
|
"mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}",
|
||||||
"downloadTemplatesUpdated": "Modèles de chemin de téléchargement mis à jour",
|
"downloadTemplatesUpdated": "Modèles de chemin de téléchargement mis à jour",
|
||||||
"downloadTemplatesFailed": "Échec de la sauvegarde des modèles de chemin de téléchargement : {message}",
|
"downloadTemplatesFailed": "Échec de la sauvegarde des modèles de chemin de téléchargement : {message}",
|
||||||
|
"filenameTemplatesUpdated": "Modèles de nom de fichier mis à jour",
|
||||||
|
"filenameTemplatesFailed": "Échec de la sauvegarde des modèles de nom de fichier : {message}",
|
||||||
"recipesPathUpdated": "Chemin de stockage des Recipes mis à jour",
|
"recipesPathUpdated": "Chemin de stockage des Recipes mis à jour",
|
||||||
"recipesPathSaveFailed": "Échec de la mise à jour du chemin de stockage des Recipes : {message}",
|
"recipesPathSaveFailed": "Échec de la mise à jour du chemin de stockage des Recipes : {message}",
|
||||||
"settingsUpdated": "Paramètres mis à jour : {setting}",
|
"settingsUpdated": "Paramètres mis à jour : {setting}",
|
||||||
@@ -2698,6 +2776,11 @@
|
|||||||
"content": "Analysez et gérez les fichiers VAE, Upscaler, Text Encoder, CLIP Vision et ControlNet, et téléchargez-les depuis CivitAI, le tout depuis une page dédiée.",
|
"content": "Analysez et gérez les fichiers VAE, Upscaler, Text Encoder, CLIP Vision et ControlNet, et téléchargez-les depuis CivitAI, le tout depuis une page dédiée.",
|
||||||
"enable": "Activer les autres modèles",
|
"enable": "Activer les autres modèles",
|
||||||
"openSettings": "Ouvrir les paramètres"
|
"openSettings": "Ouvrir les paramètres"
|
||||||
|
},
|
||||||
|
"pager": {
|
||||||
|
"previous": "Message précédent",
|
||||||
|
"next": "Message suivant",
|
||||||
|
"position": "Message {current} sur {total}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+87
-4
@@ -382,7 +382,9 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"general": "כללי",
|
"general": "כללי",
|
||||||
"interface": "ממשק",
|
"interface": "ממשק",
|
||||||
"library": "ספרייה"
|
"library": "ספרייה",
|
||||||
|
"organization": "ארגון",
|
||||||
|
"modelPaths": "נתיבי מודלים"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "חיפוש בהגדרות...",
|
"placeholder": "חיפוש בהגדרות...",
|
||||||
@@ -583,6 +585,46 @@
|
|||||||
"checkpointUnetOverlapInline": "הנתיב הזה כבר נמצא בשימוש עבור סוג מודל אחר. יש להשתמש בתיקיות נפרדות עבור checkpoints ומודלי דיפוזיה."
|
"checkpointUnetOverlapInline": "הנתיב הזה כבר נמצא בשימוש עבור סוג מודל אחר. יש להשתמש בתיקיות נפרדות עבור checkpoints ומודלי דיפוזיה."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"modelPaths": {
|
||||||
|
"title": "נתיבי ספריית המודלים",
|
||||||
|
"description": "תיקיות שורש ש-LoRA Manager סורק לאיתור המודלים שלך. אלו מיקומי המודלים הראשיים הנקראים מ-settings.json במצב עצמאי.",
|
||||||
|
"restartRequired": "נדרש אתחול כדי שהשינוי ייכנס לתוקף",
|
||||||
|
"coreTypes": "סוגי מודלים מרכזיים",
|
||||||
|
"otherTypes": "סוגי מודלים אחרים",
|
||||||
|
"otherTypesDisabledHint": "לא מופעלים סוגי מודלים אחרים. הפעל למעלה את הסוגים הדרושים לך כדי להגדיר את התיקיות שלהם.",
|
||||||
|
"saveSuccessRestart": "נתיבי ספריית המודלים עודכנו. נדרשת הפעלה מחדש כדי להחיל את השינויים.",
|
||||||
|
"pendingRestartNotice": "שינויי הנתיבים נשמרו. הפעל מחדש את LoRA Manager כדי שייכנסו לתוקף.",
|
||||||
|
"pendingRestartBannerTitle": "נדרשת הפעלה מחדש כדי להחיל את שינויי הנתיבים",
|
||||||
|
"pendingRestartBannerMessage": "נתיבי ספריית המודלים עודכנו. הפעל מחדש את שרת LoRA Manager כדי לסרוק את התיקיות החדשות.",
|
||||||
|
"folderKeys": {
|
||||||
|
"loras": "נתיבי LoRA",
|
||||||
|
"checkpoints": "נתיבי Checkpoint",
|
||||||
|
"unet": "נתיבי מודל דיפוזיה",
|
||||||
|
"embeddings": "נתיבי Embedding",
|
||||||
|
"vae": "נתיבי VAE",
|
||||||
|
"upscale_models": "נתיבי Upscaler",
|
||||||
|
"text_encoders": "נתיבי Text Encoder",
|
||||||
|
"clip": "נתיבי CLIP (ישן)",
|
||||||
|
"clip_vision": "נתיבי CLIP Vision",
|
||||||
|
"controlnet": "נתיבי ControlNet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"directoryPicker": {
|
||||||
|
"title": "עיון בתיקיות",
|
||||||
|
"selectFolder": "בחר תיקייה זו",
|
||||||
|
"goUp": "למעלה",
|
||||||
|
"pathPlaceholder": "הזן נתיב...",
|
||||||
|
"go": "עבור",
|
||||||
|
"emptyFolder": "אין תתי-תיקיות",
|
||||||
|
"loadError": "טעינת התיקייה נכשלה"
|
||||||
|
},
|
||||||
|
"pathValidation": {
|
||||||
|
"valid": "הנתיב תקין",
|
||||||
|
"pathNotFound": "הנתיב לא קיים",
|
||||||
|
"notADirectory": "לא תיקייה",
|
||||||
|
"notReadable": "הנתיב לא ניתן לקריאה",
|
||||||
|
"notWritable": "הנתיב לא ניתן לכתיבה"
|
||||||
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
"title": "תגיות עדיפות",
|
"title": "תגיות עדיפות",
|
||||||
"description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))",
|
"description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))",
|
||||||
@@ -639,6 +681,22 @@
|
|||||||
"validTemplate": "תבנית תקינה"
|
"validTemplate": "תבנית תקינה"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"filenameTemplates": {
|
||||||
|
"title": "תבניות שמות קבצים",
|
||||||
|
"help": "הגדר שמות קבצים למודלים שהורדו לפי סוג מודל. השאר ריק כדי לשמור על שמות הקבצים המקוריים בעת ההורדה; החלת תבנית ריקה משחזרת את שמות הקבצים המקוריים המתועדים של מודלים ששונה שמם בעבר. שם הקובץ המקורי תמיד נשמר במטא-נתונים של המודל.",
|
||||||
|
"availablePlaceholders": "מצייני מקום זמינים:",
|
||||||
|
"templatePlaceholder": "הזן תבנית שם קובץ (למשל, {base_model}-{model_name}-{version_name})",
|
||||||
|
"applyButton": "החל על הספרייה כעת",
|
||||||
|
"applyHelp": "משנה את שמות כל הקבצים הקיימים מסוג מודל זה בהתאם לתבנית; עם תבנית ריקה, משחזר במקום זאת את שמות הקבצים המקוריים המתועדים. אזהרה: שינוי שם משנה את הנתיב היחסי שרואים הטוענים של ComfyUI, ולכן workflows קיימים המפנים לשם הקובץ הישן עשויים לדרוש עדכון. שם הקובץ המקורי נשמר במטא-נתונים של כל מודל.",
|
||||||
|
"confirmApply": "לשנות את שמות כל הקבצים הקיימים מסוג מודל זה בהתאם לתבנית שם הקובץ? פעולה זו משנה את הנתיב היחסי שרואים הטוענים של ComfyUI. שם הקובץ המקורי נשמר במטא-נתונים של כל מודל.",
|
||||||
|
"confirmRevert": "לשחזר את שמות הקבצים המקוריים המתועדים של כל הקבצים ששונה שמם בעבר מסוג מודל זה? פעולה זו משנה את הנתיב היחסי שרואים הטוענים של ComfyUI. קבצים ללא שם קובץ מקורי מתועד ידולגו.",
|
||||||
|
"validation": {
|
||||||
|
"restoreOriginal": "תקין (תבנית ריקה משחזרת שמות קבצים מקוריים)",
|
||||||
|
"invalidChars": "זוהו תווים לא חוקיים (שם קובץ אינו יכול להכיל / \\ < > : \" | ? *)",
|
||||||
|
"invalidPlaceholder": "מציין מקום לא חוקי: {placeholder}",
|
||||||
|
"validTemplate": "תבנית תקינה"
|
||||||
|
}
|
||||||
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"downloadLocation": "מיקום הורדה",
|
"downloadLocation": "מיקום הורדה",
|
||||||
"downloadLocationPlaceholder": "הזן נתיב תיקייה לתמונות דוגמה",
|
"downloadLocationPlaceholder": "הזן נתיב תיקייה לתמונות דוגמה",
|
||||||
@@ -871,6 +929,14 @@
|
|||||||
"complete": "ארגון אוטומטי הושלם",
|
"complete": "ארגון אוטומטי הושלם",
|
||||||
"error": "שגיאה: {error}"
|
"error": "שגיאה: {error}"
|
||||||
},
|
},
|
||||||
|
"filenameTemplateProgress": {
|
||||||
|
"initializing": "מאתחל החלת תבנית שם קובץ...",
|
||||||
|
"starting": "מחיל תבנית שם קובץ על {type}...",
|
||||||
|
"processing": "מעבד ({processed}/{total}) - {success} שונו שמותם, {skipped} דולגו, {failures} נכשלו",
|
||||||
|
"completed": "הושלם: {success} שונו שמותם, {skipped} דולגו, {failures} נכשלו",
|
||||||
|
"complete": "החלת תבנית שם הקובץ הושלמה",
|
||||||
|
"error": "שגיאה: {error}"
|
||||||
|
},
|
||||||
"enrichHfAgent": "העשרת מטא-נתונים ב-AI"
|
"enrichHfAgent": "העשרת מטא-נתונים ב-AI"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
@@ -1241,11 +1307,13 @@
|
|||||||
},
|
},
|
||||||
"noPaths": {
|
"noPaths": {
|
||||||
"title": "לא נמצאו תיקיות של מודלים אחרים",
|
"title": "לא נמצאו תיקיות של מודלים אחרים",
|
||||||
"descriptionStandalone": "ניהול המודלים האחרים פועל, אך אף אחת מתיקיות המודלים המוגדרות אינה קיימת בדיסק. הוסף את נתיבי התיקיות שלמטה ל-settings.json והפעל מחדש את LoRA Manager.",
|
"descriptionStandalone": "ניהול המודלים האחרים פועל, אך לא נמצאו תיקיות של מודלים אחרים. הוסף את תיקיות המודלים שלך תחת הגדרות > נתיבי מודלים, ולאחר מכן הפעל מחדש את LoRA Manager.",
|
||||||
"hintStandalone": "רק מפתחות התיקיות המפורטים למעלה נסרקים; ניתן להשמיט מפתחות שאינך צריך.",
|
"hintStandalone": "נסרקים רק סוגי מודלים מופעלים; הפעל את הסוגים הדרושים לך תחת ספרייה > תיקיות ברירת מחדל.",
|
||||||
"descriptionComfyUI": "ניהול המודלים האחרים פועל, אך אף אחת מתיקיות המודלים המוגדרות אינה קיימת בדיסק. הוסף את תיקיות המודלים המתאימות לנתיבי המודלים של ComfyUI וטען מחדש עמוד זה.",
|
"descriptionComfyUI": "ניהול המודלים האחרים פועל, אך אף אחת מתיקיות המודלים המוגדרות אינה קיימת בדיסק. הוסף את תיקיות המודלים המתאימות לנתיבי המודלים של ComfyUI וטען מחדש עמוד זה.",
|
||||||
"hintComfyUI": "מודלים אחרים נקראים מתיקיות vae, upscale_models, text_encoders, clip_vision ו-controlnet של ComfyUI.",
|
"hintComfyUI": "מודלים אחרים נקראים מתיקיות vae, upscale_models, text_encoders, clip_vision ו-controlnet של ComfyUI.",
|
||||||
"openSettings": "פתח הגדרות"
|
"openSettings": "פתח הגדרות",
|
||||||
|
"openModelPaths": "הגדר תיקיות מודלים",
|
||||||
|
"openSettingsFolder": "פתח תיקיית הגדרות"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
@@ -1558,6 +1626,11 @@
|
|||||||
"tip": "רוצים לחלק למנות קטנות? עברו למצב בכמות גדולה, בחרו את המודלים הדרושים ואז השתמשו ב\"בדוק עדכונים לנבחרים\".",
|
"tip": "רוצים לחלק למנות קטנות? עברו למצב בכמות גדולה, בחרו את המודלים הדרושים ואז השתמשו ב\"בדוק עדכונים לנבחרים\".",
|
||||||
"action": "בדוק הכל"
|
"action": "בדוק הכל"
|
||||||
},
|
},
|
||||||
|
"filenameTemplateConfirm": {
|
||||||
|
"titleApply": "להחיל תבנית שם קובץ על הספרייה?",
|
||||||
|
"titleRevert": "לשחזר שמות קבצים מקוריים?",
|
||||||
|
"revertButton": "שחזר שמות קבצים מקוריים"
|
||||||
|
},
|
||||||
"bulkAddTags": {
|
"bulkAddTags": {
|
||||||
"title": "הוסף תגיות למספר מודלים",
|
"title": "הוסף תגיות למספר מודלים",
|
||||||
"description": "הוסף תגיות ל-",
|
"description": "הוסף תגיות ל-",
|
||||||
@@ -2267,6 +2340,9 @@
|
|||||||
"autoOrganizeSuccess": "הארגון האוטומטי הושלם בהצלחה עבור {count} {type}",
|
"autoOrganizeSuccess": "הארגון האוטומטי הושלם בהצלחה עבור {count} {type}",
|
||||||
"autoOrganizePartialSuccess": "הארגון האוטומטי הושלם עם {success} שהועברו, {failures} שנכשלו מתוך {total} מודלים",
|
"autoOrganizePartialSuccess": "הארגון האוטומטי הושלם עם {success} שהועברו, {failures} שנכשלו מתוך {total} מודלים",
|
||||||
"autoOrganizeFailed": "הארגון האוטומטי נכשל: {error}",
|
"autoOrganizeFailed": "הארגון האוטומטי נכשל: {error}",
|
||||||
|
"filenameTemplateSuccess": "תבנית שם הקובץ הוחלה בהצלחה עבור {count} {type}",
|
||||||
|
"filenameTemplatePartialSuccess": "החלת תבנית שם הקובץ הושלמה עם {success} ששונה שמם, {failures} שנכשלו מתוך {total} מודלים",
|
||||||
|
"filenameTemplateFailed": "החלת תבנית שם הקובץ נכשלה: {error}",
|
||||||
"noModelsSelected": "לא נבחרו מודלים"
|
"noModelsSelected": "לא נבחרו מודלים"
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -2433,6 +2509,8 @@
|
|||||||
"mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}",
|
"mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}",
|
||||||
"downloadTemplatesUpdated": "תבניות נתיב הורדה עודכנו",
|
"downloadTemplatesUpdated": "תבניות נתיב הורדה עודכנו",
|
||||||
"downloadTemplatesFailed": "שמירת תבניות נתיב הורדה נכשלה: {message}",
|
"downloadTemplatesFailed": "שמירת תבניות נתיב הורדה נכשלה: {message}",
|
||||||
|
"filenameTemplatesUpdated": "תבניות שמות הקבצים עודכנו",
|
||||||
|
"filenameTemplatesFailed": "שמירת תבניות שמות הקבצים נכשלה: {message}",
|
||||||
"recipesPathUpdated": "נתיב אחסון המתכונים עודכן",
|
"recipesPathUpdated": "נתיב אחסון המתכונים עודכן",
|
||||||
"recipesPathSaveFailed": "עדכון נתיב אחסון המתכונים נכשל: {message}",
|
"recipesPathSaveFailed": "עדכון נתיב אחסון המתכונים נכשל: {message}",
|
||||||
"settingsUpdated": "הגדרות עודכנו: {setting}",
|
"settingsUpdated": "הגדרות עודכנו: {setting}",
|
||||||
@@ -2698,6 +2776,11 @@
|
|||||||
"content": "סרוק ונהל קבצי VAE, Upscaler, Text Encoder, CLIP Vision ו-ControlNet, והורד אותם מ-CivitAI — מהעמוד הייעודי.",
|
"content": "סרוק ונהל קבצי VAE, Upscaler, Text Encoder, CLIP Vision ו-ControlNet, והורד אותם מ-CivitAI — מהעמוד הייעודי.",
|
||||||
"enable": "הפעל מודלים אחרים",
|
"enable": "הפעל מודלים אחרים",
|
||||||
"openSettings": "פתח הגדרות"
|
"openSettings": "פתח הגדרות"
|
||||||
|
},
|
||||||
|
"pager": {
|
||||||
|
"previous": "הודעה קודמת",
|
||||||
|
"next": "הודעה הבאה",
|
||||||
|
"position": "הודעה {current} מתוך {total}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+87
-4
@@ -382,7 +382,9 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"general": "一般",
|
"general": "一般",
|
||||||
"interface": "インターフェース",
|
"interface": "インターフェース",
|
||||||
"library": "ライブラリ"
|
"library": "ライブラリ",
|
||||||
|
"organization": "整理",
|
||||||
|
"modelPaths": "モデルパス"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "設定を検索...",
|
"placeholder": "設定を検索...",
|
||||||
@@ -583,6 +585,46 @@
|
|||||||
"checkpointUnetOverlapInline": "このパスは別のモデルタイプですでに使用されています。Checkpoints と diffusion models には別々のフォルダを使用してください。"
|
"checkpointUnetOverlapInline": "このパスは別のモデルタイプですでに使用されています。Checkpoints と diffusion models には別々のフォルダを使用してください。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"modelPaths": {
|
||||||
|
"title": "モデルライブラリパス",
|
||||||
|
"description": "LoRA Managerがモデルをスキャンするルートフォルダーです。スタンドアロンモードでは settings.json から読み込まれる主要なモデルの場所になります。",
|
||||||
|
"restartRequired": "変更を有効にするには再起動が必要です",
|
||||||
|
"coreTypes": "コアモデルタイプ",
|
||||||
|
"otherTypes": "その他のモデルタイプ",
|
||||||
|
"otherTypesDisabledHint": "その他のモデルタイプが有効になっていません。フォルダーを設定するには、上で必要なタイプをオンにしてください。",
|
||||||
|
"saveSuccessRestart": "モデルライブラリパスを更新しました。変更を適用するには再起動が必要です。",
|
||||||
|
"pendingRestartNotice": "パスの変更を保存しました。変更を有効にするにはLoRA Managerを再起動してください。",
|
||||||
|
"pendingRestartBannerTitle": "パスの変更を適用するには再起動が必要です",
|
||||||
|
"pendingRestartBannerMessage": "モデルライブラリパスが更新されました。新しいフォルダーをスキャンするにはLoRA Managerサーバーを再起動してください。",
|
||||||
|
"folderKeys": {
|
||||||
|
"loras": "LoRAパス",
|
||||||
|
"checkpoints": "Checkpointパス",
|
||||||
|
"unet": "Diffusionモデルパス",
|
||||||
|
"embeddings": "Embeddingパス",
|
||||||
|
"vae": "VAEパス",
|
||||||
|
"upscale_models": "Upscalerパス",
|
||||||
|
"text_encoders": "Text Encoderパス",
|
||||||
|
"clip": "CLIPパス(レガシー)",
|
||||||
|
"clip_vision": "CLIP Visionパス",
|
||||||
|
"controlnet": "ControlNetパス"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"directoryPicker": {
|
||||||
|
"title": "フォルダを参照",
|
||||||
|
"selectFolder": "このフォルダを選択",
|
||||||
|
"goUp": "上へ",
|
||||||
|
"pathPlaceholder": "パスを入力...",
|
||||||
|
"go": "移動",
|
||||||
|
"emptyFolder": "サブフォルダがありません",
|
||||||
|
"loadError": "ディレクトリの読み込みに失敗しました"
|
||||||
|
},
|
||||||
|
"pathValidation": {
|
||||||
|
"valid": "パスは有効です",
|
||||||
|
"pathNotFound": "パスが存在しません",
|
||||||
|
"notADirectory": "ディレクトリではありません",
|
||||||
|
"notReadable": "パスは読み取れません",
|
||||||
|
"notWritable": "パスは書き込めません"
|
||||||
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
"title": "優先タグ",
|
"title": "優先タグ",
|
||||||
"description": "各モデルタイプのタグ優先順位をカスタマイズします (例: character, concept, style(toon|toon_style))",
|
"description": "各モデルタイプのタグ優先順位をカスタマイズします (例: character, concept, style(toon|toon_style))",
|
||||||
@@ -639,6 +681,22 @@
|
|||||||
"validTemplate": "有効なテンプレート"
|
"validTemplate": "有効なテンプレート"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"filenameTemplates": {
|
||||||
|
"title": "ファイル名テンプレート",
|
||||||
|
"help": "ダウンロードしたモデルのファイル名をモデルタイプごとに設定します。空欄にするとダウンロード時は元のファイル名が保持され、空のテンプレートを適用すると以前にリネームされたモデルの記録済みの元のファイル名が復元されます。元のファイル名は常にモデルのメタデータに保持されます。",
|
||||||
|
"availablePlaceholders": "利用可能なプレースホルダー:",
|
||||||
|
"templatePlaceholder": "ファイル名テンプレートを入力(例:{base_model}-{model_name}-{version_name})",
|
||||||
|
"applyButton": "ライブラリに今すぐ適用",
|
||||||
|
"applyHelp": "このモデルタイプの既存のすべてのファイルをテンプレートに従ってリネームします。空のテンプレートの場合は、代わりに記録済みの元のファイル名を復元します。警告:リネームするとComfyUIローダーから見える相対パスが変わるため、古いファイル名を参照する既存のワークフローは更新が必要になる場合があります。元のファイル名は各モデルのメタデータに保持されます。",
|
||||||
|
"confirmApply": "このモデルタイプの既存のすべてのファイルをファイル名テンプレートに従ってリネームしますか?ComfyUIローダーから見える相対パスが変わります。元のファイル名は各モデルのメタデータに保持されます。",
|
||||||
|
"confirmRevert": "このモデルタイプの以前にリネームされたすべてのファイルについて、記録済みの元のファイル名を復元しますか?ComfyUIローダーから見える相対パスが変わります。記録済みの元のファイル名がないファイルはスキップされます。",
|
||||||
|
"validation": {
|
||||||
|
"restoreOriginal": "有効(空のテンプレートは元のファイル名を復元)",
|
||||||
|
"invalidChars": "無効な文字が検出されました(ファイル名に / \\ < > : \" | ? * は使用できません)",
|
||||||
|
"invalidPlaceholder": "無効なプレースホルダー:{placeholder}",
|
||||||
|
"validTemplate": "有効なテンプレート"
|
||||||
|
}
|
||||||
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"downloadLocation": "ダウンロード場所",
|
"downloadLocation": "ダウンロード場所",
|
||||||
"downloadLocationPlaceholder": "例画像のフォルダパスを入力",
|
"downloadLocationPlaceholder": "例画像のフォルダパスを入力",
|
||||||
@@ -871,6 +929,14 @@
|
|||||||
"complete": "自動整理が完了しました",
|
"complete": "自動整理が完了しました",
|
||||||
"error": "エラー:{error}"
|
"error": "エラー:{error}"
|
||||||
},
|
},
|
||||||
|
"filenameTemplateProgress": {
|
||||||
|
"initializing": "ファイル名テンプレートの適用を初期化中...",
|
||||||
|
"starting": "{type}にファイル名テンプレートを適用中...",
|
||||||
|
"processing": "処理中({processed}/{total})- {success} リネーム、{skipped} スキップ、{failures} 失敗",
|
||||||
|
"completed": "完了:{success} リネーム、{skipped} スキップ、{failures} 失敗",
|
||||||
|
"complete": "ファイル名テンプレートの適用が完了しました",
|
||||||
|
"error": "エラー:{error}"
|
||||||
|
},
|
||||||
"enrichHfAgent": "メタデータをAIで補完"
|
"enrichHfAgent": "メタデータをAIで補完"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
@@ -1241,11 +1307,13 @@
|
|||||||
},
|
},
|
||||||
"noPaths": {
|
"noPaths": {
|
||||||
"title": "その他のモデルのフォルダーが見つかりません",
|
"title": "その他のモデルのフォルダーが見つかりません",
|
||||||
"descriptionStandalone": "その他のモデル管理はオンですが、設定されたモデルフォルダーがディスク上に存在しません。以下のフォルダーパスをsettings.jsonに追加し、LoRA Managerを再起動してください。",
|
"descriptionStandalone": "その他のモデル管理はオンですが、その他のモデルフォルダーが見つかりませんでした。「設定 > モデルパス」でモデルフォルダーを追加し、LoRA Managerを再起動してください。",
|
||||||
"hintStandalone": "スキャンされるのは上記のフォルダーキーのみです。不要なキーは省略できます。",
|
"hintStandalone": "有効になっているモデルタイプのみがスキャンされます。必要なタイプは「ライブラリ > デフォルトルート」で有効にしてください。",
|
||||||
"descriptionComfyUI": "その他のモデル管理はオンですが、設定されたモデルフォルダーがディスク上に存在しません。該当するモデルフォルダーをComfyUIのモデルパスに追加し、このページを再読み込みしてください。",
|
"descriptionComfyUI": "その他のモデル管理はオンですが、設定されたモデルフォルダーがディスク上に存在しません。該当するモデルフォルダーをComfyUIのモデルパスに追加し、このページを再読み込みしてください。",
|
||||||
"hintComfyUI": "その他のモデルは、ComfyUIのvae、upscale_models、text_encoders、clip_vision、controlnetフォルダーから読み込まれます。",
|
"hintComfyUI": "その他のモデルは、ComfyUIのvae、upscale_models、text_encoders、clip_vision、controlnetフォルダーから読み込まれます。",
|
||||||
"openSettings": "設定を開く"
|
"openSettings": "設定を開く",
|
||||||
|
"openModelPaths": "モデルフォルダーを設定",
|
||||||
|
"openSettingsFolder": "設定フォルダーを開く"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
@@ -1558,6 +1626,11 @@
|
|||||||
"tip": "少しずつ確認したい場合は一括モードに切り替え、必要なモデルを選んで「選択項目の更新を確認」を使ってください。",
|
"tip": "少しずつ確認したい場合は一括モードに切り替え、必要なモデルを選んで「選択項目の更新を確認」を使ってください。",
|
||||||
"action": "すべて確認"
|
"action": "すべて確認"
|
||||||
},
|
},
|
||||||
|
"filenameTemplateConfirm": {
|
||||||
|
"titleApply": "ファイル名テンプレートをライブラリに適用しますか?",
|
||||||
|
"titleRevert": "元のファイル名を復元しますか?",
|
||||||
|
"revertButton": "元のファイル名を復元"
|
||||||
|
},
|
||||||
"bulkAddTags": {
|
"bulkAddTags": {
|
||||||
"title": "複数モデルにタグを追加",
|
"title": "複数モデルにタグを追加",
|
||||||
"description": "タグを追加するモデル:",
|
"description": "タグを追加するモデル:",
|
||||||
@@ -2267,6 +2340,9 @@
|
|||||||
"autoOrganizeSuccess": "{count} {type} の自動整理が正常に完了しました",
|
"autoOrganizeSuccess": "{count} {type} の自動整理が正常に完了しました",
|
||||||
"autoOrganizePartialSuccess": "自動整理が完了しました:{total} モデル中 {success} 移動、{failures} 失敗",
|
"autoOrganizePartialSuccess": "自動整理が完了しました:{total} モデル中 {success} 移動、{failures} 失敗",
|
||||||
"autoOrganizeFailed": "自動整理に失敗しました:{error}",
|
"autoOrganizeFailed": "自動整理に失敗しました:{error}",
|
||||||
|
"filenameTemplateSuccess": "{count} 件の{type}にファイル名テンプレートを正常に適用しました",
|
||||||
|
"filenameTemplatePartialSuccess": "ファイル名テンプレートを適用しました:{total} 件中 {success} 件をリネーム、{failures} 件失敗",
|
||||||
|
"filenameTemplateFailed": "ファイル名テンプレートの適用に失敗しました:{error}",
|
||||||
"noModelsSelected": "モデルが選択されていません"
|
"noModelsSelected": "モデルが選択されていません"
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -2433,6 +2509,8 @@
|
|||||||
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
|
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
|
||||||
"downloadTemplatesUpdated": "ダウンロードパステンプレートが更新されました",
|
"downloadTemplatesUpdated": "ダウンロードパステンプレートが更新されました",
|
||||||
"downloadTemplatesFailed": "ダウンロードパステンプレートの保存に失敗しました:{message}",
|
"downloadTemplatesFailed": "ダウンロードパステンプレートの保存に失敗しました:{message}",
|
||||||
|
"filenameTemplatesUpdated": "ファイル名テンプレートを更新しました",
|
||||||
|
"filenameTemplatesFailed": "ファイル名テンプレートの保存に失敗しました:{message}",
|
||||||
"recipesPathUpdated": "レシピ保存先を更新しました",
|
"recipesPathUpdated": "レシピ保存先を更新しました",
|
||||||
"recipesPathSaveFailed": "レシピ保存先の更新に失敗しました: {message}",
|
"recipesPathSaveFailed": "レシピ保存先の更新に失敗しました: {message}",
|
||||||
"settingsUpdated": "設定が更新されました:{setting}",
|
"settingsUpdated": "設定が更新されました:{setting}",
|
||||||
@@ -2698,6 +2776,11 @@
|
|||||||
"content": "専用ページで VAE、Upscaler、Text Encoder、CLIP Vision、ControlNet の各ファイルをスキャン・管理し、CivitAI からダウンロードできます。",
|
"content": "専用ページで VAE、Upscaler、Text Encoder、CLIP Vision、ControlNet の各ファイルをスキャン・管理し、CivitAI からダウンロードできます。",
|
||||||
"enable": "その他のモデルを有効にする",
|
"enable": "その他のモデルを有効にする",
|
||||||
"openSettings": "設定を開く"
|
"openSettings": "設定を開く"
|
||||||
|
},
|
||||||
|
"pager": {
|
||||||
|
"previous": "前の通知",
|
||||||
|
"next": "次の通知",
|
||||||
|
"position": "{total} 件中 {current} 件目の通知"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+87
-4
@@ -382,7 +382,9 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"general": "일반",
|
"general": "일반",
|
||||||
"interface": "인터페이스",
|
"interface": "인터페이스",
|
||||||
"library": "라이브러리"
|
"library": "라이브러리",
|
||||||
|
"organization": "정리",
|
||||||
|
"modelPaths": "모델 경로"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "설정 검색...",
|
"placeholder": "설정 검색...",
|
||||||
@@ -583,6 +585,46 @@
|
|||||||
"checkpointUnetOverlapInline": "이 경로는 다른 모델 유형에 이미 사용 중입니다. checkpoints와 diffusion models에 별도의 폴더를 사용하세요."
|
"checkpointUnetOverlapInline": "이 경로는 다른 모델 유형에 이미 사용 중입니다. checkpoints와 diffusion models에 별도의 폴더를 사용하세요."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"modelPaths": {
|
||||||
|
"title": "모델 라이브러리 경로",
|
||||||
|
"description": "LoRA Manager가 모델을 스캔하는 루트 폴더입니다. 독립 실행 모드에서는 settings.json에서 읽어오는 기본 모델 위치입니다.",
|
||||||
|
"restartRequired": "변경 사항을 적용하려면 재시작이 필요합니다",
|
||||||
|
"coreTypes": "핵심 모델 유형",
|
||||||
|
"otherTypes": "기타 모델 유형",
|
||||||
|
"otherTypesDisabledHint": "활성화된 기타 모델 유형이 없습니다. 위에서 필요한 유형을 켜면 해당 폴더를 구성할 수 있습니다.",
|
||||||
|
"saveSuccessRestart": "모델 라이브러리 경로가 업데이트되었습니다. 변경 사항을 적용하려면 재시작이 필요합니다.",
|
||||||
|
"pendingRestartNotice": "경로 변경 사항이 저장되었습니다. 적용하려면 LoRA Manager를 재시작하세요.",
|
||||||
|
"pendingRestartBannerTitle": "경로 변경 사항을 적용하려면 재시작이 필요합니다",
|
||||||
|
"pendingRestartBannerMessage": "모델 라이브러리 경로가 업데이트되었습니다. 새 폴더를 스캔하려면 LoRA Manager 서버를 재시작하세요.",
|
||||||
|
"folderKeys": {
|
||||||
|
"loras": "LoRA 경로",
|
||||||
|
"checkpoints": "Checkpoint 경로",
|
||||||
|
"unet": "Diffusion Model 경로",
|
||||||
|
"embeddings": "Embedding 경로",
|
||||||
|
"vae": "VAE 경로",
|
||||||
|
"upscale_models": "Upscaler 경로",
|
||||||
|
"text_encoders": "Text Encoder 경로",
|
||||||
|
"clip": "CLIP 경로 (레거시)",
|
||||||
|
"clip_vision": "CLIP Vision 경로",
|
||||||
|
"controlnet": "ControlNet 경로"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"directoryPicker": {
|
||||||
|
"title": "폴더 찾아보기",
|
||||||
|
"selectFolder": "이 폴더 선택",
|
||||||
|
"goUp": "위로",
|
||||||
|
"pathPlaceholder": "경로 입력...",
|
||||||
|
"go": "이동",
|
||||||
|
"emptyFolder": "하위 폴더 없음",
|
||||||
|
"loadError": "디렉터리를 불러오지 못했습니다"
|
||||||
|
},
|
||||||
|
"pathValidation": {
|
||||||
|
"valid": "유효한 경로입니다",
|
||||||
|
"pathNotFound": "경로가 존재하지 않습니다",
|
||||||
|
"notADirectory": "디렉터리가 아닙니다",
|
||||||
|
"notReadable": "경로를 읽을 수 없습니다",
|
||||||
|
"notWritable": "경로에 쓸 수 없습니다"
|
||||||
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
"title": "우선순위 태그",
|
"title": "우선순위 태그",
|
||||||
"description": "모델 유형별 태그 우선순위를 사용자 지정합니다(예: character, concept, style(toon|toon_style)).",
|
"description": "모델 유형별 태그 우선순위를 사용자 지정합니다(예: character, concept, style(toon|toon_style)).",
|
||||||
@@ -639,6 +681,22 @@
|
|||||||
"validTemplate": "유효한 템플릿"
|
"validTemplate": "유효한 템플릿"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"filenameTemplates": {
|
||||||
|
"title": "파일명 템플릿",
|
||||||
|
"help": "모델 유형별로 다운로드되는 모델의 파일명을 구성합니다. 비워 두면 다운로드 시 원본 파일명을 유지하고, 빈 템플릿을 적용하면 이전에 이름이 변경된 모델의 기록된 원본 파일명이 복원됩니다. 원본 파일명은 항상 모델의 메타데이터에 보존됩니다.",
|
||||||
|
"availablePlaceholders": "사용 가능한 플레이스홀더:",
|
||||||
|
"templatePlaceholder": "파일명 템플릿 입력 (예: {base_model}-{model_name}-{version_name})",
|
||||||
|
"applyButton": "지금 라이브러리에 적용",
|
||||||
|
"applyHelp": "이 모델 유형의 기존 파일을 모두 템플릿에 따라 이름 변경합니다. 빈 템플릿이면 기록된 원본 파일명을 대신 복원합니다. 경고: 이름을 변경하면 ComfyUI 로더에서 보이는 상대 경로가 바뀌므로 이전 파일명을 참조하는 기존 워크플로를 업데이트해야 할 수 있습니다. 원본 파일명은 각 모델의 메타데이터에 보존됩니다.",
|
||||||
|
"confirmApply": "이 모델 유형의 기존 파일을 모두 파일명 템플릿에 따라 이름 변경하시겠습니까? ComfyUI 로더에서 보이는 상대 경로가 변경됩니다. 원본 파일명은 각 모델의 메타데이터에 보존됩니다.",
|
||||||
|
"confirmRevert": "이 모델 유형에서 이전에 이름이 변경된 모든 파일의 기록된 원본 파일명을 복원하시겠습니까? ComfyUI 로더에서 보이는 상대 경로가 변경됩니다. 기록된 원본 파일명이 없는 파일은 건너뜁니다.",
|
||||||
|
"validation": {
|
||||||
|
"restoreOriginal": "유효함 (빈 템플릿은 원본 파일명을 복원합니다)",
|
||||||
|
"invalidChars": "잘못된 문자가 감지됨 (파일명에는 / \\ < > : \" | ? * 문자를 사용할 수 없습니다)",
|
||||||
|
"invalidPlaceholder": "잘못된 플레이스홀더: {placeholder}",
|
||||||
|
"validTemplate": "유효한 템플릿"
|
||||||
|
}
|
||||||
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"downloadLocation": "다운로드 위치",
|
"downloadLocation": "다운로드 위치",
|
||||||
"downloadLocationPlaceholder": "예시 이미지 폴더 경로를 입력하세요",
|
"downloadLocationPlaceholder": "예시 이미지 폴더 경로를 입력하세요",
|
||||||
@@ -871,6 +929,14 @@
|
|||||||
"complete": "자동 정리 완료",
|
"complete": "자동 정리 완료",
|
||||||
"error": "오류: {error}"
|
"error": "오류: {error}"
|
||||||
},
|
},
|
||||||
|
"filenameTemplateProgress": {
|
||||||
|
"initializing": "파일명 템플릿 적용 초기화 중...",
|
||||||
|
"starting": "{type}에 파일명 템플릿 적용 중...",
|
||||||
|
"processing": "처리 중 ({processed}/{total}) - {success}개 이름 변경, {skipped}개 건너뜀, {failures}개 실패",
|
||||||
|
"completed": "완료: {success}개 이름 변경, {skipped}개 건너뜀, {failures}개 실패",
|
||||||
|
"complete": "파일명 템플릿 적용 완료",
|
||||||
|
"error": "오류: {error}"
|
||||||
|
},
|
||||||
"enrichHfAgent": "AI로 메타데이터 보강"
|
"enrichHfAgent": "AI로 메타데이터 보강"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
@@ -1241,11 +1307,13 @@
|
|||||||
},
|
},
|
||||||
"noPaths": {
|
"noPaths": {
|
||||||
"title": "기타 모델 폴더를 찾을 수 없습니다",
|
"title": "기타 모델 폴더를 찾을 수 없습니다",
|
||||||
"descriptionStandalone": "기타 모델 관리가 켜져 있지만, 설정된 모델 폴더가 디스크에 존재하지 않습니다. 아래 폴더 경로를 settings.json에 추가한 뒤 LoRA Manager를 재시작하세요.",
|
"descriptionStandalone": "기타 모델 관리가 켜져 있지만, 기타 모델 폴더를 찾을 수 없습니다. 설정 → 모델 경로에서 모델 폴더를 추가한 뒤 LoRA Manager를 재시작하세요.",
|
||||||
"hintStandalone": "위에 나열된 폴더 키만 스캔됩니다. 필요 없는 키는 생략할 수 있습니다.",
|
"hintStandalone": "활성화된 모델 유형만 스캔됩니다. 라이브러리 → 기본 루트에서 필요한 유형을 활성화하세요.",
|
||||||
"descriptionComfyUI": "기타 모델 관리가 켜져 있지만, 설정된 모델 폴더가 디스크에 존재하지 않습니다. 해당 모델 폴더를 ComfyUI 모델 경로에 추가한 뒤 이 페이지를 새로 고침하세요.",
|
"descriptionComfyUI": "기타 모델 관리가 켜져 있지만, 설정된 모델 폴더가 디스크에 존재하지 않습니다. 해당 모델 폴더를 ComfyUI 모델 경로에 추가한 뒤 이 페이지를 새로 고침하세요.",
|
||||||
"hintComfyUI": "기타 모델은 ComfyUI의 vae, upscale_models, text_encoders, clip_vision, controlnet 폴더에서 읽어옵니다.",
|
"hintComfyUI": "기타 모델은 ComfyUI의 vae, upscale_models, text_encoders, clip_vision, controlnet 폴더에서 읽어옵니다.",
|
||||||
"openSettings": "설정 열기"
|
"openSettings": "설정 열기",
|
||||||
|
"openModelPaths": "모델 폴더 구성",
|
||||||
|
"openSettingsFolder": "설정 폴더 열기"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
@@ -1558,6 +1626,11 @@
|
|||||||
"tip": "나눠서 진행하고 싶다면 일괄 모드로 전환해 필요한 모델만 선택한 뒤 \"선택 항목 업데이트 확인\"을 사용하세요.",
|
"tip": "나눠서 진행하고 싶다면 일괄 모드로 전환해 필요한 모델만 선택한 뒤 \"선택 항목 업데이트 확인\"을 사용하세요.",
|
||||||
"action": "전체 확인"
|
"action": "전체 확인"
|
||||||
},
|
},
|
||||||
|
"filenameTemplateConfirm": {
|
||||||
|
"titleApply": "라이브러리에 파일명 템플릿을 적용하시겠습니까?",
|
||||||
|
"titleRevert": "원본 파일명을 복원하시겠습니까?",
|
||||||
|
"revertButton": "원본 파일명 복원"
|
||||||
|
},
|
||||||
"bulkAddTags": {
|
"bulkAddTags": {
|
||||||
"title": "여러 모델에 태그 추가",
|
"title": "여러 모델에 태그 추가",
|
||||||
"description": "다음에 태그를 추가합니다:",
|
"description": "다음에 태그를 추가합니다:",
|
||||||
@@ -2267,6 +2340,9 @@
|
|||||||
"autoOrganizeSuccess": "{count}개의 {type}에 대해 자동 정리가 성공적으로 완료되었습니다",
|
"autoOrganizeSuccess": "{count}개의 {type}에 대해 자동 정리가 성공적으로 완료되었습니다",
|
||||||
"autoOrganizePartialSuccess": "자동 정리 완료: 전체 {total}개 중 {success}개 이동, {failures}개 실패",
|
"autoOrganizePartialSuccess": "자동 정리 완료: 전체 {total}개 중 {success}개 이동, {failures}개 실패",
|
||||||
"autoOrganizeFailed": "자동 정리 실패: {error}",
|
"autoOrganizeFailed": "자동 정리 실패: {error}",
|
||||||
|
"filenameTemplateSuccess": "{count}개의 {type}에 파일명 템플릿이 성공적으로 적용되었습니다",
|
||||||
|
"filenameTemplatePartialSuccess": "파일명 템플릿 적용 완료: 전체 {total}개 중 {success}개 이름 변경, {failures}개 실패",
|
||||||
|
"filenameTemplateFailed": "파일명 템플릿 적용 실패: {error}",
|
||||||
"noModelsSelected": "선택된 모델이 없습니다"
|
"noModelsSelected": "선택된 모델이 없습니다"
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -2433,6 +2509,8 @@
|
|||||||
"mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}",
|
"mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}",
|
||||||
"downloadTemplatesUpdated": "다운로드 경로 템플릿이 업데이트되었습니다",
|
"downloadTemplatesUpdated": "다운로드 경로 템플릿이 업데이트되었습니다",
|
||||||
"downloadTemplatesFailed": "다운로드 경로 템플릿 저장 실패: {message}",
|
"downloadTemplatesFailed": "다운로드 경로 템플릿 저장 실패: {message}",
|
||||||
|
"filenameTemplatesUpdated": "파일명 템플릿이 업데이트되었습니다",
|
||||||
|
"filenameTemplatesFailed": "파일명 템플릿 저장 실패: {message}",
|
||||||
"recipesPathUpdated": "레시피 저장 경로가 업데이트되었습니다",
|
"recipesPathUpdated": "레시피 저장 경로가 업데이트되었습니다",
|
||||||
"recipesPathSaveFailed": "레시피 저장 경로 업데이트 실패: {message}",
|
"recipesPathSaveFailed": "레시피 저장 경로 업데이트 실패: {message}",
|
||||||
"settingsUpdated": "설정 업데이트됨: {setting}",
|
"settingsUpdated": "설정 업데이트됨: {setting}",
|
||||||
@@ -2698,6 +2776,11 @@
|
|||||||
"content": "전용 페이지에서 VAE, Upscaler, Text Encoder, CLIP Vision, ControlNet 파일을 스캔 및 관리하고 CivitAI에서 다운로드할 수 있습니다.",
|
"content": "전용 페이지에서 VAE, Upscaler, Text Encoder, CLIP Vision, ControlNet 파일을 스캔 및 관리하고 CivitAI에서 다운로드할 수 있습니다.",
|
||||||
"enable": "기타 모델 활성화",
|
"enable": "기타 모델 활성화",
|
||||||
"openSettings": "설정 열기"
|
"openSettings": "설정 열기"
|
||||||
|
},
|
||||||
|
"pager": {
|
||||||
|
"previous": "이전 알림",
|
||||||
|
"next": "다음 알림",
|
||||||
|
"position": "전체 {total}개 중 {current}번째 알림"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+87
-4
@@ -382,7 +382,9 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"general": "Общее",
|
"general": "Общее",
|
||||||
"interface": "Интерфейс",
|
"interface": "Интерфейс",
|
||||||
"library": "Библиотека"
|
"library": "Библиотека",
|
||||||
|
"organization": "Организация",
|
||||||
|
"modelPaths": "Пути к моделям"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Поиск в настройках...",
|
"placeholder": "Поиск в настройках...",
|
||||||
@@ -583,6 +585,46 @@
|
|||||||
"checkpointUnetOverlapInline": "Этот путь уже используется для другого типа модели. Используйте отдельные папки для checkpoints и diffusion models."
|
"checkpointUnetOverlapInline": "Этот путь уже используется для другого типа модели. Используйте отдельные папки для checkpoints и diffusion models."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"modelPaths": {
|
||||||
|
"title": "Пути библиотеки моделей",
|
||||||
|
"description": "Корневые папки, которые LoRA Manager сканирует в поисках ваших моделей. В автономном режиме это основные расположения моделей, считываемые из settings.json.",
|
||||||
|
"restartRequired": "Требуется перезапуск, чтобы изменения вступили в силу",
|
||||||
|
"coreTypes": "Основные типы моделей",
|
||||||
|
"otherTypes": "Другие типы моделей",
|
||||||
|
"otherTypesDisabledHint": "Другие типы моделей не включены. Включите нужные типы выше, чтобы настроить их папки.",
|
||||||
|
"saveSuccessRestart": "Пути библиотеки моделей обновлены. Требуется перезапуск для применения изменений.",
|
||||||
|
"pendingRestartNotice": "Изменения путей сохранены. Перезапустите LoRA Manager, чтобы они вступили в силу.",
|
||||||
|
"pendingRestartBannerTitle": "Требуется перезапуск для применения изменений путей",
|
||||||
|
"pendingRestartBannerMessage": "Пути библиотеки моделей обновлены. Перезапустите сервер LoRA Manager, чтобы просканировать новые папки.",
|
||||||
|
"folderKeys": {
|
||||||
|
"loras": "Пути LoRA",
|
||||||
|
"checkpoints": "Пути Checkpoint",
|
||||||
|
"unet": "Пути моделей диффузии",
|
||||||
|
"embeddings": "Пути Embedding",
|
||||||
|
"vae": "Пути VAE",
|
||||||
|
"upscale_models": "Пути Upscaler",
|
||||||
|
"text_encoders": "Пути Text Encoder",
|
||||||
|
"clip": "Пути CLIP (устаревшие)",
|
||||||
|
"clip_vision": "Пути CLIP Vision",
|
||||||
|
"controlnet": "Пути ControlNet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"directoryPicker": {
|
||||||
|
"title": "Обзор папок",
|
||||||
|
"selectFolder": "Выбрать эту папку",
|
||||||
|
"goUp": "Вверх",
|
||||||
|
"pathPlaceholder": "Введите путь...",
|
||||||
|
"go": "Перейти",
|
||||||
|
"emptyFolder": "Нет подпапок",
|
||||||
|
"loadError": "Не удалось загрузить каталог"
|
||||||
|
},
|
||||||
|
"pathValidation": {
|
||||||
|
"valid": "Путь действителен",
|
||||||
|
"pathNotFound": "Путь не существует",
|
||||||
|
"notADirectory": "Не является каталогом",
|
||||||
|
"notReadable": "Путь недоступен для чтения",
|
||||||
|
"notWritable": "Путь недоступен для записи"
|
||||||
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
"title": "Приоритетные теги",
|
"title": "Приоритетные теги",
|
||||||
"description": "Настройте порядок приоритетов тегов для каждого типа моделей (например, character, concept, style(toon|toon_style)).",
|
"description": "Настройте порядок приоритетов тегов для каждого типа моделей (например, character, concept, style(toon|toon_style)).",
|
||||||
@@ -639,6 +681,22 @@
|
|||||||
"validTemplate": "Действительный шаблон"
|
"validTemplate": "Действительный шаблон"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"filenameTemplates": {
|
||||||
|
"title": "Шаблоны имён файлов",
|
||||||
|
"help": "Настройте имена файлов загружаемых моделей для каждого типа моделей. Оставьте пустым, чтобы сохранять исходные имена файлов при загрузке; применение пустого шаблона восстанавливает записанные исходные имена файлов ранее переименованных моделей. Исходное имя файла всегда сохраняется в метаданных модели.",
|
||||||
|
"availablePlaceholders": "Доступные заполнители:",
|
||||||
|
"templatePlaceholder": "Введите шаблон имени файла (например, {base_model}-{model_name}-{version_name})",
|
||||||
|
"applyButton": "Применить к библиотеке сейчас",
|
||||||
|
"applyHelp": "Переименовывает все существующие файлы этого типа моделей согласно шаблону; при пустом шаблоне вместо этого восстанавливает записанные исходные имена файлов. Предупреждение: переименование меняет относительный путь, который видят загрузчики ComfyUI, поэтому существующие workflow, ссылающиеся на старое имя файла, может потребоваться обновить. Исходное имя файла сохраняется в метаданных каждой модели.",
|
||||||
|
"confirmApply": "Переименовать все существующие файлы этого типа моделей согласно шаблону имён файлов? Это меняет относительный путь, который видят загрузчики ComfyUI. Исходное имя файла сохраняется в метаданных каждой модели.",
|
||||||
|
"confirmRevert": "Восстановить записанные исходные имена файлов всех ранее переименованных файлов этого типа моделей? Это меняет относительный путь, который видят загрузчики ComfyUI. Файлы без записанного исходного имени файла пропускаются.",
|
||||||
|
"validation": {
|
||||||
|
"restoreOriginal": "Действительный (пустой шаблон восстанавливает исходные имена файлов)",
|
||||||
|
"invalidChars": "Обнаружены недопустимые символы (имя файла не может содержать / \\ < > : \" | ? *)",
|
||||||
|
"invalidPlaceholder": "Недопустимый заполнитель: {placeholder}",
|
||||||
|
"validTemplate": "Действительный шаблон"
|
||||||
|
}
|
||||||
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"downloadLocation": "Место загрузки",
|
"downloadLocation": "Место загрузки",
|
||||||
"downloadLocationPlaceholder": "Введите путь к папке для примеров изображений",
|
"downloadLocationPlaceholder": "Введите путь к папке для примеров изображений",
|
||||||
@@ -871,6 +929,14 @@
|
|||||||
"complete": "Автоматическая организация завершена",
|
"complete": "Автоматическая организация завершена",
|
||||||
"error": "Ошибка: {error}"
|
"error": "Ошибка: {error}"
|
||||||
},
|
},
|
||||||
|
"filenameTemplateProgress": {
|
||||||
|
"initializing": "Инициализация применения шаблона имён файлов...",
|
||||||
|
"starting": "Применение шаблона имён файлов к {type}...",
|
||||||
|
"processing": "Обработка ({processed}/{total}) — {success} переименовано, {skipped} пропущено, {failures} не удалось",
|
||||||
|
"completed": "Завершено: {success} переименовано, {skipped} пропущено, {failures} не удалось",
|
||||||
|
"complete": "Применение шаблона имён файлов завершено",
|
||||||
|
"error": "Ошибка: {error}"
|
||||||
|
},
|
||||||
"enrichHfAgent": "Обогатить метаданные с помощью ИИ"
|
"enrichHfAgent": "Обогатить метаданные с помощью ИИ"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
@@ -1241,11 +1307,13 @@
|
|||||||
},
|
},
|
||||||
"noPaths": {
|
"noPaths": {
|
||||||
"title": "Папки других моделей не найдены",
|
"title": "Папки других моделей не найдены",
|
||||||
"descriptionStandalone": "Управление другими моделями включено, но ни одна из настроенных папок моделей не существует на диске. Добавьте указанные ниже пути к папкам в settings.json и перезапустите LoRA Manager.",
|
"descriptionStandalone": "Управление другими моделями включено, но папки других моделей не найдены. Добавьте свои папки моделей в разделе «Настройки → Пути к моделям», затем перезапустите LoRA Manager.",
|
||||||
"hintStandalone": "Сканируются только перечисленные выше ключи папок; ненужные ключи можно опустить.",
|
"hintStandalone": "Сканируются только включённые типы моделей; включите нужные типы в разделе «Библиотека → Корневые папки».",
|
||||||
"descriptionComfyUI": "Управление другими моделями включено, но ни одна из настроенных папок моделей не существует на диске. Добавьте соответствующие папки моделей в пути к моделям ComfyUI и перезагрузите эту страницу.",
|
"descriptionComfyUI": "Управление другими моделями включено, но ни одна из настроенных папок моделей не существует на диске. Добавьте соответствующие папки моделей в пути к моделям ComfyUI и перезагрузите эту страницу.",
|
||||||
"hintComfyUI": "Другие модели читаются из папок vae, upscale_models, text_encoders, clip_vision и controlnet в ComfyUI.",
|
"hintComfyUI": "Другие модели читаются из папок vae, upscale_models, text_encoders, clip_vision и controlnet в ComfyUI.",
|
||||||
"openSettings": "Открыть настройки"
|
"openSettings": "Открыть настройки",
|
||||||
|
"openModelPaths": "Настроить папки моделей",
|
||||||
|
"openSettingsFolder": "Открыть папку настроек"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
@@ -1558,6 +1626,11 @@
|
|||||||
"tip": "Хотите проверять по частям? Переключитесь в массовый режим, выберите нужные модели и используйте \"Проверить обновления для выбранных\".",
|
"tip": "Хотите проверять по частям? Переключитесь в массовый режим, выберите нужные модели и используйте \"Проверить обновления для выбранных\".",
|
||||||
"action": "Проверить всё"
|
"action": "Проверить всё"
|
||||||
},
|
},
|
||||||
|
"filenameTemplateConfirm": {
|
||||||
|
"titleApply": "Применить шаблон имён файлов к библиотеке?",
|
||||||
|
"titleRevert": "Восстановить исходные имена файлов?",
|
||||||
|
"revertButton": "Восстановить исходные имена файлов"
|
||||||
|
},
|
||||||
"bulkAddTags": {
|
"bulkAddTags": {
|
||||||
"title": "Добавить теги к нескольким моделям",
|
"title": "Добавить теги к нескольким моделям",
|
||||||
"description": "Добавить теги к",
|
"description": "Добавить теги к",
|
||||||
@@ -2267,6 +2340,9 @@
|
|||||||
"autoOrganizeSuccess": "Автоматическая организация успешно завершена для {count} {type}",
|
"autoOrganizeSuccess": "Автоматическая организация успешно завершена для {count} {type}",
|
||||||
"autoOrganizePartialSuccess": "Автоматическая организация завершена: перемещено {success}, не удалось {failures} из {total} моделей",
|
"autoOrganizePartialSuccess": "Автоматическая организация завершена: перемещено {success}, не удалось {failures} из {total} моделей",
|
||||||
"autoOrganizeFailed": "Ошибка автоматической организации: {error}",
|
"autoOrganizeFailed": "Ошибка автоматической организации: {error}",
|
||||||
|
"filenameTemplateSuccess": "Шаблон имён файлов успешно применён для {count} {type}",
|
||||||
|
"filenameTemplatePartialSuccess": "Шаблон имён файлов применён: переименовано {success}, не удалось {failures} из {total} моделей",
|
||||||
|
"filenameTemplateFailed": "Не удалось применить шаблон имён файлов: {error}",
|
||||||
"noModelsSelected": "Модели не выбраны"
|
"noModelsSelected": "Модели не выбраны"
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -2433,6 +2509,8 @@
|
|||||||
"mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}",
|
"mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}",
|
||||||
"downloadTemplatesUpdated": "Шаблоны путей загрузки обновлены",
|
"downloadTemplatesUpdated": "Шаблоны путей загрузки обновлены",
|
||||||
"downloadTemplatesFailed": "Не удалось сохранить шаблоны путей загрузки: {message}",
|
"downloadTemplatesFailed": "Не удалось сохранить шаблоны путей загрузки: {message}",
|
||||||
|
"filenameTemplatesUpdated": "Шаблоны имён файлов обновлены",
|
||||||
|
"filenameTemplatesFailed": "Не удалось сохранить шаблоны имён файлов: {message}",
|
||||||
"recipesPathUpdated": "Путь хранения рецептов обновлён",
|
"recipesPathUpdated": "Путь хранения рецептов обновлён",
|
||||||
"recipesPathSaveFailed": "Не удалось обновить путь хранения рецептов: {message}",
|
"recipesPathSaveFailed": "Не удалось обновить путь хранения рецептов: {message}",
|
||||||
"settingsUpdated": "Настройки обновлены: {setting}",
|
"settingsUpdated": "Настройки обновлены: {setting}",
|
||||||
@@ -2698,6 +2776,11 @@
|
|||||||
"content": "Сканирование и управление файлами VAE, Upscaler, Text Encoder, CLIP Vision и ControlNet, а также загрузка их с CivitAI — всё на одной отдельной странице.",
|
"content": "Сканирование и управление файлами VAE, Upscaler, Text Encoder, CLIP Vision и ControlNet, а также загрузка их с CivitAI — всё на одной отдельной странице.",
|
||||||
"enable": "Включить другие модели",
|
"enable": "Включить другие модели",
|
||||||
"openSettings": "Открыть настройки"
|
"openSettings": "Открыть настройки"
|
||||||
|
},
|
||||||
|
"pager": {
|
||||||
|
"previous": "Предыдущее уведомление",
|
||||||
|
"next": "Следующее уведомление",
|
||||||
|
"position": "Уведомление {current} из {total}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+87
-4
@@ -382,7 +382,9 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"general": "通用",
|
"general": "通用",
|
||||||
"interface": "界面",
|
"interface": "界面",
|
||||||
"library": "库"
|
"library": "库",
|
||||||
|
"organization": "整理",
|
||||||
|
"modelPaths": "模型路径"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "搜索设置...",
|
"placeholder": "搜索设置...",
|
||||||
@@ -583,6 +585,46 @@
|
|||||||
"checkpointUnetOverlapInline": "此路径已被用于另一种模型类型。请为 checkpoints 和 diffusion models 使用不同的文件夹。"
|
"checkpointUnetOverlapInline": "此路径已被用于另一种模型类型。请为 checkpoints 和 diffusion models 使用不同的文件夹。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"modelPaths": {
|
||||||
|
"title": "模型库路径",
|
||||||
|
"description": "LoRA Manager 扫描模型所用的根文件夹。独立模式下,这些是从 settings.json 读取的主要模型位置。",
|
||||||
|
"restartRequired": "需要重启才能生效",
|
||||||
|
"coreTypes": "核心模型类型",
|
||||||
|
"otherTypes": "其他模型类型",
|
||||||
|
"otherTypesDisabledHint": "未启用任何其他模型类型。请在上方启用你需要的类型,然后为其配置文件夹。",
|
||||||
|
"saveSuccessRestart": "模型库路径已更新,需要重启才能生效。",
|
||||||
|
"pendingRestartNotice": "路径更改已保存。重启 LoRA Manager 后生效。",
|
||||||
|
"pendingRestartBannerTitle": "需要重启以应用路径更改",
|
||||||
|
"pendingRestartBannerMessage": "模型库路径已更新。请重启 LoRA Manager 服务器以扫描新文件夹。",
|
||||||
|
"folderKeys": {
|
||||||
|
"loras": "LoRA 路径",
|
||||||
|
"checkpoints": "Checkpoint 路径",
|
||||||
|
"unet": "Diffusion 模型路径",
|
||||||
|
"embeddings": "Embedding 路径",
|
||||||
|
"vae": "VAE 路径",
|
||||||
|
"upscale_models": "Upscaler 路径",
|
||||||
|
"text_encoders": "Text Encoder 路径",
|
||||||
|
"clip": "CLIP 路径(旧版)",
|
||||||
|
"clip_vision": "CLIP Vision 路径",
|
||||||
|
"controlnet": "ControlNet 路径"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"directoryPicker": {
|
||||||
|
"title": "浏览文件夹",
|
||||||
|
"selectFolder": "选择此文件夹",
|
||||||
|
"goUp": "上级目录",
|
||||||
|
"pathPlaceholder": "输入路径...",
|
||||||
|
"go": "跳转",
|
||||||
|
"emptyFolder": "没有子文件夹",
|
||||||
|
"loadError": "目录加载失败"
|
||||||
|
},
|
||||||
|
"pathValidation": {
|
||||||
|
"valid": "路径有效",
|
||||||
|
"pathNotFound": "路径不存在",
|
||||||
|
"notADirectory": "不是一个目录",
|
||||||
|
"notReadable": "路径不可读",
|
||||||
|
"notWritable": "路径不可写"
|
||||||
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
"title": "优先标签",
|
"title": "优先标签",
|
||||||
"description": "为每种模型类型自定义标签优先级顺序 (例如: character, concept, style(toon|toon_style))",
|
"description": "为每种模型类型自定义标签优先级顺序 (例如: character, concept, style(toon|toon_style))",
|
||||||
@@ -639,6 +681,22 @@
|
|||||||
"validTemplate": "有效模板"
|
"validTemplate": "有效模板"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"filenameTemplates": {
|
||||||
|
"title": "文件名模板",
|
||||||
|
"help": "按模型类型配置下载模型的文件名。留空则下载时保留原始文件名;应用空模板会恢复此前被重命名模型所记录的原始文件名。原始文件名始终保留在模型的元数据中。",
|
||||||
|
"availablePlaceholders": "可用占位符:",
|
||||||
|
"templatePlaceholder": "输入文件名模板(如:{base_model}-{model_name}-{version_name})",
|
||||||
|
"applyButton": "立即应用到库",
|
||||||
|
"applyHelp": "根据模板重命名此模型类型的所有现有文件;模板为空时则恢复已记录的原始文件名。警告:重命名会改变 ComfyUI 加载器所见的相对路径,因此引用旧文件名的现有工作流可能需要更新。原始文件名保留在每个模型的元数据中。",
|
||||||
|
"confirmApply": "要根据文件名模板重命名此模型类型的所有现有文件吗?这会改变 ComfyUI 加载器所见的相对路径。原始文件名保留在每个模型的元数据中。",
|
||||||
|
"confirmRevert": "要恢复此模型类型中所有此前被重命名文件所记录的原始文件名吗?这会改变 ComfyUI 加载器所见的相对路径。未记录原始文件名的文件将被跳过。",
|
||||||
|
"validation": {
|
||||||
|
"restoreOriginal": "有效(空模板将恢复原始文件名)",
|
||||||
|
"invalidChars": "检测到无效字符(文件名不能包含 / \\ < > : \" | ? *)",
|
||||||
|
"invalidPlaceholder": "无效占位符:{placeholder}",
|
||||||
|
"validTemplate": "有效模板"
|
||||||
|
}
|
||||||
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"downloadLocation": "下载位置",
|
"downloadLocation": "下载位置",
|
||||||
"downloadLocationPlaceholder": "输入示例图片文件夹路径",
|
"downloadLocationPlaceholder": "输入示例图片文件夹路径",
|
||||||
@@ -871,6 +929,14 @@
|
|||||||
"complete": "自动整理已完成",
|
"complete": "自动整理已完成",
|
||||||
"error": "错误:{error}"
|
"error": "错误:{error}"
|
||||||
},
|
},
|
||||||
|
"filenameTemplateProgress": {
|
||||||
|
"initializing": "正在初始化应用文件名模板...",
|
||||||
|
"starting": "正在为 {type} 应用文件名模板...",
|
||||||
|
"processing": "处理中({processed}/{total})- 已重命名 {success} 个,跳过 {skipped} 个,失败 {failures} 个",
|
||||||
|
"completed": "完成:已重命名 {success} 个,跳过 {skipped} 个,失败 {failures} 个",
|
||||||
|
"complete": "文件名模板应用完成",
|
||||||
|
"error": "错误:{error}"
|
||||||
|
},
|
||||||
"enrichHfAgent": "AI 元数据增强"
|
"enrichHfAgent": "AI 元数据增强"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
@@ -1241,11 +1307,13 @@
|
|||||||
},
|
},
|
||||||
"noPaths": {
|
"noPaths": {
|
||||||
"title": "未找到其他模型文件夹",
|
"title": "未找到其他模型文件夹",
|
||||||
"descriptionStandalone": "其他模型管理已开启,但配置的模型文件夹在磁盘上都不存在。请将下面的文件夹路径添加到 settings.json,然后重启 LoRA Manager。",
|
"descriptionStandalone": "其他模型管理已开启,但未找到其他模型文件夹。请在“设置 → 模型路径”中添加你的模型文件夹,然后重启 LoRA Manager。",
|
||||||
"hintStandalone": "只会扫描上面列出的文件夹键;不需要的键可以省略。",
|
"hintStandalone": "仅扫描已启用的模型类型;请在“库 → 默认根目录”中启用你需要的类型。",
|
||||||
"descriptionComfyUI": "其他模型管理已开启,但配置的模型文件夹在磁盘上都不存在。请将对应的模型文件夹添加到 ComfyUI 的模型路径,然后重新加载此页面。",
|
"descriptionComfyUI": "其他模型管理已开启,但配置的模型文件夹在磁盘上都不存在。请将对应的模型文件夹添加到 ComfyUI 的模型路径,然后重新加载此页面。",
|
||||||
"hintComfyUI": "其他模型从 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 文件夹中读取。",
|
"hintComfyUI": "其他模型从 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 文件夹中读取。",
|
||||||
"openSettings": "打开设置"
|
"openSettings": "打开设置",
|
||||||
|
"openModelPaths": "配置模型文件夹",
|
||||||
|
"openSettingsFolder": "打开设置文件夹"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
@@ -1558,6 +1626,11 @@
|
|||||||
"tip": "想分批进行?切换到批量模式,选中需要的模型,然后使用“检查所选更新”。",
|
"tip": "想分批进行?切换到批量模式,选中需要的模型,然后使用“检查所选更新”。",
|
||||||
"action": "检查全部"
|
"action": "检查全部"
|
||||||
},
|
},
|
||||||
|
"filenameTemplateConfirm": {
|
||||||
|
"titleApply": "将文件名模板应用到库?",
|
||||||
|
"titleRevert": "恢复原始文件名?",
|
||||||
|
"revertButton": "恢复原始文件名"
|
||||||
|
},
|
||||||
"bulkAddTags": {
|
"bulkAddTags": {
|
||||||
"title": "批量添加标签",
|
"title": "批量添加标签",
|
||||||
"description": "为多个模型添加标签",
|
"description": "为多个模型添加标签",
|
||||||
@@ -2267,6 +2340,9 @@
|
|||||||
"autoOrganizeSuccess": "自动整理已成功完成,共 {count} 个 {type}",
|
"autoOrganizeSuccess": "自动整理已成功完成,共 {count} 个 {type}",
|
||||||
"autoOrganizePartialSuccess": "自动整理完成:已移动 {success} 个,{failures} 个失败,共 {total} 个模型",
|
"autoOrganizePartialSuccess": "自动整理完成:已移动 {success} 个,{failures} 个失败,共 {total} 个模型",
|
||||||
"autoOrganizeFailed": "自动整理失败:{error}",
|
"autoOrganizeFailed": "自动整理失败:{error}",
|
||||||
|
"filenameTemplateSuccess": "文件名模板已成功应用,共 {count} 个 {type}",
|
||||||
|
"filenameTemplatePartialSuccess": "文件名模板应用完成:已重命名 {success} 个,{failures} 个失败,共 {total} 个模型",
|
||||||
|
"filenameTemplateFailed": "应用文件名模板失败:{error}",
|
||||||
"noModelsSelected": "未选中模型"
|
"noModelsSelected": "未选中模型"
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -2433,6 +2509,8 @@
|
|||||||
"mappingSaveFailed": "保存基础模型映射失败:{message}",
|
"mappingSaveFailed": "保存基础模型映射失败:{message}",
|
||||||
"downloadTemplatesUpdated": "下载路径模板已更新",
|
"downloadTemplatesUpdated": "下载路径模板已更新",
|
||||||
"downloadTemplatesFailed": "保存下载路径模板失败:{message}",
|
"downloadTemplatesFailed": "保存下载路径模板失败:{message}",
|
||||||
|
"filenameTemplatesUpdated": "文件名模板已更新",
|
||||||
|
"filenameTemplatesFailed": "保存文件名模板失败:{message}",
|
||||||
"recipesPathUpdated": "配方存储路径已更新",
|
"recipesPathUpdated": "配方存储路径已更新",
|
||||||
"recipesPathSaveFailed": "更新配方存储路径失败:{message}",
|
"recipesPathSaveFailed": "更新配方存储路径失败:{message}",
|
||||||
"settingsUpdated": "设置已更新:{setting}",
|
"settingsUpdated": "设置已更新:{setting}",
|
||||||
@@ -2698,6 +2776,11 @@
|
|||||||
"content": "在一个专属页面中扫描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 文件,并从 CivitAI 下载。",
|
"content": "在一个专属页面中扫描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 文件,并从 CivitAI 下载。",
|
||||||
"enable": "启用其他模型",
|
"enable": "启用其他模型",
|
||||||
"openSettings": "打开设置"
|
"openSettings": "打开设置"
|
||||||
|
},
|
||||||
|
"pager": {
|
||||||
|
"previous": "上一条通知",
|
||||||
|
"next": "下一条通知",
|
||||||
|
"position": "第 {current} 条通知,共 {total} 条"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+87
-4
@@ -382,7 +382,9 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"general": "通用",
|
"general": "通用",
|
||||||
"interface": "介面",
|
"interface": "介面",
|
||||||
"library": "模型庫"
|
"library": "模型庫",
|
||||||
|
"organization": "整理",
|
||||||
|
"modelPaths": "模型路徑"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "搜尋設定...",
|
"placeholder": "搜尋設定...",
|
||||||
@@ -583,6 +585,46 @@
|
|||||||
"checkpointUnetOverlapInline": "此路徑已被用於另一種模型類型。請為 checkpoints 和 diffusion models 使用不同的資料夾。"
|
"checkpointUnetOverlapInline": "此路徑已被用於另一種模型類型。請為 checkpoints 和 diffusion models 使用不同的資料夾。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"modelPaths": {
|
||||||
|
"title": "模型庫路徑",
|
||||||
|
"description": "LoRA Manager 掃描您模型的根目錄資料夾。這些是獨立模式下從 settings.json 讀取的主要模型位置。",
|
||||||
|
"restartRequired": "需要重新啟動才能生效",
|
||||||
|
"coreTypes": "核心模型類型",
|
||||||
|
"otherTypes": "其他模型類型",
|
||||||
|
"otherTypesDisabledHint": "尚未啟用任何其他模型類型。請在上方開啟您需要的類型,以設定其資料夾。",
|
||||||
|
"saveSuccessRestart": "模型庫路徑已更新,需要重新啟動才能生效。",
|
||||||
|
"pendingRestartNotice": "路徑變更已儲存。請重新啟動 LoRA Manager 以使其生效。",
|
||||||
|
"pendingRestartBannerTitle": "需要重新啟動才能套用路徑變更",
|
||||||
|
"pendingRestartBannerMessage": "模型庫路徑已更新。請重新啟動 LoRA Manager 伺服器以掃描新的資料夾。",
|
||||||
|
"folderKeys": {
|
||||||
|
"loras": "LoRA 路徑",
|
||||||
|
"checkpoints": "Checkpoint 路徑",
|
||||||
|
"unet": "Diffusion 模型路徑",
|
||||||
|
"embeddings": "Embedding 路徑",
|
||||||
|
"vae": "VAE 路徑",
|
||||||
|
"upscale_models": "Upscaler 路徑",
|
||||||
|
"text_encoders": "Text Encoder 路徑",
|
||||||
|
"clip": "CLIP 路徑(舊版)",
|
||||||
|
"clip_vision": "CLIP Vision 路徑",
|
||||||
|
"controlnet": "ControlNet 路徑"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"directoryPicker": {
|
||||||
|
"title": "瀏覽資料夾",
|
||||||
|
"selectFolder": "選擇此資料夾",
|
||||||
|
"goUp": "上一層",
|
||||||
|
"pathPlaceholder": "輸入路徑...",
|
||||||
|
"go": "前往",
|
||||||
|
"emptyFolder": "沒有子資料夾",
|
||||||
|
"loadError": "目錄載入失敗"
|
||||||
|
},
|
||||||
|
"pathValidation": {
|
||||||
|
"valid": "路徑有效",
|
||||||
|
"pathNotFound": "路徑不存在",
|
||||||
|
"notADirectory": "不是目錄",
|
||||||
|
"notReadable": "路徑無法讀取",
|
||||||
|
"notWritable": "路徑無法寫入"
|
||||||
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
"title": "優先標籤",
|
"title": "優先標籤",
|
||||||
"description": "為每種模型類型自訂標籤的優先順序 (例如: character, concept, style(toon|toon_style))",
|
"description": "為每種模型類型自訂標籤的優先順序 (例如: character, concept, style(toon|toon_style))",
|
||||||
@@ -639,6 +681,22 @@
|
|||||||
"validTemplate": "範本有效"
|
"validTemplate": "範本有效"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"filenameTemplates": {
|
||||||
|
"title": "檔案名稱範本",
|
||||||
|
"help": "依模型類型設定已下載模型的檔案名稱。留空則下載時保留原始檔案名稱;套用空範本會還原先前已重新命名模型所記錄的原始檔案名稱。原始檔案名稱一律會保存在模型的中繼資料中。",
|
||||||
|
"availablePlaceholders": "可用佔位符:",
|
||||||
|
"templatePlaceholder": "輸入檔案名稱範本(例如:{base_model}-{model_name}-{version_name})",
|
||||||
|
"applyButton": "立即套用至模型庫",
|
||||||
|
"applyHelp": "依範本重新命名此模型類型的所有現有檔案;若範本為空,則改為還原已記錄的原始檔案名稱。警告:重新命名會變更 ComfyUI 載入器所見的相對路徑,因此參照舊檔案名稱的現有工作流可能需要更新。原始檔案名稱會保存在每個模型的中繼資料中。",
|
||||||
|
"confirmApply": "要依檔案名稱範本重新命名此模型類型的所有現有檔案嗎?這會變更 ComfyUI 載入器所見的相對路徑。原始檔案名稱會保存在每個模型的中繼資料中。",
|
||||||
|
"confirmRevert": "要將此模型類型所有先前已重新命名的檔案還原為已記錄的原始檔案名稱嗎?這會變更 ComfyUI 載入器所見的相對路徑。沒有記錄原始檔案名稱的檔案將被略過。",
|
||||||
|
"validation": {
|
||||||
|
"restoreOriginal": "有效(空範本會還原原始檔案名稱)",
|
||||||
|
"invalidChars": "偵測到無效字元(檔案名稱不能包含 / \\ < > : \" | ? *)",
|
||||||
|
"invalidPlaceholder": "無效佔位符:{placeholder}",
|
||||||
|
"validTemplate": "範本有效"
|
||||||
|
}
|
||||||
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"downloadLocation": "下載位置",
|
"downloadLocation": "下載位置",
|
||||||
"downloadLocationPlaceholder": "輸入範例圖片的資料夾路徑",
|
"downloadLocationPlaceholder": "輸入範例圖片的資料夾路徑",
|
||||||
@@ -871,6 +929,14 @@
|
|||||||
"complete": "自動整理完成",
|
"complete": "自動整理完成",
|
||||||
"error": "錯誤:{error}"
|
"error": "錯誤:{error}"
|
||||||
},
|
},
|
||||||
|
"filenameTemplateProgress": {
|
||||||
|
"initializing": "正在初始化檔案名稱範本套用...",
|
||||||
|
"starting": "正在將檔案名稱範本套用至 {type}...",
|
||||||
|
"processing": "處理中({processed}/{total})- 已重新命名 {success},已略過 {skipped},失敗 {failures}",
|
||||||
|
"completed": "完成:已重新命名 {success},已略過 {skipped},失敗 {failures}",
|
||||||
|
"complete": "檔案名稱範本套用完成",
|
||||||
|
"error": "錯誤:{error}"
|
||||||
|
},
|
||||||
"enrichHfAgent": "AI 中繼資料增強"
|
"enrichHfAgent": "AI 中繼資料增強"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
@@ -1241,11 +1307,13 @@
|
|||||||
},
|
},
|
||||||
"noPaths": {
|
"noPaths": {
|
||||||
"title": "找不到其他模型資料夾",
|
"title": "找不到其他模型資料夾",
|
||||||
"descriptionStandalone": "其他模型管理已開啟,但設定的模型資料夾在磁碟上都不存在。請將下方的資料夾路徑加入 settings.json,然後重新啟動 LoRA Manager。",
|
"descriptionStandalone": "其他模型管理已開啟,但找不到其他模型的資料夾。請在「設定 > 模型路徑」中加入您的模型資料夾,然後重新啟動 LoRA Manager。",
|
||||||
"hintStandalone": "只會掃描上方列出的資料夾鍵;不需要的鍵可以省略。",
|
"hintStandalone": "僅會掃描已啟用的模型類型;請在「模型庫 > 預設根目錄」中啟用您需要的類型。",
|
||||||
"descriptionComfyUI": "其他模型管理已開啟,但設定的模型資料夾在磁碟上都不存在。請將對應的模型資料夾加入 ComfyUI 的模型路徑,然後重新載入此頁面。",
|
"descriptionComfyUI": "其他模型管理已開啟,但設定的模型資料夾在磁碟上都不存在。請將對應的模型資料夾加入 ComfyUI 的模型路徑,然後重新載入此頁面。",
|
||||||
"hintComfyUI": "其他模型會從 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 資料夾讀取。",
|
"hintComfyUI": "其他模型會從 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 資料夾讀取。",
|
||||||
"openSettings": "開啟設定"
|
"openSettings": "開啟設定",
|
||||||
|
"openModelPaths": "設定模型資料夾",
|
||||||
|
"openSettingsFolder": "開啟設定資料夾"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
@@ -1558,6 +1626,11 @@
|
|||||||
"tip": "想分批處理?切換到批次模式,選擇需要的模型,然後使用「檢查所選更新」。",
|
"tip": "想分批處理?切換到批次模式,選擇需要的模型,然後使用「檢查所選更新」。",
|
||||||
"action": "全部檢查"
|
"action": "全部檢查"
|
||||||
},
|
},
|
||||||
|
"filenameTemplateConfirm": {
|
||||||
|
"titleApply": "要將檔案名稱範本套用至模型庫嗎?",
|
||||||
|
"titleRevert": "要還原原始檔案名稱嗎?",
|
||||||
|
"revertButton": "還原原始檔案名稱"
|
||||||
|
},
|
||||||
"bulkAddTags": {
|
"bulkAddTags": {
|
||||||
"title": "新增標籤到多個模型",
|
"title": "新增標籤到多個模型",
|
||||||
"description": "新增標籤到",
|
"description": "新增標籤到",
|
||||||
@@ -2267,6 +2340,9 @@
|
|||||||
"autoOrganizeSuccess": "自動整理已成功完成,共 {count} 個 {type} 已整理",
|
"autoOrganizeSuccess": "自動整理已成功完成,共 {count} 個 {type} 已整理",
|
||||||
"autoOrganizePartialSuccess": "自動整理完成:已移動 {success} 個,{failures} 個失敗,共 {total} 個模型",
|
"autoOrganizePartialSuccess": "自動整理完成:已移動 {success} 個,{failures} 個失敗,共 {total} 個模型",
|
||||||
"autoOrganizeFailed": "自動整理失敗:{error}",
|
"autoOrganizeFailed": "自動整理失敗:{error}",
|
||||||
|
"filenameTemplateSuccess": "已成功為 {count} 個 {type} 套用檔案名稱範本",
|
||||||
|
"filenameTemplatePartialSuccess": "檔案名稱範本套用完成:已重新命名 {success} 個,{failures} 個失敗,共 {total} 個模型",
|
||||||
|
"filenameTemplateFailed": "套用檔案名稱範本失敗:{error}",
|
||||||
"noModelsSelected": "未選擇任何模型"
|
"noModelsSelected": "未選擇任何模型"
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -2433,6 +2509,8 @@
|
|||||||
"mappingSaveFailed": "儲存基礎模型對應失敗:{message}",
|
"mappingSaveFailed": "儲存基礎模型對應失敗:{message}",
|
||||||
"downloadTemplatesUpdated": "下載路徑範本已更新",
|
"downloadTemplatesUpdated": "下載路徑範本已更新",
|
||||||
"downloadTemplatesFailed": "儲存下載路徑範本失敗:{message}",
|
"downloadTemplatesFailed": "儲存下載路徑範本失敗:{message}",
|
||||||
|
"filenameTemplatesUpdated": "檔案名稱範本已更新",
|
||||||
|
"filenameTemplatesFailed": "儲存檔案名稱範本失敗:{message}",
|
||||||
"recipesPathUpdated": "配方儲存路徑已更新",
|
"recipesPathUpdated": "配方儲存路徑已更新",
|
||||||
"recipesPathSaveFailed": "更新配方儲存路徑失敗:{message}",
|
"recipesPathSaveFailed": "更新配方儲存路徑失敗:{message}",
|
||||||
"settingsUpdated": "設定已更新:{setting}",
|
"settingsUpdated": "設定已更新:{setting}",
|
||||||
@@ -2698,6 +2776,11 @@
|
|||||||
"content": "在專屬頁面中掃描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 檔案,並從 CivitAI 下載。",
|
"content": "在專屬頁面中掃描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 檔案,並從 CivitAI 下載。",
|
||||||
"enable": "啟用其他模型",
|
"enable": "啟用其他模型",
|
||||||
"openSettings": "開啟設定"
|
"openSettings": "開啟設定"
|
||||||
|
},
|
||||||
|
"pager": {
|
||||||
|
"previous": "上一則通知",
|
||||||
|
"next": "下一則通知",
|
||||||
|
"position": "第 {current} 則通知,共 {total} 則"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,11 @@ from ..services.use_cases import (
|
|||||||
AutoOrganizeUseCase,
|
AutoOrganizeUseCase,
|
||||||
BulkMetadataRefreshUseCase,
|
BulkMetadataRefreshUseCase,
|
||||||
DownloadModelUseCase,
|
DownloadModelUseCase,
|
||||||
|
FilenameTemplateUseCase,
|
||||||
)
|
)
|
||||||
from ..services.websocket_progress_callback import (
|
from ..services.websocket_progress_callback import (
|
||||||
WebSocketBroadcastCallback,
|
WebSocketBroadcastCallback,
|
||||||
|
WebSocketFilenameTemplateProgressCallback,
|
||||||
WebSocketProgressCallback,
|
WebSocketProgressCallback,
|
||||||
)
|
)
|
||||||
from ..utils.exif_utils import ExifUtils
|
from ..utils.exif_utils import ExifUtils
|
||||||
@@ -37,6 +39,7 @@ from .handlers.model_handlers import (
|
|||||||
ModelAutoOrganizeHandler,
|
ModelAutoOrganizeHandler,
|
||||||
ModelCivitaiHandler,
|
ModelCivitaiHandler,
|
||||||
ModelDownloadHandler,
|
ModelDownloadHandler,
|
||||||
|
ModelFilenameTemplateHandler,
|
||||||
ModelHandlerSet,
|
ModelHandlerSet,
|
||||||
ModelListingHandler,
|
ModelListingHandler,
|
||||||
ModelManagementHandler,
|
ModelManagementHandler,
|
||||||
@@ -83,6 +86,9 @@ class BaseModelRoutes(ABC):
|
|||||||
self.model_lifecycle_service: ModelLifecycleService | None = None
|
self.model_lifecycle_service: ModelLifecycleService | None = None
|
||||||
self.websocket_progress_callback = WebSocketProgressCallback()
|
self.websocket_progress_callback = WebSocketProgressCallback()
|
||||||
self.metadata_progress_callback = WebSocketBroadcastCallback()
|
self.metadata_progress_callback = WebSocketBroadcastCallback()
|
||||||
|
self.filename_template_progress_callback = (
|
||||||
|
WebSocketFilenameTemplateProgressCallback()
|
||||||
|
)
|
||||||
|
|
||||||
self._handler_set: ModelHandlerSet | None = None
|
self._handler_set: ModelHandlerSet | None = None
|
||||||
self._handler_mapping: Dict[str, Callable[[web.Request], Awaitable[web.Response]]] | None = None
|
self._handler_mapping: Dict[str, Callable[[web.Request], Awaitable[web.Response]]] | None = None
|
||||||
@@ -202,6 +208,17 @@ class BaseModelRoutes(ABC):
|
|||||||
ws_manager=self._ws_manager,
|
ws_manager=self._ws_manager,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
)
|
)
|
||||||
|
filename_template_use_case = FilenameTemplateUseCase(
|
||||||
|
scanner=service.scanner,
|
||||||
|
lifecycle_service=self._ensure_lifecycle_service(),
|
||||||
|
lock_provider=self._ws_manager,
|
||||||
|
model_type=service.model_type,
|
||||||
|
)
|
||||||
|
filename_template = ModelFilenameTemplateHandler(
|
||||||
|
use_case=filename_template_use_case,
|
||||||
|
progress_callback=self.filename_template_progress_callback,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
updates = ModelUpdateHandler(
|
updates = ModelUpdateHandler(
|
||||||
service=service,
|
service=service,
|
||||||
update_service=update_service,
|
update_service=update_service,
|
||||||
@@ -218,6 +235,7 @@ class BaseModelRoutes(ABC):
|
|||||||
civitai=civitai,
|
civitai=civitai,
|
||||||
move=move,
|
move=move,
|
||||||
auto_organize=auto_organize,
|
auto_organize=auto_organize,
|
||||||
|
filename_template=filename_template,
|
||||||
updates=updates,
|
updates=updates,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -54,12 +54,14 @@ from ...utils.constants import (
|
|||||||
SUPPORTED_MEDIA_EXTENSIONS,
|
SUPPORTED_MEDIA_EXTENSIONS,
|
||||||
VALID_LORA_TYPES,
|
VALID_LORA_TYPES,
|
||||||
VALID_OTHER_CIVITAI_TYPES,
|
VALID_OTHER_CIVITAI_TYPES,
|
||||||
|
folder_path_schema,
|
||||||
)
|
)
|
||||||
from .model_source_handlers import ModelSourceHandler
|
from .model_source_handlers import ModelSourceHandler
|
||||||
from .agent_handlers import AgentHandler
|
from .agent_handlers import AgentHandler
|
||||||
from .download_routing_handlers import DownloadRoutingHandler
|
from .download_routing_handlers import DownloadRoutingHandler
|
||||||
from .model_handlers import ModelCivitaiHandler
|
from .model_handlers import ModelCivitaiHandler
|
||||||
from ...utils.civitai_utils import rewrite_preview_url
|
from ...utils.civitai_utils import rewrite_preview_url
|
||||||
|
from ...utils.directory_browser import browse_directory
|
||||||
from ...utils.example_images_paths import (
|
from ...utils.example_images_paths import (
|
||||||
find_non_compliant_items_in_example_images_root,
|
find_non_compliant_items_in_example_images_root,
|
||||||
is_valid_example_images_root,
|
is_valid_example_images_root,
|
||||||
@@ -421,6 +423,11 @@ def _wsl_to_windows_path(wsl_path: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_gui_display() -> bool:
|
||||||
|
"""Check whether a GUI session is reachable for xdg-open."""
|
||||||
|
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
||||||
|
|
||||||
|
|
||||||
class PromptServerProtocol(Protocol):
|
class PromptServerProtocol(Protocol):
|
||||||
"""Subset of PromptServer used by the handlers."""
|
"""Subset of PromptServer used by the handlers."""
|
||||||
|
|
||||||
@@ -1575,6 +1582,30 @@ class SettingsHandler:
|
|||||||
availability_error,
|
availability_error,
|
||||||
)
|
)
|
||||||
response_data["other_models_paths_available"] = None
|
response_data["other_models_paths_available"] = None
|
||||||
|
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
|
||||||
|
response_data["standalone_mode"] = standalone_mode
|
||||||
|
if standalone_mode:
|
||||||
|
# Standalone reads its model roots exclusively from
|
||||||
|
# settings.json, so the Model Paths settings UI needs the
|
||||||
|
# current values plus the editable-key schema. In plugin mode
|
||||||
|
# the paths come from the ComfyUI host and stay hidden.
|
||||||
|
folder_paths = self._settings.get("folder_paths") or {}
|
||||||
|
# A fresh install is seeded from settings.json.example, whose
|
||||||
|
# folder_paths are documentation placeholders — hide them so
|
||||||
|
# the UI starts with empty editors instead of fake paths.
|
||||||
|
get_placeholders = getattr(
|
||||||
|
self._settings, "get_template_folder_path_placeholders", None
|
||||||
|
)
|
||||||
|
placeholders = get_placeholders() if get_placeholders else set()
|
||||||
|
if placeholders:
|
||||||
|
folder_paths = {
|
||||||
|
key: [p for p in paths if p not in placeholders]
|
||||||
|
if isinstance(paths, list)
|
||||||
|
else paths
|
||||||
|
for key, paths in folder_paths.items()
|
||||||
|
}
|
||||||
|
response_data["folder_paths"] = folder_paths
|
||||||
|
response_data["folder_path_schema"] = folder_path_schema()
|
||||||
settings_file = getattr(self._settings, "settings_file", None)
|
settings_file = getattr(self._settings, "settings_file", None)
|
||||||
if settings_file:
|
if settings_file:
|
||||||
response_data["settings_file"] = settings_file
|
response_data["settings_file"] = settings_file
|
||||||
@@ -2759,12 +2790,40 @@ class ModelLibraryHandler:
|
|||||||
|
|
||||||
normalized_type, scanner = await self._get_scanner_for_type(model_type)
|
normalized_type, scanner = await self._get_scanner_for_type(model_type)
|
||||||
if not normalized_type:
|
if not normalized_type:
|
||||||
|
# The lookup cannot be served as a fully interactive list. Two
|
||||||
|
# cases share this branch: a CivitAI type with no scanner at all
|
||||||
|
# (Wildcards, Workflows, Hypernetwork, Poses, AestheticGradient)
|
||||||
|
# and an Other-model type while the opt-in master switch is off.
|
||||||
|
# Answer 200 with the CivitAI list marked read-only plus a
|
||||||
|
# machine-readable reason, so clients can still show the
|
||||||
|
# versions and explain why the actions are missing. Legacy
|
||||||
|
# clients keep working: they only read `success`/`versions`.
|
||||||
|
reason = (
|
||||||
|
"other_models_disabled"
|
||||||
|
if self._normalize_model_type(model_type) == "other"
|
||||||
|
else "model_type_unsupported"
|
||||||
|
)
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
"success": False,
|
"success": True,
|
||||||
"error": f'Model type "{model_type}" is not supported',
|
"modelId": model_id,
|
||||||
},
|
"modelName": model_name,
|
||||||
status=400,
|
"modelType": model_type,
|
||||||
|
"supported": False,
|
||||||
|
"reason": reason,
|
||||||
|
"versions": [
|
||||||
|
{
|
||||||
|
"id": version.get("id"),
|
||||||
|
"name": version.get("name", ""),
|
||||||
|
"thumbnailUrl": version.get("images")[0]["url"]
|
||||||
|
if version.get("images")
|
||||||
|
else None,
|
||||||
|
"inLibrary": False,
|
||||||
|
"hasBeenDownloaded": False,
|
||||||
|
}
|
||||||
|
for version in versions
|
||||||
|
],
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if not scanner:
|
if not scanner:
|
||||||
@@ -2806,6 +2865,7 @@ class ModelLibraryHandler:
|
|||||||
"modelId": model_id,
|
"modelId": model_id,
|
||||||
"modelName": model_name,
|
"modelName": model_name,
|
||||||
"modelType": model_type,
|
"modelType": model_type,
|
||||||
|
"supported": True,
|
||||||
"versions": enriched_versions,
|
"versions": enriched_versions,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -3393,6 +3453,18 @@ class FileSystemHandler:
|
|||||||
subprocess.Popen(["open", "-R", settings_file])
|
subprocess.Popen(["open", "-R", settings_file])
|
||||||
else:
|
else:
|
||||||
folder = os.path.dirname(settings_file)
|
folder = os.path.dirname(settings_file)
|
||||||
|
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": settings_file,
|
||||||
|
"mode": "clipboard",
|
||||||
|
}
|
||||||
|
)
|
||||||
subprocess.Popen(["xdg-open", folder])
|
subprocess.Popen(["xdg-open", folder])
|
||||||
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -3426,6 +3498,76 @@ class FileSystemHandler:
|
|||||||
logger.error("Failed to open wildcards location: %s", exc, exc_info=True)
|
logger.error("Failed to open wildcards location: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
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:
|
||||||
|
data = await request.json()
|
||||||
|
payload, status = browse_directory(data.get("path", ""))
|
||||||
|
return web.json_response(payload, status=status)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Invalid JSON"}, status=400
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Failed to browse directory: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def validate_path(self, request: web.Request) -> web.Response:
|
||||||
|
"""Validate a filesystem path for the settings UI.
|
||||||
|
|
||||||
|
A well-formed request always returns HTTP 200; invalid paths are
|
||||||
|
reported via ``error_code`` in the payload. HTTP 400 is reserved for
|
||||||
|
malformed requests (missing path, invalid JSON).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
raw_path = data.get("path")
|
||||||
|
expect = data.get("expect", "directory")
|
||||||
|
|
||||||
|
if not raw_path or not isinstance(raw_path, str):
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Missing path parameter"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
# Business path convention: abspath only, never realpath.
|
||||||
|
path = os.path.abspath(os.path.expanduser(raw_path))
|
||||||
|
|
||||||
|
exists = os.path.exists(path)
|
||||||
|
is_directory = os.path.isdir(path) if exists else False
|
||||||
|
readable = bool(exists and os.access(path, os.R_OK))
|
||||||
|
writable = bool(exists and os.access(path, os.W_OK))
|
||||||
|
|
||||||
|
error_code = None
|
||||||
|
if not exists:
|
||||||
|
error_code = "path_not_found"
|
||||||
|
elif expect == "directory" and not is_directory:
|
||||||
|
error_code = "not_a_directory"
|
||||||
|
elif expect == "file" and not os.path.isfile(path):
|
||||||
|
error_code = "not_a_file"
|
||||||
|
elif not readable:
|
||||||
|
error_code = "not_readable"
|
||||||
|
elif not writable:
|
||||||
|
error_code = "not_writable"
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"path": path,
|
||||||
|
"exists": exists,
|
||||||
|
"is_directory": is_directory,
|
||||||
|
"readable": readable,
|
||||||
|
"writable": writable,
|
||||||
|
"error_code": error_code,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Invalid JSON"}, status=400
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Failed to validate path: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
class CustomWordsHandler:
|
class CustomWordsHandler:
|
||||||
"""Handler for autocomplete via TagFTSIndex."""
|
"""Handler for autocomplete via TagFTSIndex."""
|
||||||
@@ -4070,6 +4212,8 @@ class MiscHandlerSet:
|
|||||||
"open_settings_location": self.filesystem.open_settings_location,
|
"open_settings_location": self.filesystem.open_settings_location,
|
||||||
"open_backup_location": self.filesystem.open_backup_location,
|
"open_backup_location": self.filesystem.open_backup_location,
|
||||||
"open_wildcards_location": self.filesystem.open_wildcards_location,
|
"open_wildcards_location": self.filesystem.open_wildcards_location,
|
||||||
|
"browse_directory": self.filesystem.browse_directory,
|
||||||
|
"validate_path": self.filesystem.validate_path,
|
||||||
"search_custom_words": self.custom_words.search_custom_words,
|
"search_custom_words": self.custom_words.search_custom_words,
|
||||||
"search_wildcards": self.wildcards.search_wildcards,
|
"search_wildcards": self.wildcards.search_wildcards,
|
||||||
"get_supporters": self.supporters.get_supporters,
|
"get_supporters": self.supporters.get_supporters,
|
||||||
|
|||||||
@@ -37,10 +37,14 @@ from ...services.use_cases import (
|
|||||||
DownloadModelEarlyAccessError,
|
DownloadModelEarlyAccessError,
|
||||||
DownloadModelUseCase,
|
DownloadModelUseCase,
|
||||||
DownloadModelValidationError,
|
DownloadModelValidationError,
|
||||||
|
FilenameTemplateUseCase,
|
||||||
MetadataRefreshProgressReporter,
|
MetadataRefreshProgressReporter,
|
||||||
)
|
)
|
||||||
from ...services.websocket_manager import WebSocketManager
|
from ...services.websocket_manager import WebSocketManager
|
||||||
from ...services.websocket_progress_callback import WebSocketProgressCallback
|
from ...services.websocket_progress_callback import (
|
||||||
|
WebSocketFilenameTemplateProgressCallback,
|
||||||
|
WebSocketProgressCallback,
|
||||||
|
)
|
||||||
from ...services.download_queue_service import DownloadQueueService
|
from ...services.download_queue_service import DownloadQueueService
|
||||||
from ...services.errors import RateLimitError, ResourceNotFoundError
|
from ...services.errors import RateLimitError, ResourceNotFoundError
|
||||||
from ...utils.civitai_utils import resolve_license_payload
|
from ...utils.civitai_utils import resolve_license_payload
|
||||||
@@ -2692,6 +2696,71 @@ class ModelAutoOrganizeHandler:
|
|||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
class ModelFilenameTemplateHandler:
|
||||||
|
"""Apply the configured filename template to existing library models."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
use_case: FilenameTemplateUseCase,
|
||||||
|
progress_callback: WebSocketFilenameTemplateProgressCallback,
|
||||||
|
logger: logging.Logger,
|
||||||
|
) -> None:
|
||||||
|
self._use_case = use_case
|
||||||
|
self._progress_callback = progress_callback
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
async def apply_filename_template(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
file_paths = None
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
file_paths = data.get("file_paths")
|
||||||
|
except Exception: # pragma: no cover - permissive path
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# GET variant (browser extension is GET-only): comma-separated
|
||||||
|
# file_paths query parameter.
|
||||||
|
raw_file_paths = request.query.get("file_paths")
|
||||||
|
if raw_file_paths:
|
||||||
|
file_paths = [
|
||||||
|
path.strip()
|
||||||
|
for path in raw_file_paths.split(",")
|
||||||
|
if path.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
result = await self._use_case.execute(
|
||||||
|
file_paths=file_paths,
|
||||||
|
progress_callback=self._progress_callback,
|
||||||
|
)
|
||||||
|
_broadcast_models_changed()
|
||||||
|
return web.json_response(result.to_dict())
|
||||||
|
except AutoOrganizeInProgressError:
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "Another library operation is already running. Please wait for it to complete.",
|
||||||
|
},
|
||||||
|
status=409,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error(
|
||||||
|
"Error in apply_filename_template: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self._progress_callback.on_progress(
|
||||||
|
{
|
||||||
|
"type": "filename_template_progress",
|
||||||
|
"status": "error",
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception: # pragma: no cover - defensive reporting
|
||||||
|
pass
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
class ModelUpdateHandler:
|
class ModelUpdateHandler:
|
||||||
"""Handle update tracking requests."""
|
"""Handle update tracking requests."""
|
||||||
|
|
||||||
@@ -3459,6 +3528,7 @@ class ModelHandlerSet:
|
|||||||
civitai: ModelCivitaiHandler
|
civitai: ModelCivitaiHandler
|
||||||
move: ModelMoveHandler
|
move: ModelMoveHandler
|
||||||
auto_organize: ModelAutoOrganizeHandler
|
auto_organize: ModelAutoOrganizeHandler
|
||||||
|
filename_template: ModelFilenameTemplateHandler
|
||||||
updates: ModelUpdateHandler
|
updates: ModelUpdateHandler
|
||||||
|
|
||||||
def to_route_mapping(
|
def to_route_mapping(
|
||||||
@@ -3523,6 +3593,7 @@ class ModelHandlerSet:
|
|||||||
"rename_folder": self.move.rename_folder,
|
"rename_folder": self.move.rename_folder,
|
||||||
"auto_organize_models": self.auto_organize.auto_organize_models,
|
"auto_organize_models": self.auto_organize.auto_organize_models,
|
||||||
"get_auto_organize_progress": self.auto_organize.get_auto_organize_progress,
|
"get_auto_organize_progress": self.auto_organize.get_auto_organize_progress,
|
||||||
|
"apply_filename_template": self.filename_template.apply_filename_template,
|
||||||
"get_model_notes": self.query.get_model_notes,
|
"get_model_notes": self.query.get_model_notes,
|
||||||
"get_model_preview_url": self.query.get_model_preview_url,
|
"get_model_preview_url": self.query.get_model_preview_url,
|
||||||
"get_model_civitai_url": self.query.get_model_civitai_url,
|
"get_model_civitai_url": self.query.get_model_civitai_url,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import re
|
|||||||
import asyncio
|
import asyncio
|
||||||
import tempfile
|
import tempfile
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Protocol, Tuple
|
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Protocol, Tuple
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
@@ -34,6 +33,7 @@ from ...utils.civitai_utils import (
|
|||||||
rewrite_preview_url,
|
rewrite_preview_url,
|
||||||
)
|
)
|
||||||
from ...utils.constants import NSFW_LEVELS
|
from ...utils.constants import NSFW_LEVELS
|
||||||
|
from ...utils.directory_browser import WINDOWS_DRIVES_TOKEN, browse_directory
|
||||||
from ...utils.exif_utils import ExifUtils
|
from ...utils.exif_utils import ExifUtils
|
||||||
from ...utils.recipe_open_stats import RecipeOpenStats
|
from ...utils.recipe_open_stats import RecipeOpenStats
|
||||||
from ...recipes.merger import GenParamsMerger
|
from ...recipes.merger import GenParamsMerger
|
||||||
@@ -3124,11 +3124,10 @@ class RecipeWorkflowHandler:
|
|||||||
class BatchImportHandler:
|
class BatchImportHandler:
|
||||||
"""Handle batch import operations for recipes."""
|
"""Handle batch import operations for recipes."""
|
||||||
|
|
||||||
# Virtual path token for the Windows drive list. Browsing up from a drive
|
# Virtual path token for the Windows drive list. Kept as a class
|
||||||
# root (e.g. C:\) lands here so users can switch drives without typing a
|
# attribute for backwards compatibility; the canonical definition lives
|
||||||
# path. Only meaningful on Windows; elsewhere it falls through to normal
|
# in py/utils/directory_browser.py.
|
||||||
# path handling and fails the existence check.
|
WINDOWS_DRIVES_TOKEN = WINDOWS_DRIVES_TOKEN
|
||||||
WINDOWS_DRIVES_TOKEN = "__drives__"
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -3301,131 +3300,8 @@ class BatchImportHandler:
|
|||||||
"""Browse a directory and return its contents (subdirectories and files)."""
|
"""Browse a directory and return its contents (subdirectories and files)."""
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
directory_path = data.get("path", "")
|
payload, status = browse_directory(data.get("path", ""))
|
||||||
|
return web.json_response(payload, status=status)
|
||||||
if os.name == "nt" and directory_path == self.WINDOWS_DRIVES_TOKEN:
|
|
||||||
return self._windows_drives_response()
|
|
||||||
|
|
||||||
# Default to the user's home directory. The frontend previously
|
|
||||||
# sent "/" as the initial path, which is POSIX-only: on Windows it
|
|
||||||
# resolves to the current drive root and then fails the access
|
|
||||||
# check below.
|
|
||||||
if not directory_path:
|
|
||||||
path = Path.home()
|
|
||||||
else:
|
|
||||||
path = Path(directory_path).expanduser().resolve()
|
|
||||||
|
|
||||||
# Access check: browsing intentionally covers the whole server
|
|
||||||
# filesystem (the server operator browses their own machine). On
|
|
||||||
# POSIX every absolute path is under "/", but Path("/") has no
|
|
||||||
# drive letter on Windows and can never anchor a drive-qualified
|
|
||||||
# path in relative_to(), so test for a drive there instead.
|
|
||||||
if os.name == "nt":
|
|
||||||
is_allowed = bool(path.drive)
|
|
||||||
else:
|
|
||||||
is_allowed = path.is_absolute()
|
|
||||||
|
|
||||||
if not is_allowed:
|
|
||||||
return web.json_response(
|
|
||||||
{"success": False, "error": "Access denied to this directory"},
|
|
||||||
status=403,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not path.exists():
|
|
||||||
return web.json_response(
|
|
||||||
{"success": False, "error": "Directory does not exist"},
|
|
||||||
status=404,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not path.is_dir():
|
|
||||||
return web.json_response(
|
|
||||||
{"success": False, "error": "Path is not a directory"},
|
|
||||||
status=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
# List directory contents
|
|
||||||
directories = []
|
|
||||||
image_files = []
|
|
||||||
|
|
||||||
image_extensions = {
|
|
||||||
".jpg",
|
|
||||||
".jpeg",
|
|
||||||
".png",
|
|
||||||
".gif",
|
|
||||||
".webp",
|
|
||||||
".bmp",
|
|
||||||
".tiff",
|
|
||||||
".tif",
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
for item in path.iterdir():
|
|
||||||
try:
|
|
||||||
if item.is_dir():
|
|
||||||
# Skip hidden directories and common system folders
|
|
||||||
if not item.name.startswith(".") and item.name not in [
|
|
||||||
"__pycache__",
|
|
||||||
"node_modules",
|
|
||||||
]:
|
|
||||||
directories.append(
|
|
||||||
{
|
|
||||||
"name": item.name,
|
|
||||||
"path": str(item),
|
|
||||||
"is_parent": False,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
elif item.is_file() and item.suffix.lower() in image_extensions:
|
|
||||||
image_files.append(
|
|
||||||
{
|
|
||||||
"name": item.name,
|
|
||||||
"path": str(item),
|
|
||||||
"size": item.stat().st_size,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except (PermissionError, OSError):
|
|
||||||
# Skip files/directories we can't access
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Sort directories and files alphabetically
|
|
||||||
directories.sort(key=lambda x: x["name"].lower())
|
|
||||||
image_files.sort(key=lambda x: x["name"].lower())
|
|
||||||
|
|
||||||
# Parent directory. A filesystem root is its own parent
|
|
||||||
# (parent == path): POSIX "/" gets no parent, while a Windows
|
|
||||||
# drive root (C:\) links up to the virtual drive list so users
|
|
||||||
# can switch drives. The previous str(path) != str(path.root)
|
|
||||||
# check misfired on Windows, where a drive root's parent is
|
|
||||||
# itself, producing an infinite self-loop.
|
|
||||||
if path.parent == path:
|
|
||||||
parent_path = (
|
|
||||||
self.WINDOWS_DRIVES_TOKEN if os.name == "nt" else None
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
parent_path = str(path.parent)
|
|
||||||
|
|
||||||
return web.json_response(
|
|
||||||
{
|
|
||||||
"success": True,
|
|
||||||
"current_path": str(path),
|
|
||||||
"parent_path": parent_path,
|
|
||||||
"directories": directories,
|
|
||||||
"image_files": image_files,
|
|
||||||
"image_count": len(image_files),
|
|
||||||
"directory_count": len(directories),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
except PermissionError:
|
|
||||||
return web.json_response(
|
|
||||||
{"success": False, "error": "Permission denied"},
|
|
||||||
status=403,
|
|
||||||
)
|
|
||||||
except OSError as exc:
|
|
||||||
return web.json_response(
|
|
||||||
{"success": False, "error": f"Error reading directory: {str(exc)}"},
|
|
||||||
status=500,
|
|
||||||
)
|
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "Invalid JSON"},
|
{"success": False, "error": "Invalid JSON"},
|
||||||
@@ -3434,30 +3310,3 @@ class BatchImportHandler:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._logger.error("Error browsing directory: %s", exc, exc_info=True)
|
self._logger.error("Error browsing directory: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
def _windows_drives_response(self) -> web.Response:
|
|
||||||
"""List available drive letters as a virtual directory (Windows only)."""
|
|
||||||
try:
|
|
||||||
drives = os.listdrives()
|
|
||||||
except AttributeError: # Python < 3.12
|
|
||||||
drives = [
|
|
||||||
f"{letter}:\\"
|
|
||||||
for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
||||||
if os.path.exists(f"{letter}:\\")
|
|
||||||
]
|
|
||||||
directories = [
|
|
||||||
{"name": drive, "path": drive, "is_parent": False} for drive in drives
|
|
||||||
]
|
|
||||||
return web.json_response(
|
|
||||||
{
|
|
||||||
"success": True,
|
|
||||||
# Empty current_path marks the virtual level; the frontend
|
|
||||||
# disables folder selection there.
|
|
||||||
"current_path": "",
|
|
||||||
"parent_path": None,
|
|
||||||
"directories": directories,
|
|
||||||
"image_files": [],
|
|
||||||
"image_count": 0,
|
|
||||||
"directory_count": len(directories),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("GET", "/api/lm/wildcards/search", "search_wildcards"),
|
RouteDefinition("GET", "/api/lm/wildcards/search", "search_wildcards"),
|
||||||
RouteDefinition("POST", "/api/lm/wildcards/open-location", "open_wildcards_location"),
|
RouteDefinition("POST", "/api/lm/wildcards/open-location", "open_wildcards_location"),
|
||||||
RouteDefinition("POST", "/api/lm/open-file-location", "open_file_location"),
|
RouteDefinition("POST", "/api/lm/open-file-location", "open_file_location"),
|
||||||
|
RouteDefinition("POST", "/api/lm/browse-directory", "browse_directory"),
|
||||||
|
RouteDefinition("POST", "/api/lm/validate-path", "validate_path"),
|
||||||
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
|
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
|
||||||
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
|
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
|
||||||
RouteDefinition("POST", "/api/lm/update-lora-code", "update_lora_code"),
|
RouteDefinition("POST", "/api/lm/update-lora-code", "update_lora_code"),
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
"GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress"
|
"GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress"
|
||||||
),
|
),
|
||||||
|
RouteDefinition(
|
||||||
|
"GET", "/api/lm/{prefix}/apply-filename-template", "apply_filename_template"
|
||||||
|
),
|
||||||
|
RouteDefinition(
|
||||||
|
"POST", "/api/lm/{prefix}/apply-filename-template", "apply_filename_template"
|
||||||
|
),
|
||||||
RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"),
|
RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"),
|
||||||
RouteDefinition("GET", "/api/lm/{prefix}/search-tags", "search_tags"),
|
RouteDefinition("GET", "/api/lm/{prefix}/search-tags", "search_tags"),
|
||||||
RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"),
|
RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"),
|
||||||
|
|||||||
@@ -83,11 +83,16 @@ class OtherRoutes(BaseModelRoutes):
|
|||||||
# resolved to no existing folder. Render an actionable empty state
|
# resolved to no existing folder. Render an actionable empty state
|
||||||
# instead of an apparently broken empty grid.
|
# instead of an apparently broken empty grid.
|
||||||
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
|
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
|
||||||
return {
|
context = {
|
||||||
"other_disabled": False,
|
"other_disabled": False,
|
||||||
"other_no_paths": not bool(config.other_roots),
|
"other_no_paths": not bool(config.other_roots),
|
||||||
"standalone_mode": standalone_mode,
|
"standalone_mode": standalone_mode,
|
||||||
}
|
}
|
||||||
|
if standalone_mode:
|
||||||
|
# The empty state points at the Model Paths settings section and
|
||||||
|
# shows the settings.json path as a fallback reference.
|
||||||
|
context["settings_file"] = getattr(self._settings, "settings_file", "") or ""
|
||||||
|
return context
|
||||||
|
|
||||||
def _get_expected_model_types(self) -> str:
|
def _get_expected_model_types(self) -> str:
|
||||||
"""Get expected model types string for error messages"""
|
"""Get expected model types string for error messages"""
|
||||||
|
|||||||
@@ -21,7 +21,20 @@ NETWORK_EXCEPTIONS = (ClientError, OSError, asyncio.TimeoutError)
|
|||||||
# otherwise delete them because they are untracked and, in released tags,
|
# otherwise delete them because they are untracked and, in released tags,
|
||||||
# not listed in ``.gitignore``. ``-e`` excludes a path from cleaning
|
# not listed in ``.gitignore``. ``-e`` excludes a path from cleaning
|
||||||
# regardless of whether it is ignored.
|
# regardless of whether it is ignored.
|
||||||
_PRESERVE_DIRS = ('settings.json', 'civitai', 'wildcards', 'backups', 'stats', 'logs', 'cache', 'model_cache')
|
# ``cache`` covers the resolved cache tree (cache/model, cache/recipe,
|
||||||
|
# cache/fts, ...); the legacy ``recipe_cache`` / ``model_cache`` directories
|
||||||
|
# are listed too because a portable install can predate the cache/ move.
|
||||||
|
_PRESERVE_DIRS = (
|
||||||
|
'settings.json',
|
||||||
|
'civitai',
|
||||||
|
'wildcards',
|
||||||
|
'backups',
|
||||||
|
'stats',
|
||||||
|
'logs',
|
||||||
|
'cache',
|
||||||
|
'model_cache',
|
||||||
|
'recipe_cache',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _clean_excludes() -> List[str]:
|
def _clean_excludes() -> List[str]:
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from ..utils.constants import (
|
|||||||
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
|
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
|
||||||
from ..utils.file_utils import calculate_sha256, calculate_autov3
|
from ..utils.file_utils import calculate_sha256, calculate_autov3
|
||||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||||
from ..utils.utils import sanitize_folder_name
|
from ..utils.utils import calculate_filename_for_model, sanitize_folder_name
|
||||||
from ..utils.exif_utils import ExifUtils
|
from ..utils.exif_utils import ExifUtils
|
||||||
from ..utils.metadata_manager import MetadataManager
|
from ..utils.metadata_manager import MetadataManager
|
||||||
from .service_registry import ServiceRegistry
|
from .service_registry import ServiceRegistry
|
||||||
@@ -45,6 +45,7 @@ from .errors import RateLimitError
|
|||||||
from .aria2_downloader import Aria2Error, get_aria2_downloader
|
from .aria2_downloader import Aria2Error, get_aria2_downloader
|
||||||
from .aria2_transfer_state import Aria2TransferStateStore
|
from .aria2_transfer_state import Aria2TransferStateStore
|
||||||
from .download_queue_service import DownloadQueueService
|
from .download_queue_service import DownloadQueueService
|
||||||
|
from .model_lifecycle_service import ModelLifecycleService, load_local_metadata
|
||||||
|
|
||||||
# Download to temporary file first
|
# Download to temporary file first
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -2746,6 +2747,7 @@ class DownloadManager:
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
downloaded_metadata: List[Dict[str, Any]] = []
|
||||||
for index, entry in enumerate(metadata_entries):
|
for index, entry in enumerate(metadata_entries):
|
||||||
file_path_for_adjust = getattr(
|
file_path_for_adjust = getattr(
|
||||||
entry, "file_path", actual_file_paths[index]
|
entry, "file_path", actual_file_paths[index]
|
||||||
@@ -2788,6 +2790,15 @@ class DownloadManager:
|
|||||||
if scanner is not None:
|
if scanner is not None:
|
||||||
await scanner.add_model_to_cache(metadata_dict, relative_path)
|
await scanner.add_model_to_cache(metadata_dict, relative_path)
|
||||||
|
|
||||||
|
downloaded_metadata.append(metadata_dict)
|
||||||
|
|
||||||
|
await self._apply_download_filename_template(
|
||||||
|
scanner=scanner,
|
||||||
|
model_type=model_type,
|
||||||
|
downloaded_metadata=downloaded_metadata,
|
||||||
|
download_id=download_id,
|
||||||
|
)
|
||||||
|
|
||||||
if transfer_backend == "aria2" and download_id:
|
if transfer_backend == "aria2" and download_id:
|
||||||
await self._aria2_state_store.remove(download_id)
|
await self._aria2_state_store.remove(download_id)
|
||||||
|
|
||||||
@@ -2827,6 +2838,83 @@ class DownloadManager:
|
|||||||
|
|
||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
async def _apply_download_filename_template(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
scanner,
|
||||||
|
model_type: str,
|
||||||
|
downloaded_metadata: List[Dict[str, Any]],
|
||||||
|
download_id: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
"""Rename freshly downloaded models according to the filename template.
|
||||||
|
|
||||||
|
Best-effort post-download step: any failure (including name conflicts)
|
||||||
|
is logged and skipped so a successful download is never turned into a
|
||||||
|
failure by a rename problem.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if scanner is None or not downloaded_metadata:
|
||||||
|
return
|
||||||
|
|
||||||
|
template = get_settings_manager().get_download_filename_template(
|
||||||
|
model_type
|
||||||
|
)
|
||||||
|
if not template:
|
||||||
|
return
|
||||||
|
|
||||||
|
lifecycle_service = ModelLifecycleService(
|
||||||
|
scanner=scanner,
|
||||||
|
metadata_manager=MetadataManager,
|
||||||
|
metadata_loader=load_local_metadata,
|
||||||
|
recipe_scanner_factory=ServiceRegistry.get_recipe_scanner,
|
||||||
|
)
|
||||||
|
|
||||||
|
for metadata_dict in downloaded_metadata:
|
||||||
|
file_path = metadata_dict.get("file_path")
|
||||||
|
if not isinstance(file_path, str) or not file_path:
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_stem = calculate_filename_for_model(metadata_dict, model_type)
|
||||||
|
if not new_stem:
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_stem = os.path.splitext(os.path.basename(file_path))[0]
|
||||||
|
if new_stem == current_stem or os.path.normcase(
|
||||||
|
new_stem
|
||||||
|
) == os.path.normcase(current_stem):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await lifecycle_service.rename_model(
|
||||||
|
file_path=file_path, new_file_name=new_stem
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Keeping original filename for %s: %s", file_path, exc
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_file_path = result.get("new_file_path")
|
||||||
|
if download_id and isinstance(new_file_path, str):
|
||||||
|
info = self._active_downloads.get(download_id)
|
||||||
|
if info is None:
|
||||||
|
continue
|
||||||
|
if info.get("file_path") == file_path:
|
||||||
|
info["file_path"] = new_file_path
|
||||||
|
extracted = info.get("extracted_paths")
|
||||||
|
if isinstance(extracted, list):
|
||||||
|
info["extracted_paths"] = [
|
||||||
|
new_file_path if path == file_path else path
|
||||||
|
for path in extracted
|
||||||
|
]
|
||||||
|
except Exception as exc: # Rename phase must never fail the download
|
||||||
|
logger.warning(
|
||||||
|
"Filename template rename failed for %s download: %s",
|
||||||
|
model_type,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
def _get_supported_extensions_for_type(self, model_type: str) -> Set[str]:
|
def _get_supported_extensions_for_type(self, model_type: str) -> Set[str]:
|
||||||
if model_type in ("checkpoint", "other"):
|
if model_type in ("checkpoint", "other"):
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -714,12 +714,18 @@ class LoraService(BaseModelService):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Return minimal data needed for cycling
|
# Return minimal data needed for cycling. usage_tips is only included
|
||||||
return [
|
# when non-empty so widget consumers (recommended strength range cues)
|
||||||
{
|
# can build their lookup without inflating the payload.
|
||||||
|
result = []
|
||||||
|
for lora in available_loras:
|
||||||
|
entry = {
|
||||||
"file_name": f"{lora['folder']}/{lora['file_name']}" if lora.get("folder") else lora["file_name"],
|
"file_name": f"{lora['folder']}/{lora['file_name']}" if lora.get("folder") else lora["file_name"],
|
||||||
"model_name": lora.get("model_name", lora["file_name"]),
|
"model_name": lora.get("model_name", lora["file_name"]),
|
||||||
"folder": lora.get("folder", ""),
|
"folder": lora.get("folder", ""),
|
||||||
}
|
}
|
||||||
for lora in available_loras
|
usage_tips = lora.get("usage_tips")
|
||||||
]
|
if usage_tips:
|
||||||
|
entry["usage_tips"] = usage_tips
|
||||||
|
result.append(entry)
|
||||||
|
return result
|
||||||
|
|||||||
@@ -43,10 +43,22 @@ class AutoOrganizeResult:
|
|||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
"""Convert result to dictionary"""
|
"""Convert result to dictionary"""
|
||||||
|
if self.operation_type == 'filename_template':
|
||||||
|
message = (
|
||||||
|
f'Filename template applied: {self.success_count} renamed, '
|
||||||
|
f'{self.skipped_count} skipped, {self.failure_count} failed '
|
||||||
|
f'out of {self.total} total'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
message = (
|
||||||
|
f'Auto-organize {self.operation_type} completed: '
|
||||||
|
f'{self.success_count} moved, {self.skipped_count} skipped, '
|
||||||
|
f'{self.failure_count} failed out of {self.total} total'
|
||||||
|
)
|
||||||
result: Dict[str, Any] = {
|
result: Dict[str, Any] = {
|
||||||
'success': self.status != 'error',
|
'success': self.status != 'error',
|
||||||
'status': self.status,
|
'status': self.status,
|
||||||
'message': f'Auto-organize {self.operation_type} completed: {self.success_count} moved, {self.skipped_count} skipped, {self.failure_count} failed out of {self.total} total',
|
'message': message,
|
||||||
'summary': {
|
'summary': {
|
||||||
'total': self.total,
|
'total': self.total,
|
||||||
'success': self.success_count,
|
'success': self.success_count,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional, TYPE_CHECKING, cast
|
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional, TYPE_CHECKING, cast
|
||||||
@@ -17,6 +18,26 @@ if TYPE_CHECKING:
|
|||||||
from ..services.model_update_service import ModelUpdateService
|
from ..services.model_update_service import ModelUpdateService
|
||||||
|
|
||||||
|
|
||||||
|
async def load_local_metadata(metadata_path: str) -> Dict[str, Any]:
|
||||||
|
"""Load a metadata sidecar JSON, returning an empty dict when missing.
|
||||||
|
|
||||||
|
Thin equivalent of ``MetadataSyncService.load_local_metadata`` for callers
|
||||||
|
(download manager, use cases) that do not hold a sync-service instance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not os.path.exists(metadata_path):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(metadata_path, "r", encoding="utf-8") as handle:
|
||||||
|
payload = json.load(handle)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to load metadata from %s: %s", metadata_path, exc)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return payload if isinstance(payload, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
async def delete_model_artifacts(
|
async def delete_model_artifacts(
|
||||||
target_dir: str, file_name: str, main_extension: str | None = None
|
target_dir: str, file_name: str, main_extension: str | None = None
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
@@ -404,6 +425,9 @@ class ModelLifecycleService:
|
|||||||
if metadata and new_metadata_path:
|
if metadata and new_metadata_path:
|
||||||
metadata["file_name"] = new_file_name
|
metadata["file_name"] = new_file_name
|
||||||
metadata["file_path"] = new_file_path
|
metadata["file_path"] = new_file_path
|
||||||
|
# Preserve the pre-rename stem so the original download filename
|
||||||
|
# stays recoverable after template-driven renames.
|
||||||
|
metadata.setdefault("original_file_name", old_file_name)
|
||||||
|
|
||||||
if metadata.get("preview_url"):
|
if metadata.get("preview_url"):
|
||||||
old_preview = str(metadata["preview_url"])
|
old_preview = str(metadata["preview_url"])
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import threading
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
from ..utils.cache_db import connect_cache_db
|
||||||
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
||||||
|
from ..utils.file_lock import exclusive_lock
|
||||||
from .model_sources import normalize_metadata_source
|
from .model_sources import normalize_metadata_source
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -257,267 +259,271 @@ class PersistentModelCache:
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
with self._db_lock:
|
with self._db_lock:
|
||||||
conn = self._connect()
|
# Cross-process serialization: another LoRA Manager instance may
|
||||||
try:
|
# share this settings directory, and the read-merge-write below
|
||||||
conn.execute("PRAGMA foreign_keys = ON")
|
# spans several statements.
|
||||||
conn.execute("BEGIN")
|
with exclusive_lock(self._db_path):
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
conn.execute("BEGIN")
|
||||||
|
|
||||||
model_rows = [self._prepare_model_row(model_type, item) for item in raw_data]
|
model_rows = [self._prepare_model_row(model_type, item) for item in raw_data]
|
||||||
model_map: Dict[str, Tuple[Any, ...]] = {
|
model_map: Dict[str, Tuple[Any, ...]] = {
|
||||||
row[1]: row for row in model_rows if row[1] # row[1] is file_path
|
row[1]: row for row in model_rows if row[1] # row[1] is file_path
|
||||||
}
|
}
|
||||||
|
|
||||||
existing_models = conn.execute(
|
existing_models = conn.execute(
|
||||||
"SELECT "
|
"SELECT "
|
||||||
+ ", ".join(self._MODEL_COLUMNS[1:])
|
+ ", ".join(self._MODEL_COLUMNS[1:])
|
||||||
+ " FROM models WHERE model_type = ?",
|
+ " FROM models WHERE model_type = ?",
|
||||||
(model_type,),
|
|
||||||
).fetchall()
|
|
||||||
existing_model_map: Dict[str, sqlite3.Row] = {
|
|
||||||
row["file_path"]: row for row in existing_models
|
|
||||||
}
|
|
||||||
|
|
||||||
to_remove_models = [
|
|
||||||
(model_type, path)
|
|
||||||
for path in existing_model_map.keys()
|
|
||||||
if path not in model_map
|
|
||||||
]
|
|
||||||
if to_remove_models:
|
|
||||||
conn.executemany(
|
|
||||||
"DELETE FROM models WHERE model_type = ? AND file_path = ?",
|
|
||||||
to_remove_models,
|
|
||||||
)
|
|
||||||
conn.executemany(
|
|
||||||
"DELETE FROM model_tags WHERE model_type = ? AND file_path = ?",
|
|
||||||
to_remove_models,
|
|
||||||
)
|
|
||||||
conn.executemany(
|
|
||||||
"DELETE FROM hash_index WHERE model_type = ? AND file_path = ?",
|
|
||||||
to_remove_models,
|
|
||||||
)
|
|
||||||
conn.executemany(
|
|
||||||
"DELETE FROM autov3_index WHERE model_type = ? AND file_path = ?",
|
|
||||||
to_remove_models,
|
|
||||||
)
|
|
||||||
conn.executemany(
|
|
||||||
"DELETE FROM excluded_models WHERE model_type = ? AND file_path = ?",
|
|
||||||
to_remove_models,
|
|
||||||
)
|
|
||||||
|
|
||||||
insert_rows: List[Tuple[Any, ...]] = []
|
|
||||||
update_rows: List[Tuple[Any, ...]] = []
|
|
||||||
|
|
||||||
for file_path, row in model_map.items():
|
|
||||||
existing = existing_model_map.get(file_path)
|
|
||||||
if existing is None:
|
|
||||||
insert_rows.append(row)
|
|
||||||
continue
|
|
||||||
|
|
||||||
existing_values = tuple(
|
|
||||||
existing[column] for column in self._MODEL_COLUMNS[1:]
|
|
||||||
)
|
|
||||||
current_values = row[1:]
|
|
||||||
if existing_values != current_values:
|
|
||||||
update_rows.append(row[2:] + (model_type, file_path))
|
|
||||||
|
|
||||||
if insert_rows:
|
|
||||||
conn.executemany(self._insert_model_sql(), insert_rows)
|
|
||||||
|
|
||||||
if update_rows:
|
|
||||||
set_clause = ", ".join(
|
|
||||||
f"{column} = ?"
|
|
||||||
for column in self._MODEL_UPDATE_COLUMNS
|
|
||||||
)
|
|
||||||
update_sql = (
|
|
||||||
f"UPDATE models SET {set_clause} WHERE model_type = ? AND file_path = ?"
|
|
||||||
)
|
|
||||||
conn.executemany(update_sql, update_rows)
|
|
||||||
|
|
||||||
existing_tags_rows = conn.execute(
|
|
||||||
"SELECT file_path, tag FROM model_tags WHERE model_type = ?",
|
|
||||||
(model_type,),
|
|
||||||
).fetchall()
|
|
||||||
existing_tags: Dict[str, set[str]] = {}
|
|
||||||
for row in existing_tags_rows:
|
|
||||||
existing_tags.setdefault(row["file_path"], set()).add(row["tag"])
|
|
||||||
|
|
||||||
new_tags: Dict[str, set[str]] = {}
|
|
||||||
for item in raw_data:
|
|
||||||
file_path = item.get("file_path")
|
|
||||||
if not file_path:
|
|
||||||
continue
|
|
||||||
tags = set(item.get("tags") or [])
|
|
||||||
if tags:
|
|
||||||
new_tags[file_path] = tags
|
|
||||||
|
|
||||||
tag_inserts: List[Tuple[str, str, str]] = []
|
|
||||||
tag_deletes: List[Tuple[str, str, str]] = []
|
|
||||||
|
|
||||||
all_tag_paths = set(existing_tags.keys()) | set(new_tags.keys())
|
|
||||||
for path in all_tag_paths:
|
|
||||||
existing_set = existing_tags.get(path, set())
|
|
||||||
new_set = new_tags.get(path, set())
|
|
||||||
to_add = new_set - existing_set
|
|
||||||
to_remove = existing_set - new_set
|
|
||||||
|
|
||||||
for tag in to_add:
|
|
||||||
tag_inserts.append((model_type, path, tag))
|
|
||||||
for tag in to_remove:
|
|
||||||
tag_deletes.append((model_type, path, tag))
|
|
||||||
|
|
||||||
if tag_deletes:
|
|
||||||
conn.executemany(
|
|
||||||
"DELETE FROM model_tags WHERE model_type = ? AND file_path = ? AND tag = ?",
|
|
||||||
tag_deletes,
|
|
||||||
)
|
|
||||||
if tag_inserts:
|
|
||||||
conn.executemany(
|
|
||||||
"INSERT INTO model_tags (model_type, file_path, tag) VALUES (?, ?, ?)",
|
|
||||||
tag_inserts,
|
|
||||||
)
|
|
||||||
|
|
||||||
existing_hash_rows = conn.execute(
|
|
||||||
"SELECT sha256, file_path FROM hash_index WHERE model_type = ?",
|
|
||||||
(model_type,),
|
|
||||||
).fetchall()
|
|
||||||
existing_hash_map: Dict[str, set[str]] = {}
|
|
||||||
for row in existing_hash_rows:
|
|
||||||
sha_value = (row["sha256"] or "").lower()
|
|
||||||
if not sha_value:
|
|
||||||
continue
|
|
||||||
existing_hash_map.setdefault(sha_value, set()).add(row["file_path"])
|
|
||||||
|
|
||||||
new_hash_map: Dict[str, set[str]] = {}
|
|
||||||
for sha_value, paths in hash_index.items():
|
|
||||||
normalized_sha = (sha_value or "").lower()
|
|
||||||
if not normalized_sha:
|
|
||||||
continue
|
|
||||||
bucket = new_hash_map.setdefault(normalized_sha, set())
|
|
||||||
for path in paths:
|
|
||||||
if path:
|
|
||||||
bucket.add(path)
|
|
||||||
|
|
||||||
hash_inserts: List[Tuple[str, str, str]] = []
|
|
||||||
hash_deletes: List[Tuple[str, str, str]] = []
|
|
||||||
|
|
||||||
all_shas = set(existing_hash_map.keys()) | set(new_hash_map.keys())
|
|
||||||
for sha_value in all_shas:
|
|
||||||
existing_paths = existing_hash_map.get(sha_value, set())
|
|
||||||
new_paths = new_hash_map.get(sha_value, set())
|
|
||||||
|
|
||||||
for path in existing_paths - new_paths:
|
|
||||||
hash_deletes.append((model_type, sha_value, path))
|
|
||||||
for path in new_paths - existing_paths:
|
|
||||||
hash_inserts.append((model_type, sha_value, path))
|
|
||||||
|
|
||||||
if hash_deletes:
|
|
||||||
conn.executemany(
|
|
||||||
"DELETE FROM hash_index WHERE model_type = ? AND sha256 = ? AND file_path = ?",
|
|
||||||
hash_deletes,
|
|
||||||
)
|
|
||||||
if hash_inserts:
|
|
||||||
conn.executemany(
|
|
||||||
"INSERT OR IGNORE INTO hash_index (model_type, sha256, file_path) VALUES (?, ?, ?)",
|
|
||||||
hash_inserts,
|
|
||||||
)
|
|
||||||
|
|
||||||
if autov3_hash_index is not None:
|
|
||||||
existing_autov3_rows = conn.execute(
|
|
||||||
"SELECT autov3, file_path FROM autov3_index WHERE model_type = ?",
|
|
||||||
(model_type,),
|
(model_type,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
existing_autov3_map: Dict[str, set[str]] = {}
|
existing_model_map: Dict[str, sqlite3.Row] = {
|
||||||
for row in existing_autov3_rows:
|
row["file_path"]: row for row in existing_models
|
||||||
autov3_value = (row["autov3"] or "").lower()
|
}
|
||||||
if not autov3_value:
|
|
||||||
continue
|
|
||||||
existing_autov3_map.setdefault(autov3_value, set()).add(row["file_path"])
|
|
||||||
|
|
||||||
new_autov3_map: Dict[str, set[str]] = {}
|
to_remove_models = [
|
||||||
for autov3_value, paths in autov3_hash_index.items():
|
(model_type, path)
|
||||||
normalized_autov3 = (autov3_value or "").lower()
|
for path in existing_model_map.keys()
|
||||||
if not normalized_autov3:
|
if path not in model_map
|
||||||
|
]
|
||||||
|
if to_remove_models:
|
||||||
|
conn.executemany(
|
||||||
|
"DELETE FROM models WHERE model_type = ? AND file_path = ?",
|
||||||
|
to_remove_models,
|
||||||
|
)
|
||||||
|
conn.executemany(
|
||||||
|
"DELETE FROM model_tags WHERE model_type = ? AND file_path = ?",
|
||||||
|
to_remove_models,
|
||||||
|
)
|
||||||
|
conn.executemany(
|
||||||
|
"DELETE FROM hash_index WHERE model_type = ? AND file_path = ?",
|
||||||
|
to_remove_models,
|
||||||
|
)
|
||||||
|
conn.executemany(
|
||||||
|
"DELETE FROM autov3_index WHERE model_type = ? AND file_path = ?",
|
||||||
|
to_remove_models,
|
||||||
|
)
|
||||||
|
conn.executemany(
|
||||||
|
"DELETE FROM excluded_models WHERE model_type = ? AND file_path = ?",
|
||||||
|
to_remove_models,
|
||||||
|
)
|
||||||
|
|
||||||
|
insert_rows: List[Tuple[Any, ...]] = []
|
||||||
|
update_rows: List[Tuple[Any, ...]] = []
|
||||||
|
|
||||||
|
for file_path, row in model_map.items():
|
||||||
|
existing = existing_model_map.get(file_path)
|
||||||
|
if existing is None:
|
||||||
|
insert_rows.append(row)
|
||||||
continue
|
continue
|
||||||
bucket = new_autov3_map.setdefault(normalized_autov3, set())
|
|
||||||
|
existing_values = tuple(
|
||||||
|
existing[column] for column in self._MODEL_COLUMNS[1:]
|
||||||
|
)
|
||||||
|
current_values = row[1:]
|
||||||
|
if existing_values != current_values:
|
||||||
|
update_rows.append(row[2:] + (model_type, file_path))
|
||||||
|
|
||||||
|
if insert_rows:
|
||||||
|
conn.executemany(self._insert_model_sql(), insert_rows)
|
||||||
|
|
||||||
|
if update_rows:
|
||||||
|
set_clause = ", ".join(
|
||||||
|
f"{column} = ?"
|
||||||
|
for column in self._MODEL_UPDATE_COLUMNS
|
||||||
|
)
|
||||||
|
update_sql = (
|
||||||
|
f"UPDATE models SET {set_clause} WHERE model_type = ? AND file_path = ?"
|
||||||
|
)
|
||||||
|
conn.executemany(update_sql, update_rows)
|
||||||
|
|
||||||
|
existing_tags_rows = conn.execute(
|
||||||
|
"SELECT file_path, tag FROM model_tags WHERE model_type = ?",
|
||||||
|
(model_type,),
|
||||||
|
).fetchall()
|
||||||
|
existing_tags: Dict[str, set[str]] = {}
|
||||||
|
for row in existing_tags_rows:
|
||||||
|
existing_tags.setdefault(row["file_path"], set()).add(row["tag"])
|
||||||
|
|
||||||
|
new_tags: Dict[str, set[str]] = {}
|
||||||
|
for item in raw_data:
|
||||||
|
file_path = item.get("file_path")
|
||||||
|
if not file_path:
|
||||||
|
continue
|
||||||
|
tags = set(item.get("tags") or [])
|
||||||
|
if tags:
|
||||||
|
new_tags[file_path] = tags
|
||||||
|
|
||||||
|
tag_inserts: List[Tuple[str, str, str]] = []
|
||||||
|
tag_deletes: List[Tuple[str, str, str]] = []
|
||||||
|
|
||||||
|
all_tag_paths = set(existing_tags.keys()) | set(new_tags.keys())
|
||||||
|
for path in all_tag_paths:
|
||||||
|
existing_set = existing_tags.get(path, set())
|
||||||
|
new_set = new_tags.get(path, set())
|
||||||
|
to_add = new_set - existing_set
|
||||||
|
to_remove = existing_set - new_set
|
||||||
|
|
||||||
|
for tag in to_add:
|
||||||
|
tag_inserts.append((model_type, path, tag))
|
||||||
|
for tag in to_remove:
|
||||||
|
tag_deletes.append((model_type, path, tag))
|
||||||
|
|
||||||
|
if tag_deletes:
|
||||||
|
conn.executemany(
|
||||||
|
"DELETE FROM model_tags WHERE model_type = ? AND file_path = ? AND tag = ?",
|
||||||
|
tag_deletes,
|
||||||
|
)
|
||||||
|
if tag_inserts:
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO model_tags (model_type, file_path, tag) VALUES (?, ?, ?)",
|
||||||
|
tag_inserts,
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_hash_rows = conn.execute(
|
||||||
|
"SELECT sha256, file_path FROM hash_index WHERE model_type = ?",
|
||||||
|
(model_type,),
|
||||||
|
).fetchall()
|
||||||
|
existing_hash_map: Dict[str, set[str]] = {}
|
||||||
|
for row in existing_hash_rows:
|
||||||
|
sha_value = (row["sha256"] or "").lower()
|
||||||
|
if not sha_value:
|
||||||
|
continue
|
||||||
|
existing_hash_map.setdefault(sha_value, set()).add(row["file_path"])
|
||||||
|
|
||||||
|
new_hash_map: Dict[str, set[str]] = {}
|
||||||
|
for sha_value, paths in hash_index.items():
|
||||||
|
normalized_sha = (sha_value or "").lower()
|
||||||
|
if not normalized_sha:
|
||||||
|
continue
|
||||||
|
bucket = new_hash_map.setdefault(normalized_sha, set())
|
||||||
for path in paths:
|
for path in paths:
|
||||||
if path:
|
if path:
|
||||||
bucket.add(path)
|
bucket.add(path)
|
||||||
|
|
||||||
autov3_inserts: List[Tuple[str, str, str]] = []
|
hash_inserts: List[Tuple[str, str, str]] = []
|
||||||
autov3_deletes: List[Tuple[str, str, str]] = []
|
hash_deletes: List[Tuple[str, str, str]] = []
|
||||||
|
|
||||||
all_autov3 = set(existing_autov3_map.keys()) | set(new_autov3_map.keys())
|
all_shas = set(existing_hash_map.keys()) | set(new_hash_map.keys())
|
||||||
for autov3_value in all_autov3:
|
for sha_value in all_shas:
|
||||||
existing_paths = existing_autov3_map.get(autov3_value, set())
|
existing_paths = existing_hash_map.get(sha_value, set())
|
||||||
new_paths = new_autov3_map.get(autov3_value, set())
|
new_paths = new_hash_map.get(sha_value, set())
|
||||||
|
|
||||||
for path in existing_paths - new_paths:
|
for path in existing_paths - new_paths:
|
||||||
autov3_deletes.append((model_type, autov3_value, path))
|
hash_deletes.append((model_type, sha_value, path))
|
||||||
for path in new_paths - existing_paths:
|
for path in new_paths - existing_paths:
|
||||||
autov3_inserts.append((model_type, autov3_value, path))
|
hash_inserts.append((model_type, sha_value, path))
|
||||||
|
|
||||||
if autov3_deletes:
|
if hash_deletes:
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
"DELETE FROM autov3_index WHERE model_type = ? AND autov3 = ? AND file_path = ?",
|
"DELETE FROM hash_index WHERE model_type = ? AND sha256 = ? AND file_path = ?",
|
||||||
autov3_deletes,
|
hash_deletes,
|
||||||
)
|
)
|
||||||
if autov3_inserts:
|
if hash_inserts:
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
"INSERT OR IGNORE INTO autov3_index (model_type, autov3, file_path) VALUES (?, ?, ?)",
|
"INSERT OR IGNORE INTO hash_index (model_type, sha256, file_path) VALUES (?, ?, ?)",
|
||||||
autov3_inserts,
|
hash_inserts,
|
||||||
)
|
)
|
||||||
|
|
||||||
existing_excluded_rows = conn.execute(
|
if autov3_hash_index is not None:
|
||||||
"SELECT file_path FROM excluded_models WHERE model_type = ?",
|
existing_autov3_rows = conn.execute(
|
||||||
(model_type,),
|
"SELECT autov3, file_path FROM autov3_index WHERE model_type = ?",
|
||||||
).fetchall()
|
(model_type,),
|
||||||
existing_excluded = {row["file_path"] for row in existing_excluded_rows}
|
).fetchall()
|
||||||
new_excluded = {path for path in excluded_models if path}
|
existing_autov3_map: Dict[str, set[str]] = {}
|
||||||
|
for row in existing_autov3_rows:
|
||||||
|
autov3_value = (row["autov3"] or "").lower()
|
||||||
|
if not autov3_value:
|
||||||
|
continue
|
||||||
|
existing_autov3_map.setdefault(autov3_value, set()).add(row["file_path"])
|
||||||
|
|
||||||
excluded_deletes = [
|
new_autov3_map: Dict[str, set[str]] = {}
|
||||||
(model_type, path)
|
for autov3_value, paths in autov3_hash_index.items():
|
||||||
for path in existing_excluded - new_excluded
|
normalized_autov3 = (autov3_value or "").lower()
|
||||||
]
|
if not normalized_autov3:
|
||||||
excluded_inserts = [
|
continue
|
||||||
(model_type, path)
|
bucket = new_autov3_map.setdefault(normalized_autov3, set())
|
||||||
for path in new_excluded - existing_excluded
|
for path in paths:
|
||||||
]
|
if path:
|
||||||
|
bucket.add(path)
|
||||||
|
|
||||||
if excluded_deletes:
|
autov3_inserts: List[Tuple[str, str, str]] = []
|
||||||
conn.executemany(
|
autov3_deletes: List[Tuple[str, str, str]] = []
|
||||||
"DELETE FROM excluded_models WHERE model_type = ? AND file_path = ?",
|
|
||||||
excluded_deletes,
|
|
||||||
)
|
|
||||||
if excluded_inserts:
|
|
||||||
conn.executemany(
|
|
||||||
"INSERT OR IGNORE INTO excluded_models (model_type, file_path) VALUES (?, ?)",
|
|
||||||
excluded_inserts,
|
|
||||||
)
|
|
||||||
|
|
||||||
if all_folders is not None:
|
all_autov3 = set(existing_autov3_map.keys()) | set(new_autov3_map.keys())
|
||||||
conn.execute(
|
for autov3_value in all_autov3:
|
||||||
"DELETE FROM folders WHERE model_type = ?",
|
existing_paths = existing_autov3_map.get(autov3_value, set())
|
||||||
|
new_paths = new_autov3_map.get(autov3_value, set())
|
||||||
|
|
||||||
|
for path in existing_paths - new_paths:
|
||||||
|
autov3_deletes.append((model_type, autov3_value, path))
|
||||||
|
for path in new_paths - existing_paths:
|
||||||
|
autov3_inserts.append((model_type, autov3_value, path))
|
||||||
|
|
||||||
|
if autov3_deletes:
|
||||||
|
conn.executemany(
|
||||||
|
"DELETE FROM autov3_index WHERE model_type = ? AND autov3 = ? AND file_path = ?",
|
||||||
|
autov3_deletes,
|
||||||
|
)
|
||||||
|
if autov3_inserts:
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR IGNORE INTO autov3_index (model_type, autov3, file_path) VALUES (?, ?, ?)",
|
||||||
|
autov3_inserts,
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_excluded_rows = conn.execute(
|
||||||
|
"SELECT file_path FROM excluded_models WHERE model_type = ?",
|
||||||
(model_type,),
|
(model_type,),
|
||||||
)
|
).fetchall()
|
||||||
folder_inserts = [
|
existing_excluded = {row["file_path"] for row in existing_excluded_rows}
|
||||||
(model_type, path) for path in all_folders if path
|
new_excluded = {path for path in excluded_models if path}
|
||||||
]
|
|
||||||
if folder_inserts:
|
|
||||||
conn.executemany(
|
|
||||||
"INSERT OR IGNORE INTO folders (model_type, path) VALUES (?, ?)",
|
|
||||||
folder_inserts,
|
|
||||||
)
|
|
||||||
# Mark the snapshot as having folder data even when the
|
|
||||||
# library has no subfolders, so an empty list is not
|
|
||||||
# mistaken for "never recorded" on load.
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR REPLACE INTO cache_meta (key, value) VALUES (?, ?)",
|
|
||||||
(f"folders_recorded:{model_type}", "1"),
|
|
||||||
)
|
|
||||||
|
|
||||||
conn.commit()
|
excluded_deletes = [
|
||||||
finally:
|
(model_type, path)
|
||||||
conn.close()
|
for path in existing_excluded - new_excluded
|
||||||
|
]
|
||||||
|
excluded_inserts = [
|
||||||
|
(model_type, path)
|
||||||
|
for path in new_excluded - existing_excluded
|
||||||
|
]
|
||||||
|
|
||||||
|
if excluded_deletes:
|
||||||
|
conn.executemany(
|
||||||
|
"DELETE FROM excluded_models WHERE model_type = ? AND file_path = ?",
|
||||||
|
excluded_deletes,
|
||||||
|
)
|
||||||
|
if excluded_inserts:
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR IGNORE INTO excluded_models (model_type, file_path) VALUES (?, ?)",
|
||||||
|
excluded_inserts,
|
||||||
|
)
|
||||||
|
|
||||||
|
if all_folders is not None:
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM folders WHERE model_type = ?",
|
||||||
|
(model_type,),
|
||||||
|
)
|
||||||
|
folder_inserts = [
|
||||||
|
(model_type, path) for path in all_folders if path
|
||||||
|
]
|
||||||
|
if folder_inserts:
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR IGNORE INTO folders (model_type, path) VALUES (?, ?)",
|
||||||
|
folder_inserts,
|
||||||
|
)
|
||||||
|
# Mark the snapshot as having folder data even when the
|
||||||
|
# library has no subfolders, so an empty list is not
|
||||||
|
# mistaken for "never recorded" on load.
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO cache_meta (key, value) VALUES (?, ?)",
|
||||||
|
(f"folders_recorded:{model_type}", "1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Failed to persist cache for %s: %s", model_type, exc)
|
logger.warning("Failed to persist cache for %s: %s", model_type, exc)
|
||||||
|
|
||||||
@@ -650,16 +656,14 @@ class PersistentModelCache:
|
|||||||
conn.execute(f"ALTER TABLE models ADD COLUMN {column} {definition}")
|
conn.execute(f"ALTER TABLE models ADD COLUMN {column} {definition}")
|
||||||
|
|
||||||
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
|
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
|
||||||
uri = False
|
if readonly and not os.path.exists(self._db_path):
|
||||||
path = self._db_path
|
raise FileNotFoundError(self._db_path)
|
||||||
if readonly:
|
return connect_cache_db(
|
||||||
if not os.path.exists(path):
|
self._db_path,
|
||||||
raise FileNotFoundError(path)
|
readonly=readonly,
|
||||||
path = f"file:{path}?mode=ro"
|
detect_types=sqlite3.PARSE_DECLTYPES,
|
||||||
uri = True
|
row_factory=sqlite3.Row,
|
||||||
conn = sqlite3.connect(path, check_same_thread=False, uri=uri, detect_types=sqlite3.PARSE_DECLTYPES)
|
)
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
return conn
|
|
||||||
|
|
||||||
def _prepare_model_row(self, model_type: str, item: Dict[str, Any]) -> Tuple[Any, ...]:
|
def _prepare_model_row(self, model_type: str, item: Dict[str, Any]) -> Tuple[Any, ...]:
|
||||||
# Keep `source_*` and the legacy `hf_url` alias consistent no matter
|
# Keep `source_*` and the legacy `hf_url` alias consistent no matter
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ import threading
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||||
|
|
||||||
|
from ..utils.cache_db import connect_cache_db
|
||||||
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
||||||
|
from ..utils.file_lock import exclusive_lock
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -170,65 +172,98 @@ class PersistentRecipeCache:
|
|||||||
recipes: List[Dict[str, Any]],
|
recipes: List[Dict[str, Any]],
|
||||||
json_paths: Optional[Dict[str, str]] = None,
|
json_paths: Optional[Dict[str, str]] = None,
|
||||||
image_id_map: Optional[Dict[str, str]] = None,
|
image_id_map: Optional[Dict[str, str]] = None,
|
||||||
) -> None:
|
skip_if_empty: bool = False,
|
||||||
|
) -> bool:
|
||||||
"""Save all recipes to SQLite cache.
|
"""Save all recipes to SQLite cache.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
recipes: List of recipe dictionaries to persist.
|
recipes: List of recipe dictionaries to persist.
|
||||||
json_paths: Optional mapping of recipe_id -> json_path for file stats.
|
json_paths: Optional mapping of recipe_id -> json_path for file stats.
|
||||||
image_id_map: Optional precomputed civitai image_id → recipe_id mapping.
|
image_id_map: Optional precomputed civitai image_id → recipe_id mapping.
|
||||||
|
skip_if_empty: When True, refuse to replace a non-empty cache with an
|
||||||
|
empty one. This is the storage-level backstop against a scan that
|
||||||
|
silently loses every recipe (unavailable drive / mis-resolved
|
||||||
|
recipes directory): overwriting both deletes the user's data and
|
||||||
|
destroys their only record of it. Intentional full clears (manual
|
||||||
|
rebuild) must pass ``skip_if_empty=False``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``True`` when the write happened, ``False`` when it was skipped.
|
||||||
"""
|
"""
|
||||||
if not self.is_enabled():
|
if not self.is_enabled():
|
||||||
return
|
return False
|
||||||
if not self._schema_initialized:
|
if not self._schema_initialized:
|
||||||
self._initialize_schema()
|
self._initialize_schema()
|
||||||
if not self._schema_initialized:
|
if not self._schema_initialized:
|
||||||
return
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with self._db_lock:
|
with self._db_lock:
|
||||||
conn = self._connect()
|
# Cross-process serialization: another LoRA Manager instance may
|
||||||
try:
|
# share this settings directory, and a full-table replace is a
|
||||||
conn.execute("PRAGMA foreign_keys = ON")
|
# read-modify-write that SQLite alone cannot make atomic.
|
||||||
conn.execute("BEGIN")
|
with exclusive_lock(self._db_path):
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
conn.execute("BEGIN")
|
||||||
|
|
||||||
# Clear existing data
|
if skip_if_empty and not recipes:
|
||||||
conn.execute("DELETE FROM recipes")
|
existing = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM recipes"
|
||||||
|
).fetchone()
|
||||||
|
if existing and existing[0]:
|
||||||
|
conn.rollback()
|
||||||
|
logger.warning(
|
||||||
|
"Refusing to persist an empty recipe cache: the "
|
||||||
|
"stored cache still holds %d recipe(s). The scan "
|
||||||
|
"found nothing, which usually means the recipes "
|
||||||
|
"path was unavailable or resolved elsewhere; "
|
||||||
|
"keeping the stored cache so the data stays "
|
||||||
|
"recoverable.",
|
||||||
|
existing[0],
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
# Prepare and insert all rows
|
# Clear existing data
|
||||||
recipe_rows = []
|
conn.execute("DELETE FROM recipes")
|
||||||
for recipe in recipes:
|
|
||||||
recipe_id = str(recipe.get("id", ""))
|
|
||||||
if not recipe_id:
|
|
||||||
continue
|
|
||||||
|
|
||||||
json_path = ""
|
# Prepare and insert all rows
|
||||||
if json_paths:
|
recipe_rows = []
|
||||||
json_path = json_paths.get(recipe_id, "")
|
for recipe in recipes:
|
||||||
|
recipe_id = str(recipe.get("id", ""))
|
||||||
|
if not recipe_id:
|
||||||
|
continue
|
||||||
|
|
||||||
row = self._prepare_recipe_row(recipe, json_path)
|
json_path = ""
|
||||||
recipe_rows.append(row)
|
if json_paths:
|
||||||
|
json_path = json_paths.get(recipe_id, "")
|
||||||
|
|
||||||
if recipe_rows:
|
row = self._prepare_recipe_row(recipe, json_path)
|
||||||
placeholders = ", ".join(["?"] * len(self._RECIPE_COLUMNS))
|
recipe_rows.append(row)
|
||||||
columns = ", ".join(self._RECIPE_COLUMNS)
|
|
||||||
conn.executemany(
|
if recipe_rows:
|
||||||
f"INSERT INTO recipes ({columns}) VALUES ({placeholders})",
|
placeholders = ", ".join(["?"] * len(self._RECIPE_COLUMNS))
|
||||||
recipe_rows,
|
columns = ", ".join(self._RECIPE_COLUMNS)
|
||||||
|
conn.executemany(
|
||||||
|
f"INSERT INTO recipes ({columns}) VALUES ({placeholders})",
|
||||||
|
recipe_rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Persist image_id_map for O(1) lookups on cache load
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO cache_metadata (key, value) VALUES (?, ?)",
|
||||||
|
("image_id_map", json.dumps(image_id_map or {})),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Persist image_id_map for O(1) lookups on cache load
|
conn.commit()
|
||||||
conn.execute(
|
logger.debug("Persisted %d recipes to cache", len(recipe_rows))
|
||||||
"INSERT OR REPLACE INTO cache_metadata (key, value) VALUES (?, ?)",
|
return True
|
||||||
("image_id_map", json.dumps(image_id_map or {})),
|
finally:
|
||||||
)
|
conn.close()
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
logger.debug("Persisted %d recipes to cache", len(recipe_rows))
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Failed to persist recipe cache: %s", exc)
|
logger.warning("Failed to persist recipe cache: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
def get_file_stats(self) -> Dict[str, Tuple[float, int]]:
|
def get_file_stats(self) -> Dict[str, Tuple[float, int]]:
|
||||||
"""Return stored file stats for all cached recipes.
|
"""Return stored file stats for all cached recipes.
|
||||||
@@ -486,16 +521,14 @@ class PersistentRecipeCache:
|
|||||||
logger.warning("Failed to initialize persistent recipe cache schema: %s", exc)
|
logger.warning("Failed to initialize persistent recipe cache schema: %s", exc)
|
||||||
|
|
||||||
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
|
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
|
||||||
uri = False
|
if readonly and not os.path.exists(self._db_path):
|
||||||
path = self._db_path
|
raise FileNotFoundError(self._db_path)
|
||||||
if readonly:
|
return connect_cache_db(
|
||||||
if not os.path.exists(path):
|
self._db_path,
|
||||||
raise FileNotFoundError(path)
|
readonly=readonly,
|
||||||
path = f"file:{path}?mode=ro"
|
detect_types=sqlite3.PARSE_DECLTYPES,
|
||||||
uri = True
|
row_factory=sqlite3.Row,
|
||||||
conn = sqlite3.connect(path, check_same_thread=False, uri=uri, detect_types=sqlite3.PARSE_DECLTYPES)
|
)
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
return conn
|
|
||||||
|
|
||||||
def _prepare_recipe_row(self, recipe: Dict[str, Any], json_path: str) -> Tuple[Any, ...]:
|
def _prepare_recipe_row(self, recipe: Dict[str, Any], json_path: str) -> Tuple[Any, ...]:
|
||||||
"""Convert a recipe dict to a row tuple for SQLite insertion."""
|
"""Convert a recipe dict to a row tuple for SQLite insertion."""
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||||
|
|
||||||
|
from ..utils.cache_db import connect_cache_db
|
||||||
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -633,16 +634,13 @@ class RecipeFTSIndex:
|
|||||||
|
|
||||||
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
|
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
|
||||||
"""Create a database connection."""
|
"""Create a database connection."""
|
||||||
uri = False
|
if readonly and not os.path.exists(self._db_path):
|
||||||
path = self._db_path
|
raise FileNotFoundError(self._db_path)
|
||||||
if readonly:
|
return connect_cache_db(
|
||||||
if not os.path.exists(path):
|
self._db_path,
|
||||||
raise FileNotFoundError(path)
|
readonly=readonly,
|
||||||
path = f"file:{path}?mode=ro"
|
row_factory=sqlite3.Row,
|
||||||
uri = True
|
)
|
||||||
conn = sqlite3.connect(path, check_same_thread=False, uri=uri)
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
return conn
|
|
||||||
|
|
||||||
def _remove_recipe_locked(self, conn: sqlite3.Connection, recipe_id: str) -> None:
|
def _remove_recipe_locked(self, conn: sqlite3.Connection, recipe_id: str) -> None:
|
||||||
"""Remove a recipe entry. Caller must hold the lock."""
|
"""Remove a recipe entry. Caller must hold the lock."""
|
||||||
|
|||||||
+124
-16
@@ -116,6 +116,12 @@ class RecipeScanner:
|
|||||||
self._persistent_cache: Optional[PersistentRecipeCache] = None
|
self._persistent_cache: Optional[PersistentRecipeCache] = None
|
||||||
self._civitai_client: Any = None # Lazily initialized from registry
|
self._civitai_client: Any = None # Lazily initialized from registry
|
||||||
self._json_path_map: Dict[str, str] = {} # recipe_id -> json_path
|
self._json_path_map: Dict[str, str] = {} # recipe_id -> json_path
|
||||||
|
# True when the last scan refused to prune the stored cache because
|
||||||
|
# every recorded recipe file was missing (see
|
||||||
|
# :meth:`_initialize_recipe_cache_sync`). Keeps dependent background
|
||||||
|
# work (FTS index) aligned with the stored rows instead of the
|
||||||
|
# intentionally out-of-sync in-memory view.
|
||||||
|
self._prune_skipped: bool = False
|
||||||
if lora_scanner:
|
if lora_scanner:
|
||||||
self._lora_scanner = lora_scanner
|
self._lora_scanner = lora_scanner
|
||||||
if checkpoint_scanner:
|
if checkpoint_scanner:
|
||||||
@@ -1651,8 +1657,12 @@ class RecipeScanner:
|
|||||||
'pageType': 'recipes',
|
'pageType': 'recipes',
|
||||||
})
|
})
|
||||||
self._schedule_post_scan_enrichment()
|
self._schedule_post_scan_enrichment()
|
||||||
# Schedule FTS index build in background (non-blocking)
|
# Schedule FTS index build in background (non-blocking). When the
|
||||||
self._schedule_fts_index_build()
|
# prune was skipped the in-memory cache is intentionally out of sync
|
||||||
|
# with the stored rows, so leave the existing index alone instead of
|
||||||
|
# rebuilding it from the empty view.
|
||||||
|
if not self._prune_skipped:
|
||||||
|
self._schedule_fts_index_build()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Recipe Scanner: Error initializing cache in background: {e}")
|
logger.error(f"Recipe Scanner: Error initializing cache in background: {e}")
|
||||||
# Ensure the cache is never None so the page stops showing the
|
# Ensure the cache is never None so the page stops showing the
|
||||||
@@ -1723,6 +1733,7 @@ class RecipeScanner:
|
|||||||
"""
|
"""
|
||||||
loop = None
|
loop = None
|
||||||
scan_start_time: Optional[float] = None
|
scan_start_time: Optional[float] = None
|
||||||
|
self._prune_skipped = False
|
||||||
try:
|
try:
|
||||||
# Ensure cache exists to avoid None reference errors
|
# Ensure cache exists to avoid None reference errors
|
||||||
if self._cache is None:
|
if self._cache is None:
|
||||||
@@ -1749,14 +1760,38 @@ class RecipeScanner:
|
|||||||
logger.warning(f"Recipes directory not found: {recipes_dir}")
|
logger.warning(f"Recipes directory not found: {recipes_dir}")
|
||||||
return self._cache
|
return self._cache
|
||||||
|
|
||||||
|
# Record which directory the scan actually used. When the Recipes
|
||||||
|
# Storage Path is empty this falls back to the first LoRA root, and
|
||||||
|
# a support reader needs that path to tell a real wipe apart from a
|
||||||
|
# scan that looked somewhere else (see the prune guard below).
|
||||||
|
logger.info(f"Recipe scan directory: {recipes_dir}")
|
||||||
|
|
||||||
# Try to load from persistent cache first
|
# Try to load from persistent cache first
|
||||||
persisted = self._persistent_cache.load_cache()
|
persisted = self._persistent_cache.load_cache()
|
||||||
if persisted:
|
if persisted:
|
||||||
recipes, changed, json_paths = self._reconcile_recipe_cache(
|
(
|
||||||
persisted, recipes_dir
|
recipes,
|
||||||
)
|
changed,
|
||||||
|
json_paths,
|
||||||
|
skipped_prune_reason,
|
||||||
|
) = self._reconcile_recipe_cache(persisted, recipes_dir)
|
||||||
self._json_path_map = json_paths
|
self._json_path_map = json_paths
|
||||||
|
|
||||||
|
if skipped_prune_reason:
|
||||||
|
# Every persisted recipe file vanished at once. That is not a
|
||||||
|
# reliable deletion signal: a drive that did not mount, a
|
||||||
|
# recipes_path that silently fell back to another root, or a
|
||||||
|
# shared cache touched by a second instance all look exactly
|
||||||
|
# like this. Keep the stored cache and skip the prune, so the
|
||||||
|
# only copy of the user's recipes is not destroyed.
|
||||||
|
logger.warning(
|
||||||
|
f"Recipe cache prune skipped: {skipped_prune_reason}. "
|
||||||
|
f"Keeping {len(persisted.raw_data)} stored recipe(s); this "
|
||||||
|
"session reports no recipes until the files are found again."
|
||||||
|
)
|
||||||
|
self._prune_skipped = True
|
||||||
|
return self._cache
|
||||||
|
|
||||||
if not changed:
|
if not changed:
|
||||||
# Fast path: use cached data directly
|
# Fast path: use cached data directly
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1770,7 +1805,10 @@ class RecipeScanner:
|
|||||||
if self._backfill_source_path_if_needed(recipes, json_paths):
|
if self._backfill_source_path_if_needed(recipes, json_paths):
|
||||||
self._cache.image_id_map = self._build_image_id_map()
|
self._cache.image_id_map = self._build_image_id_map()
|
||||||
self._persistent_cache.save_cache(
|
self._persistent_cache.save_cache(
|
||||||
recipes, json_paths, self._cache.image_id_map
|
recipes,
|
||||||
|
json_paths,
|
||||||
|
self._cache.image_id_map,
|
||||||
|
skip_if_empty=True,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Use persisted map, or rebuild if empty (e.g. first startup
|
# Use persisted map, or rebuild if empty (e.g. first startup
|
||||||
@@ -1798,7 +1836,10 @@ class RecipeScanner:
|
|||||||
self._cache.image_id_map = self._build_image_id_map()
|
self._cache.image_id_map = self._build_image_id_map()
|
||||||
# Persist updated cache
|
# Persist updated cache
|
||||||
self._persistent_cache.save_cache(
|
self._persistent_cache.save_cache(
|
||||||
recipes, json_paths, self._cache.image_id_map
|
recipes,
|
||||||
|
json_paths,
|
||||||
|
self._cache.image_id_map,
|
||||||
|
skip_if_empty=True,
|
||||||
)
|
)
|
||||||
return self._cache
|
return self._cache
|
||||||
|
|
||||||
@@ -1825,7 +1866,10 @@ class RecipeScanner:
|
|||||||
|
|
||||||
# Persist for next startup
|
# Persist for next startup
|
||||||
self._persistent_cache.save_cache(
|
self._persistent_cache.save_cache(
|
||||||
recipes, json_paths, self._cache.image_id_map
|
recipes,
|
||||||
|
json_paths,
|
||||||
|
self._cache.image_id_map,
|
||||||
|
skip_if_empty=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if report_progress:
|
if report_progress:
|
||||||
@@ -1862,7 +1906,7 @@ class RecipeScanner:
|
|||||||
self,
|
self,
|
||||||
persisted: PersistedRecipeData,
|
persisted: PersistedRecipeData,
|
||||||
recipes_dir: str,
|
recipes_dir: str,
|
||||||
) -> Tuple[List[Dict[str, Any]], bool, Dict[str, str]]:
|
) -> Tuple[List[Dict[str, Any]], bool, Dict[str, str], Optional[str]]:
|
||||||
"""Reconcile persisted cache with current filesystem state.
|
"""Reconcile persisted cache with current filesystem state.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -1870,7 +1914,11 @@ class RecipeScanner:
|
|||||||
recipes_dir: Path to the recipes directory.
|
recipes_dir: Path to the recipes directory.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (recipes list, changed flag, json_paths dict).
|
Tuple of (recipes list, changed flag, json_paths dict,
|
||||||
|
skipped_prune_reason). The last element is ``None`` on a normal
|
||||||
|
reconcile. When it is a string, the scan saw every persisted recipe
|
||||||
|
file disappear at once; the caller must then keep the persisted
|
||||||
|
cache instead of overwriting it. The reason text is user-facing.
|
||||||
"""
|
"""
|
||||||
recipes: List[Dict[str, Any]] = []
|
recipes: List[Dict[str, Any]] = []
|
||||||
json_paths: Dict[str, str] = {}
|
json_paths: Dict[str, str] = {}
|
||||||
@@ -1951,12 +1999,67 @@ class RecipeScanner:
|
|||||||
time.sleep(0)
|
time.sleep(0)
|
||||||
|
|
||||||
# Check for deleted files
|
# Check for deleted files
|
||||||
for json_path in persisted.file_stats.keys():
|
orphaned_stats = [
|
||||||
if json_path not in current_files:
|
json_path
|
||||||
changed = True
|
for json_path in persisted.file_stats.keys()
|
||||||
logger.debug("Recipe file deleted: %s", json_path)
|
if json_path not in current_files
|
||||||
|
]
|
||||||
|
if orphaned_stats:
|
||||||
|
changed = True
|
||||||
|
# This single line plus the resolved scan directory logged by the
|
||||||
|
# caller are the evidence a support reader gets for a recipes path
|
||||||
|
# that moved; the per-file lines stay at debug to avoid flooding.
|
||||||
|
if len(orphaned_stats) > 10:
|
||||||
|
logger.info(
|
||||||
|
f"Recipe reconcile: {len(orphaned_stats)} of "
|
||||||
|
f"{len(persisted.file_stats)} cached recipe file(s) are not in "
|
||||||
|
f"{recipes_dir} (first: {orphaned_stats[0]}, "
|
||||||
|
f"last: {orphaned_stats[-1]})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for json_path in orphaned_stats:
|
||||||
|
logger.debug("Recipe file deleted: %s", json_path)
|
||||||
|
|
||||||
return recipes, changed, json_paths
|
skipped_prune_reason: Optional[str] = None
|
||||||
|
if not current_files and persisted.file_stats:
|
||||||
|
metadata_is_coherent = self._persisted_metadata_is_coherent(persisted)
|
||||||
|
if metadata_is_coherent:
|
||||||
|
skipped_prune_reason = (
|
||||||
|
f"every recipe file recorded in the cache "
|
||||||
|
f"({len(persisted.file_stats)}) is missing from {recipes_dir}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# The stored row set and its recorded file stats disagree, so
|
||||||
|
# this cache is stale rather than a faithful record of recipes
|
||||||
|
# that have just gone missing. Pruning it is safe.
|
||||||
|
logger.info(
|
||||||
|
f"Recipe reconcile: stored cache is inconsistent "
|
||||||
|
f"({len(persisted.raw_data)} row(s) vs "
|
||||||
|
f"{len(persisted.file_stats)} file record(s)); falling back "
|
||||||
|
"to a normal prune."
|
||||||
|
)
|
||||||
|
|
||||||
|
return recipes, changed, json_paths, skipped_prune_reason
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _persisted_metadata_is_coherent(persisted: PersistedRecipeData) -> bool:
|
||||||
|
"""Return True when the stored rows and their file stats describe one set.
|
||||||
|
|
||||||
|
The prune guard treats "no recipe files found" as a signal that the
|
||||||
|
directory moved out from under us, which is only meaningful when the
|
||||||
|
stored cache is a faithful record of recipes that exist on disk. A cache
|
||||||
|
whose row set and file-stat set have diverged (left behind by an older
|
||||||
|
reconcile) carries recipes that were already orphaned, so it is not
|
||||||
|
evidence of a fresh disappearance.
|
||||||
|
"""
|
||||||
|
stats_ids = {
|
||||||
|
os.path.basename(json_path)[: -len(".recipe.json")]
|
||||||
|
for json_path in persisted.file_stats
|
||||||
|
if os.path.basename(json_path).lower().endswith(".recipe.json")
|
||||||
|
}
|
||||||
|
rows_ids = {str(recipe.get("id", "")) for recipe in persisted.raw_data}
|
||||||
|
rows_ids.discard("")
|
||||||
|
return bool(rows_ids) and rows_ids == stats_ids
|
||||||
|
|
||||||
# Metadata key recording that the one-shot source_path backfill has run.
|
# Metadata key recording that the one-shot source_path backfill has run.
|
||||||
_SOURCE_PATH_BACKFILL_MARKER = "source_path_backfilled"
|
_SOURCE_PATH_BACKFILL_MARKER = "source_path_backfilled"
|
||||||
@@ -2626,6 +2729,10 @@ class RecipeScanner:
|
|||||||
try:
|
try:
|
||||||
# Invalidate persistent cache so the sync path does a
|
# Invalidate persistent cache so the sync path does a
|
||||||
# full directory scan instead of reconciling stale data.
|
# full directory scan instead of reconciling stale data.
|
||||||
|
# This is the deliberate escape hatch from the
|
||||||
|
# all-missing prune guard: an explicit user rebuild is
|
||||||
|
# allowed to clear the stored cache, while an implicit
|
||||||
|
# startup scan is not.
|
||||||
if self._persistent_cache:
|
if self._persistent_cache:
|
||||||
self._persistent_cache.save_cache([], {})
|
self._persistent_cache.save_cache([], {})
|
||||||
self._json_path_map = {}
|
self._json_path_map = {}
|
||||||
@@ -2656,7 +2763,8 @@ class RecipeScanner:
|
|||||||
|
|
||||||
# Schedule non-blocking background work
|
# Schedule non-blocking background work
|
||||||
self._schedule_post_scan_enrichment()
|
self._schedule_post_scan_enrichment()
|
||||||
self._schedule_fts_index_build()
|
if not self._prune_skipped:
|
||||||
|
self._schedule_fts_index_build()
|
||||||
|
|
||||||
return cast(RecipeCache, self._cache)
|
return cast(RecipeCache, self._cache)
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from typing import (
|
|||||||
Mapping,
|
Mapping,
|
||||||
Optional,
|
Optional,
|
||||||
Sequence,
|
Sequence,
|
||||||
|
Set,
|
||||||
Tuple,
|
Tuple,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ from ..utils.constants import (
|
|||||||
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
|
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
|
||||||
from ..utils.settings_paths import (
|
from ..utils.settings_paths import (
|
||||||
APP_NAME,
|
APP_NAME,
|
||||||
|
_portable_env_override,
|
||||||
ensure_settings_file,
|
ensure_settings_file,
|
||||||
get_legacy_settings_path,
|
get_legacy_settings_path,
|
||||||
get_settings_dir_override,
|
get_settings_dir_override,
|
||||||
@@ -96,6 +98,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
"recipes_path": "",
|
"recipes_path": "",
|
||||||
"base_model_path_mappings": {},
|
"base_model_path_mappings": {},
|
||||||
"download_path_templates": {},
|
"download_path_templates": {},
|
||||||
|
"download_filename_templates": {},
|
||||||
"folder_paths": {},
|
"folder_paths": {},
|
||||||
"extra_folder_paths": {},
|
"extra_folder_paths": {},
|
||||||
"example_images_path": "",
|
"example_images_path": "",
|
||||||
@@ -172,13 +175,23 @@ class SettingsManager:
|
|||||||
self._check_environment_variables()
|
self._check_environment_variables()
|
||||||
self._collect_configuration_warnings()
|
self._collect_configuration_warnings()
|
||||||
|
|
||||||
if (
|
portable_override = _portable_env_override()
|
||||||
os.environ.get("LORA_MANAGER_PORTABLE", "0") == "1"
|
if portable_override is True and not is_settings_dir_pinned():
|
||||||
and not is_settings_dir_pinned()
|
|
||||||
):
|
|
||||||
if not self.settings.get("use_portable_settings"):
|
if not self.settings.get("use_portable_settings"):
|
||||||
self.settings["use_portable_settings"] = True
|
self.settings["use_portable_settings"] = True
|
||||||
self._save_settings()
|
self._save_settings()
|
||||||
|
elif portable_override is False and self.settings.get(
|
||||||
|
"use_portable_settings"
|
||||||
|
):
|
||||||
|
# Explicit opt-out from a persisted portable mode: clear the flag so
|
||||||
|
# later runs go back to the shared settings directory instead of
|
||||||
|
# requiring a manual edit of settings.json.
|
||||||
|
logger.info(
|
||||||
|
"Clearing the persisted portable-mode flag because %s=0",
|
||||||
|
"LORA_MANAGER_PORTABLE",
|
||||||
|
)
|
||||||
|
self.settings["use_portable_settings"] = False
|
||||||
|
self._save_settings()
|
||||||
|
|
||||||
if self._needs_initial_save:
|
if self._needs_initial_save:
|
||||||
self._save_settings()
|
self._save_settings()
|
||||||
@@ -297,6 +310,29 @@ class SettingsManager:
|
|||||||
|
|
||||||
return payload == template
|
return payload == template
|
||||||
|
|
||||||
|
def get_template_folder_path_placeholders(self) -> Set[str]:
|
||||||
|
"""Placeholder folder_paths values shipped in settings.json.example.
|
||||||
|
|
||||||
|
A fresh standalone install is seeded from the template, so its
|
||||||
|
documentation-only placeholder paths end up in the live settings
|
||||||
|
file. The Model Paths settings UI hides them; the first real save
|
||||||
|
overwrites them via ``set("folder_paths")``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
template = self._read_template_payload()
|
||||||
|
if not template:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
folder_paths = template.get("folder_paths")
|
||||||
|
if not isinstance(folder_paths, Mapping):
|
||||||
|
return set()
|
||||||
|
|
||||||
|
placeholders: Set[str] = set()
|
||||||
|
for value in folder_paths.values():
|
||||||
|
paths = value if isinstance(value, list) else [value]
|
||||||
|
placeholders.update(p for p in paths if isinstance(p, str) and p)
|
||||||
|
return placeholders
|
||||||
|
|
||||||
def _merge_template_with_defaults(
|
def _merge_template_with_defaults(
|
||||||
self, defaults: Dict[str, Any], template: Mapping[str, Any]
|
self, defaults: Dict[str, Any], template: Mapping[str, Any]
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
@@ -1208,19 +1244,27 @@ class SettingsManager:
|
|||||||
if self._bootstrap_reason == "missing":
|
if self._bootstrap_reason == "missing":
|
||||||
message = (
|
message = (
|
||||||
"LoRA Manager created a default settings.json because no configuration was found. "
|
"LoRA Manager created a default settings.json because no configuration was found. "
|
||||||
"Edit settings.json to add your model directories so library scanning can run."
|
"Open Settings → Model Paths to add your model directories so library scanning can run."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
message = (
|
message = (
|
||||||
"LoRA Manager could not locate any configured model directories. "
|
"LoRA Manager could not locate any configured model directories. "
|
||||||
"Edit settings.json to add your model folders so library scanning can run."
|
"Open Settings → Model Paths to add your model folders so library scanning can run."
|
||||||
)
|
)
|
||||||
self._add_startup_message(
|
self._add_startup_message(
|
||||||
code="missing-model-paths",
|
code="missing-model-paths",
|
||||||
title="Model folders need setup",
|
title="Model folders need setup",
|
||||||
message=message,
|
message=message,
|
||||||
severity="warning",
|
severity="warning",
|
||||||
actions=self._default_settings_actions(),
|
actions=[
|
||||||
|
{
|
||||||
|
"action": "open-model-paths-settings",
|
||||||
|
"label": "Configure model folders",
|
||||||
|
"type": "primary",
|
||||||
|
"icon": "fas fa-cog",
|
||||||
|
},
|
||||||
|
*self._default_settings_actions(),
|
||||||
|
],
|
||||||
dismissible=False,
|
dismissible=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1233,6 +1277,7 @@ class SettingsManager:
|
|||||||
defaults = copy.deepcopy(DEFAULT_SETTINGS)
|
defaults = copy.deepcopy(DEFAULT_SETTINGS)
|
||||||
defaults["base_model_path_mappings"] = {}
|
defaults["base_model_path_mappings"] = {}
|
||||||
defaults["download_path_templates"] = {}
|
defaults["download_path_templates"] = {}
|
||||||
|
defaults["download_filename_templates"] = {}
|
||||||
defaults["priority_tags"] = DEFAULT_PRIORITY_TAG_CONFIG.copy()
|
defaults["priority_tags"] = DEFAULT_PRIORITY_TAG_CONFIG.copy()
|
||||||
defaults.setdefault("folder_paths", {})
|
defaults.setdefault("folder_paths", {})
|
||||||
defaults.setdefault("extra_folder_paths", {})
|
defaults.setdefault("extra_folder_paths", {})
|
||||||
@@ -2381,6 +2426,49 @@ class SettingsManager:
|
|||||||
model_type, DEFAULT_DOWNLOAD_PATH_TEMPLATES.get(model_type, "")
|
model_type, DEFAULT_DOWNLOAD_PATH_TEMPLATES.get(model_type, "")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_download_filename_template(self, model_type: str) -> str:
|
||||||
|
"""Get the download filename template for a specific model type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_type: The type of model ('lora', 'checkpoint', 'embedding',
|
||||||
|
'other')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Template string for the model type. Empty string (the default for
|
||||||
|
every model type) means downloaded files keep their original
|
||||||
|
filename.
|
||||||
|
"""
|
||||||
|
templates = self.settings.get("download_filename_templates", {})
|
||||||
|
|
||||||
|
# Handle edge case where templates might be stored as JSON string
|
||||||
|
if isinstance(templates, str):
|
||||||
|
try:
|
||||||
|
parsed_templates = json.loads(templates)
|
||||||
|
if isinstance(parsed_templates, dict):
|
||||||
|
self.settings["download_filename_templates"] = parsed_templates
|
||||||
|
self._save_settings()
|
||||||
|
templates = parsed_templates
|
||||||
|
logger.info(
|
||||||
|
"Successfully parsed download_filename_templates from JSON string"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError("Parsed JSON is not a dictionary")
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to parse download_filename_templates JSON string: {e}. Resetting to empty templates."
|
||||||
|
)
|
||||||
|
templates = {}
|
||||||
|
self.settings["download_filename_templates"] = templates
|
||||||
|
self._save_settings()
|
||||||
|
|
||||||
|
if not isinstance(templates, dict):
|
||||||
|
templates = {}
|
||||||
|
self.settings["download_filename_templates"] = templates
|
||||||
|
self._save_settings()
|
||||||
|
|
||||||
|
template = templates.get(model_type, "")
|
||||||
|
return template if isinstance(template, str) else ""
|
||||||
|
|
||||||
|
|
||||||
_SETTINGS_MANAGER: Optional["SettingsManager"] = None
|
_SETTINGS_MANAGER: Optional["SettingsManager"] = None
|
||||||
_SETTINGS_MANAGER_LOCK = Lock()
|
_SETTINGS_MANAGER_LOCK = Lock()
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Set
|
from typing import Any, Dict, List, Optional, Set
|
||||||
|
|
||||||
|
from ..utils.cache_db import connect_cache_db
|
||||||
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -677,16 +678,13 @@ class TagFTSIndex:
|
|||||||
|
|
||||||
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
|
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
|
||||||
"""Create a database connection."""
|
"""Create a database connection."""
|
||||||
uri = False
|
if readonly and not os.path.exists(self._db_path):
|
||||||
path = self._db_path
|
raise FileNotFoundError(self._db_path)
|
||||||
if readonly:
|
return connect_cache_db(
|
||||||
if not os.path.exists(path):
|
self._db_path,
|
||||||
raise FileNotFoundError(path)
|
readonly=readonly,
|
||||||
path = f"file:{path}?mode=ro"
|
row_factory=sqlite3.Row,
|
||||||
uri = True
|
)
|
||||||
conn = sqlite3.connect(path, check_same_thread=False, uri=uri)
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
return conn
|
|
||||||
|
|
||||||
def _build_fts_query(self, query: str) -> str:
|
def _build_fts_query(self, query: str) -> str:
|
||||||
"""Build an FTS5 query string with prefix matching.
|
"""Build an FTS5 query string with prefix matching.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from .example_images import (
|
|||||||
ImportExampleImagesUseCase,
|
ImportExampleImagesUseCase,
|
||||||
ImportExampleImagesValidationError,
|
ImportExampleImagesValidationError,
|
||||||
)
|
)
|
||||||
|
from .filename_template_use_case import FilenameTemplateUseCase
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AutoOrganizeInProgressError",
|
"AutoOrganizeInProgressError",
|
||||||
@@ -34,4 +35,5 @@ __all__ = [
|
|||||||
"DownloadExampleImagesUseCase",
|
"DownloadExampleImagesUseCase",
|
||||||
"ImportExampleImagesUseCase",
|
"ImportExampleImagesUseCase",
|
||||||
"ImportExampleImagesValidationError",
|
"ImportExampleImagesValidationError",
|
||||||
|
"FilenameTemplateUseCase",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"""Filename template use case: bulk-rename library models per the configured template.
|
||||||
|
|
||||||
|
An empty template reverts previously renamed models to the original filename
|
||||||
|
recorded in their ``.metadata.json`` sidecar (``original_file_name``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence
|
||||||
|
|
||||||
|
from ...utils.constants import AUTO_ORGANIZE_BATCH_SIZE
|
||||||
|
from ...utils.utils import calculate_filename_for_model
|
||||||
|
from ..model_file_service import AutoOrganizeResult, ProgressCallback
|
||||||
|
from ..model_lifecycle_service import ModelLifecycleService, load_local_metadata
|
||||||
|
from ..settings_manager import get_settings_manager
|
||||||
|
from .auto_organize_use_case import (
|
||||||
|
AutoOrganizeInProgressError,
|
||||||
|
AutoOrganizeLockProvider,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_PROGRESS_TYPE = "filename_template_progress"
|
||||||
|
|
||||||
|
|
||||||
|
class FilenameTemplateUseCase:
|
||||||
|
"""Apply the download filename template to existing library models.
|
||||||
|
|
||||||
|
An empty template restores the recorded original filename instead of
|
||||||
|
rendering a template. Shares the auto-organize lock (and its in-progress
|
||||||
|
error) so a bulk rename never runs concurrently with an auto-organize
|
||||||
|
operation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
scanner,
|
||||||
|
lifecycle_service: ModelLifecycleService,
|
||||||
|
lock_provider: AutoOrganizeLockProvider,
|
||||||
|
model_type: str,
|
||||||
|
metadata_loader: Callable[[str], Awaitable[Dict[str, Any]]] = load_local_metadata,
|
||||||
|
) -> None:
|
||||||
|
self._scanner = scanner
|
||||||
|
self._lifecycle_service = lifecycle_service
|
||||||
|
self._lock_provider = lock_provider
|
||||||
|
self._model_type = model_type
|
||||||
|
self._metadata_loader = metadata_loader
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
file_paths: Optional[Sequence[str]] = None,
|
||||||
|
progress_callback: Optional[ProgressCallback] = None,
|
||||||
|
) -> AutoOrganizeResult:
|
||||||
|
"""Run the bulk rename guarded by the shared library-operation lock."""
|
||||||
|
|
||||||
|
is_running = getattr(self._lock_provider, "is_filename_template_running", None)
|
||||||
|
if callable(is_running) and is_running():
|
||||||
|
raise AutoOrganizeInProgressError(
|
||||||
|
"A filename template operation is already running"
|
||||||
|
)
|
||||||
|
if self._lock_provider.is_auto_organize_running():
|
||||||
|
raise AutoOrganizeInProgressError("Auto-organize is already running")
|
||||||
|
|
||||||
|
lock = await self._lock_provider.get_auto_organize_lock()
|
||||||
|
if lock.locked():
|
||||||
|
raise AutoOrganizeInProgressError(
|
||||||
|
"Another library operation is already running"
|
||||||
|
)
|
||||||
|
|
||||||
|
async with lock:
|
||||||
|
return await self._run(
|
||||||
|
file_paths=file_paths, progress_callback=progress_callback
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _run(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
file_paths: Optional[Sequence[str]],
|
||||||
|
progress_callback: Optional[ProgressCallback],
|
||||||
|
) -> AutoOrganizeResult:
|
||||||
|
result = AutoOrganizeResult()
|
||||||
|
result.operation_type = "filename_template"
|
||||||
|
|
||||||
|
self._scanner.reset_cancellation()
|
||||||
|
|
||||||
|
try:
|
||||||
|
template = get_settings_manager().get_download_filename_template(
|
||||||
|
self._model_type
|
||||||
|
)
|
||||||
|
|
||||||
|
cache = await self._scanner.get_cached_data()
|
||||||
|
models = list(cache.raw_data)
|
||||||
|
if file_paths:
|
||||||
|
wanted = set(file_paths)
|
||||||
|
models = [
|
||||||
|
model for model in models if model.get("file_path") in wanted
|
||||||
|
]
|
||||||
|
|
||||||
|
result.total = len(models)
|
||||||
|
|
||||||
|
await self._emit_progress(progress_callback, result, "started")
|
||||||
|
|
||||||
|
for index in range(0, result.total, AUTO_ORGANIZE_BATCH_SIZE):
|
||||||
|
if self._scanner.is_cancelled():
|
||||||
|
logger.info(
|
||||||
|
"Filename template apply cancelled for %s", self._model_type
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
batch = models[index : index + AUTO_ORGANIZE_BATCH_SIZE]
|
||||||
|
for model in batch:
|
||||||
|
if self._scanner.is_cancelled():
|
||||||
|
break
|
||||||
|
await self._process_model(model, template, result)
|
||||||
|
result.processed += 1
|
||||||
|
|
||||||
|
await self._emit_progress(progress_callback, result, "processing")
|
||||||
|
# Yield between batches so the server stays responsive.
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
if self._scanner.is_cancelled():
|
||||||
|
result.status = "cancelled"
|
||||||
|
await self._emit_progress(progress_callback, result, "cancelled")
|
||||||
|
return result
|
||||||
|
|
||||||
|
await self._emit_progress(progress_callback, result, "completed")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error in filename template apply: %s", exc, exc_info=True)
|
||||||
|
if progress_callback:
|
||||||
|
await progress_callback.on_progress(
|
||||||
|
{
|
||||||
|
"type": _PROGRESS_TYPE,
|
||||||
|
"status": "error",
|
||||||
|
"error": str(exc),
|
||||||
|
"operation_type": result.operation_type,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _process_model(
|
||||||
|
self,
|
||||||
|
model: Dict[str, Any],
|
||||||
|
template: str,
|
||||||
|
result: AutoOrganizeResult,
|
||||||
|
) -> None:
|
||||||
|
model_name = model.get("model_name", "Unknown")
|
||||||
|
try:
|
||||||
|
file_path = model.get("file_path")
|
||||||
|
if not file_path:
|
||||||
|
self._add_result(result, model_name, False, "No file path found")
|
||||||
|
result.failure_count += 1
|
||||||
|
return
|
||||||
|
|
||||||
|
if not template:
|
||||||
|
# Empty template = revert to the original filename recorded
|
||||||
|
# by the first rename; models without a record are skipped.
|
||||||
|
new_stem = await self._resolve_recorded_original(file_path)
|
||||||
|
else:
|
||||||
|
new_stem = calculate_filename_for_model(model, self._model_type)
|
||||||
|
if not new_stem:
|
||||||
|
result.skipped_count += 1
|
||||||
|
return
|
||||||
|
|
||||||
|
current_stem = os.path.splitext(os.path.basename(file_path))[0]
|
||||||
|
if new_stem == current_stem or os.path.normcase(
|
||||||
|
new_stem
|
||||||
|
) == os.path.normcase(current_stem):
|
||||||
|
result.skipped_count += 1
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._lifecycle_service.rename_model(
|
||||||
|
file_path=file_path, new_file_name=new_stem
|
||||||
|
)
|
||||||
|
result.success_count += 1
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
# Conflicts (e.g. target name already exists) count as failures
|
||||||
|
# without aborting the batch.
|
||||||
|
self._add_result(result, model_name, False, str(exc))
|
||||||
|
result.failure_count += 1
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Error applying filename template to %s: %s", model_name, exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
self._add_result(result, model_name, False, f"Error: {exc}")
|
||||||
|
result.failure_count += 1
|
||||||
|
|
||||||
|
async def _resolve_recorded_original(self, file_path: str) -> str:
|
||||||
|
"""Return the original filename stem recorded at the first rename.
|
||||||
|
|
||||||
|
Reads the ``.metadata.json`` sidecar; returns an empty string when no
|
||||||
|
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 = await self._metadata_loader(metadata_path)
|
||||||
|
original = metadata.get("original_file_name")
|
||||||
|
if not isinstance(original, str):
|
||||||
|
return ""
|
||||||
|
return original.strip()
|
||||||
|
|
||||||
|
async def _emit_progress(
|
||||||
|
self,
|
||||||
|
progress_callback: Optional[ProgressCallback],
|
||||||
|
result: AutoOrganizeResult,
|
||||||
|
status: str,
|
||||||
|
) -> None:
|
||||||
|
if not progress_callback:
|
||||||
|
return
|
||||||
|
await progress_callback.on_progress(
|
||||||
|
{
|
||||||
|
"type": _PROGRESS_TYPE,
|
||||||
|
"status": status,
|
||||||
|
"total": result.total,
|
||||||
|
"processed": result.processed,
|
||||||
|
"success": result.success_count,
|
||||||
|
"failures": result.failure_count,
|
||||||
|
"skipped": result.skipped_count,
|
||||||
|
"operation_type": result.operation_type,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _add_result(
|
||||||
|
result: AutoOrganizeResult,
|
||||||
|
model_name: str,
|
||||||
|
success: bool,
|
||||||
|
message: str,
|
||||||
|
) -> None:
|
||||||
|
"""Add a result entry if under the limit (mirrors ModelFileService)."""
|
||||||
|
if len(result.results) < 100:
|
||||||
|
result.results.append(
|
||||||
|
{"model": model_name, "success": success, "message": message}
|
||||||
|
)
|
||||||
|
elif len(result.results) == 100:
|
||||||
|
result.results_truncated = True
|
||||||
|
result.sample_results = result.results[:50]
|
||||||
@@ -20,6 +20,8 @@ class WebSocketManager:
|
|||||||
self._last_init_progress: Dict[str, Dict[str, Any]] = {}
|
self._last_init_progress: Dict[str, Dict[str, Any]] = {}
|
||||||
# Add auto-organize progress tracking
|
# Add auto-organize progress tracking
|
||||||
self._auto_organize_progress: Optional[Dict[str, Any]] = None
|
self._auto_organize_progress: Optional[Dict[str, Any]] = None
|
||||||
|
# Add filename template progress tracking
|
||||||
|
self._filename_template_progress: Optional[Dict[str, Any]] = None
|
||||||
# Add recipe rematch progress tracking
|
# Add recipe rematch progress tracking
|
||||||
self._recipe_rematch_progress: Optional[Dict[str, Any]] = None
|
self._recipe_rematch_progress: Optional[Dict[str, Any]] = None
|
||||||
self._auto_organize_lock = asyncio.Lock()
|
self._auto_organize_lock = asyncio.Lock()
|
||||||
@@ -206,6 +208,26 @@ class WebSocketManager:
|
|||||||
"""Clear auto-organize progress data"""
|
"""Clear auto-organize progress data"""
|
||||||
self._auto_organize_progress = None
|
self._auto_organize_progress = None
|
||||||
|
|
||||||
|
async def broadcast_filename_template_progress(self, data: Dict[str, Any]):
|
||||||
|
"""Broadcast filename template progress to connected clients"""
|
||||||
|
self._filename_template_progress = data
|
||||||
|
await self.broadcast(data)
|
||||||
|
|
||||||
|
def get_filename_template_progress(self) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get current filename template progress"""
|
||||||
|
return self._filename_template_progress
|
||||||
|
|
||||||
|
def cleanup_filename_template_progress(self):
|
||||||
|
"""Clear filename template progress data"""
|
||||||
|
self._filename_template_progress = None
|
||||||
|
|
||||||
|
def is_filename_template_running(self) -> bool:
|
||||||
|
"""Check if a filename template operation is currently running"""
|
||||||
|
if not self._filename_template_progress:
|
||||||
|
return False
|
||||||
|
status = self._filename_template_progress.get('status')
|
||||||
|
return status in ['started', 'processing']
|
||||||
|
|
||||||
async def broadcast_recipe_rematch_progress(self, data: Dict[str, Any]):
|
async def broadcast_recipe_rematch_progress(self, data: Dict[str, Any]):
|
||||||
"""Broadcast recipe rematch progress to connected clients"""
|
"""Broadcast recipe rematch progress to connected clients"""
|
||||||
# Store progress data in memory
|
# Store progress data in memory
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ class WebSocketProgressCallback(ProgressCallback):
|
|||||||
await ws_manager.broadcast_auto_organize_progress(progress_data)
|
await ws_manager.broadcast_auto_organize_progress(progress_data)
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketFilenameTemplateProgressCallback(ProgressCallback):
|
||||||
|
"""WebSocket progress callback for filename template operations."""
|
||||||
|
|
||||||
|
async def on_progress(self, progress_data: Dict[str, Any]) -> None:
|
||||||
|
"""Send filename template progress via WebSocket."""
|
||||||
|
await ws_manager.broadcast_filename_template_progress(progress_data)
|
||||||
|
|
||||||
|
|
||||||
class WebSocketBroadcastCallback:
|
class WebSocketBroadcastCallback:
|
||||||
"""Generic WebSocket progress callback broadcasting to all clients."""
|
"""Generic WebSocket progress callback broadcasting to all clients."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Shared SQLite connection setup for LoRA Manager cache databases.
|
||||||
|
|
||||||
|
Cache databases live under the settings directory (``cache/model/<library>.sqlite``,
|
||||||
|
``cache/recipe/<library>.sqlite``, ``cache/fts/*.sqlite``). With portable mode or a
|
||||||
|
pinned ``LORA_MANAGER_SETTINGS_DIR`` off, that directory is shared by every ComfyUI
|
||||||
|
instance on the machine, so two processes can open the same cache file at once.
|
||||||
|
|
||||||
|
SQLite serializes writers, but the default ``timeout`` is 5 seconds: a second
|
||||||
|
instance that writes while the first is mid-transaction fails with "database is
|
||||||
|
locked". These settings make concurrent access wait instead of failing, and keep
|
||||||
|
the write path in WAL so readers are never blocked by a writer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# How long a connection waits for a competing writer before raising.
|
||||||
|
CONCURRENT_TIMEOUT_SECONDS = 30.0
|
||||||
|
|
||||||
|
# PRAGMAs applied to every cache connection.
|
||||||
|
#
|
||||||
|
# ``busy_timeout`` mirrors the connection timeout so a busy database is retried
|
||||||
|
# inside SQLite rather than surfacing as an immediate error. ``synchronous=NORMAL``
|
||||||
|
# is the documented companion of WAL: still crash-safe, far fewer fsyncs.
|
||||||
|
_TUNING_PRAGMAS = (
|
||||||
|
"PRAGMA busy_timeout = 30000",
|
||||||
|
"PRAGMA synchronous = NORMAL",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def connect_cache_db(
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
readonly: bool = False,
|
||||||
|
uri: bool = False,
|
||||||
|
detect_types: int = 0,
|
||||||
|
row_factory: Any = None,
|
||||||
|
) -> sqlite3.Connection:
|
||||||
|
"""Open a cache database with multi-instance-friendly settings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Database path, or a ``file:`` URI when *uri* is True.
|
||||||
|
readonly: Open through a read-only URI. Callers still pass the
|
||||||
|
plain path; the ``mode=ro`` suffix is added here. The
|
||||||
|
write-oriented tuning pragmas are skipped in that case so a
|
||||||
|
read-only connection never attempts to change the file.
|
||||||
|
uri: Treat *path* as a SQLite URI.
|
||||||
|
detect_types: Forwarded to :func:`sqlite3.connect`.
|
||||||
|
row_factory: Optional ``row_factory`` for the connection.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A configured :class:`sqlite3.Connection`.
|
||||||
|
"""
|
||||||
|
if readonly:
|
||||||
|
if not uri and not path.startswith("file:"):
|
||||||
|
path = f"file:{path}?mode=ro"
|
||||||
|
uri = True
|
||||||
|
|
||||||
|
conn = sqlite3.connect(
|
||||||
|
path,
|
||||||
|
check_same_thread=False,
|
||||||
|
uri=uri,
|
||||||
|
detect_types=detect_types,
|
||||||
|
timeout=CONCURRENT_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
if row_factory is not None:
|
||||||
|
conn.row_factory = row_factory
|
||||||
|
|
||||||
|
try:
|
||||||
|
for pragma in _TUNING_PRAGMAS:
|
||||||
|
# A read-only connection may reject write PRAGMAs; they are not
|
||||||
|
# needed there anyway.
|
||||||
|
conn.execute(pragma)
|
||||||
|
except sqlite3.Error:
|
||||||
|
# Tuning is best-effort: a connection that cannot set pragmas still
|
||||||
|
# works, just without the concurrency headroom.
|
||||||
|
pass
|
||||||
|
|
||||||
|
return conn
|
||||||
@@ -127,6 +127,29 @@ def other_sub_type_folder_keys() -> Dict[str, List[str]]:
|
|||||||
# Precomputed inverse of OTHER_MODEL_FOLDER_SUBTYPES, keeping the table order.
|
# Precomputed inverse of OTHER_MODEL_FOLDER_SUBTYPES, keeping the table order.
|
||||||
OTHER_SUB_TYPE_FOLDER_KEYS: Dict[str, List[str]] = other_sub_type_folder_keys()
|
OTHER_SUB_TYPE_FOLDER_KEYS: Dict[str, List[str]] = other_sub_type_folder_keys()
|
||||||
|
|
||||||
|
# Core folder_paths keys every LoRA Manager installation understands.
|
||||||
|
CORE_FOLDER_PATH_KEYS: List[str] = ["loras", "checkpoints", "unet", "embeddings"]
|
||||||
|
|
||||||
|
|
||||||
|
def folder_path_schema() -> List[Dict[str, Any]]:
|
||||||
|
"""Ordered schema describing the editable folder_paths keys.
|
||||||
|
|
||||||
|
Drives the standalone-only Model Paths settings UI: the frontend renders
|
||||||
|
one multi-path editor per entry and resolves labels via the
|
||||||
|
``settings.modelPaths.folderKeys.<key>`` i18n keys, so adding a new model
|
||||||
|
category is a constants + locale change only. ``sub_type`` lets the UI
|
||||||
|
hide editors for other-model categories the user has not enabled.
|
||||||
|
"""
|
||||||
|
schema: List[Dict[str, Any]] = [
|
||||||
|
{"key": key, "category": "core", "sub_type": None}
|
||||||
|
for key in CORE_FOLDER_PATH_KEYS
|
||||||
|
]
|
||||||
|
schema.extend(
|
||||||
|
{"key": folder_key, "category": "other", "sub_type": sub_type}
|
||||||
|
for folder_key, sub_type in OTHER_MODEL_FOLDER_SUBTYPES.items()
|
||||||
|
)
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
def normalize_other_sub_types(value: Any) -> List[str]:
|
def normalize_other_sub_types(value: Any) -> List[str]:
|
||||||
"""Normalize a stored/requested enabled-sub_type list.
|
"""Normalize a stored/requested enabled-sub_type list.
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""Shared directory-browsing logic for HTTP directory pickers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Tuple
|
||||||
|
|
||||||
|
# Virtual path token for the Windows drive list. Browsing up from a drive
|
||||||
|
# root (e.g. C:\) lands here so users can switch drives without typing a
|
||||||
|
# path. Only meaningful on Windows; elsewhere it falls through to normal
|
||||||
|
# path handling and fails the existence check.
|
||||||
|
WINDOWS_DRIVES_TOKEN = "__drives__"
|
||||||
|
|
||||||
|
_IMAGE_EXTENSIONS = {
|
||||||
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".png",
|
||||||
|
".gif",
|
||||||
|
".webp",
|
||||||
|
".bmp",
|
||||||
|
".tiff",
|
||||||
|
".tif",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def browse_directory(directory_path: str) -> Tuple[Dict[str, Any], int]:
|
||||||
|
"""Browse a directory and return (payload, http_status).
|
||||||
|
|
||||||
|
The payload shape matches the JSON responses historically produced by
|
||||||
|
``BatchImportHandler.browse_directory``: on success a dict with
|
||||||
|
``success``, ``current_path``, ``parent_path``, ``directories``,
|
||||||
|
``image_files``, ``image_count`` and ``directory_count``; on failure a
|
||||||
|
``{"success": False, "error": ...}`` dict with a 400/403/404/500 status.
|
||||||
|
"""
|
||||||
|
if os.name == "nt" and directory_path == WINDOWS_DRIVES_TOKEN:
|
||||||
|
return _windows_drives_payload(), 200
|
||||||
|
|
||||||
|
# Default to the user's home directory. The frontend previously
|
||||||
|
# sent "/" as the initial path, which is POSIX-only: on Windows it
|
||||||
|
# resolves to the current drive root and then fails the access
|
||||||
|
# check below.
|
||||||
|
if not directory_path:
|
||||||
|
path = Path.home()
|
||||||
|
else:
|
||||||
|
path = Path(directory_path).expanduser().resolve()
|
||||||
|
|
||||||
|
# Access check: browsing intentionally covers the whole server
|
||||||
|
# filesystem (the server operator browses their own machine). On
|
||||||
|
# POSIX every absolute path is under "/", but Path("/") has no
|
||||||
|
# drive letter on Windows and can never anchor a drive-qualified
|
||||||
|
# path in relative_to(), so test for a drive there instead.
|
||||||
|
if os.name == "nt":
|
||||||
|
is_allowed = bool(path.drive)
|
||||||
|
else:
|
||||||
|
is_allowed = path.is_absolute()
|
||||||
|
|
||||||
|
if not is_allowed:
|
||||||
|
return {"success": False, "error": "Access denied to this directory"}, 403
|
||||||
|
|
||||||
|
if not path.exists():
|
||||||
|
return {"success": False, "error": "Directory does not exist"}, 404
|
||||||
|
|
||||||
|
if not path.is_dir():
|
||||||
|
return {"success": False, "error": "Path is not a directory"}, 400
|
||||||
|
|
||||||
|
directories = []
|
||||||
|
image_files = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for item in path.iterdir():
|
||||||
|
try:
|
||||||
|
if item.is_dir():
|
||||||
|
# Skip hidden directories and common system folders
|
||||||
|
if not item.name.startswith(".") and item.name not in [
|
||||||
|
"__pycache__",
|
||||||
|
"node_modules",
|
||||||
|
]:
|
||||||
|
directories.append(
|
||||||
|
{
|
||||||
|
"name": item.name,
|
||||||
|
"path": str(item),
|
||||||
|
"is_parent": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif item.is_file() and item.suffix.lower() in _IMAGE_EXTENSIONS:
|
||||||
|
image_files.append(
|
||||||
|
{
|
||||||
|
"name": item.name,
|
||||||
|
"path": str(item),
|
||||||
|
"size": item.stat().st_size,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except (PermissionError, OSError):
|
||||||
|
# Skip files/directories we can't access
|
||||||
|
continue
|
||||||
|
|
||||||
|
directories.sort(key=lambda x: x["name"].lower())
|
||||||
|
image_files.sort(key=lambda x: x["name"].lower())
|
||||||
|
|
||||||
|
# Parent directory. A filesystem root is its own parent
|
||||||
|
# (parent == path): POSIX "/" gets no parent, while a Windows
|
||||||
|
# drive root (C:\) links up to the virtual drive list so users
|
||||||
|
# can switch drives. The previous str(path) != str(path.root)
|
||||||
|
# check misfired on Windows, where a drive root's parent is
|
||||||
|
# itself, producing an infinite self-loop.
|
||||||
|
if path.parent == path:
|
||||||
|
parent_path = WINDOWS_DRIVES_TOKEN if os.name == "nt" else None
|
||||||
|
else:
|
||||||
|
parent_path = str(path.parent)
|
||||||
|
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"current_path": str(path),
|
||||||
|
"parent_path": parent_path,
|
||||||
|
"directories": directories,
|
||||||
|
"image_files": image_files,
|
||||||
|
"image_count": len(image_files),
|
||||||
|
"directory_count": len(directories),
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
)
|
||||||
|
|
||||||
|
except PermissionError:
|
||||||
|
return {"success": False, "error": "Permission denied"}, 403
|
||||||
|
except OSError as exc:
|
||||||
|
return {"success": False, "error": f"Error reading directory: {str(exc)}"}, 500
|
||||||
|
|
||||||
|
|
||||||
|
def _windows_drives_payload() -> Dict[str, Any]:
|
||||||
|
"""List available drive letters as a virtual directory (Windows only)."""
|
||||||
|
try:
|
||||||
|
drives = os.listdrives()
|
||||||
|
except AttributeError: # Python < 3.12
|
||||||
|
drives = [
|
||||||
|
f"{letter}:\\"
|
||||||
|
for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
if os.path.exists(f"{letter}:\\")
|
||||||
|
]
|
||||||
|
directories = [{"name": drive, "path": drive, "is_parent": False} for drive in drives]
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
# Empty current_path marks the virtual level; the frontend
|
||||||
|
# disables folder selection there.
|
||||||
|
"current_path": "",
|
||||||
|
"parent_path": None,
|
||||||
|
"directories": directories,
|
||||||
|
"image_files": [],
|
||||||
|
"image_count": 0,
|
||||||
|
"directory_count": len(directories),
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import inspect
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
from typing import TYPE_CHECKING, Any, Dict, Mapping, MutableMapping, Optional
|
||||||
|
|
||||||
from ..recipes.constants import GEN_PARAM_KEYS
|
from ..recipes.constants import GEN_PARAM_KEYS
|
||||||
from ..services.metadata_service import get_default_metadata_provider, get_metadata_provider
|
from ..services.metadata_service import get_default_metadata_provider, get_metadata_provider
|
||||||
@@ -13,9 +13,20 @@ from ..services.downloader import get_downloader
|
|||||||
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
|
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
|
||||||
from ..utils.exif_utils import ExifUtils
|
from ..utils.exif_utils import ExifUtils
|
||||||
from ..utils.metadata_manager import MetadataManager
|
from ..utils.metadata_manager import MetadataManager
|
||||||
|
from ..utils.video_metadata import get_video_dimensions
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Placeholder dimensions written when the real ones cannot be determined.
|
||||||
|
# Kept for backwards compatibility with pre-existing metadata entries.
|
||||||
|
_DEFAULT_MEDIA_WIDTH = 720
|
||||||
|
_DEFAULT_MEDIA_HEIGHT = 1280
|
||||||
|
|
||||||
|
# Example metadata entries carry a marker: ``customImages`` use their ``id``
|
||||||
|
# while ``images`` use the positional index. Either way the marker must be a
|
||||||
|
# plain filename-safe token, never a path fragment.
|
||||||
|
_ENTRY_MARKER_PATTERN = re.compile(r"^(?:custom_|image_)?([^./\\]+)$")
|
||||||
|
|
||||||
_preview_service = PreviewAssetService(
|
_preview_service = PreviewAssetService(
|
||||||
metadata_manager=MetadataManager,
|
metadata_manager=MetadataManager,
|
||||||
downloader_factory=get_downloader,
|
downloader_factory=get_downloader,
|
||||||
@@ -66,6 +77,141 @@ def _build_metadata_sync_service(settings_manager: "SettingsManager") -> Metadat
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_media_dimensions(path: str, is_video: bool) -> tuple[int, int]:
|
||||||
|
"""Return ``(width, height)`` for an example image or video file.
|
||||||
|
|
||||||
|
Videos are read from their container headers (PIL cannot open them) so the
|
||||||
|
showcase viewer sizes the gallery to the real aspect ratio. Falls back to
|
||||||
|
the legacy ``720x1280`` placeholder when the dimensions cannot be
|
||||||
|
determined — e.g. an unreadable file or an exotic codec — which only
|
||||||
|
affects the displayed aspect ratio, never the file itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
dimensions = None
|
||||||
|
|
||||||
|
if is_video:
|
||||||
|
dimensions = get_video_dimensions(path)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
if os.path.exists(path):
|
||||||
|
with Image.open(path) as img:
|
||||||
|
dimensions = img.size
|
||||||
|
except Exception:
|
||||||
|
dimensions = None
|
||||||
|
|
||||||
|
if dimensions:
|
||||||
|
width, height = dimensions
|
||||||
|
if width > 0 and height > 0:
|
||||||
|
return int(width), int(height)
|
||||||
|
|
||||||
|
return _DEFAULT_MEDIA_WIDTH, _DEFAULT_MEDIA_HEIGHT
|
||||||
|
|
||||||
|
|
||||||
|
def _is_video_entry(file_path: Optional[str], entry: Mapping[str, Any]) -> bool:
|
||||||
|
"""Return True when an example entry points at a video file.
|
||||||
|
|
||||||
|
The local file extension wins over the recorded ``type`` because files in
|
||||||
|
the wild are frequently mislabelled (animated WebP saved as ``.mp4``);
|
||||||
|
``_read_media_dimensions`` handles that correctly either way.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if file_path:
|
||||||
|
ext = os.path.splitext(file_path)[1].lower()
|
||||||
|
if ext in SUPPORTED_MEDIA_EXTENSIONS["videos"]:
|
||||||
|
return True
|
||||||
|
if ext in SUPPORTED_MEDIA_EXTENSIONS["images"]:
|
||||||
|
return False
|
||||||
|
return str(entry.get("type", "")).lower() == "video"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_local_file(
|
||||||
|
entry: Mapping[str, Any],
|
||||||
|
index: int,
|
||||||
|
local_files: Mapping[str, str],
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Map a metadata entry onto its example file inside the model folder.
|
||||||
|
|
||||||
|
Reads the entry's own marker (``id`` for ``customImages``, positional
|
||||||
|
``index`` for ``images``) with an anchored regex, so the identifier can
|
||||||
|
never bleed into a neighbouring filename the way a prefix comparison can.
|
||||||
|
"""
|
||||||
|
|
||||||
|
marker = entry.get("id")
|
||||||
|
if not isinstance(marker, str) or not marker:
|
||||||
|
marker = str(index)
|
||||||
|
|
||||||
|
match = _ENTRY_MARKER_PATTERN.fullmatch(marker)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return local_files.get(match.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def repair_local_video_dimensions(
|
||||||
|
metadata: MutableMapping[str, Any],
|
||||||
|
local_files: Mapping[str, str],
|
||||||
|
*,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> int:
|
||||||
|
"""Backfill real video dimensions for an entry that has local files.
|
||||||
|
|
||||||
|
Only entries with an empty ``url`` are considered: those have no remote
|
||||||
|
source, so the local file is the single source of truth for their size and
|
||||||
|
rewriting them cannot discard API-supplied data. Entries whose dimensions
|
||||||
|
already match the file are left byte-identical.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
metadata: Raw metadata payload (mutated in place unless ``dry_run``).
|
||||||
|
local_files: ``{identifier: path}`` for files present in the model's
|
||||||
|
example folder, where the identifier is the entry's ``id`` (for
|
||||||
|
``customImages``) or its positional index (for ``images``).
|
||||||
|
dry_run: Count the fixes without mutating ``metadata``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The number of entries that were (or would be) repaired.
|
||||||
|
"""
|
||||||
|
|
||||||
|
civitai = metadata.get("civitai")
|
||||||
|
if not isinstance(civitai, dict):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
repaired = 0
|
||||||
|
|
||||||
|
for key in ("customImages", "images"):
|
||||||
|
entries = civitai.get(key)
|
||||||
|
if not isinstance(entries, list) or not entries:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for index, entry in enumerate(entries):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
if entry.get("url", "") != "":
|
||||||
|
# Remote-backed entry: never rebuilt from local state.
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_path = _resolve_local_file(entry, index, local_files)
|
||||||
|
if not file_path or not os.path.isfile(file_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
dimensions = _read_media_dimensions(
|
||||||
|
file_path, _is_video_entry(file_path, entry)
|
||||||
|
)
|
||||||
|
width, height = dimensions
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
continue
|
||||||
|
if entry.get("width") == width and entry.get("height") == height:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
entry["width"] = width
|
||||||
|
entry["height"] = height
|
||||||
|
repaired += 1
|
||||||
|
|
||||||
|
return repaired
|
||||||
|
|
||||||
|
|
||||||
def _get_metadata_sync_service() -> MetadataSyncService:
|
def _get_metadata_sync_service() -> MetadataSyncService:
|
||||||
"""Return the shared metadata sync service, initialising it lazily."""
|
"""Return the shared metadata sync service, initialising it lazily."""
|
||||||
|
|
||||||
@@ -231,28 +377,20 @@ class MetadataUpdater:
|
|||||||
file_ext = os.path.splitext(path)[1].lower()
|
file_ext = os.path.splitext(path)[1].lower()
|
||||||
is_video = file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
|
is_video = file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
|
||||||
|
|
||||||
|
width, height = _read_media_dimensions(path, is_video)
|
||||||
|
|
||||||
# Create image metadata entry
|
# Create image metadata entry
|
||||||
image_entry = {
|
image_entry = {
|
||||||
"url": "", # Empty URL as required
|
"url": "", # Empty URL as required
|
||||||
"nsfwLevel": 0,
|
"nsfwLevel": 0,
|
||||||
"width": 720, # Default dimensions
|
"width": width,
|
||||||
"height": 1280,
|
"height": height,
|
||||||
"type": "video" if is_video else "image",
|
"type": "video" if is_video else "image",
|
||||||
"meta": None,
|
"meta": None,
|
||||||
"hasMeta": False,
|
"hasMeta": False,
|
||||||
"hasPositivePrompt": False
|
"hasPositivePrompt": False
|
||||||
}
|
}
|
||||||
|
|
||||||
# If it's an image, try to get actual dimensions (optional enhancement)
|
|
||||||
try:
|
|
||||||
from PIL import Image
|
|
||||||
if not is_video and os.path.exists(path):
|
|
||||||
with Image.open(path) as img:
|
|
||||||
image_entry["width"], image_entry["height"] = img.size
|
|
||||||
except:
|
|
||||||
# If PIL fails or is unavailable, use default dimensions
|
|
||||||
pass
|
|
||||||
|
|
||||||
images.append(image_entry)
|
images.append(image_entry)
|
||||||
|
|
||||||
# Update the model's civitai.images field
|
# Update the model's civitai.images field
|
||||||
@@ -322,13 +460,15 @@ class MetadataUpdater:
|
|||||||
file_ext = os.path.splitext(path)[1].lower()
|
file_ext = os.path.splitext(path)[1].lower()
|
||||||
is_video = file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
|
is_video = file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
|
||||||
|
|
||||||
|
width, height = _read_media_dimensions(path, is_video)
|
||||||
|
|
||||||
# Create image metadata entry
|
# Create image metadata entry
|
||||||
image_entry = {
|
image_entry = {
|
||||||
"url": "", # Empty URL as requested
|
"url": "", # Empty URL as requested
|
||||||
"id": short_id,
|
"id": short_id,
|
||||||
"nsfwLevel": 0,
|
"nsfwLevel": 0,
|
||||||
"width": 720, # Default dimensions
|
"width": width,
|
||||||
"height": 1280,
|
"height": height,
|
||||||
"type": "video" if is_video else "image",
|
"type": "video" if is_video else "image",
|
||||||
"meta": None,
|
"meta": None,
|
||||||
"hasMeta": False,
|
"hasMeta": False,
|
||||||
@@ -353,16 +493,6 @@ class MetadataUpdater:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to extract metadata from {os.path.basename(path)}: {e}")
|
logger.warning(f"Failed to extract metadata from {os.path.basename(path)}: {e}")
|
||||||
|
|
||||||
# If it's an image, try to get actual dimensions
|
|
||||||
try:
|
|
||||||
from PIL import Image
|
|
||||||
if not is_video and os.path.exists(path):
|
|
||||||
with Image.open(path) as img:
|
|
||||||
image_entry["width"], image_entry["height"] = img.size
|
|
||||||
except:
|
|
||||||
# If PIL fails or is unavailable, use default dimensions
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Append to existing customImages array
|
# Append to existing customImages array
|
||||||
custom_images.append(image_entry)
|
custom_images.append(image_entry)
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,20 @@ from ..utils.example_images_paths import (
|
|||||||
)
|
)
|
||||||
from ..utils.metadata_manager import MetadataManager
|
from ..utils.metadata_manager import MetadataManager
|
||||||
from ..utils.example_images_processor import ExampleImagesProcessor
|
from ..utils.example_images_processor import ExampleImagesProcessor
|
||||||
from ..utils.example_images_metadata import update_cache_from_metadata
|
from ..utils.example_images_metadata import (
|
||||||
|
repair_local_video_dimensions,
|
||||||
|
update_cache_from_metadata,
|
||||||
|
)
|
||||||
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
|
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
CURRENT_NAMING_VERSION = 2 # Increment this when naming conventions change
|
CURRENT_NAMING_VERSION = 3 # Increment this when naming conventions change
|
||||||
|
|
||||||
|
# Example files worth inspecting during the dimension repair.
|
||||||
|
_REPAIRABLE_EXTENSIONS = frozenset(
|
||||||
|
SUPPORTED_MEDIA_EXTENSIONS["images"] + SUPPORTED_MEDIA_EXTENSIONS["videos"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _SettingsProxy:
|
class _SettingsProxy:
|
||||||
@@ -185,6 +193,9 @@ class ExampleImagesMigration:
|
|||||||
if from_version < 2 and to_version >= 2:
|
if from_version < 2 and to_version >= 2:
|
||||||
await ExampleImagesMigration._migrate_to_v2(model_folders)
|
await ExampleImagesMigration._migrate_to_v2(model_folders)
|
||||||
|
|
||||||
|
if from_version < 3 and to_version >= 3:
|
||||||
|
await ExampleImagesMigration._migrate_to_v3(example_images_path, model_folders)
|
||||||
|
|
||||||
# Update version in progress file
|
# Update version in progress file
|
||||||
progress_file = os.path.join(example_images_path, '.download_progress.json')
|
progress_file = os.path.join(example_images_path, '.download_progress.json')
|
||||||
try:
|
try:
|
||||||
@@ -438,3 +449,136 @@ class ExampleImagesMigration:
|
|||||||
migration_errors += 1
|
migration_errors += 1
|
||||||
|
|
||||||
logger.info(f"Migration to v2 complete: migrated {count} custom examples across {updated_models} models with {migration_errors} errors")
|
logger.info(f"Migration to v2 complete: migrated {count} custom examples across {updated_models} models with {migration_errors} errors")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_local_file_map(folder):
|
||||||
|
"""Map entry markers to their files inside a model's example folder.
|
||||||
|
|
||||||
|
Keys are the marker alone (``custom_<id>`` → ``<id>``,
|
||||||
|
``image_<index>`` → ``<index>``) so they line up with the metadata
|
||||||
|
entries' ``id``/positional index without any prefix ambiguity.
|
||||||
|
"""
|
||||||
|
|
||||||
|
local_files = {}
|
||||||
|
try:
|
||||||
|
entries = os.listdir(folder)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.debug("Could not list example folder %s: %s", folder, exc)
|
||||||
|
return local_files
|
||||||
|
|
||||||
|
for name in entries:
|
||||||
|
stem, ext = os.path.splitext(name)
|
||||||
|
if ext.lower() not in _REPAIRABLE_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
if stem.startswith("custom_"):
|
||||||
|
local_files[stem[len("custom_"):]] = os.path.join(folder, name)
|
||||||
|
elif stem.startswith("image_"):
|
||||||
|
local_files[stem[len("image_"):]] = os.path.join(folder, name)
|
||||||
|
|
||||||
|
return local_files
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _find_scanner_for_hash(model_hash):
|
||||||
|
"""Return the scanner owning ``model_hash``, or ``None``."""
|
||||||
|
|
||||||
|
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||||
|
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||||
|
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
|
|
||||||
|
for scanner in (lora_scanner, checkpoint_scanner, embedding_scanner):
|
||||||
|
if scanner is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if scanner.has_hash(model_hash):
|
||||||
|
return scanner
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
logger.debug("has_hash check failed for %s: %s", type(scanner).__name__, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _migrate_to_v3(example_images_path, model_folders):
|
||||||
|
"""Backfill real dimensions for locally imported example videos.
|
||||||
|
|
||||||
|
Imported videos were stored with a hardcoded ``720x1280`` placeholder
|
||||||
|
(issue #1115), so landscape clips were rendered inside a portrait
|
||||||
|
container. Only entries with an empty ``url`` are touched — those have
|
||||||
|
no remote source, which makes the local file authoritative and the
|
||||||
|
rewrite lossless. Entries already carrying the right size are left
|
||||||
|
untouched, so re-running this migration is a no-op.
|
||||||
|
|
||||||
|
This runs once per library via the ``naming_version`` gate in
|
||||||
|
``run_migrations``; it is deliberately not wired into any request path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
repaired_entries = 0
|
||||||
|
updated_models = 0
|
||||||
|
migration_errors = 0
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Starting v3 migration (local example video dimensions) for %d model folders",
|
||||||
|
len(model_folders),
|
||||||
|
)
|
||||||
|
|
||||||
|
for folder in model_folders:
|
||||||
|
try:
|
||||||
|
model_hash = os.path.basename(folder)
|
||||||
|
if not model_hash or len(model_hash) != 64:
|
||||||
|
continue
|
||||||
|
|
||||||
|
local_files = ExampleImagesMigration._build_local_file_map(folder)
|
||||||
|
if not local_files:
|
||||||
|
continue
|
||||||
|
|
||||||
|
scanner = await ExampleImagesMigration._find_scanner_for_hash(model_hash)
|
||||||
|
if scanner is None:
|
||||||
|
logger.debug(
|
||||||
|
"Model %s not found in any scanner cache, skipping dimension repair",
|
||||||
|
model_hash,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
model_data = None
|
||||||
|
for item in cache.raw_data:
|
||||||
|
if item.get("sha256") == model_hash:
|
||||||
|
model_data = item
|
||||||
|
break
|
||||||
|
|
||||||
|
if not model_data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_path = model_data.get("file_path")
|
||||||
|
if not file_path:
|
||||||
|
continue
|
||||||
|
|
||||||
|
payload = await MetadataManager.load_metadata_payload(file_path)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
repaired = repair_local_video_dimensions(payload, local_files)
|
||||||
|
if repaired <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# The model cache shape differs from the on-disk payload, so
|
||||||
|
# persist the file first and let the cache sync re-read it.
|
||||||
|
await MetadataManager.save_metadata(file_path, payload)
|
||||||
|
await update_cache_from_metadata(scanner, file_path, payload)
|
||||||
|
|
||||||
|
repaired_entries += repaired
|
||||||
|
updated_models += 1
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to repair example video dimensions for %s: %s",
|
||||||
|
folder,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
migration_errors += 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Migration to v3 complete: repaired %d example entr(ies) across %d model(s) "
|
||||||
|
"with %d error(s)",
|
||||||
|
repaired_entries,
|
||||||
|
updated_models,
|
||||||
|
migration_errors,
|
||||||
|
)
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""Cross-process advisory locking for shared LoRA Manager state.
|
||||||
|
|
||||||
|
Two LoRA Manager processes (the ComfyUI plugin and a standalone server, or two
|
||||||
|
ComfyUI installs pointed at the same settings directory) can open the same cache
|
||||||
|
database. SQLite serializes individual statements, but it cannot make a
|
||||||
|
read-modify-write *sequence* atomic across processes: two full-table cache
|
||||||
|
replacements can interleave so that one process's snapshot overwrites the
|
||||||
|
other's.
|
||||||
|
|
||||||
|
This module provides a small advisory file lock for those sequences. It is
|
||||||
|
deliberately non-fatal: if locking is unavailable or the wait times out, callers
|
||||||
|
keep working with SQLite's own ``busy_timeout`` as the fallback.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# How long to wait for another process to release the lock before giving up.
|
||||||
|
DEFAULT_LOCK_TIMEOUT_SECONDS = 30.0
|
||||||
|
_POLL_INTERVAL_SECONDS = 0.05
|
||||||
|
|
||||||
|
# Windows byte-range locks; fcntl.flock on POSIX.
|
||||||
|
try: # pragma: no cover - platform dependent
|
||||||
|
import fcntl
|
||||||
|
except ImportError: # pragma: no cover - Windows
|
||||||
|
fcntl = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
try: # pragma: no cover - Windows only
|
||||||
|
import msvcrt
|
||||||
|
except ImportError: # pragma: no cover - POSIX
|
||||||
|
msvcrt = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
|
class FileLockUnavailable(RuntimeError):
|
||||||
|
"""Raised when the lock could not be acquired within the timeout."""
|
||||||
|
|
||||||
|
|
||||||
|
def lock_path_for(db_path: str) -> str:
|
||||||
|
"""Return the sibling lock file path used for *db_path*."""
|
||||||
|
absolute = os.path.abspath(db_path)
|
||||||
|
directory = os.path.dirname(absolute)
|
||||||
|
if not directory:
|
||||||
|
raise ValueError(f"Cannot derive a lock directory from {db_path!r}")
|
||||||
|
return os.path.join(directory, f".{os.path.basename(absolute)}.lock")
|
||||||
|
|
||||||
|
|
||||||
|
class CrossProcessLock:
|
||||||
|
"""A best-effort advisory lock backed by a lock file.
|
||||||
|
|
||||||
|
The lock file is a sibling of the guarded resource and is never deleted:
|
||||||
|
unlinking it would let a second process create a fresh inode and lock that
|
||||||
|
instead, defeating mutual exclusion.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: str, timeout: float = DEFAULT_LOCK_TIMEOUT_SECONDS):
|
||||||
|
self.path = path
|
||||||
|
self.timeout = timeout
|
||||||
|
self._handle = None
|
||||||
|
|
||||||
|
def acquire(self) -> bool:
|
||||||
|
"""Try to take the lock, waiting up to ``timeout`` seconds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True when the lock is held (including when another lock is already
|
||||||
|
held by *this* process — the calls are not reentrant, so callers must
|
||||||
|
not nest them). False when locking is unsupported or timed out; the
|
||||||
|
caller should proceed and rely on the SQLite busy timeout instead.
|
||||||
|
"""
|
||||||
|
if fcntl is None and msvcrt is None: # pragma: no cover - exotic platform
|
||||||
|
return False
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
||||||
|
try:
|
||||||
|
handle = open(self.path, "a+b")
|
||||||
|
except OSError as exc:
|
||||||
|
logger.debug("Could not open lock file %s: %s", self.path, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
deadline = time.monotonic() + max(0.0, self.timeout)
|
||||||
|
while True:
|
||||||
|
if self._try_lock(handle):
|
||||||
|
self._handle = handle
|
||||||
|
return True
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
handle.close()
|
||||||
|
return False
|
||||||
|
time.sleep(_POLL_INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
def release(self) -> None:
|
||||||
|
"""Release the lock if held. Safe to call more than once."""
|
||||||
|
handle = self._handle
|
||||||
|
if handle is None:
|
||||||
|
return
|
||||||
|
self._handle = None
|
||||||
|
try:
|
||||||
|
self._unlock(handle)
|
||||||
|
except OSError as exc: # pragma: no cover - defensive
|
||||||
|
logger.debug("Failed to release lock %s: %s", self.path, exc)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
handle.close()
|
||||||
|
except OSError: # pragma: no cover - defensive
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __enter__(self) -> "CrossProcessLock":
|
||||||
|
self.acquire()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_exc_info: object) -> None:
|
||||||
|
self.release()
|
||||||
|
|
||||||
|
# -- platform primitives -------------------------------------------------
|
||||||
|
|
||||||
|
def _try_lock(self, handle) -> bool:
|
||||||
|
if fcntl is not None:
|
||||||
|
try:
|
||||||
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
if msvcrt is not None: # pragma: no cover - Windows
|
||||||
|
try:
|
||||||
|
handle.seek(0)
|
||||||
|
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _unlock(self, handle) -> None:
|
||||||
|
if fcntl is not None:
|
||||||
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||||
|
return
|
||||||
|
if msvcrt is not None: # pragma: no cover - Windows
|
||||||
|
handle.seek(0)
|
||||||
|
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def exclusive_lock(db_path: str, timeout: float = DEFAULT_LOCK_TIMEOUT_SECONDS):
|
||||||
|
"""Return a :class:`CrossProcessLock` for the database at *db_path*."""
|
||||||
|
return CrossProcessLock(lock_path_for(db_path), timeout=timeout)
|
||||||
@@ -174,12 +174,42 @@ def ensure_settings_file(logger: Optional[logging.Logger] = None) -> str:
|
|||||||
return target_path
|
return target_path
|
||||||
|
|
||||||
|
|
||||||
|
def _portable_env_override() -> Optional[bool]:
|
||||||
|
"""Return the portable mode forced by ``LORA_MANAGER_PORTABLE``, if any.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``True`` when the variable enables portable mode, ``False`` when it is
|
||||||
|
explicitly set to ``"0"``, and ``None`` when it is unset or holds some
|
||||||
|
other value (in which case the persisted settings flag decides).
|
||||||
|
"""
|
||||||
|
|
||||||
|
raw = os.environ.get(_LM_PORTABLE_ENV)
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
if raw == "1":
|
||||||
|
return True
|
||||||
|
if raw == "0":
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _should_use_portable_settings(path: str, logger: logging.Logger) -> bool:
|
def _should_use_portable_settings(path: str, logger: logging.Logger) -> bool:
|
||||||
"""Return ``True`` when the env var forces it or the settings file enables it."""
|
"""Return ``True`` when the env var forces it or the settings file enables it."""
|
||||||
|
|
||||||
if os.environ.get(_LM_PORTABLE_ENV, "0") == "1":
|
override = _portable_env_override()
|
||||||
|
if override is True:
|
||||||
logger.debug("Portable mode enabled via %s", _LM_PORTABLE_ENV)
|
logger.debug("Portable mode enabled via %s", _LM_PORTABLE_ENV)
|
||||||
return True
|
return True
|
||||||
|
if override is False:
|
||||||
|
# Explicit opt-out. Without this, a single `LORA_MANAGER_PORTABLE=1`
|
||||||
|
# run would pin the shared plugin settings.json to portable mode
|
||||||
|
# forever, with no way back except editing that file by hand.
|
||||||
|
logger.info(
|
||||||
|
"Portable mode disabled via %s=%s",
|
||||||
|
_LM_PORTABLE_ENV,
|
||||||
|
os.environ.get(_LM_PORTABLE_ENV, ""),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
@@ -7,6 +8,8 @@ from ..config import config
|
|||||||
from ..services.settings_manager import get_settings_manager
|
from ..services.settings_manager import get_settings_manager
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_lora_info(lora_name):
|
def get_lora_info(lora_name):
|
||||||
"""Get the lora path and trigger words from cache"""
|
"""Get the lora path and trigger words from cache"""
|
||||||
@@ -598,6 +601,107 @@ def calculate_relative_path_for_model(
|
|||||||
return formatted_path
|
return formatted_path
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_filename_for_model(
|
||||||
|
model_data: Dict[str, Any], model_type: str = "lora"
|
||||||
|
) -> str:
|
||||||
|
"""Calculate the filename stem for a model using the filename template.
|
||||||
|
|
||||||
|
Mirrors the data extraction of :func:`calculate_relative_path_for_model`
|
||||||
|
but renders a single filename (no path segments). Missing values resolve
|
||||||
|
to empty segments instead of the path-oriented defaults ("Anonymous" /
|
||||||
|
"no tags") so templates degrade gracefully.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_data: Model data from scanner cache
|
||||||
|
model_type: Type of model ('lora', 'checkpoint', 'embedding')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sanitized filename stem without extension, or an empty string when no
|
||||||
|
template is configured, the template is invalid, or the rendered name
|
||||||
|
is empty.
|
||||||
|
"""
|
||||||
|
settings_manager = get_settings_manager()
|
||||||
|
template = settings_manager.get_download_filename_template(model_type)
|
||||||
|
|
||||||
|
if not template:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# A filename template must render a single name, never folder segments.
|
||||||
|
if "/" in template or "\\" in template:
|
||||||
|
logger.warning(
|
||||||
|
"Filename template for %s contains a path separator and is ignored: %r",
|
||||||
|
model_type,
|
||||||
|
template,
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
civitai_data = model_data.get("civitai", {})
|
||||||
|
|
||||||
|
author = ""
|
||||||
|
if isinstance(civitai_data, dict) and civitai_data.get("id") is not None:
|
||||||
|
creator_info = civitai_data.get("creator") or {}
|
||||||
|
author = creator_info.get("username") or ""
|
||||||
|
|
||||||
|
base_model = model_data.get("base_model", "")
|
||||||
|
base_model_mappings = settings_manager.get("base_model_path_mappings", {})
|
||||||
|
mapped_base_model = base_model_mappings.get(base_model, base_model)
|
||||||
|
|
||||||
|
lowercase_tags = [
|
||||||
|
tag.lower() for tag in model_data.get("tags", []) if isinstance(tag, str)
|
||||||
|
]
|
||||||
|
first_tag = settings_manager.resolve_priority_tag_for_model(
|
||||||
|
lowercase_tags, model_type
|
||||||
|
)
|
||||||
|
|
||||||
|
model_name = model_data.get("model_name", "")
|
||||||
|
version_name = ""
|
||||||
|
if isinstance(civitai_data, dict):
|
||||||
|
version_name = civitai_data.get("name") or ""
|
||||||
|
|
||||||
|
sha256 = model_data.get("sha256") or ""
|
||||||
|
hash_short = sha256[:10].lower() if isinstance(sha256, str) else ""
|
||||||
|
|
||||||
|
file_path = model_data.get("file_path") or ""
|
||||||
|
if isinstance(file_path, str) and file_path:
|
||||||
|
original_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||||
|
else:
|
||||||
|
original_name = os.path.splitext(str(model_data.get("file_name", "")))[0]
|
||||||
|
|
||||||
|
def _sanitize_value(value: Any) -> str:
|
||||||
|
# sanitize_folder_name falls back to "unnamed" for empty input; for
|
||||||
|
# templates an empty value must stay empty so segments collapse.
|
||||||
|
text = str(value) if value else ""
|
||||||
|
return sanitize_folder_name(text) if text else ""
|
||||||
|
|
||||||
|
replacements = {
|
||||||
|
"{model_name}": _sanitize_value(model_name),
|
||||||
|
"{version_name}": _sanitize_value(version_name),
|
||||||
|
"{base_model}": _sanitize_value(mapped_base_model),
|
||||||
|
"{author}": _sanitize_value(author),
|
||||||
|
"{first_tag}": _sanitize_value(first_tag),
|
||||||
|
"{hash_short}": hash_short,
|
||||||
|
"{original_name}": _sanitize_value(original_name),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = template
|
||||||
|
for placeholder, value in replacements.items():
|
||||||
|
result = result.replace(placeholder, value)
|
||||||
|
|
||||||
|
if model_type == "embedding":
|
||||||
|
result = result.replace(" ", "_")
|
||||||
|
|
||||||
|
# Strip characters that are illegal in filenames on common filesystems.
|
||||||
|
result = re.sub(r'[:*?"<>|]', "", result)
|
||||||
|
# Collapse runs of identical separators introduced by empty substitutions.
|
||||||
|
result = re.sub(r"([-_. ])\1+", r"\1", result)
|
||||||
|
# Drop separators left dangling next to each other ("- -" -> "-").
|
||||||
|
result = re.sub(r" ?([-_.]) (?=[-_.])", r"\1", result)
|
||||||
|
# A stem must not start or end with separators, spaces or dots.
|
||||||
|
result = result.strip("-_. ")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def remove_empty_dirs(path):
|
def remove_empty_dirs(path):
|
||||||
"""Recursively remove empty directories starting from the given path.
|
"""Recursively remove empty directories starting from the given path.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,623 @@
|
|||||||
|
"""Read intrinsic dimensions from video containers without external tooling.
|
||||||
|
|
||||||
|
PIL cannot open ``.mp4``/``.webm`` files, so example videos imported through
|
||||||
|
the "Add examples" flow used to fall back to a hardcoded ``720x1280`` (portrait)
|
||||||
|
entry, which forced the showcase viewer to letterbox landscape videos.
|
||||||
|
|
||||||
|
This module reads the dimensions out of the container headers themselves:
|
||||||
|
|
||||||
|
* ISO base media files (``.mp4``/``.mov``/``.m4v``) — ``moov/trak/tkhd``,
|
||||||
|
falling back to the sample description of the video track.
|
||||||
|
* WebM/Matroska (``.webm``/``.mkv``) — ``Segment/Tracks/TrackEntry/Video``
|
||||||
|
``PixelWidth``/``PixelHeight``.
|
||||||
|
* Animated WebP (``RIFF``/``WEBP``) — handled because users routinely save
|
||||||
|
animated examples with a video extension.
|
||||||
|
|
||||||
|
The container signature decides which reader runs, so a mislabelled file
|
||||||
|
(a ``.mp4`` that is really WebM) still reports the right dimensions.
|
||||||
|
|
||||||
|
Both readers stream over the file: only container headers are read, so a
|
||||||
|
multi-gigabyte ``mdat`` is never pulled into memory (it is seeked past).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import functools
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
from typing import BinaryIO, Iterator, Optional, Tuple
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ISO_MEDIA_EXTENSIONS = frozenset({".mp4", ".m4v", ".mov"})
|
||||||
|
EBML_MEDIA_EXTENSIONS = frozenset({".webm", ".mkv"})
|
||||||
|
|
||||||
|
_EBML_MAGIC = b"\x1a\x45\xdf\xa3"
|
||||||
|
|
||||||
|
# Cap recursion into nesting containers so a crafted/corrupt file cannot blow
|
||||||
|
# the Python stack.
|
||||||
|
_MAX_BOX_DEPTH = 12
|
||||||
|
_MAX_EBML_DEPTH = 12
|
||||||
|
|
||||||
|
# Header structs (``tkhd``, sample entries) are tiny; guard against a bogus
|
||||||
|
# size claiming the whole file.
|
||||||
|
_MAX_HEADER_PAYLOAD = 1024 * 1024
|
||||||
|
|
||||||
|
_WIDTH_HEIGHT_UNSET = (0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=4096)
|
||||||
|
def _get_video_dimensions_cached(
|
||||||
|
path: str, _mtime_ns: int, _size: int
|
||||||
|
) -> Optional[Tuple[int, int]]:
|
||||||
|
"""Return ``(width, height)`` for ``path``, or ``None`` on any failure.
|
||||||
|
|
||||||
|
``_mtime_ns`` and ``_size`` participate in the cache key only so a replaced
|
||||||
|
file is re-probed; they are never read by the parser.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return _read_video_dimensions(path)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to read video dimensions for %s", path, exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_video_dimensions(path: str) -> Optional[Tuple[int, int]]:
|
||||||
|
"""Dispatch to the ISO or EBML reader based on the container's magic bytes.
|
||||||
|
|
||||||
|
Real libraries contain files whose extension lies about their container
|
||||||
|
(a ``.mp4`` that is really WebM, typically), so the sniffed signature wins
|
||||||
|
and the extension is only a fallback.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ext = os.path.splitext(path)[1].lower()
|
||||||
|
file_size = os.path.getsize(path)
|
||||||
|
|
||||||
|
with open(path, "rb") as stream:
|
||||||
|
magic = stream.read(12)
|
||||||
|
|
||||||
|
if _looks_like_iso_media(magic):
|
||||||
|
return _read_iso_media_dimensions(stream, file_size)
|
||||||
|
if magic[:4] == _EBML_MAGIC:
|
||||||
|
return _read_ebml_dimensions(stream, file_size)
|
||||||
|
if magic[:4] == b"RIFF" and magic[8:12] == b"WEBP":
|
||||||
|
return _read_riff_webp_dimensions(stream, file_size)
|
||||||
|
|
||||||
|
# Signature is inconclusive (truncated or unusual file): fall back to
|
||||||
|
# the extension.
|
||||||
|
if ext in EBML_MEDIA_EXTENSIONS:
|
||||||
|
return _read_ebml_dimensions(stream, file_size)
|
||||||
|
if ext in ISO_MEDIA_EXTENSIONS:
|
||||||
|
return _read_iso_media_dimensions(stream, file_size)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_iso_media(magic: bytes) -> bool:
|
||||||
|
"""Return True when the leading bytes are an ISO base media box header."""
|
||||||
|
|
||||||
|
return len(magic) >= 8 and magic[4:8] in {
|
||||||
|
b"ftyp",
|
||||||
|
b"moov",
|
||||||
|
b"mdat",
|
||||||
|
b"free",
|
||||||
|
b"skip",
|
||||||
|
b"wide",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_video_dimensions(path: str) -> Optional[Tuple[int, int]]:
|
||||||
|
"""Return the intrinsic ``(width, height)`` of a local video file.
|
||||||
|
|
||||||
|
Returns ``None`` when the extension is unsupported, the file is missing or
|
||||||
|
corrupt, or the dimensions cannot be determined. Never raises.
|
||||||
|
"""
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
stat = os.stat(path)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return _get_video_dimensions_cached(path, stat.st_mtime_ns, stat.st_size)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_video_dimensions_cache() -> None:
|
||||||
|
"""Drop the dimension cache (used by tests)."""
|
||||||
|
|
||||||
|
_get_video_dimensions_cached.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# ISO base media (MP4 / MOV)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_boxes(
|
||||||
|
stream: BinaryIO, end: int, depth: int = 0
|
||||||
|
) -> Iterator[Tuple[bytes, int, int]]:
|
||||||
|
"""Yield ``(type, payload_start, box_end)`` for boxes in ``[tell, end)``.
|
||||||
|
|
||||||
|
The stream is left at the next box boundary after each yielded box.
|
||||||
|
"""
|
||||||
|
if depth > _MAX_BOX_DEPTH:
|
||||||
|
return
|
||||||
|
|
||||||
|
while True:
|
||||||
|
start = stream.tell()
|
||||||
|
if start + 8 > end:
|
||||||
|
return
|
||||||
|
|
||||||
|
header = stream.read(8)
|
||||||
|
if len(header) < 8:
|
||||||
|
return
|
||||||
|
|
||||||
|
size, box_type = struct.unpack(">I4s", header)
|
||||||
|
header_size = 8
|
||||||
|
|
||||||
|
if size == 1:
|
||||||
|
# 64-bit ``largesize`` follows the type.
|
||||||
|
extended = stream.read(8)
|
||||||
|
if len(extended) < 8:
|
||||||
|
return
|
||||||
|
size = struct.unpack(">Q", extended)[0]
|
||||||
|
header_size = 16
|
||||||
|
elif size == 0:
|
||||||
|
# Box extends to the end of the enclosing container.
|
||||||
|
size = end - start
|
||||||
|
|
||||||
|
if size < header_size or start + size > end:
|
||||||
|
return
|
||||||
|
|
||||||
|
yield box_type, start + header_size, start + size
|
||||||
|
stream.seek(start + size)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_iso_media_dimensions(
|
||||||
|
stream: BinaryIO, file_size: int
|
||||||
|
) -> Optional[Tuple[int, int]]:
|
||||||
|
"""Walk ``moov`` looking for the video track's dimensions."""
|
||||||
|
|
||||||
|
stream.seek(0)
|
||||||
|
moov: Optional[Tuple[int, int]] = None
|
||||||
|
for box_type, payload_start, box_end in _iter_boxes(stream, file_size):
|
||||||
|
if box_type == b"moov":
|
||||||
|
moov = (payload_start, box_end)
|
||||||
|
break
|
||||||
|
|
||||||
|
if moov is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
stream.seek(moov[0])
|
||||||
|
for box_type, payload_start, box_end in _iter_boxes(stream, moov[1], depth=1):
|
||||||
|
if box_type != b"trak":
|
||||||
|
continue
|
||||||
|
dimensions = _read_trak_dimensions(stream, payload_start, box_end)
|
||||||
|
if dimensions is not None:
|
||||||
|
return dimensions
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_trak_dimensions(
|
||||||
|
stream: BinaryIO, trak_start: int, trak_end: int
|
||||||
|
) -> Optional[Tuple[int, int]]:
|
||||||
|
"""Return the dimensions of a ``trak`` when it describes a video track."""
|
||||||
|
|
||||||
|
stream.seek(trak_start)
|
||||||
|
|
||||||
|
is_video = False
|
||||||
|
tkhd_dimensions = _WIDTH_HEIGHT_UNSET
|
||||||
|
stsd_dimensions = _WIDTH_HEIGHT_UNSET
|
||||||
|
|
||||||
|
for box_type, payload_start, box_end in _iter_boxes(stream, trak_end, depth=2):
|
||||||
|
if box_type == b"tkhd":
|
||||||
|
tkhd_dimensions = _parse_tkhd(stream, payload_start, box_end)
|
||||||
|
elif box_type == b"mdia":
|
||||||
|
stream.seek(payload_start)
|
||||||
|
media = _read_mdia_dimensions(stream, payload_start, box_end)
|
||||||
|
if media is not None:
|
||||||
|
is_video, stsd_dimensions = media
|
||||||
|
|
||||||
|
if not is_video:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ``tkhd`` is preferred: it is display space, and its 16.16 fixed point
|
||||||
|
# encoding keeps non-integer dimensions (odd crops produce those).
|
||||||
|
for width, height in (tkhd_dimensions, stsd_dimensions):
|
||||||
|
if width > 0 and height > 0:
|
||||||
|
return int(round(width)), int(round(height))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_mdia_dimensions(
|
||||||
|
stream: BinaryIO, mdia_start: int, mdia_end: int
|
||||||
|
) -> Optional[Tuple[bool, Tuple[float, float]]]:
|
||||||
|
"""Return ``(is_video, dimensions)`` for a ``mdia`` box."""
|
||||||
|
|
||||||
|
handler_type = b""
|
||||||
|
stsd_dimensions = _WIDTH_HEIGHT_UNSET
|
||||||
|
|
||||||
|
for box_type, payload_start, box_end in _iter_boxes(stream, mdia_end, depth=3):
|
||||||
|
if box_type == b"hdlr":
|
||||||
|
handler_type = _parse_handler_type(stream, payload_start, box_end)
|
||||||
|
elif box_type == b"minf":
|
||||||
|
stream.seek(payload_start)
|
||||||
|
stsd_dimensions = _read_minf_dimensions(stream, payload_start, box_end)
|
||||||
|
|
||||||
|
return handler_type == b"vide", stsd_dimensions
|
||||||
|
|
||||||
|
|
||||||
|
def _read_minf_dimensions(
|
||||||
|
stream: BinaryIO, minf_start: int, minf_end: int
|
||||||
|
) -> Tuple[float, float]:
|
||||||
|
"""Return the sample-entry dimensions declared under ``minf/stbl/stsd``."""
|
||||||
|
|
||||||
|
for box_type, payload_start, box_end in _iter_boxes(stream, minf_end, depth=4):
|
||||||
|
if box_type != b"stbl":
|
||||||
|
continue
|
||||||
|
stream.seek(payload_start)
|
||||||
|
for inner_type, inner_start, inner_end in _iter_boxes(
|
||||||
|
stream, box_end, depth=5
|
||||||
|
):
|
||||||
|
if inner_type == b"stsd":
|
||||||
|
return _parse_stsd(stream, inner_start, inner_end)
|
||||||
|
return _WIDTH_HEIGHT_UNSET
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_tkhd(
|
||||||
|
stream: BinaryIO, payload_start: int, box_end: int
|
||||||
|
) -> Tuple[float, float]:
|
||||||
|
"""Parse the 16.16 fixed point width/height trailer of a ``tkhd`` box."""
|
||||||
|
|
||||||
|
size = box_end - payload_start
|
||||||
|
if size < 8 or size > _MAX_HEADER_PAYLOAD:
|
||||||
|
return _WIDTH_HEIGHT_UNSET
|
||||||
|
|
||||||
|
stream.seek(box_end - 8)
|
||||||
|
trailer = stream.read(8)
|
||||||
|
if len(trailer) < 8:
|
||||||
|
return _WIDTH_HEIGHT_UNSET
|
||||||
|
|
||||||
|
width, height = struct.unpack(">II", trailer)
|
||||||
|
return width / 65536.0, height / 65536.0
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_handler_type(
|
||||||
|
stream: BinaryIO, payload_start: int, box_end: int
|
||||||
|
) -> bytes:
|
||||||
|
"""Parse the handler type from an ``hdlr`` box.
|
||||||
|
|
||||||
|
Layout: version/flags (4) + pre_defined (4) + handler_type (4).
|
||||||
|
"""
|
||||||
|
|
||||||
|
if box_end - payload_start < 12:
|
||||||
|
return b""
|
||||||
|
stream.seek(payload_start)
|
||||||
|
data = stream.read(12)
|
||||||
|
if len(data) < 12:
|
||||||
|
return b""
|
||||||
|
return data[8:12]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_stsd(
|
||||||
|
stream: BinaryIO, payload_start: int, box_end: int
|
||||||
|
) -> Tuple[float, float]:
|
||||||
|
"""Parse the visual sample entry dimensions from an ``stsd`` box.
|
||||||
|
|
||||||
|
Only the first entry is inspected: video tracks are single-entry in every
|
||||||
|
container we import from.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if box_end - payload_start < 16:
|
||||||
|
return _WIDTH_HEIGHT_UNSET
|
||||||
|
|
||||||
|
stream.seek(payload_start)
|
||||||
|
header = stream.read(8) # version/flags + entry_count
|
||||||
|
if len(header) < 8:
|
||||||
|
return _WIDTH_HEIGHT_UNSET
|
||||||
|
|
||||||
|
entry_start = payload_start + 8
|
||||||
|
if entry_start + 8 > box_end:
|
||||||
|
return _WIDTH_HEIGHT_UNSET
|
||||||
|
|
||||||
|
stream.seek(entry_start)
|
||||||
|
entry_header = stream.read(8)
|
||||||
|
if len(entry_header) < 8:
|
||||||
|
return _WIDTH_HEIGHT_UNSET
|
||||||
|
|
||||||
|
entry_size = struct.unpack(">I", entry_header[:4])[0]
|
||||||
|
header_size = 8
|
||||||
|
|
||||||
|
if entry_size == 1:
|
||||||
|
extended = stream.read(8)
|
||||||
|
if len(extended) < 8:
|
||||||
|
return _WIDTH_HEIGHT_UNSET
|
||||||
|
entry_size = struct.unpack(">Q", extended)[0]
|
||||||
|
header_size = 16
|
||||||
|
elif entry_size == 0:
|
||||||
|
entry_size = box_end - entry_start
|
||||||
|
|
||||||
|
if entry_size < header_size + 8 or entry_start + entry_size > box_end:
|
||||||
|
return _WIDTH_HEIGHT_UNSET
|
||||||
|
|
||||||
|
# Visual sample entries: 6 bytes reserved + 2 bytes data_reference_index,
|
||||||
|
# then width (2) and height (2).
|
||||||
|
stream.seek(entry_start + header_size + 6 + 2)
|
||||||
|
dimensions = stream.read(4)
|
||||||
|
if len(dimensions) < 4:
|
||||||
|
return _WIDTH_HEIGHT_UNSET
|
||||||
|
|
||||||
|
width, height = struct.unpack(">HH", dimensions)
|
||||||
|
return float(width), float(height)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# WebM / Matroska (EBML)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
# EBML element IDs (stored with their length marker, as they appear on disk).
|
||||||
|
_ID_SEGMENT = 0x18538067
|
||||||
|
_ID_TRACKS = 0x1654AE6B
|
||||||
|
_ID_TRACK_ENTRY = 0xAE
|
||||||
|
_ID_TRACK_TYPE = 0x83
|
||||||
|
_ID_VIDEO = 0xE0
|
||||||
|
_ID_PIXEL_WIDTH = 0xB0
|
||||||
|
_ID_PIXEL_HEIGHT = 0xBA
|
||||||
|
|
||||||
|
# Nested containers we descend into while hunting for video dimensions.
|
||||||
|
_EBML_CONTAINER_IDS = frozenset({_ID_SEGMENT, _ID_TRACKS, _ID_TRACK_ENTRY})
|
||||||
|
|
||||||
|
|
||||||
|
def _read_ebml_vint(stream: BinaryIO, *, keep_marker: bool) -> Optional[Tuple[int, int]]:
|
||||||
|
"""Read an EBML variable-length integer.
|
||||||
|
|
||||||
|
Returns ``(value, byte_length)``. For element IDs the marker bit is kept
|
||||||
|
(``keep_marker=True``) because IDs are compared in their on-disk form; for
|
||||||
|
sizes the marker is stripped to yield the actual payload length.
|
||||||
|
"""
|
||||||
|
|
||||||
|
first = stream.read(1)
|
||||||
|
if not first:
|
||||||
|
return None
|
||||||
|
|
||||||
|
first_byte = first[0]
|
||||||
|
if first_byte == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
length = 1
|
||||||
|
mask = 0x80
|
||||||
|
while not first_byte & mask:
|
||||||
|
mask >>= 1
|
||||||
|
length += 1
|
||||||
|
if length > 8:
|
||||||
|
return None
|
||||||
|
|
||||||
|
value = first_byte if keep_marker else first_byte & (mask - 1)
|
||||||
|
remaining = length - 1
|
||||||
|
|
||||||
|
if remaining:
|
||||||
|
extra = stream.read(remaining)
|
||||||
|
if len(extra) < remaining:
|
||||||
|
return None
|
||||||
|
for byte in extra:
|
||||||
|
value = (value << 8) | byte
|
||||||
|
|
||||||
|
return value, length
|
||||||
|
|
||||||
|
|
||||||
|
def _read_ebml_dimensions(
|
||||||
|
stream: BinaryIO, file_size: int
|
||||||
|
) -> Optional[Tuple[int, int]]:
|
||||||
|
"""Parse ``Segment/Tracks`` for the first video ``TrackEntry``."""
|
||||||
|
|
||||||
|
stream.seek(0)
|
||||||
|
header = stream.read(4)
|
||||||
|
if header != _EBML_MAGIC:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return _walk_ebml(stream, 0, file_size, depth=0)
|
||||||
|
|
||||||
|
|
||||||
|
def _walk_ebml(
|
||||||
|
stream: BinaryIO, start: int, end: int, *, depth: int
|
||||||
|
) -> Optional[Tuple[int, int]]:
|
||||||
|
"""Recursively scan EBML elements in ``[start, end)`` for video dimensions."""
|
||||||
|
|
||||||
|
if depth > _MAX_EBML_DEPTH:
|
||||||
|
return None
|
||||||
|
|
||||||
|
stream.seek(start)
|
||||||
|
|
||||||
|
while stream.tell() < end:
|
||||||
|
element_start = stream.tell()
|
||||||
|
|
||||||
|
element_id = _read_ebml_vint(stream, keep_marker=True)
|
||||||
|
if element_id is None:
|
||||||
|
return None
|
||||||
|
element_id_value = element_id[0]
|
||||||
|
|
||||||
|
size_field = _read_ebml_vint(stream, keep_marker=False)
|
||||||
|
if size_field is None:
|
||||||
|
return None
|
||||||
|
payload_size, size_length = size_field
|
||||||
|
|
||||||
|
payload_start = element_start + element_id[1] + size_length
|
||||||
|
|
||||||
|
# A size field of all-ones marks an unknown-size element, which is
|
||||||
|
# legal for Segment/Tracks; treat it as "until the parent ends".
|
||||||
|
unknown_size = payload_size == (1 << (7 * size_length)) - 1
|
||||||
|
payload_end = end if unknown_size else payload_start + payload_size
|
||||||
|
|
||||||
|
if payload_end > end:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if element_id_value == _ID_VIDEO:
|
||||||
|
dimensions = _read_ebml_video(stream, payload_start, min(payload_end, end))
|
||||||
|
if dimensions is not None:
|
||||||
|
return dimensions
|
||||||
|
elif element_id_value == _ID_TRACK_ENTRY:
|
||||||
|
track = _read_ebml_track_entry(
|
||||||
|
stream, payload_start, min(payload_end, end)
|
||||||
|
)
|
||||||
|
if track is not None:
|
||||||
|
return track
|
||||||
|
elif element_id_value in _EBML_CONTAINER_IDS:
|
||||||
|
found = _walk_ebml(
|
||||||
|
stream, payload_start, min(payload_end, end), depth=depth + 1
|
||||||
|
)
|
||||||
|
if found is not None:
|
||||||
|
return found
|
||||||
|
|
||||||
|
if unknown_size:
|
||||||
|
# Cannot resume after an unknown-size element; its siblings cannot
|
||||||
|
# be located reliably, so stop scanning this level.
|
||||||
|
return None
|
||||||
|
|
||||||
|
stream.seek(payload_end)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_ebml_track_entry(
|
||||||
|
stream: BinaryIO, start: int, end: int
|
||||||
|
) -> Optional[Tuple[int, int]]:
|
||||||
|
"""Return dimensions when a ``TrackEntry`` is a video track."""
|
||||||
|
|
||||||
|
track_type: Optional[int] = None
|
||||||
|
dimensions: Optional[Tuple[int, int]] = None
|
||||||
|
|
||||||
|
stream.seek(start)
|
||||||
|
while stream.tell() < end:
|
||||||
|
element_start = stream.tell()
|
||||||
|
|
||||||
|
element_id = _read_ebml_vint(stream, keep_marker=True)
|
||||||
|
if element_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
size_field = _read_ebml_vint(stream, keep_marker=False)
|
||||||
|
if size_field is None:
|
||||||
|
return None
|
||||||
|
payload_size, size_length = size_field
|
||||||
|
|
||||||
|
payload_start = element_start + element_id[1] + size_length
|
||||||
|
payload_end = min(payload_start + payload_size, end)
|
||||||
|
|
||||||
|
if element_id[0] == _ID_TRACK_TYPE:
|
||||||
|
track_type = _read_ebml_uint(stream, payload_start, payload_end)
|
||||||
|
elif element_id[0] == _ID_VIDEO:
|
||||||
|
dimensions = _read_ebml_video(stream, payload_start, payload_end)
|
||||||
|
|
||||||
|
stream.seek(payload_end)
|
||||||
|
|
||||||
|
# Track type 1 is video.
|
||||||
|
if track_type == 1 and dimensions is not None:
|
||||||
|
return dimensions
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_ebml_video(
|
||||||
|
stream: BinaryIO, start: int, end: int
|
||||||
|
) -> Optional[Tuple[int, int]]:
|
||||||
|
"""Return ``PixelWidth``/``PixelHeight`` from a ``Video`` element."""
|
||||||
|
|
||||||
|
width: Optional[int] = None
|
||||||
|
height: Optional[int] = None
|
||||||
|
|
||||||
|
stream.seek(start)
|
||||||
|
while stream.tell() < end:
|
||||||
|
element_start = stream.tell()
|
||||||
|
|
||||||
|
element_id = _read_ebml_vint(stream, keep_marker=True)
|
||||||
|
if element_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
size_field = _read_ebml_vint(stream, keep_marker=False)
|
||||||
|
if size_field is None:
|
||||||
|
return None
|
||||||
|
payload_size, size_length = size_field
|
||||||
|
|
||||||
|
payload_start = element_start + element_id[1] + size_length
|
||||||
|
payload_end = min(payload_start + payload_size, end)
|
||||||
|
|
||||||
|
if element_id[0] == _ID_PIXEL_WIDTH:
|
||||||
|
width = _read_ebml_uint(stream, payload_start, payload_end)
|
||||||
|
elif element_id[0] == _ID_PIXEL_HEIGHT:
|
||||||
|
height = _read_ebml_uint(stream, payload_start, payload_end)
|
||||||
|
|
||||||
|
stream.seek(payload_end)
|
||||||
|
|
||||||
|
if width and height and width > 0 and height > 0:
|
||||||
|
return width, height
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_ebml_uint(stream: BinaryIO, start: int, end: int) -> Optional[int]:
|
||||||
|
"""Read an unsigned big-endian integer element payload."""
|
||||||
|
|
||||||
|
length = end - start
|
||||||
|
if length <= 0 or length > 8:
|
||||||
|
return None
|
||||||
|
|
||||||
|
stream.seek(start)
|
||||||
|
raw = stream.read(length)
|
||||||
|
if len(raw) < length:
|
||||||
|
return None
|
||||||
|
|
||||||
|
value = 0
|
||||||
|
for byte in raw:
|
||||||
|
value = (value << 8) | byte
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# RIFF / WebP (animated examples are often renamed to ``.mp4``)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _read_riff_webp_dimensions(
|
||||||
|
stream: BinaryIO, file_size: int
|
||||||
|
) -> Optional[Tuple[int, int]]:
|
||||||
|
"""Return dimensions from a WebP file's first dimension-bearing chunk."""
|
||||||
|
|
||||||
|
stream.seek(12)
|
||||||
|
|
||||||
|
while stream.tell() + 8 <= file_size:
|
||||||
|
header = stream.read(8)
|
||||||
|
if len(header) < 8:
|
||||||
|
return None
|
||||||
|
|
||||||
|
fourcc, chunk_size = struct.unpack("<4sI", header)
|
||||||
|
payload_start = stream.tell()
|
||||||
|
|
||||||
|
if fourcc == b"VP8X":
|
||||||
|
payload = stream.read(10)
|
||||||
|
if len(payload) < 10:
|
||||||
|
return None
|
||||||
|
# Canvas size is stored minus one, as 24-bit little endian values.
|
||||||
|
width = int.from_bytes(payload[4:7], "little") + 1
|
||||||
|
height = int.from_bytes(payload[7:10], "little") + 1
|
||||||
|
return width, height
|
||||||
|
|
||||||
|
if fourcc == b"VP8 ":
|
||||||
|
# Frame tag (3 bytes, bit 0 = key frame) then the key frame start
|
||||||
|
# code 0x9d 0x01 0x2a and the 16-bit dimensions.
|
||||||
|
payload = stream.read(10)
|
||||||
|
if len(payload) < 10:
|
||||||
|
return None
|
||||||
|
start = payload.find(b"\x9d\x01\x2a")
|
||||||
|
if start < 0 or start + 7 > len(payload):
|
||||||
|
return None
|
||||||
|
width, height = struct.unpack("<HH", payload[start + 3 : start + 7])
|
||||||
|
return width & 0x3FFF, height & 0x3FFF
|
||||||
|
|
||||||
|
if fourcc == b"VP8L":
|
||||||
|
payload = stream.read(5)
|
||||||
|
if len(payload) < 5 or payload[0] != 0x2F:
|
||||||
|
return None
|
||||||
|
bits = int.from_bytes(payload[1:5], "little")
|
||||||
|
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
|
||||||
|
|
||||||
|
# Skip this chunk (payloads are padded to an even byte boundary).
|
||||||
|
stream.seek(payload_start + chunk_size + (chunk_size & 1))
|
||||||
|
|
||||||
|
return None
|
||||||
@@ -225,10 +225,9 @@ def main() -> int:
|
|||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Get project root (parent of .agents directory)
|
# Get project root: this script lives in <project_root>/scripts/e2e/.
|
||||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
skill_dir = os.path.dirname(script_dir)
|
project_root = os.path.dirname(os.path.dirname(script_dir))
|
||||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(skill_dir)))
|
|
||||||
|
|
||||||
managed_pids = read_managed_pids(args.port)
|
managed_pids = read_managed_pids(args.port)
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,44 @@
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Banner Pager (cycles through multiple active banners) */
|
||||||
|
.banner-pager {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-pager-btn {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: var(--transition-base);
|
||||||
|
font-size: 0.75em;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-pager-btn:hover {
|
||||||
|
background: oklch(var(--lora-accent) / 0.1);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-pager-indicator {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 2.8em;
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
/* Dismiss Button */
|
/* Dismiss Button */
|
||||||
.banner-dismiss {
|
.banner-dismiss {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -184,6 +222,10 @@
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.banner-pager {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.banner-action {
|
.banner-action {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -27,6 +27,12 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Self-managed by SettingsManager: stacks above the settings modal like the
|
||||||
|
directory picker (settings panels sit at 10000/10002). */
|
||||||
|
#filenameTemplateConfirmModal {
|
||||||
|
z-index: 10010;
|
||||||
|
}
|
||||||
|
|
||||||
.delete-modal-content {
|
.delete-modal-content {
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/* Directory Picker Modal */
|
||||||
|
/* Stacks above the settings modal: settings tooltips/combobox panels sit at
|
||||||
|
10000/10002, so 10010 keeps the picker on top of everything settings-side. */
|
||||||
|
#directoryPickerModal {
|
||||||
|
z-index: 10010;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directory-picker-content {
|
||||||
|
max-width: 560px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directory-picker-content h3 {
|
||||||
|
color: var(--text-color);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Manual path row */
|
||||||
|
#directoryPickerModal .directory-picker-path-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .directory-picker-path-row input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
background: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .directory-picker-path-row input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
box-shadow: 0 0 0 2px oklch(from var(--lora-accent) l c h / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Directory browser (class names shared with the batch import browser) */
|
||||||
|
#directoryPickerModal .directory-browser {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
background: var(--lora-surface);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .browser-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .back-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .back-btn:hover {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .back-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .current-path {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .browser-content {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .folder-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .folder-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-base);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .folder-item:hover {
|
||||||
|
background: var(--lora-surface-hover, oklch(from var(--lora-accent) l c h / 0.1));
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .folder-item i {
|
||||||
|
color: #fbbf24;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .item-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .browser-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .directory-picker-error {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
background: oklch(from var(--lora-error) l c h / 0.12);
|
||||||
|
color: var(--lora-error);
|
||||||
|
font-size: 0.85em;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directoryPickerModal .directory-picker-empty {
|
||||||
|
padding: var(--space-2);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme adjustments */
|
||||||
|
[data-theme="dark"] #directoryPickerModal .directory-browser {
|
||||||
|
background: var(--card-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] #directoryPickerModal .browser-header,
|
||||||
|
[data-theme="dark"] #directoryPickerModal .browser-footer {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] #directoryPickerModal .folder-item i {
|
||||||
|
color: #fcd34d;
|
||||||
|
}
|
||||||
@@ -1692,6 +1692,87 @@ input:checked + .toggle-slider:before {
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Browse (directory picker) button — boxed accent style used on the dynamic
|
||||||
|
extra-folder-path / model-path rows, mirroring .remove-path-btn. Static
|
||||||
|
path fields use the .inset variant below instead. */
|
||||||
|
#settingsModal .browse-path-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
border: 1px solid var(--lora-accent);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--lora-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: var(--transition-base);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settingsModal .browse-path-btn:hover {
|
||||||
|
background: var(--lora-accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inset variant (static path fields): the button floats inside the right
|
||||||
|
edge of the input, so the setting row keeps its single-control look and
|
||||||
|
narrow columns never push it onto a second line. */
|
||||||
|
#settingsModal .browse-path-btn.inset {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settingsModal .browse-path-btn.inset:hover {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--lora-accent);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settingsModal input.has-inset-browse {
|
||||||
|
padding-right: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Advisory path validation feedback (wraps below the input row) */
|
||||||
|
#settingsModal .text-input-wrapper,
|
||||||
|
#settingsModal .path-control {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settingsModal .path-control > .text-input-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-validation {
|
||||||
|
display: none;
|
||||||
|
flex-basis: 100%;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--lora-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-validation.visible {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-validation.valid {
|
||||||
|
color: var(--lora-success);
|
||||||
|
}
|
||||||
|
|
||||||
/* Highlight animation for setting items targeted from Doctor actions */
|
/* Highlight animation for setting items targeted from Doctor actions */
|
||||||
@keyframes settings-highlight-pulse {
|
@keyframes settings-highlight-pulse {
|
||||||
0%, 100% { box-shadow: 0 0 0 0 rgba(from var(--lora-accent) r g b / 0.4); }
|
0%, 100% { box-shadow: 0 0 0 0 rgba(from var(--lora-accent) r g b / 0.4); }
|
||||||
@@ -1780,3 +1861,38 @@ input:checked + .toggle-slider:before {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Standalone Model Paths: pending-restart cues */
|
||||||
|
.settings-nav-item.has-pending-restart {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-item.has-pending-restart::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--lora-warning, #e67e22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-paths-restart-notice {
|
||||||
|
display: none;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
border: 1px solid var(--lora-warning, #e67e22);
|
||||||
|
background: rgba(230, 126, 34, 0.08);
|
||||||
|
color: var(--lora-warning, #e67e22);
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1.4;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-paths-restart-notice.visible {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
@import 'components/modal/example-access-modal.css';
|
@import 'components/modal/example-access-modal.css';
|
||||||
@import 'components/modal/support-modal.css';
|
@import 'components/modal/support-modal.css';
|
||||||
@import 'components/modal/download-modal.css';
|
@import 'components/modal/download-modal.css';
|
||||||
|
@import 'components/modal/directory-picker-modal.css';
|
||||||
@import 'components/toast.css';
|
@import 'components/toast.css';
|
||||||
@import 'components/loading.css';
|
@import 'components/loading.css';
|
||||||
@import 'components/menu.css';
|
@import 'components/menu.css';
|
||||||
|
|||||||
@@ -122,6 +122,9 @@ export function getApiEndpoints(modelType) {
|
|||||||
autoOrganize: `/api/lm/${modelType}/auto-organize`,
|
autoOrganize: `/api/lm/${modelType}/auto-organize`,
|
||||||
autoOrganizeProgress: `/api/lm/${modelType}/auto-organize-progress`,
|
autoOrganizeProgress: `/api/lm/${modelType}/auto-organize-progress`,
|
||||||
|
|
||||||
|
// Filename template operations
|
||||||
|
applyFilenameTemplate: `/api/lm/${modelType}/apply-filename-template`,
|
||||||
|
|
||||||
// Model-specific endpoints (will be merged with specific configs)
|
// Model-specific endpoints (will be merged with specific configs)
|
||||||
specific: {}
|
specific: {}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2175,6 +2175,135 @@ export class BaseModelApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the configured download filename template to models, renaming their files
|
||||||
|
* @param {Array} filePaths - Optional array of file paths to rename. If not provided, applies to all models.
|
||||||
|
* @returns {Promise} - Promise that resolves when the operation is complete
|
||||||
|
*/
|
||||||
|
async applyFilenameTemplate(filePaths = null) {
|
||||||
|
let ws = null;
|
||||||
|
|
||||||
|
await state.loadingManager.showWithProgress(async (loading) => {
|
||||||
|
loading.showCancelButton(() => this.cancelTask());
|
||||||
|
try {
|
||||||
|
// Connect to WebSocket for progress updates
|
||||||
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||||
|
ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
|
||||||
|
|
||||||
|
const operationComplete = new Promise((resolve, reject) => {
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.type !== 'filename_template_progress') return;
|
||||||
|
|
||||||
|
switch (data.status) {
|
||||||
|
case 'started':
|
||||||
|
loading.setProgress(0);
|
||||||
|
const operationType = data.operation_type === 'bulk' ? 'selected models' : 'all models';
|
||||||
|
loading.setStatus(translate('loras.bulkOperations.filenameTemplateProgress.starting', { type: operationType }, `Applying filename template to ${operationType}...`));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'processing':
|
||||||
|
const percent = data.total > 0 ? ((data.processed / data.total) * 90).toFixed(1) : 0;
|
||||||
|
loading.setProgress(percent);
|
||||||
|
loading.setStatus(
|
||||||
|
translate('loras.bulkOperations.filenameTemplateProgress.processing', {
|
||||||
|
processed: data.processed,
|
||||||
|
total: data.total,
|
||||||
|
success: data.success,
|
||||||
|
failures: data.failures,
|
||||||
|
skipped: data.skipped
|
||||||
|
}, `Processing (${data.processed}/${data.total}) - ${data.success} renamed, ${data.skipped} skipped, ${data.failures} failed`)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'completed':
|
||||||
|
loading.setProgress(100);
|
||||||
|
loading.setStatus(
|
||||||
|
translate('loras.bulkOperations.filenameTemplateProgress.completed', {
|
||||||
|
success: data.success,
|
||||||
|
skipped: data.skipped,
|
||||||
|
failures: data.failures,
|
||||||
|
total: data.total
|
||||||
|
}, `Completed: ${data.success} renamed, ${data.skipped} skipped, ${data.failures} failed`)
|
||||||
|
);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve(data);
|
||||||
|
}, 1500);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'cancelled':
|
||||||
|
loading.setStatus(translate('toast.api.operationCancelled', {}, 'Operation cancelled by user'));
|
||||||
|
resolve(data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
loading.setStatus(translate('loras.bulkOperations.filenameTemplateProgress.error', { error: data.error }, `Error: ${data.error}`));
|
||||||
|
reject(new Error(data.error));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket error during filename template apply:', error);
|
||||||
|
reject(new Error('Connection error'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the filename template operation
|
||||||
|
const endpoint = this.apiConfig.endpoints.applyFilenameTemplate;
|
||||||
|
|
||||||
|
const requestBody = {};
|
||||||
|
if (filePaths) {
|
||||||
|
requestBody.file_paths = filePaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.error || 'Failed to start filename template operation');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the operation to complete via WebSocket
|
||||||
|
const result = await operationComplete;
|
||||||
|
|
||||||
|
// Show appropriate success message based on results
|
||||||
|
if (result.status === 'cancelled') {
|
||||||
|
showToast('toast.api.operationCancelledPartial', { success: result.success, total: result.total }, 'info');
|
||||||
|
} else if (result.failures === 0) {
|
||||||
|
showToast('toast.loras.filenameTemplateSuccess', {
|
||||||
|
count: result.success,
|
||||||
|
type: result.operation_type === 'bulk' ? 'selected models' : 'all models'
|
||||||
|
}, 'success');
|
||||||
|
} else {
|
||||||
|
showToast('toast.loras.filenameTemplatePartialSuccess', {
|
||||||
|
success: result.success,
|
||||||
|
failures: result.failures,
|
||||||
|
total: result.total
|
||||||
|
}, 'warning');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error applying filename template:', error);
|
||||||
|
showToast('toast.loras.filenameTemplateFailed', { error: error.message }, 'error');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
initialMessage: translate('loras.bulkOperations.filenameTemplateProgress.initializing', {}, 'Initializing filename template apply...'),
|
||||||
|
completionMessage: translate('loras.bulkOperations.filenameTemplateProgress.complete', {}, 'Filename template apply complete')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async stopExampleImages() {
|
async stopExampleImages() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/lm/stop-example-images', {
|
const response = await fetch('/api/lm/stop-example-images', {
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable directory picker modal backed by POST /api/lm/browse-directory.
|
||||||
|
* Self-managed (NOT registered with ModalManager): it stacks above the
|
||||||
|
* settings modal, so ModalManager's "close current modal on open" behavior
|
||||||
|
* would kill the modal underneath.
|
||||||
|
*/
|
||||||
|
class DirectoryPickerModal {
|
||||||
|
constructor() {
|
||||||
|
this.isOpen = false;
|
||||||
|
this.currentPath = '';
|
||||||
|
this.parentPath = null;
|
||||||
|
this.onSelect = null;
|
||||||
|
this.elements = {};
|
||||||
|
this._bindings = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
open({ initialPath = '', onSelect } = {}) {
|
||||||
|
this._cacheElements();
|
||||||
|
if (!this.elements.modal) {
|
||||||
|
console.warn('DirectoryPickerModal: #directoryPickerModal not found in DOM');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._unbindEvents();
|
||||||
|
this.onSelect = typeof onSelect === 'function' ? onSelect : null;
|
||||||
|
this.currentPath = '';
|
||||||
|
this.parentPath = null;
|
||||||
|
this._clearError();
|
||||||
|
this.elements.folderList.innerHTML = '';
|
||||||
|
this.elements.currentPathEl.textContent = '';
|
||||||
|
this.elements.upBtn.disabled = true;
|
||||||
|
this.elements.pathInput.value = initialPath || '';
|
||||||
|
|
||||||
|
this._bindEvents();
|
||||||
|
document.body.classList.add('modal-open');
|
||||||
|
this.elements.modal.style.display = 'block';
|
||||||
|
this.isOpen = true;
|
||||||
|
|
||||||
|
// An empty path lets the server pick its default (user home).
|
||||||
|
this.loadDirectory(initialPath || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
if (!this.isOpen) return;
|
||||||
|
this.isOpen = false;
|
||||||
|
this._unbindEvents();
|
||||||
|
if (this.elements.modal) {
|
||||||
|
this.elements.modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
this.onSelect = null;
|
||||||
|
// Keep body.modal-open: the settings modal underneath may still be open.
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadDirectory(path) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/lm/browse-directory', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this._clearError();
|
||||||
|
this._renderDirectory(data);
|
||||||
|
} else {
|
||||||
|
this._showError(data.error || translate('settings.directoryPicker.loadError', {}, 'Failed to load directory'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading directory:', error);
|
||||||
|
this._showError(translate('settings.directoryPicker.loadError', {}, 'Failed to load directory'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_cacheElements() {
|
||||||
|
const modal = document.getElementById('directoryPickerModal');
|
||||||
|
this.elements = {
|
||||||
|
modal,
|
||||||
|
closeBtn: document.getElementById('directoryPickerCloseBtn'),
|
||||||
|
pathInput: document.getElementById('directoryPickerPathInput'),
|
||||||
|
goBtn: document.getElementById('directoryPickerGoBtn'),
|
||||||
|
upBtn: document.getElementById('directoryPickerUpBtn'),
|
||||||
|
currentPathEl: document.getElementById('directoryPickerCurrentPath'),
|
||||||
|
folderList: document.getElementById('directoryPickerFolderList'),
|
||||||
|
errorEl: document.getElementById('directoryPickerError'),
|
||||||
|
selectBtn: document.getElementById('directoryPickerSelectBtn')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_bind(target, type, handler, options) {
|
||||||
|
target.addEventListener(type, handler, options);
|
||||||
|
this._bindings.push([target, type, handler, options]);
|
||||||
|
}
|
||||||
|
|
||||||
|
_bindEvents() {
|
||||||
|
const { modal, closeBtn, pathInput, goBtn, upBtn, selectBtn } = this.elements;
|
||||||
|
|
||||||
|
this._bind(closeBtn, 'click', () => this.close());
|
||||||
|
this._bind(goBtn, 'click', () => this.loadDirectory(pathInput.value.trim()));
|
||||||
|
this._bind(pathInput, 'keydown', (event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
this.loadDirectory(pathInput.value.trim());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this._bind(upBtn, 'click', () => {
|
||||||
|
// Server-provided parent_path: Windows paths cannot be derived client-side.
|
||||||
|
if (this.parentPath) {
|
||||||
|
this.loadDirectory(this.parentPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this._bind(selectBtn, 'click', () => this._selectCurrent());
|
||||||
|
|
||||||
|
// Capture phase + stopPropagation so an ESC here never reaches the
|
||||||
|
// settings modal's own ESC handler underneath.
|
||||||
|
this._bind(document, 'keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
// Backdrop click (the .modal element itself, not its content).
|
||||||
|
this._bind(modal, 'click', (event) => {
|
||||||
|
if (event.target === modal) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_unbindEvents() {
|
||||||
|
for (const [target, type, handler, options] of this._bindings) {
|
||||||
|
target.removeEventListener(type, handler, options);
|
||||||
|
}
|
||||||
|
this._bindings = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderDirectory(data) {
|
||||||
|
this.currentPath = data.current_path || '';
|
||||||
|
this.parentPath = data.parent_path || null;
|
||||||
|
|
||||||
|
this.elements.currentPathEl.textContent = this.currentPath;
|
||||||
|
this.elements.pathInput.value = this.currentPath;
|
||||||
|
this.elements.upBtn.disabled = !this.parentPath;
|
||||||
|
|
||||||
|
const folderList = this.elements.folderList;
|
||||||
|
folderList.innerHTML = '';
|
||||||
|
|
||||||
|
const directories = data.directories || [];
|
||||||
|
if (directories.length === 0) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'directory-picker-empty';
|
||||||
|
empty.textContent = translate('settings.directoryPicker.emptyFolder', {}, 'This folder is empty');
|
||||||
|
folderList.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
directories.forEach((entry) => {
|
||||||
|
folderList.appendChild(this._createFolderItem(entry));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each entry is { name, path, is_parent }; the server supplies the full
|
||||||
|
// child path, so navigation never joins path segments client-side.
|
||||||
|
_createFolderItem(entry) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'folder-item';
|
||||||
|
item.innerHTML = `
|
||||||
|
<i class="fas fa-folder"></i>
|
||||||
|
<span class="item-name">${this._escapeHtml(entry.name)}</span>
|
||||||
|
`;
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
this.loadDirectory(entry.path);
|
||||||
|
});
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectCurrent() {
|
||||||
|
if (!this.currentPath) return;
|
||||||
|
if (this.onSelect) {
|
||||||
|
this.onSelect(this.currentPath);
|
||||||
|
}
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
_showError(message) {
|
||||||
|
this.elements.errorEl.textContent = message;
|
||||||
|
this.elements.errorEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearError() {
|
||||||
|
this.elements.errorEl.textContent = '';
|
||||||
|
this.elements.errorEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
_escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const directoryPickerModal = new DirectoryPickerModal();
|
||||||
|
export { DirectoryPickerModal };
|
||||||
@@ -31,6 +31,9 @@ class BannerService {
|
|||||||
this.banners = new Map();
|
this.banners = new Map();
|
||||||
this.container = null;
|
this.container = null;
|
||||||
this.initialized = false;
|
this.initialized = false;
|
||||||
|
// Only one banner is rendered at a time; this index selects which of
|
||||||
|
// the active (non-dismissed) banners is currently displayed.
|
||||||
|
this.currentBannerIndex = 0;
|
||||||
this.recentHistory = this.loadBannerHistory();
|
this.recentHistory = this.loadBannerHistory();
|
||||||
this.bannerHistoryViewedAt = this.loadBannerHistoryViewedAt();
|
this.bannerHistoryViewedAt = this.loadBannerHistoryViewedAt();
|
||||||
|
|
||||||
@@ -122,11 +125,21 @@ class BannerService {
|
|||||||
registerBanner(id, bannerConfig) {
|
registerBanner(id, bannerConfig) {
|
||||||
this.banners.set(id, bannerConfig);
|
this.banners.set(id, bannerConfig);
|
||||||
|
|
||||||
// If already initialized, render the banner immediately
|
if (!this.initialized || !this.container || this.isBannerDismissed(id)) {
|
||||||
if (this.initialized && !this.isBannerDismissed(id) && this.container) {
|
return;
|
||||||
this.renderBanner(bannerConfig);
|
|
||||||
this.updateContainerVisibility();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preempt the currently displayed banner only when the new one has a
|
||||||
|
// strictly higher priority (i.e. sorts earlier).
|
||||||
|
const activeBanners = this.getSortedActiveBanners();
|
||||||
|
const displayedId = this.container.querySelector('.banner-item')
|
||||||
|
?.getAttribute('data-banner-id');
|
||||||
|
const newIndex = activeBanners.findIndex(banner => banner.id === id);
|
||||||
|
const displayedIndex = activeBanners.findIndex(banner => banner.id === displayedId);
|
||||||
|
if (displayedIndex === -1 || (newIndex !== -1 && newIndex < displayedIndex)) {
|
||||||
|
this.currentBannerIndex = Math.max(newIndex, 0);
|
||||||
|
}
|
||||||
|
this.renderCurrentBanner();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -167,8 +180,7 @@ class BannerService {
|
|||||||
|
|
||||||
bannerElement.style.animation = 'banner-slide-up 0.3s ease-in-out forwards';
|
bannerElement.style.animation = 'banner-slide-up 0.3s ease-in-out forwards';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
bannerElement.remove();
|
this.renderCurrentBanner();
|
||||||
this.updateContainerVisibility();
|
|
||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,28 +205,87 @@ class BannerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active (non-dismissed) banners sorted by priority, highest first
|
||||||
|
* @returns {Object[]}
|
||||||
|
*/
|
||||||
|
getSortedActiveBanners() {
|
||||||
|
return Array.from(this.banners.values())
|
||||||
|
.filter(banner => !this.isBannerDismissed(banner.id))
|
||||||
|
.sort((a, b) => (b.priority || 0) - (a.priority || 0));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show all active (non-dismissed) banners
|
* Show all active (non-dismissed) banners
|
||||||
*/
|
*/
|
||||||
async showActiveBanners() {
|
async showActiveBanners() {
|
||||||
if (!this.container) return;
|
if (!this.container) return;
|
||||||
|
|
||||||
const activeBanners = Array.from(this.banners.values())
|
this.currentBannerIndex = 0;
|
||||||
.filter(banner => !this.isBannerDismissed(banner.id))
|
this.renderCurrentBanner();
|
||||||
.sort((a, b) => (b.priority || 0) - (a.priority || 0));
|
|
||||||
|
|
||||||
activeBanners.forEach(banner => {
|
|
||||||
this.renderBanner(banner);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.updateContainerVisibility();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a banner to the DOM
|
* Render the currently selected banner into the container. Only one
|
||||||
* @param {Object} banner - Banner configuration
|
* banner is visible at a time; a pager lets the user cycle through the
|
||||||
|
* remaining active banners.
|
||||||
*/
|
*/
|
||||||
renderBanner(banner) {
|
renderCurrentBanner() {
|
||||||
|
if (!this.container) return;
|
||||||
|
|
||||||
|
const activeBanners = this.getSortedActiveBanners();
|
||||||
|
|
||||||
|
this.container.innerHTML = '';
|
||||||
|
|
||||||
|
if (activeBanners.length === 0) {
|
||||||
|
this.currentBannerIndex = 0;
|
||||||
|
this.updateContainerVisibility();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.currentBannerIndex >= activeBanners.length) {
|
||||||
|
this.currentBannerIndex = activeBanners.length - 1;
|
||||||
|
}
|
||||||
|
if (this.currentBannerIndex < 0) {
|
||||||
|
this.currentBannerIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record every active banner once so dismissed/cycled-away banners
|
||||||
|
// remain reachable through the notification center history.
|
||||||
|
activeBanners.forEach(banner => this.recordBannerAppearance(banner));
|
||||||
|
|
||||||
|
const banner = activeBanners[this.currentBannerIndex];
|
||||||
|
const bannerElement = this.buildBannerElement(banner, activeBanners.length);
|
||||||
|
this.container.appendChild(bannerElement);
|
||||||
|
|
||||||
|
this.updateContainerVisibility();
|
||||||
|
|
||||||
|
// Call onRegister callback if provided
|
||||||
|
if (typeof banner.onRegister === 'function') {
|
||||||
|
banner.onRegister(bannerElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance the displayed banner by offset, wrapping around
|
||||||
|
* @param {number} offset - +1 for next, -1 for previous
|
||||||
|
*/
|
||||||
|
showAdjacentBanner(offset) {
|
||||||
|
const activeBanners = this.getSortedActiveBanners();
|
||||||
|
if (activeBanners.length < 2) return;
|
||||||
|
|
||||||
|
this.currentBannerIndex =
|
||||||
|
(this.currentBannerIndex + offset + activeBanners.length) % activeBanners.length;
|
||||||
|
this.renderCurrentBanner();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a banner DOM element
|
||||||
|
* @param {Object} banner - Banner configuration
|
||||||
|
* @param {number} totalCount - Total number of active banners
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
buildBannerElement(banner, totalCount) {
|
||||||
const bannerElement = document.createElement('div');
|
const bannerElement = document.createElement('div');
|
||||||
bannerElement.className = 'banner-item';
|
bannerElement.className = 'banner-item';
|
||||||
bannerElement.setAttribute('data-banner-id', banner.id);
|
bannerElement.setAttribute('data-banner-id', banner.id);
|
||||||
@@ -235,6 +306,29 @@ class BannerService {
|
|||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>` : '';
|
</button>` : '';
|
||||||
|
|
||||||
|
let pagerHtml = '';
|
||||||
|
if (totalCount > 1) {
|
||||||
|
const previousLabel = translate('banners.pager.previous', {}, 'Previous message');
|
||||||
|
const nextLabel = translate('banners.pager.next', {}, 'Next message');
|
||||||
|
const positionLabel = translate('banners.pager.position', {
|
||||||
|
current: this.currentBannerIndex + 1,
|
||||||
|
total: totalCount
|
||||||
|
}, `Message ${this.currentBannerIndex + 1} of ${totalCount}`);
|
||||||
|
|
||||||
|
pagerHtml = `
|
||||||
|
<div class="banner-pager">
|
||||||
|
<button type="button" class="banner-pager-btn" data-pager="prev"
|
||||||
|
aria-label="${previousLabel}" title="${previousLabel}">
|
||||||
|
<i class="fas fa-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
<span class="banner-pager-indicator" aria-label="${positionLabel}">${this.currentBannerIndex + 1} / ${totalCount}</span>
|
||||||
|
<button type="button" class="banner-pager-btn" data-pager="next"
|
||||||
|
aria-label="${nextLabel}" title="${nextLabel}">
|
||||||
|
<i class="fas fa-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
bannerElement.innerHTML = `
|
bannerElement.innerHTML = `
|
||||||
<div class="banner-content">
|
<div class="banner-content">
|
||||||
<div class="banner-text">
|
<div class="banner-text">
|
||||||
@@ -244,18 +338,19 @@ class BannerService {
|
|||||||
<div class="banner-actions">
|
<div class="banner-actions">
|
||||||
${actionsHtml}
|
${actionsHtml}
|
||||||
</div>
|
</div>
|
||||||
|
${pagerHtml}
|
||||||
</div>
|
</div>
|
||||||
${dismissButtonHtml}
|
${dismissButtonHtml}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.container.appendChild(bannerElement);
|
bannerElement.querySelectorAll('.banner-pager-btn').forEach(button => {
|
||||||
|
button.addEventListener('click', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
this.showAdjacentBanner(button.getAttribute('data-pager') === 'next' ? 1 : -1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
this.recordBannerAppearance(banner);
|
return bannerElement;
|
||||||
|
|
||||||
// Call onRegister callback if provided
|
|
||||||
if (typeof banner.onRegister === 'function') {
|
|
||||||
banner.onRegister(bannerElement);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -458,17 +553,18 @@ class BannerService {
|
|||||||
* @param {string} bannerId - Banner ID to remove
|
* @param {string} bannerId - Banner ID to remove
|
||||||
*/
|
*/
|
||||||
removeBannerElement(bannerId) {
|
removeBannerElement(bannerId) {
|
||||||
|
// Also remove from banners map
|
||||||
|
this.banners.delete(bannerId);
|
||||||
|
|
||||||
const bannerElement = document.querySelector(`[data-banner-id="${bannerId}"]`);
|
const bannerElement = document.querySelector(`[data-banner-id="${bannerId}"]`);
|
||||||
if (bannerElement) {
|
if (bannerElement) {
|
||||||
bannerElement.style.animation = 'banner-slide-up 0.3s ease-in-out forwards';
|
bannerElement.style.animation = 'banner-slide-up 0.3s ease-in-out forwards';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
bannerElement.remove();
|
this.renderCurrentBanner();
|
||||||
this.updateContainerVisibility();
|
|
||||||
}, 300);
|
}, 300);
|
||||||
|
} else {
|
||||||
|
this.renderCurrentBanner();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also remove from banners map
|
|
||||||
this.banners.delete(bannerId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
prepareCommunitySupportBanner() {
|
prepareCommunitySupportBanner() {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,16 @@
|
|||||||
import { appCore } from './core.js';
|
import { appCore } from './core.js';
|
||||||
import { showToast } from './utils/uiHelpers.js';
|
import { showToast } from './utils/uiHelpers.js';
|
||||||
import { enableOtherModels, openOtherModelsSettings } from './utils/otherModels.js';
|
import { enableOtherModels, openOtherModelsSettings, openModelPathsSettings } from './utils/otherModels.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Other Models is an opt-in feature. While it is disabled this page renders an
|
* Other Models is an opt-in feature. While it is disabled this page renders an
|
||||||
* empty state whose button turns the feature on; the backend then rebuilds the
|
* empty state whose button turns the feature on; the backend then rebuilds the
|
||||||
* other-model roots and starts scanning, so a reload lands on the real page.
|
* other-model roots and starts scanning, so a reload lands on the real page.
|
||||||
*
|
*
|
||||||
* The same module backs the "enabled but no folders found" state, where the
|
* The same module backs the "enabled but no folders found" state: ComfyUI
|
||||||
* only useful action is jumping to Settings instead of enabling anything.
|
* mode points to the Settings page's Library section, while standalone mode
|
||||||
|
* points to the standalone-only Model Paths section (which edits the primary
|
||||||
|
* folder_paths) and still offers the settings.json location as a fallback.
|
||||||
*/
|
*/
|
||||||
async function handleEnableClick() {
|
async function handleEnableClick() {
|
||||||
const button = document.getElementById('enableOtherModelsBtn');
|
const button = document.getElementById('enableOtherModelsBtn');
|
||||||
@@ -32,6 +34,49 @@ function handleOpenSettingsClick(event) {
|
|||||||
openOtherModelsSettings();
|
openOtherModelsSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open Settings on the Model Paths section for the standalone "no folders
|
||||||
|
* found" state, so the missing folders can be added directly.
|
||||||
|
*/
|
||||||
|
function handleOpenModelPathsSettingsClick(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
openModelPathsSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the settings.json location from the standalone no-folders state,
|
||||||
|
* offered as a fallback next to the Model Paths settings button.
|
||||||
|
*/
|
||||||
|
async function handleOpenSettingsFolderClick() {
|
||||||
|
const button = document.getElementById('openSettingsFolderBtn');
|
||||||
|
if (!button || button.disabled) return;
|
||||||
|
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/lm/settings/open-location', { method: 'POST' });
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || data.success === false) {
|
||||||
|
throw new Error(data.error || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
if (data.mode === 'clipboard' && data.path) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(data.path);
|
||||||
|
showToast('settings.openSettingsFileLocation.copied', { path: data.path }, 'success');
|
||||||
|
} catch (clipboardError) {
|
||||||
|
console.warn('Clipboard API not available:', clipboardError);
|
||||||
|
showToast('settings.openSettingsFileLocation.clipboardFallback', { path: data.path }, 'info');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast('settings.openSettingsFileLocation.success', {}, 'success');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to open settings location:', error);
|
||||||
|
showToast('settings.openSettingsFileLocation.failed', {}, 'error');
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function initializeOtherDisabledPage() {
|
async function initializeOtherDisabledPage() {
|
||||||
// appCore.initialize() wires the shared header (theme, settings modal,
|
// appCore.initialize() wires the shared header (theme, settings modal,
|
||||||
// language) so this page is not a dead end.
|
// language) so this page is not a dead end.
|
||||||
@@ -46,8 +91,18 @@ async function initializeOtherDisabledPage() {
|
|||||||
if (settingsButton) {
|
if (settingsButton) {
|
||||||
settingsButton.addEventListener('click', handleOpenSettingsClick);
|
settingsButton.addEventListener('click', handleOpenSettingsClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modelPathsButton = document.getElementById('openModelPathsSettingsBtn');
|
||||||
|
if (modelPathsButton) {
|
||||||
|
modelPathsButton.addEventListener('click', handleOpenModelPathsSettingsClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsFolderButton = document.getElementById('openSettingsFolderBtn');
|
||||||
|
if (settingsFolderButton) {
|
||||||
|
settingsFolderButton.addEventListener('click', handleOpenSettingsFolderClick);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', initializeOtherDisabledPage);
|
document.addEventListener('DOMContentLoaded', initializeOtherDisabledPage);
|
||||||
|
|
||||||
export { handleEnableClick as enableOtherModels, initializeOtherDisabledPage };
|
export { handleEnableClick as enableOtherModels, handleOpenSettingsFolderClick, initializeOtherDisabledPage };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Create the new hierarchical state structure
|
// Create the new hierarchical state structure
|
||||||
import { getStorageItem, getMapFromStorage } from '../utils/storageHelpers.js';
|
import { getStorageItem, getMapFromStorage } from '../utils/storageHelpers.js';
|
||||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||||
import { DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/constants.js';
|
import { DEFAULT_PATH_TEMPLATES, DEFAULT_FILENAME_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/constants.js';
|
||||||
|
|
||||||
const DEFAULT_SETTINGS_BASE = Object.freeze({
|
const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||||
civitai_api_key: '',
|
civitai_api_key: '',
|
||||||
@@ -30,6 +30,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
|||||||
recipes_path: '',
|
recipes_path: '',
|
||||||
base_model_path_mappings: {},
|
base_model_path_mappings: {},
|
||||||
download_path_templates: {},
|
download_path_templates: {},
|
||||||
|
download_filename_templates: {},
|
||||||
example_images_path: '',
|
example_images_path: '',
|
||||||
example_images_open_mode: 'system',
|
example_images_open_mode: 'system',
|
||||||
example_images_local_root: '',
|
example_images_local_root: '',
|
||||||
@@ -74,9 +75,16 @@ export function createDefaultSettings() {
|
|||||||
...DEFAULT_SETTINGS_BASE,
|
...DEFAULT_SETTINGS_BASE,
|
||||||
base_model_path_mappings: {},
|
base_model_path_mappings: {},
|
||||||
download_path_templates: { ...DEFAULT_PATH_TEMPLATES },
|
download_path_templates: { ...DEFAULT_PATH_TEMPLATES },
|
||||||
|
download_filename_templates: { ...DEFAULT_FILENAME_TEMPLATES },
|
||||||
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
|
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
|
||||||
default_other_roots: {},
|
default_other_roots: {},
|
||||||
enabled_other_sub_types: ['vae', 'upscaler', 'text_encoder'],
|
enabled_other_sub_types: ['vae', 'upscaler', 'text_encoder'],
|
||||||
|
// Standalone-only fields populated by GET /api/lm/settings; in plugin
|
||||||
|
// mode the backend omits folder_paths/folder_path_schema and these
|
||||||
|
// defaults apply.
|
||||||
|
standalone_mode: false,
|
||||||
|
folder_paths: {},
|
||||||
|
folder_path_schema: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -360,6 +360,25 @@ export const DEFAULT_PATH_TEMPLATES = {
|
|||||||
other: ''
|
other: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Valid placeholders for download filename templates (opt-in rename of
|
||||||
|
// downloaded safetensors; the result is a filename stem, no path separators)
|
||||||
|
export const FILENAME_TEMPLATE_PLACEHOLDERS = [
|
||||||
|
'{model_name}',
|
||||||
|
'{version_name}',
|
||||||
|
'{base_model}',
|
||||||
|
'{author}',
|
||||||
|
'{first_tag}',
|
||||||
|
'{hash_short}',
|
||||||
|
'{original_name}'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Default filename templates per model type; empty string keeps the original filename
|
||||||
|
export const DEFAULT_FILENAME_TEMPLATES = {
|
||||||
|
lora: '',
|
||||||
|
checkpoint: '',
|
||||||
|
embedding: ''
|
||||||
|
};
|
||||||
|
|
||||||
// Model type labels for UI
|
// Model type labels for UI
|
||||||
export const MODEL_TYPE_LABELS = {
|
export const MODEL_TYPE_LABELS = {
|
||||||
lora: 'LoRA Models',
|
lora: 'LoRA Models',
|
||||||
|
|||||||
@@ -50,3 +50,19 @@ export function openOtherModelsSettings() {
|
|||||||
});
|
});
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the settings modal on the standalone-only Model Paths section, where
|
||||||
|
* primary folder_paths are edited. The section only exists in standalone mode,
|
||||||
|
* so the nav item lookup simply no-ops elsewhere.
|
||||||
|
*/
|
||||||
|
export function openModelPathsSettings() {
|
||||||
|
const modalManager = window.modalManager;
|
||||||
|
if (modalManager && typeof modalManager.showModal === 'function') {
|
||||||
|
modalManager.showModal('settingsModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
document.querySelector('.settings-nav-item[data-section="modelPaths"]')?.click();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,3 +14,4 @@
|
|||||||
{% include 'components/modals/move_modal.html' %}
|
{% include 'components/modals/move_modal.html' %}
|
||||||
{% include 'components/modals/bulk_add_tags_modal.html' %}
|
{% include 'components/modals/bulk_add_tags_modal.html' %}
|
||||||
{% include 'components/modals/bulk_base_model_modal.html' %}
|
{% include 'components/modals/bulk_base_model_modal.html' %}
|
||||||
|
{% include 'components/modals/directory_picker_modal.html' %}
|
||||||
|
|||||||
@@ -82,6 +82,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Filename Template Apply/Revert Confirmation Modal
|
||||||
|
Self-managed by SettingsManager (NOT registered with ModalManager): it
|
||||||
|
stacks above the settings modal, like the directory picker. -->
|
||||||
|
<div id="filenameTemplateConfirmModal" class="modal delete-modal">
|
||||||
|
<div class="modal-content delete-modal-content">
|
||||||
|
<h2 data-role="title"></h2>
|
||||||
|
<p class="delete-message" data-role="message"></p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="cancel-btn" data-action="cancel-filename-template">{{ t('common.actions.cancel') }}</button>
|
||||||
|
<button class="primary-btn" data-action="confirm-filename-template"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Sidebar Folder Delete Confirmation Modal -->
|
<!-- Sidebar Folder Delete Confirmation Modal -->
|
||||||
<!-- Shared by two states: 'confirm' (model-free folder) and 'blocked' (the
|
<!-- Shared by two states: 'confirm' (model-free folder) and 'blocked' (the
|
||||||
subtree still holds models, so a cascade delete is refused). -->
|
subtree still holds models, so a cascade delete is refused). -->
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<!-- Directory Picker Modal (self-managed by DirectoryPickerModal.js, stacked above the settings modal) -->
|
||||||
|
<div id="directoryPickerModal" class="modal directory-picker-modal" style="display: none;">
|
||||||
|
<div class="modal-content directory-picker-content">
|
||||||
|
<button class="close" id="directoryPickerCloseBtn">×</button>
|
||||||
|
<h3>{{ t('settings.directoryPicker.title') }}</h3>
|
||||||
|
|
||||||
|
<div class="directory-picker-path-row">
|
||||||
|
<input type="text" id="directoryPickerPathInput" placeholder="{{ t('settings.directoryPicker.pathPlaceholder') }}" autocomplete="off">
|
||||||
|
<button class="secondary-btn" id="directoryPickerGoBtn">
|
||||||
|
<i class="fas fa-arrow-right"></i> {{ t('settings.directoryPicker.go') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="directory-browser" id="directoryPickerBrowser">
|
||||||
|
<div class="browser-header">
|
||||||
|
<button class="back-btn" id="directoryPickerUpBtn" title="{{ t('settings.directoryPicker.goUp') }}" disabled>
|
||||||
|
<i class="fas fa-arrow-up"></i>
|
||||||
|
</button>
|
||||||
|
<div class="current-path" id="directoryPickerCurrentPath"></div>
|
||||||
|
</div>
|
||||||
|
<div class="browser-content">
|
||||||
|
<div class="folder-list" id="directoryPickerFolderList"></div>
|
||||||
|
<div class="directory-picker-error" id="directoryPickerError" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="browser-footer">
|
||||||
|
<button class="primary-btn" id="directoryPickerSelectBtn">
|
||||||
|
<i class="fas fa-check"></i> {{ t('settings.directoryPicker.selectFolder') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,15 +1,4 @@
|
|||||||
{% import 'components/modals/settings/_macros.html' as sm with context %}
|
{% import 'components/modals/settings/_macros.html' as sm with context %}
|
||||||
{% set template_preset_options = [
|
|
||||||
('', 'settings.downloadPathTemplates.templateOptions.flatStructure'),
|
|
||||||
('{base_model}', 'settings.downloadPathTemplates.templateOptions.byBaseModel'),
|
|
||||||
('{author}', 'settings.downloadPathTemplates.templateOptions.byAuthor'),
|
|
||||||
('{first_tag}', 'settings.downloadPathTemplates.templateOptions.byFirstTag'),
|
|
||||||
('{base_model}/{first_tag}', 'settings.downloadPathTemplates.templateOptions.baseModelFirstTag'),
|
|
||||||
('{base_model}/{author}', 'settings.downloadPathTemplates.templateOptions.baseModelAuthor'),
|
|
||||||
('{author}/{first_tag}', 'settings.downloadPathTemplates.templateOptions.authorFirstTag'),
|
|
||||||
('{base_model}/{author}/{first_tag}', 'settings.downloadPathTemplates.templateOptions.baseModelAuthorFirstTag'),
|
|
||||||
('custom', 'settings.downloadPathTemplates.templateOptions.customTemplate'),
|
|
||||||
] %}
|
|
||||||
<!-- Section 3: Library -->
|
<!-- Section 3: Library -->
|
||||||
<div id="section-library" class="settings-section" data-section="library">
|
<div id="section-library" class="settings-section" data-section="library">
|
||||||
<!-- Folder Settings -->
|
<!-- Folder Settings -->
|
||||||
@@ -187,192 +176,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Download Path Templates -->
|
|
||||||
<div class="settings-subsection">
|
|
||||||
<div class="settings-subsection-header">
|
|
||||||
<h4>
|
|
||||||
{{ t('settings.downloadPathTemplates.title') }}
|
|
||||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadPathTemplates.help') }}"></i>
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="input-help">
|
|
||||||
<div class="placeholder-info">
|
|
||||||
<strong>{{ t('settings.downloadPathTemplates.availablePlaceholders') }}</strong>
|
|
||||||
<span class="placeholder-tag">{base_model}</span>
|
|
||||||
<span class="placeholder-tag">{author}</span>
|
|
||||||
<span class="placeholder-tag">{first_tag}</span>
|
|
||||||
<span class="placeholder-tag">{model_name}</span>
|
|
||||||
<span class="placeholder-tag">{version_name}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-info">
|
|
||||||
<label for="loraTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.lora') }}</label>
|
|
||||||
</div>
|
|
||||||
<div class="setting-control select-control">
|
|
||||||
<select id="loraTemplatePreset" onchange="settingsManager.updateTemplatePreset('lora', this.value)">
|
|
||||||
{% for value, option_label in template_preset_options %}
|
|
||||||
<option value="{{ value }}">{{ t(option_label) }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="template-custom-row" id="loraCustomRow" style="display: none;">
|
|
||||||
<input type="text" id="loraCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
|
|
||||||
<div class="template-validation" id="loraValidation"></div>
|
|
||||||
</div>
|
|
||||||
<div class="template-preview" id="loraPreview"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-info">
|
|
||||||
<label for="checkpointTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.checkpoint') }}</label>
|
|
||||||
</div>
|
|
||||||
<div class="setting-control select-control">
|
|
||||||
<select id="checkpointTemplatePreset" onchange="settingsManager.updateTemplatePreset('checkpoint', this.value)">
|
|
||||||
{% for value, option_label in template_preset_options %}
|
|
||||||
<option value="{{ value }}">{{ t(option_label) }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="template-custom-row" id="checkpointCustomRow" style="display: none;">
|
|
||||||
<input type="text" id="checkpointCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
|
|
||||||
<div class="template-validation" id="checkpointValidation"></div>
|
|
||||||
</div>
|
|
||||||
<div class="template-preview" id="checkpointPreview"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-info">
|
|
||||||
<label for="embeddingTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.embedding') }}</label>
|
|
||||||
</div>
|
|
||||||
<div class="setting-control select-control">
|
|
||||||
<select id="embeddingTemplatePreset" onchange="settingsManager.updateTemplatePreset('embedding', this.value)">
|
|
||||||
{% for value, option_label in template_preset_options %}
|
|
||||||
<option value="{{ value }}">{{ t(option_label) }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="template-custom-row" id="embeddingCustomRow" style="display: none;">
|
|
||||||
<input type="text" id="embeddingCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
|
|
||||||
<div class="template-validation" id="embeddingValidation"></div>
|
|
||||||
</div>
|
|
||||||
<div class="template-preview" id="embeddingPreview"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-info">
|
|
||||||
<label>
|
|
||||||
{{ t('settings.downloadPathTemplates.baseModelPathMappings') }}
|
|
||||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadPathTemplates.baseModelPathMappingsHelp') }}"></i>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="setting-control">
|
|
||||||
<button type="button" class="add-mapping-btn" onclick="settingsManager.addMappingRow()">
|
|
||||||
<i class="fas fa-plus"></i>
|
|
||||||
<span>{{ t('settings.downloadPathTemplates.addMapping') }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mappings-container">
|
|
||||||
<div id="baseModelMappingsContainer">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{ sm.setting_toggle('skipPreviouslyDownloadedModelVersions', 'skip_previously_downloaded_model_versions', 'settings.skipPreviouslyDownloadedModelVersions.label', 'settings.skipPreviouslyDownloadedModelVersions.help') }}
|
|
||||||
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-info">
|
|
||||||
<label for="downloadSkipBaseModelsToggle">
|
|
||||||
{{ t('settings.downloadSkipBaseModels.label') }}
|
|
||||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadSkipBaseModels.help') }}"></i>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="setting-control">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="downloadSkipBaseModelsToggle"
|
|
||||||
class="secondary-btn base-model-skip-toggle"
|
|
||||||
aria-expanded="false"
|
|
||||||
>
|
|
||||||
<span id="downloadSkipBaseModelsSummary">{{ t('settings.downloadSkipBaseModels.summary.none') }}</span>
|
|
||||||
<span class="base-model-skip-toggle-label">{{ t('settings.downloadSkipBaseModels.actions.edit') }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="downloadSkipBaseModelsPanel" class="base-model-skip-panel" hidden>
|
|
||||||
<div class="base-model-skip-toolbar">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="downloadSkipBaseModelsSearch"
|
|
||||||
class="base-model-skip-search"
|
|
||||||
placeholder="{{ t('settings.downloadSkipBaseModels.searchPlaceholder') }}"
|
|
||||||
/>
|
|
||||||
<button type="button" class="text-btn base-model-skip-clear" id="downloadSkipBaseModelsClear">
|
|
||||||
{{ t('settings.downloadSkipBaseModels.actions.clear') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="downloadSkipBaseModelsContainer" class="base-model-skip-list"></div>
|
|
||||||
<div id="downloadSkipBaseModelsEmpty" class="base-model-skip-empty" hidden>
|
|
||||||
{{ t('settings.downloadSkipBaseModels.empty') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="settings-input-error-message" id="downloadSkipBaseModelsError"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Priority Tags -->
|
|
||||||
<div class="setting-item priority-tags-item">
|
|
||||||
<div class="setting-row priority-tags-header-row">
|
|
||||||
<div class="setting-info priority-tags-header">
|
|
||||||
<label>
|
|
||||||
{{ t('settings.priorityTags.title') }}
|
|
||||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.priorityTags.description') }}"></i>
|
|
||||||
</label>
|
|
||||||
<a class="settings-action-link priority-tags-help-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Priority-Tags-Configuration-Guide" target="_blank" rel="noopener" aria-label="{{ t('settings.priorityTags.helpLinkLabel') }}" title="{{ t('settings.priorityTags.helpLinkLabel') }}">
|
|
||||||
<i class="fas fa-question-circle" aria-hidden="true"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="priority-tags-tabs">
|
|
||||||
<input type="radio" id="priority-tags-tab-lora" name="priority-tags-tab" class="priority-tags-tab-input" checked>
|
|
||||||
<input type="radio" id="priority-tags-tab-checkpoint" name="priority-tags-tab" class="priority-tags-tab-input">
|
|
||||||
<input type="radio" id="priority-tags-tab-embedding" name="priority-tags-tab" class="priority-tags-tab-input">
|
|
||||||
|
|
||||||
<div class="priority-tags-tablist">
|
|
||||||
<label class="priority-tags-tab-label" for="priority-tags-tab-lora" id="priority-tags-tab-lora-label">{{ t('settings.priorityTags.modelTypes.lora') }}</label>
|
|
||||||
<label class="priority-tags-tab-label" for="priority-tags-tab-checkpoint" id="priority-tags-tab-checkpoint-label">{{ t('settings.priorityTags.modelTypes.checkpoint') }}</label>
|
|
||||||
<label class="priority-tags-tab-label" for="priority-tags-tab-embedding" id="priority-tags-tab-embedding-label">{{ t('settings.priorityTags.modelTypes.embedding') }}</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="priority-tags-panels">
|
|
||||||
<div class="priority-tags-panel" id="priority-tags-panel-lora">
|
|
||||||
<textarea id="loraPriorityTagsInput" class="priority-tags-input" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
|
|
||||||
<div class="settings-input-error-message" id="loraPriorityTagsError"></div>
|
|
||||||
</div>
|
|
||||||
<div class="priority-tags-panel" id="priority-tags-panel-checkpoint">
|
|
||||||
<textarea id="checkpointPriorityTagsInput" class="priority-tags-input" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
|
|
||||||
<div class="settings-input-error-message" id="checkpointPriorityTagsError"></div>
|
|
||||||
</div>
|
|
||||||
<div class="priority-tags-panel" id="priority-tags-panel-embedding">
|
|
||||||
<textarea id="embeddingPriorityTagsInput" class="priority-tags-input" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
|
|
||||||
<div class="settings-input-error-message" id="embeddingPriorityTagsError"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Version Scope -->
|
<!-- Version Scope -->
|
||||||
<div class="settings-subsection">
|
<div class="settings-subsection">
|
||||||
{{ sm.subsection_header('settings.sections.versionScope') }}
|
{{ sm.subsection_header('settings.sections.versionScope') }}
|
||||||
@@ -463,25 +266,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Auto-organize -->
|
|
||||||
<div class="settings-subsection">
|
|
||||||
{{ sm.subsection_header('settings.sections.autoOrganize') }}
|
|
||||||
|
|
||||||
<!-- Auto-organize Exclusions -->
|
|
||||||
<div class="setting-item auto-organize-exclusions-item">
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-info">
|
|
||||||
<label for="autoOrganizeExclusions">
|
|
||||||
{{ t('settings.autoOrganizeExclusions.label') }}
|
|
||||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.autoOrganizeExclusions.help') }}"></i>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<textarea id="autoOrganizeExclusions" class="priority-tags-input auto-organize-exclusions-input" placeholder="{{ t('settings.autoOrganizeExclusions.placeholder') }}"></textarea>
|
|
||||||
<div class="settings-input-error-message" id="autoOrganizeExclusionsError"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Metadata -->
|
<!-- Metadata -->
|
||||||
<div class="settings-subsection">
|
<div class="settings-subsection">
|
||||||
{{ sm.subsection_header('settings.sections.metadata') }}
|
{{ sm.subsection_header('settings.sections.metadata') }}
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
{% import 'components/modals/settings/_macros.html' as sm with context %}
|
||||||
|
{% set template_preset_options = [
|
||||||
|
('', 'settings.downloadPathTemplates.templateOptions.flatStructure'),
|
||||||
|
('{base_model}', 'settings.downloadPathTemplates.templateOptions.byBaseModel'),
|
||||||
|
('{author}', 'settings.downloadPathTemplates.templateOptions.byAuthor'),
|
||||||
|
('{first_tag}', 'settings.downloadPathTemplates.templateOptions.byFirstTag'),
|
||||||
|
('{base_model}/{first_tag}', 'settings.downloadPathTemplates.templateOptions.baseModelFirstTag'),
|
||||||
|
('{base_model}/{author}', 'settings.downloadPathTemplates.templateOptions.baseModelAuthor'),
|
||||||
|
('{author}/{first_tag}', 'settings.downloadPathTemplates.templateOptions.authorFirstTag'),
|
||||||
|
('{base_model}/{author}/{first_tag}', 'settings.downloadPathTemplates.templateOptions.baseModelAuthorFirstTag'),
|
||||||
|
('custom', 'settings.downloadPathTemplates.templateOptions.customTemplate'),
|
||||||
|
] %}
|
||||||
|
<!-- Section 4: Organization -->
|
||||||
|
<div id="section-organization" class="settings-section" data-section="organization">
|
||||||
|
<!-- Download Path Templates -->
|
||||||
|
<div class="settings-subsection">
|
||||||
|
<div class="settings-subsection-header">
|
||||||
|
<h4>
|
||||||
|
{{ t('settings.downloadPathTemplates.title') }}
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadPathTemplates.help') }}"></i>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="input-help">
|
||||||
|
<div class="placeholder-info">
|
||||||
|
<strong>{{ t('settings.downloadPathTemplates.availablePlaceholders') }}</strong>
|
||||||
|
<span class="placeholder-tag">{base_model}</span>
|
||||||
|
<span class="placeholder-tag">{author}</span>
|
||||||
|
<span class="placeholder-tag">{first_tag}</span>
|
||||||
|
<span class="placeholder-tag">{model_name}</span>
|
||||||
|
<span class="placeholder-tag">{version_name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="loraTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.lora') }}</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control select-control">
|
||||||
|
<select id="loraTemplatePreset" onchange="settingsManager.updateTemplatePreset('lora', this.value)">
|
||||||
|
{% for value, option_label in template_preset_options %}
|
||||||
|
<option value="{{ value }}">{{ t(option_label) }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="template-custom-row" id="loraCustomRow" style="display: none;">
|
||||||
|
<input type="text" id="loraCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
|
||||||
|
<div class="template-validation" id="loraValidation"></div>
|
||||||
|
</div>
|
||||||
|
<div class="template-preview" id="loraPreview"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="checkpointTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.checkpoint') }}</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control select-control">
|
||||||
|
<select id="checkpointTemplatePreset" onchange="settingsManager.updateTemplatePreset('checkpoint', this.value)">
|
||||||
|
{% for value, option_label in template_preset_options %}
|
||||||
|
<option value="{{ value }}">{{ t(option_label) }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="template-custom-row" id="checkpointCustomRow" style="display: none;">
|
||||||
|
<input type="text" id="checkpointCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
|
||||||
|
<div class="template-validation" id="checkpointValidation"></div>
|
||||||
|
</div>
|
||||||
|
<div class="template-preview" id="checkpointPreview"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="embeddingTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.embedding') }}</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control select-control">
|
||||||
|
<select id="embeddingTemplatePreset" onchange="settingsManager.updateTemplatePreset('embedding', this.value)">
|
||||||
|
{% for value, option_label in template_preset_options %}
|
||||||
|
<option value="{{ value }}">{{ t(option_label) }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="template-custom-row" id="embeddingCustomRow" style="display: none;">
|
||||||
|
<input type="text" id="embeddingCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
|
||||||
|
<div class="template-validation" id="embeddingValidation"></div>
|
||||||
|
</div>
|
||||||
|
<div class="template-preview" id="embeddingPreview"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label>
|
||||||
|
{{ t('settings.downloadPathTemplates.baseModelPathMappings') }}
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadPathTemplates.baseModelPathMappingsHelp') }}"></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<button type="button" class="add-mapping-btn" onclick="settingsManager.addMappingRow()">
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
<span>{{ t('settings.downloadPathTemplates.addMapping') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mappings-container">
|
||||||
|
<div id="baseModelMappingsContainer">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ sm.setting_toggle('skipPreviouslyDownloadedModelVersions', 'skip_previously_downloaded_model_versions', 'settings.skipPreviouslyDownloadedModelVersions.label', 'settings.skipPreviouslyDownloadedModelVersions.help') }}
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="downloadSkipBaseModelsToggle">
|
||||||
|
{{ t('settings.downloadSkipBaseModels.label') }}
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadSkipBaseModels.help') }}"></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="downloadSkipBaseModelsToggle"
|
||||||
|
class="secondary-btn base-model-skip-toggle"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
<span id="downloadSkipBaseModelsSummary">{{ t('settings.downloadSkipBaseModels.summary.none') }}</span>
|
||||||
|
<span class="base-model-skip-toggle-label">{{ t('settings.downloadSkipBaseModels.actions.edit') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="downloadSkipBaseModelsPanel" class="base-model-skip-panel" hidden>
|
||||||
|
<div class="base-model-skip-toolbar">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="downloadSkipBaseModelsSearch"
|
||||||
|
class="base-model-skip-search"
|
||||||
|
placeholder="{{ t('settings.downloadSkipBaseModels.searchPlaceholder') }}"
|
||||||
|
/>
|
||||||
|
<button type="button" class="text-btn base-model-skip-clear" id="downloadSkipBaseModelsClear">
|
||||||
|
{{ t('settings.downloadSkipBaseModels.actions.clear') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="downloadSkipBaseModelsContainer" class="base-model-skip-list"></div>
|
||||||
|
<div id="downloadSkipBaseModelsEmpty" class="base-model-skip-empty" hidden>
|
||||||
|
{{ t('settings.downloadSkipBaseModels.empty') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-input-error-message" id="downloadSkipBaseModelsError"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Priority Tags -->
|
||||||
|
<div class="setting-item priority-tags-item">
|
||||||
|
<div class="setting-row priority-tags-header-row">
|
||||||
|
<div class="setting-info priority-tags-header">
|
||||||
|
<label>
|
||||||
|
{{ t('settings.priorityTags.title') }}
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.priorityTags.description') }}"></i>
|
||||||
|
</label>
|
||||||
|
<a class="settings-action-link priority-tags-help-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Priority-Tags-Configuration-Guide" target="_blank" rel="noopener" aria-label="{{ t('settings.priorityTags.helpLinkLabel') }}" title="{{ t('settings.priorityTags.helpLinkLabel') }}">
|
||||||
|
<i class="fas fa-question-circle" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="priority-tags-tabs">
|
||||||
|
<input type="radio" id="priority-tags-tab-lora" name="priority-tags-tab" class="priority-tags-tab-input" checked>
|
||||||
|
<input type="radio" id="priority-tags-tab-checkpoint" name="priority-tags-tab" class="priority-tags-tab-input">
|
||||||
|
<input type="radio" id="priority-tags-tab-embedding" name="priority-tags-tab" class="priority-tags-tab-input">
|
||||||
|
|
||||||
|
<div class="priority-tags-tablist">
|
||||||
|
<label class="priority-tags-tab-label" for="priority-tags-tab-lora" id="priority-tags-tab-lora-label">{{ t('settings.priorityTags.modelTypes.lora') }}</label>
|
||||||
|
<label class="priority-tags-tab-label" for="priority-tags-tab-checkpoint" id="priority-tags-tab-checkpoint-label">{{ t('settings.priorityTags.modelTypes.checkpoint') }}</label>
|
||||||
|
<label class="priority-tags-tab-label" for="priority-tags-tab-embedding" id="priority-tags-tab-embedding-label">{{ t('settings.priorityTags.modelTypes.embedding') }}</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="priority-tags-panels">
|
||||||
|
<div class="priority-tags-panel" id="priority-tags-panel-lora">
|
||||||
|
<textarea id="loraPriorityTagsInput" class="priority-tags-input" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
|
||||||
|
<div class="settings-input-error-message" id="loraPriorityTagsError"></div>
|
||||||
|
</div>
|
||||||
|
<div class="priority-tags-panel" id="priority-tags-panel-checkpoint">
|
||||||
|
<textarea id="checkpointPriorityTagsInput" class="priority-tags-input" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
|
||||||
|
<div class="settings-input-error-message" id="checkpointPriorityTagsError"></div>
|
||||||
|
</div>
|
||||||
|
<div class="priority-tags-panel" id="priority-tags-panel-embedding">
|
||||||
|
<textarea id="embeddingPriorityTagsInput" class="priority-tags-input" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
|
||||||
|
<div class="settings-input-error-message" id="embeddingPriorityTagsError"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filename Templates -->
|
||||||
|
<div class="settings-subsection">
|
||||||
|
<div class="settings-subsection-header">
|
||||||
|
<h4>
|
||||||
|
{{ t('settings.filenameTemplates.title') }}
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.filenameTemplates.help') }}"></i>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="input-help">
|
||||||
|
<div class="placeholder-info">
|
||||||
|
<strong>{{ t('settings.filenameTemplates.availablePlaceholders') }}</strong>
|
||||||
|
<span class="placeholder-tag">{model_name}</span>
|
||||||
|
<span class="placeholder-tag">{version_name}</span>
|
||||||
|
<span class="placeholder-tag">{base_model}</span>
|
||||||
|
<span class="placeholder-tag">{author}</span>
|
||||||
|
<span class="placeholder-tag">{first_tag}</span>
|
||||||
|
<span class="placeholder-tag">{hash_short}</span>
|
||||||
|
<span class="placeholder-tag">{original_name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="loraFilenameTemplate">{{ t('settings.downloadPathTemplates.modelTypes.lora') }}</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<button type="button" id="loraApplyFilenameTemplate" class="primary-btn" onclick="settingsManager.applyFilenameTemplate('lora')">
|
||||||
|
{{ t('settings.filenameTemplates.applyButton') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="loraFilenameTemplate" class="template-custom-input" placeholder="{{ t('settings.filenameTemplates.templatePlaceholder') }}" />
|
||||||
|
<div class="template-validation" id="loraFilenameValidation"></div>
|
||||||
|
<div class="template-preview" id="loraFilenamePreview"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="checkpointFilenameTemplate">{{ t('settings.downloadPathTemplates.modelTypes.checkpoint') }}</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<button type="button" id="checkpointApplyFilenameTemplate" class="primary-btn" onclick="settingsManager.applyFilenameTemplate('checkpoint')">
|
||||||
|
{{ t('settings.filenameTemplates.applyButton') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="checkpointFilenameTemplate" class="template-custom-input" placeholder="{{ t('settings.filenameTemplates.templatePlaceholder') }}" />
|
||||||
|
<div class="template-validation" id="checkpointFilenameValidation"></div>
|
||||||
|
<div class="template-preview" id="checkpointFilenamePreview"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="embeddingFilenameTemplate">{{ t('settings.downloadPathTemplates.modelTypes.embedding') }}</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<button type="button" id="embeddingApplyFilenameTemplate" class="primary-btn" onclick="settingsManager.applyFilenameTemplate('embedding')">
|
||||||
|
{{ t('settings.filenameTemplates.applyButton') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="embeddingFilenameTemplate" class="template-custom-input" placeholder="{{ t('settings.filenameTemplates.templatePlaceholder') }}" />
|
||||||
|
<div class="template-validation" id="embeddingFilenameValidation"></div>
|
||||||
|
<div class="template-preview" id="embeddingFilenamePreview"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="input-help">{{ t('settings.filenameTemplates.applyHelp') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto-organize -->
|
||||||
|
<div class="settings-subsection">
|
||||||
|
{{ sm.subsection_header('settings.sections.autoOrganize') }}
|
||||||
|
|
||||||
|
<!-- Auto-organize Exclusions -->
|
||||||
|
<div class="setting-item auto-organize-exclusions-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="autoOrganizeExclusions">
|
||||||
|
{{ t('settings.autoOrganizeExclusions.label') }}
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.autoOrganizeExclusions.help') }}"></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea id="autoOrganizeExclusions" class="priority-tags-input auto-organize-exclusions-input" placeholder="{{ t('settings.autoOrganizeExclusions.placeholder') }}"></textarea>
|
||||||
|
<div class="settings-input-error-message" id="autoOrganizeExclusionsError"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
<button type="button" class="settings-nav-item active" data-section="general">{{ t('settings.nav.general') }}</button>
|
<button type="button" class="settings-nav-item active" data-section="general">{{ t('settings.nav.general') }}</button>
|
||||||
<button type="button" class="settings-nav-item" data-section="interface">{{ t('settings.nav.interface') }}</button>
|
<button type="button" class="settings-nav-item" data-section="interface">{{ t('settings.nav.interface') }}</button>
|
||||||
<button type="button" class="settings-nav-item" data-section="library">{{ t('settings.nav.library') }}</button>
|
<button type="button" class="settings-nav-item" data-section="library">{{ t('settings.nav.library') }}</button>
|
||||||
|
<button type="button" class="settings-nav-item" data-section="organization">{{ t('settings.nav.organization') }}</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -46,6 +47,7 @@
|
|||||||
{% include 'components/modals/settings/general.html' %}
|
{% include 'components/modals/settings/general.html' %}
|
||||||
{% include 'components/modals/settings/interface.html' %}
|
{% include 'components/modals/settings/interface.html' %}
|
||||||
{% include 'components/modals/settings/library.html' %}
|
{% include 'components/modals/settings/library.html' %}
|
||||||
|
{% include 'components/modals/settings/organization.html' %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+20
-17
@@ -51,17 +51,18 @@
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
.other-no-paths-config {
|
.other-settings-file {
|
||||||
margin: 4px 0 0;
|
display: flex;
|
||||||
padding: 12px 16px;
|
align-items: center;
|
||||||
max-width: 520px;
|
gap: 8px;
|
||||||
overflow-x: auto;
|
font-size: 13px;
|
||||||
text-align: left;
|
}
|
||||||
font-size: 12px;
|
.other-settings-file code {
|
||||||
line-height: 1.5;
|
padding: 4px 8px;
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
background: rgba(127, 127, 127, 0.15);
|
background: rgba(127, 127, 127, 0.15);
|
||||||
border: 1px solid rgba(127, 127, 127, 0.25);
|
border: 1px solid rgba(127, 127, 127, 0.25);
|
||||||
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -127,21 +128,23 @@
|
|||||||
<h2>{{ t('other.noPaths.title') }}</h2>
|
<h2>{{ t('other.noPaths.title') }}</h2>
|
||||||
{% if standalone_mode %}
|
{% if standalone_mode %}
|
||||||
<p>{{ t('other.noPaths.descriptionStandalone') }}</p>
|
<p>{{ t('other.noPaths.descriptionStandalone') }}</p>
|
||||||
<pre class="other-no-paths-config"><code>"folder_paths": {
|
|
||||||
"vae": ["/path/to/vae"],
|
|
||||||
"upscale_models": ["/path/to/upscale_models"],
|
|
||||||
"text_encoders": ["/path/to/text_encoders"],
|
|
||||||
"clip_vision": ["/path/to/clip_vision"],
|
|
||||||
"controlnet": ["/path/to/controlnet"]
|
|
||||||
}</code></pre>
|
|
||||||
<p class="other-disabled-hint">{{ t('other.noPaths.hintStandalone') }}</p>
|
<p class="other-disabled-hint">{{ t('other.noPaths.hintStandalone') }}</p>
|
||||||
|
<button id="openModelPathsSettingsBtn" type="button">
|
||||||
|
<i class="fas fa-cog"></i> {{ t('other.noPaths.openModelPaths') }}
|
||||||
|
</button>
|
||||||
|
{% if settings_file %}
|
||||||
|
<p class="other-settings-file"><i class="fas fa-file-alt"></i> <code>{{ settings_file }}</code></p>
|
||||||
|
{% endif %}
|
||||||
|
<button id="openSettingsFolderBtn" type="button">
|
||||||
|
<i class="fas fa-folder-open"></i> {{ t('other.noPaths.openSettingsFolder') }}
|
||||||
|
</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>{{ t('other.noPaths.descriptionComfyUI') }}</p>
|
<p>{{ t('other.noPaths.descriptionComfyUI') }}</p>
|
||||||
<p class="other-disabled-hint">{{ t('other.noPaths.hintComfyUI') }}</p>
|
<p class="other-disabled-hint">{{ t('other.noPaths.hintComfyUI') }}</p>
|
||||||
{% endif %}
|
|
||||||
<button id="openOtherModelsSettingsBtn" type="button">
|
<button id="openOtherModelsSettingsBtn" type="button">
|
||||||
<i class="fas fa-cog"></i> {{ t('other.noPaths.openSettings') }}
|
<i class="fas fa-cog"></i> {{ t('other.noPaths.openSettings') }}
|
||||||
</button>
|
</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="sticky-topbar">
|
<div class="sticky-topbar">
|
||||||
|
|||||||
@@ -333,6 +333,21 @@ def mock_websocket_manager():
|
|||||||
return RecordingWebSocketManager()
|
return RecordingWebSocketManager()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_media_dimension_caches():
|
||||||
|
"""Clear path-keyed dimension caches so files reused across tests re-probe."""
|
||||||
|
from py.utils.exif_utils import _get_image_dimensions_cached
|
||||||
|
from py.utils.video_metadata import _clear_video_dimensions_cache
|
||||||
|
|
||||||
|
_get_image_dimensions_cached.cache_clear()
|
||||||
|
_clear_video_dimensions_cache()
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
_get_image_dimensions_cached.cache_clear()
|
||||||
|
_clear_video_dimensions_cache()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def reset_singletons():
|
def reset_singletons():
|
||||||
"""Reset all singletons before each test to ensure isolation."""
|
"""Reset all singletons before each test to ensure isolation."""
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||||
|
translate: (key, params = {}, fallback = null) => fallback ?? key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { directoryPickerModal } from '../../../static/js/components/DirectoryPickerModal.js';
|
||||||
|
|
||||||
|
function buildModalDom() {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="directoryPickerModal" class="modal directory-picker-modal" style="display: none;">
|
||||||
|
<div class="modal-content directory-picker-content">
|
||||||
|
<button class="close" id="directoryPickerCloseBtn">×</button>
|
||||||
|
<h3>Select folder</h3>
|
||||||
|
<div class="directory-picker-path-row">
|
||||||
|
<input type="text" id="directoryPickerPathInput">
|
||||||
|
<button id="directoryPickerGoBtn">Go</button>
|
||||||
|
</div>
|
||||||
|
<div class="directory-browser" id="directoryPickerBrowser">
|
||||||
|
<div class="browser-header">
|
||||||
|
<button class="back-btn" id="directoryPickerUpBtn"></button>
|
||||||
|
<div class="current-path" id="directoryPickerCurrentPath"></div>
|
||||||
|
</div>
|
||||||
|
<div class="browser-content">
|
||||||
|
<div class="folder-list" id="directoryPickerFolderList"></div>
|
||||||
|
<div class="directory-picker-error" id="directoryPickerError" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="browser-footer">
|
||||||
|
<button class="primary-btn" id="directoryPickerSelectBtn">Select</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function okResponse(payload) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ success: true, ...payload }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DirectoryPickerModal', () => {
|
||||||
|
let fetchMock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
buildModalDom();
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
|
||||||
|
fetchMock = vi.fn(async () => okResponse({
|
||||||
|
current_path: '/home/user',
|
||||||
|
parent_path: '/home',
|
||||||
|
directories: [
|
||||||
|
{ name: 'photos', path: '/home/user/photos', is_parent: false },
|
||||||
|
{ name: 'models', path: '/home/user/models', is_parent: false },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
directoryPickerModal.close();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function lastRequestBody() {
|
||||||
|
return JSON.parse(fetchMock.mock.calls.at(-1)[1].body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function modalEl() {
|
||||||
|
return document.getElementById('directoryPickerModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('open() loads the initial path via POST /api/lm/browse-directory', async () => {
|
||||||
|
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||||
|
const [url, options] = fetchMock.mock.calls[0];
|
||||||
|
expect(url).toBe('/api/lm/browse-directory');
|
||||||
|
expect(options.method).toBe('POST');
|
||||||
|
expect(lastRequestBody().path).toBe('/home/user');
|
||||||
|
expect(modalEl().style.display).toBe('block');
|
||||||
|
expect(document.body.classList.contains('modal-open')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the folder list and current path', async () => {
|
||||||
|
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelectorAll('#directoryPickerFolderList .folder-item')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
expect(document.getElementById('directoryPickerCurrentPath').textContent).toBe('/home/user');
|
||||||
|
const names = [...document.querySelectorAll('#directoryPickerFolderList .item-name')].map((el) => el.textContent);
|
||||||
|
expect(names).toEqual(['photos', 'models']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drills down on folder click using the server-provided entry path', async () => {
|
||||||
|
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelectorAll('#directoryPickerFolderList .folder-item')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
fetchMock.mockClear();
|
||||||
|
|
||||||
|
document.querySelectorAll('#directoryPickerFolderList .folder-item')[0].click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||||
|
expect(lastRequestBody().path).toBe('/home/user/photos');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drills down from a Windows path using the server-provided entry path', async () => {
|
||||||
|
fetchMock.mockImplementation(async () => okResponse({
|
||||||
|
current_path: 'C:\\Users\\miao',
|
||||||
|
parent_path: 'C:\\Users',
|
||||||
|
directories: [
|
||||||
|
{ name: 'models', path: 'C:\\Users\\miao\\models', is_parent: false },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
directoryPickerModal.open({ initialPath: 'C:\\Users\\miao', onSelect: vi.fn() });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelectorAll('#directoryPickerFolderList .folder-item')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
fetchMock.mockClear();
|
||||||
|
|
||||||
|
document.querySelector('#directoryPickerFolderList .folder-item').click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||||
|
expect(lastRequestBody().path).toBe('C:\\Users\\miao\\models');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates up via the server-provided parent_path', async () => {
|
||||||
|
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.getElementById('directoryPickerCurrentPath').textContent).toBe('/home/user');
|
||||||
|
});
|
||||||
|
fetchMock.mockClear();
|
||||||
|
|
||||||
|
document.getElementById('directoryPickerUpBtn').click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||||
|
expect(lastRequestBody().path).toBe('/home');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the Up button when parent_path is null', async () => {
|
||||||
|
fetchMock.mockImplementation(async () => okResponse({
|
||||||
|
current_path: '/',
|
||||||
|
parent_path: null,
|
||||||
|
directories: [],
|
||||||
|
}));
|
||||||
|
directoryPickerModal.open({ initialPath: '/', onSelect: vi.fn() });
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.getElementById('directoryPickerCurrentPath').textContent).toBe('/');
|
||||||
|
});
|
||||||
|
const upBtn = document.getElementById('directoryPickerUpBtn');
|
||||||
|
expect(upBtn.disabled).toBe(true);
|
||||||
|
fetchMock.mockClear();
|
||||||
|
|
||||||
|
upBtn.click();
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an empty-folder message for a directory without subfolders', async () => {
|
||||||
|
fetchMock.mockImplementation(async () => okResponse({
|
||||||
|
current_path: '/home/user/empty',
|
||||||
|
parent_path: '/home/user',
|
||||||
|
directories: [],
|
||||||
|
}));
|
||||||
|
directoryPickerModal.open({ initialPath: '/home/user/empty', onSelect: vi.fn() });
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelector('#directoryPickerFolderList .directory-picker-empty')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSelect with current_path and closes on Select', async () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
directoryPickerModal.open({ initialPath: '/home/user', onSelect });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.getElementById('directoryPickerCurrentPath').textContent).toBe('/home/user');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('directoryPickerSelectBtn').click();
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledWith('/home/user');
|
||||||
|
expect(modalEl().style.display).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the backend error message and keeps the previous listing', async () => {
|
||||||
|
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelectorAll('#directoryPickerFolderList .folder-item')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchMock.mockImplementation(async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
json: async () => ({ success: false, error: 'Directory not found' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await directoryPickerModal.loadDirectory('/gone');
|
||||||
|
|
||||||
|
const errorEl = document.getElementById('directoryPickerError');
|
||||||
|
expect(errorEl.textContent).toBe('Directory not found');
|
||||||
|
expect(errorEl.style.display).toBe('block');
|
||||||
|
expect(document.querySelectorAll('#directoryPickerFolderList .folder-item')).toHaveLength(2);
|
||||||
|
expect(document.getElementById('directoryPickerCurrentPath').textContent).toBe('/home/user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes on ESC and stops propagation to modals underneath', async () => {
|
||||||
|
const underlyingEscSpy = vi.fn();
|
||||||
|
document.addEventListener('keydown', underlyingEscSpy);
|
||||||
|
|
||||||
|
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
|
||||||
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
|
||||||
|
document.getElementById('directoryPickerPathInput').dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(modalEl().style.display).toBe('none');
|
||||||
|
expect(underlyingEscSpy).not.toHaveBeenCalled();
|
||||||
|
// The settings modal's body lock must survive the picker closing.
|
||||||
|
expect(document.body.classList.contains('modal-open')).toBe(true);
|
||||||
|
|
||||||
|
document.removeEventListener('keydown', underlyingEscSpy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the typed path on Go click and on Enter', async () => {
|
||||||
|
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
|
||||||
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||||
|
fetchMock.mockClear();
|
||||||
|
|
||||||
|
const input = document.getElementById('directoryPickerPathInput');
|
||||||
|
input.value = '/var/models';
|
||||||
|
document.getElementById('directoryPickerGoBtn').click();
|
||||||
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||||
|
expect(lastRequestBody().path).toBe('/var/models');
|
||||||
|
|
||||||
|
fetchMock.mockClear();
|
||||||
|
input.value = '/tmp/other';
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
|
||||||
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||||
|
expect(lastRequestBody().path).toBe('/tmp/other');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes on backdrop click but not on content click', async () => {
|
||||||
|
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
|
||||||
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||||
|
|
||||||
|
modalEl().querySelector('.directory-picker-content').click();
|
||||||
|
expect(modalEl().style.display).toBe('block');
|
||||||
|
|
||||||
|
modalEl().click();
|
||||||
|
expect(modalEl().style.display).toBe('none');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
const {
|
||||||
|
APP_MODULE,
|
||||||
|
API_MODULE,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname,
|
||||||
|
API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(APP_MODULE, () => ({
|
||||||
|
app: { graph: {} },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { fetchApiMock } = vi.hoisted(() => ({ fetchApiMock: vi.fn() }));
|
||||||
|
vi.mock(API_MODULE, () => ({
|
||||||
|
api: { fetchApi: fetchApiMock },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
parseStrengthRange,
|
||||||
|
describeStrengthRangeViolation,
|
||||||
|
applyStrengthRangeCue,
|
||||||
|
buildStrengthRangeMap,
|
||||||
|
getLoraStrengthRange,
|
||||||
|
getAvailableLoras,
|
||||||
|
resetAvailableLorasCache,
|
||||||
|
} from '../../../web/comfyui/loras_widget_utils.js';
|
||||||
|
|
||||||
|
describe('parseStrengthRange', () => {
|
||||||
|
it('parses explicit strength_min/strength_max keys', () => {
|
||||||
|
expect(parseStrengthRange('{"strength_min": 0.4, "strength_max": 0.8}')).toEqual({
|
||||||
|
min: 0.4,
|
||||||
|
max: 0.8,
|
||||||
|
recommended: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the strength_range shorthand', () => {
|
||||||
|
expect(parseStrengthRange('{"strength_range": "0.4-0.8"}')).toEqual({
|
||||||
|
min: 0.4,
|
||||||
|
max: 0.8,
|
||||||
|
recommended: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts camelCase variants', () => {
|
||||||
|
expect(parseStrengthRange('{"strengthMin": 0.2, "strengthMax": 1.5}')).toEqual({
|
||||||
|
min: 0.2,
|
||||||
|
max: 1.5,
|
||||||
|
recommended: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers explicit min/max over the range string per side', () => {
|
||||||
|
expect(
|
||||||
|
parseStrengthRange('{"strength_min": 0.1, "strength_range": "0.4-0.8"}')
|
||||||
|
).toEqual({ min: 0.1, max: 0.8, recommended: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports open-ended ranges', () => {
|
||||||
|
expect(parseStrengthRange('{"strength_max": 1.0}')).toEqual({
|
||||||
|
min: null,
|
||||||
|
max: 1.0,
|
||||||
|
recommended: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('captures the recommended strength when present', () => {
|
||||||
|
expect(
|
||||||
|
parseStrengthRange('{"strength": 0.6, "strength_min": 0.4, "strength_max": 0.8}')
|
||||||
|
).toEqual({ min: 0.4, max: 0.8, recommended: 0.6 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts numeric strings for bounds', () => {
|
||||||
|
expect(parseStrengthRange('{"strength_min": "0.4", "strength_max": "0.8"}')).toEqual({
|
||||||
|
min: 0.4,
|
||||||
|
max: 0.8,
|
||||||
|
recommended: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses negative bounds in range strings', () => {
|
||||||
|
expect(parseStrengthRange('{"strength_range": "-0.5-0.8"}')).toEqual({
|
||||||
|
min: -0.5,
|
||||||
|
max: 0.8,
|
||||||
|
recommended: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no range is configured', () => {
|
||||||
|
expect(parseStrengthRange('{"strength": 0.6}')).toBeNull();
|
||||||
|
expect(parseStrengthRange('{}')).toBeNull();
|
||||||
|
expect(parseStrengthRange('')).toBeNull();
|
||||||
|
expect(parseStrengthRange(null)).toBeNull();
|
||||||
|
expect(parseStrengthRange(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for malformed JSON', () => {
|
||||||
|
expect(parseStrengthRange('{invalid')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for inverted ranges', () => {
|
||||||
|
expect(parseStrengthRange('{"strength_min": 0.9, "strength_max": 0.2}')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts already-parsed objects', () => {
|
||||||
|
expect(parseStrengthRange({ strength_min: 0.3 })).toEqual({
|
||||||
|
min: 0.3,
|
||||||
|
max: null,
|
||||||
|
recommended: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('describeStrengthRangeViolation', () => {
|
||||||
|
const range = { min: 0.4, max: 0.8, recommended: 0.6 };
|
||||||
|
|
||||||
|
it('returns null when the value is inside the range', () => {
|
||||||
|
expect(describeStrengthRangeViolation(0.6, range)).toBeNull();
|
||||||
|
expect(describeStrengthRangeViolation(0.4, range)).toBeNull();
|
||||||
|
expect(describeStrengthRangeViolation(0.8, range)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes values below the range', () => {
|
||||||
|
expect(describeStrengthRangeViolation(0.2, range)).toBe(
|
||||||
|
'Below recommended strength range (0.40\u20130.80); recommended: 0.60'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes values above the range', () => {
|
||||||
|
expect(describeStrengthRangeViolation('1.0', range)).toBe(
|
||||||
|
'Above recommended strength range (0.40\u20130.80); recommended: 0.60'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the recommended part when not configured', () => {
|
||||||
|
expect(describeStrengthRangeViolation(0.1, { min: 0.4, max: null, recommended: null })).toBe(
|
||||||
|
'Below recommended strength range (\u2265 0.40)'
|
||||||
|
);
|
||||||
|
expect(describeStrengthRangeViolation(1.5, { min: null, max: 1.0, recommended: null })).toBe(
|
||||||
|
'Above recommended strength range (\u2264 1.00)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null without a range or with a non-numeric value', () => {
|
||||||
|
expect(describeStrengthRangeViolation(0.1, null)).toBeNull();
|
||||||
|
expect(describeStrengthRangeViolation('abc', range)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyStrengthRangeCue', () => {
|
||||||
|
const range = { min: 0.4, max: 0.8, recommended: 0.6 };
|
||||||
|
|
||||||
|
it('adds the cue class and tooltip for out-of-range values', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
applyStrengthRangeCue(input, 1.5, range);
|
||||||
|
expect(input.classList.contains('lm-strength-out-of-range')).toBe(true);
|
||||||
|
expect(input.title).toContain('Above recommended strength range');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the cue for in-range values', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
applyStrengthRangeCue(input, 1.5, range);
|
||||||
|
applyStrengthRangeCue(input, 0.6, range);
|
||||||
|
expect(input.classList.contains('lm-strength-out-of-range')).toBe(false);
|
||||||
|
expect(input.hasAttribute('title')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the cue when no range is configured', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.classList.add('lm-strength-out-of-range');
|
||||||
|
input.title = 'stale';
|
||||||
|
applyStrengthRangeCue(input, 99, null);
|
||||||
|
expect(input.classList.contains('lm-strength-out-of-range')).toBe(false);
|
||||||
|
expect(input.hasAttribute('title')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildStrengthRangeMap', () => {
|
||||||
|
it('keys ranges by normalized path and basename', () => {
|
||||||
|
const map = buildStrengthRangeMap([
|
||||||
|
{ file_name: 'sub/a', usage_tips: '{"strength_min": 0.4, "strength_max": 0.8}' },
|
||||||
|
]);
|
||||||
|
expect(map.get('sub/a')).toEqual({ min: 0.4, max: 0.8, recommended: null });
|
||||||
|
expect(map.get('a')).toEqual({ min: 0.4, max: 0.8, recommended: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips extensions from keys', () => {
|
||||||
|
const map = buildStrengthRangeMap([
|
||||||
|
{ file_name: 'sub/a.safetensors', usage_tips: '{"strength_max": 1.0}' },
|
||||||
|
]);
|
||||||
|
expect(map.get('sub/a')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips entries without a valid range', () => {
|
||||||
|
const map = buildStrengthRangeMap([
|
||||||
|
{ file_name: 'a', usage_tips: '' },
|
||||||
|
{ file_name: 'b' },
|
||||||
|
{ file_name: 'c', usage_tips: '{"strength": 0.6}' },
|
||||||
|
null,
|
||||||
|
]);
|
||||||
|
expect(map.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLoraStrengthRange', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchApiMock.mockReset();
|
||||||
|
resetAvailableLorasCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null while the cache is not loaded', () => {
|
||||||
|
expect(getLoraStrengthRange('a')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves ranges from the cached cycler list', async () => {
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
loras: [
|
||||||
|
{
|
||||||
|
file_name: 'sub/a.safetensors',
|
||||||
|
usage_tips: '{"strength": 0.6, "strength_min": 0.4, "strength_max": 0.8}',
|
||||||
|
},
|
||||||
|
{ file_name: 'b.safetensors' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await getAvailableLoras();
|
||||||
|
expect(getLoraStrengthRange('sub/a.safetensors')).toEqual({
|
||||||
|
min: 0.4,
|
||||||
|
max: 0.8,
|
||||||
|
recommended: 0.6,
|
||||||
|
});
|
||||||
|
// Extension-free and basename forms resolve to the same entry.
|
||||||
|
expect(getLoraStrengthRange('sub/a')).toEqual({
|
||||||
|
min: 0.4,
|
||||||
|
max: 0.8,
|
||||||
|
recommended: 0.6,
|
||||||
|
});
|
||||||
|
expect(getLoraStrengthRange('a')).toEqual({
|
||||||
|
min: 0.4,
|
||||||
|
max: 0.8,
|
||||||
|
recommended: 0.6,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the basename for folder-qualified names', async () => {
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
loras: [
|
||||||
|
{ file_name: 'sub/a.safetensors', usage_tips: '{"strength_max": 1.0}' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await getAvailableLoras();
|
||||||
|
expect(getLoraStrengthRange('any/folder/a.safetensors')).toEqual({
|
||||||
|
min: null,
|
||||||
|
max: 1.0,
|
||||||
|
recommended: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for loras without a configured range', async () => {
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
loras: [{ file_name: 'b.safetensors' }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await getAvailableLoras();
|
||||||
|
expect(getLoraStrengthRange('b')).toBeNull();
|
||||||
|
expect(getLoraStrengthRange('missing')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for absolute paths', async () => {
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
loras: [
|
||||||
|
{ file_name: 'a.safetensors', usage_tips: '{"strength_max": 1.0}' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await getAvailableLoras();
|
||||||
|
expect(getLoraStrengthRange('/abs/path/a.safetensors')).toBeNull();
|
||||||
|
expect(getLoraStrengthRange('C:/abs/path/a.safetensors')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -43,6 +43,7 @@ describe('BannerService', () => {
|
|||||||
// Reset banner service state
|
// Reset banner service state
|
||||||
bannerService.banners.clear();
|
bannerService.banners.clear();
|
||||||
bannerService.initialized = false;
|
bannerService.initialized = false;
|
||||||
|
bannerService.currentBannerIndex = 0;
|
||||||
bannerService.recentHistory = []; // Clear history for each test
|
bannerService.recentHistory = []; // Clear history for each test
|
||||||
|
|
||||||
// Clear DOM
|
// Clear DOM
|
||||||
@@ -331,6 +332,116 @@ describe('BannerService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Banner Rotation', () => {
|
||||||
|
const registerTestBanner = (id, priority) => {
|
||||||
|
bannerService.registerBanner(id, {
|
||||||
|
id,
|
||||||
|
title: `Banner ${id}`,
|
||||||
|
content: `Content ${id}`,
|
||||||
|
dismissible: true,
|
||||||
|
priority
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayedBannerId = () =>
|
||||||
|
document.querySelector('#banner-container .banner-item')
|
||||||
|
?.getAttribute('data-banner-id');
|
||||||
|
|
||||||
|
let dismissedStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dismissedStore = [];
|
||||||
|
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
|
||||||
|
if (key === 'dismissed_banners') {
|
||||||
|
return dismissedStore;
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
});
|
||||||
|
storageHelpers.setStorageItem.mockImplementation((key, value) => {
|
||||||
|
if (key === 'dismissed_banners') {
|
||||||
|
dismissedStore = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
bannerService.container = document.getElementById('banner-container');
|
||||||
|
bannerService.initialized = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders only the highest priority banner when multiple are active', () => {
|
||||||
|
registerTestBanner('low', 1);
|
||||||
|
registerTestBanner('high', 10);
|
||||||
|
|
||||||
|
const rendered = document.querySelectorAll('#banner-container .banner-item');
|
||||||
|
expect(rendered).toHaveLength(1);
|
||||||
|
expect(displayedBannerId()).toBe('high');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a pager with position indicator when multiple banners are active', () => {
|
||||||
|
registerTestBanner('a', 1);
|
||||||
|
registerTestBanner('b', 2);
|
||||||
|
|
||||||
|
const pager = document.querySelector('.banner-pager');
|
||||||
|
expect(pager).not.toBeNull();
|
||||||
|
expect(pager.querySelector('.banner-pager-indicator').textContent.trim())
|
||||||
|
.toBe('1 / 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show a pager for a single banner', () => {
|
||||||
|
registerTestBanner('only', 1);
|
||||||
|
|
||||||
|
expect(document.querySelector('.banner-pager')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cycles to the next banner and wraps around', () => {
|
||||||
|
registerTestBanner('a', 1);
|
||||||
|
registerTestBanner('b', 2);
|
||||||
|
|
||||||
|
document.querySelector('[data-pager="next"]')
|
||||||
|
.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
expect(displayedBannerId()).toBe('a');
|
||||||
|
expect(document.querySelector('.banner-pager-indicator').textContent.trim())
|
||||||
|
.toBe('2 / 2');
|
||||||
|
|
||||||
|
document.querySelector('[data-pager="next"]')
|
||||||
|
.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
expect(displayedBannerId()).toBe('b');
|
||||||
|
expect(document.querySelector('.banner-pager-indicator').textContent.trim())
|
||||||
|
.toBe('1 / 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cycles backwards with the previous button', () => {
|
||||||
|
registerTestBanner('a', 1);
|
||||||
|
registerTestBanner('b', 2);
|
||||||
|
|
||||||
|
document.querySelector('[data-pager="prev"]')
|
||||||
|
.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
expect(displayedBannerId()).toBe('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the next banner after the displayed one is dismissed', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
registerTestBanner('a', 1);
|
||||||
|
registerTestBanner('b', 2);
|
||||||
|
expect(displayedBannerId()).toBe('b');
|
||||||
|
|
||||||
|
await bannerService.dismissBanner('b');
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
|
||||||
|
expect(displayedBannerId()).toBe('a');
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records all active banners in history, not just the displayed one', () => {
|
||||||
|
registerTestBanner('a', 1);
|
||||||
|
registerTestBanner('b', 2);
|
||||||
|
|
||||||
|
const historyIds = bannerService.recentHistory.map(entry => entry.id);
|
||||||
|
expect(historyIds).toEqual(expect.arrayContaining(['a', 'b']));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Banner History', () => {
|
describe('Banner History', () => {
|
||||||
const testBanner = {
|
const testBanner = {
|
||||||
id: 'test-banner',
|
id: 'test-banner',
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ vi.mock('../../../static/js/utils/constants.js', () => ({
|
|||||||
DEFAULT_PATH_TEMPLATES: {},
|
DEFAULT_PATH_TEMPLATES: {},
|
||||||
MAPPABLE_BASE_MODELS: ['Flux.1 D', 'Pony', 'SDXL 1.0', 'Other'],
|
MAPPABLE_BASE_MODELS: ['Flux.1 D', 'Pony', 'SDXL 1.0', 'Other'],
|
||||||
PATH_TEMPLATE_PLACEHOLDERS: {},
|
PATH_TEMPLATE_PLACEHOLDERS: {},
|
||||||
|
FILENAME_TEMPLATE_PLACEHOLDERS: [],
|
||||||
|
DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' },
|
||||||
DEFAULT_PRIORITY_TAG_CONFIG: {
|
DEFAULT_PRIORITY_TAG_CONFIG: {
|
||||||
lora: 'character, style',
|
lora: 'character, style',
|
||||||
checkpoint: 'base, guide',
|
checkpoint: 'base, guide',
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { describe, it, expect, beforeEach, 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createDefaultSettings: () => ({
|
||||||
|
language: 'en',
|
||||||
|
download_filename_templates: { lora: '', checkpoint: '', embedding: '' },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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: [
|
||||||
|
'{model_name}',
|
||||||
|
'{version_name}',
|
||||||
|
'{base_model}',
|
||||||
|
'{author}',
|
||||||
|
'{first_tag}',
|
||||||
|
'{hash_short}',
|
||||||
|
'{original_name}',
|
||||||
|
],
|
||||||
|
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) => {
|
||||||
|
if (params && fallback) {
|
||||||
|
return fallback.replace(/\{(\w+)\}/g, (match, name) => params[name] ?? match);
|
||||||
|
}
|
||||||
|
return 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(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/managers/BannerService.js', () => ({
|
||||||
|
bannerService: {
|
||||||
|
registerBanner: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { SettingsManager } from '../../../static/js/managers/SettingsManager.js';
|
||||||
|
import { state } from '../../../static/js/state/index.js';
|
||||||
|
import { resetAndReload, getModelApiClient } from '../../../static/js/api/modelApiFactory.js';
|
||||||
|
|
||||||
|
const createManager = () => {
|
||||||
|
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 appendFilenameTemplateUi = (modelType = 'lora') => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<input id="${modelType}FilenameTemplate" />
|
||||||
|
<div id="${modelType}FilenameValidation"></div>
|
||||||
|
<div id="${modelType}FilenamePreview"></div>
|
||||||
|
<button id="${modelType}ApplyFilenameTemplate" type="button"></button>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendConfirmModal = () => {
|
||||||
|
document.body.insertAdjacentHTML('beforeend', `
|
||||||
|
<div id="filenameTemplateConfirmModal" class="modal delete-modal">
|
||||||
|
<h2 data-role="title"></h2>
|
||||||
|
<p data-role="message"></p>
|
||||||
|
<button data-action="cancel-filename-template"></button>
|
||||||
|
<button data-action="confirm-filename-template"></button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('SettingsManager filename templates', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.global.settings = {
|
||||||
|
download_filename_templates: { lora: '', checkpoint: '', embedding: '' },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an empty template as valid (restores original filenames)', () => {
|
||||||
|
appendFilenameTemplateUi();
|
||||||
|
const manager = createManager();
|
||||||
|
|
||||||
|
expect(manager.validateFilenameTemplate('lora', '')).toBe(true);
|
||||||
|
|
||||||
|
const validation = document.getElementById('loraFilenameValidation');
|
||||||
|
expect(validation.classList.contains('valid')).toBe(true);
|
||||||
|
expect(validation.textContent).toContain('restores original filenames');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects templates with path separators or OS-illegal characters', () => {
|
||||||
|
appendFilenameTemplateUi();
|
||||||
|
const manager = createManager();
|
||||||
|
|
||||||
|
expect(manager.validateFilenameTemplate('lora', '{base_model}/{model_name}')).toBe(false);
|
||||||
|
expect(manager.validateFilenameTemplate('lora', 'a:b')).toBe(false);
|
||||||
|
|
||||||
|
const validation = document.getElementById('loraFilenameValidation');
|
||||||
|
expect(validation.classList.contains('invalid')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown placeholders', () => {
|
||||||
|
appendFilenameTemplateUi();
|
||||||
|
const manager = createManager();
|
||||||
|
|
||||||
|
expect(manager.validateFilenameTemplate('lora', '{bogus}-{model_name}')).toBe(false);
|
||||||
|
|
||||||
|
const validation = document.getElementById('loraFilenameValidation');
|
||||||
|
expect(validation.textContent).toContain('{bogus}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a template using only known placeholders', () => {
|
||||||
|
appendFilenameTemplateUi();
|
||||||
|
const manager = createManager();
|
||||||
|
|
||||||
|
const template = '{base_model}-{model_name}-{version_name}-{hash_short}';
|
||||||
|
expect(manager.validateFilenameTemplate('lora', template)).toBe(true);
|
||||||
|
expect(document.getElementById('loraFilenameValidation').classList.contains('valid')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves a valid template via saveSetting with the merged dict', () => {
|
||||||
|
appendFilenameTemplateUi();
|
||||||
|
const manager = createManager();
|
||||||
|
manager.saveSetting = vi.fn().mockResolvedValue();
|
||||||
|
|
||||||
|
manager.updateFilenameTemplate('lora', '{model_name}');
|
||||||
|
|
||||||
|
expect(state.global.settings.download_filename_templates.lora).toBe('{model_name}');
|
||||||
|
expect(manager.saveSetting).toHaveBeenCalledWith(
|
||||||
|
'download_filename_templates',
|
||||||
|
{ lora: '{model_name}', checkpoint: '', embedding: '' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not save an invalid template', () => {
|
||||||
|
appendFilenameTemplateUi();
|
||||||
|
const manager = createManager();
|
||||||
|
manager.saveSetting = vi.fn().mockResolvedValue();
|
||||||
|
|
||||||
|
manager.updateFilenameTemplate('lora', '{unknown_placeholder}');
|
||||||
|
|
||||||
|
expect(state.global.settings.download_filename_templates.lora).toBe('');
|
||||||
|
expect(manager.saveSetting).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('previews the recorded original filename when the template is empty', () => {
|
||||||
|
appendFilenameTemplateUi();
|
||||||
|
const manager = createManager();
|
||||||
|
|
||||||
|
manager.updateFilenamePreview('lora', '');
|
||||||
|
|
||||||
|
expect(document.getElementById('loraFilenamePreview').textContent).toBe('V1.safetensors');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a preview with example placeholder values', () => {
|
||||||
|
appendFilenameTemplateUi();
|
||||||
|
const manager = createManager();
|
||||||
|
|
||||||
|
manager.updateFilenamePreview('lora', '{base_model}-{model_name}-{version_name}-{hash_short}');
|
||||||
|
|
||||||
|
expect(document.getElementById('loraFilenamePreview').textContent)
|
||||||
|
.toBe('Flux.1 D-model-name-v3-a1b2c3d4e5.safetensors');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies an empty template as a revert after modal confirmation', async () => {
|
||||||
|
appendFilenameTemplateUi();
|
||||||
|
appendConfirmModal();
|
||||||
|
const manager = createManager();
|
||||||
|
const apiClient = { applyFilenameTemplate: vi.fn().mockResolvedValue() };
|
||||||
|
getModelApiClient.mockReturnValue(apiClient);
|
||||||
|
|
||||||
|
const applyPromise = manager.applyFilenameTemplate('lora');
|
||||||
|
|
||||||
|
const modal = document.getElementById('filenameTemplateConfirmModal');
|
||||||
|
expect(modal.classList.contains('show')).toBe(true);
|
||||||
|
expect(modal.querySelector('[data-role="title"]').textContent)
|
||||||
|
.toBe('Restore original filenames?');
|
||||||
|
expect(modal.querySelector('[data-role="message"]').textContent)
|
||||||
|
.toContain('Restore the recorded original filename');
|
||||||
|
expect(modal.querySelector('[data-action="confirm-filename-template"]').textContent)
|
||||||
|
.toBe('Restore Original Filenames');
|
||||||
|
|
||||||
|
modal.querySelector('[data-action="confirm-filename-template"]').click();
|
||||||
|
await applyPromise;
|
||||||
|
|
||||||
|
expect(getModelApiClient).toHaveBeenCalledWith('loras');
|
||||||
|
expect(apiClient.applyFilenameTemplate).toHaveBeenCalledWith();
|
||||||
|
expect(resetAndReload).toHaveBeenCalledWith(true);
|
||||||
|
expect(modal.classList.contains('show')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not revert when the modal is cancelled', async () => {
|
||||||
|
appendFilenameTemplateUi();
|
||||||
|
appendConfirmModal();
|
||||||
|
const manager = createManager();
|
||||||
|
|
||||||
|
const applyPromise = manager.applyFilenameTemplate('lora');
|
||||||
|
document.querySelector('[data-action="cancel-filename-template"]').click();
|
||||||
|
await applyPromise;
|
||||||
|
|
||||||
|
expect(getModelApiClient).not.toHaveBeenCalled();
|
||||||
|
expect(resetAndReload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges backend download_filename_templates over defaults', () => {
|
||||||
|
const manager = createManager();
|
||||||
|
|
||||||
|
const merged = manager.mergeSettingsWithDefaults({
|
||||||
|
download_filename_templates: { lora: '{model_name}' },
|
||||||
|
});
|
||||||
|
expect(merged.download_filename_templates).toEqual({
|
||||||
|
lora: '{model_name}',
|
||||||
|
checkpoint: '',
|
||||||
|
embedding: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const fromString = manager.mergeSettingsWithDefaults({
|
||||||
|
download_filename_templates: '{"checkpoint":"{hash_short}"}',
|
||||||
|
});
|
||||||
|
expect(fromString.download_filename_templates).toEqual({
|
||||||
|
lora: '',
|
||||||
|
checkpoint: '{hash_short}',
|
||||||
|
embedding: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the template through the model API client and reloads', async () => {
|
||||||
|
appendFilenameTemplateUi();
|
||||||
|
state.global.settings.download_filename_templates.lora = '{model_name}';
|
||||||
|
const manager = createManager();
|
||||||
|
const apiClient = { applyFilenameTemplate: vi.fn().mockResolvedValue() };
|
||||||
|
getModelApiClient.mockReturnValue(apiClient);
|
||||||
|
|
||||||
|
await manager.applyFilenameTemplate('lora');
|
||||||
|
|
||||||
|
expect(getModelApiClient).toHaveBeenCalledWith('loras');
|
||||||
|
expect(apiClient.applyFilenameTemplate).toHaveBeenCalledWith();
|
||||||
|
expect(resetAndReload).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the apply wording for a non-empty template and honours cancellation', async () => {
|
||||||
|
appendFilenameTemplateUi();
|
||||||
|
appendConfirmModal();
|
||||||
|
state.global.settings.download_filename_templates.lora = '{model_name}';
|
||||||
|
const manager = createManager();
|
||||||
|
|
||||||
|
const applyPromise = manager.applyFilenameTemplate('lora');
|
||||||
|
|
||||||
|
const modal = document.getElementById('filenameTemplateConfirmModal');
|
||||||
|
expect(modal.classList.contains('show')).toBe(true);
|
||||||
|
expect(modal.querySelector('[data-role="title"]').textContent)
|
||||||
|
.toBe('Apply filename template to library?');
|
||||||
|
expect(modal.querySelector('[data-role="message"]').textContent)
|
||||||
|
.toContain('Rename all existing files');
|
||||||
|
|
||||||
|
modal.querySelector('[data-action="cancel-filename-template"]').click();
|
||||||
|
await applyPromise;
|
||||||
|
|
||||||
|
expect(getModelApiClient).not.toHaveBeenCalled();
|
||||||
|
expect(resetAndReload).not.toHaveBeenCalled();
|
||||||
|
expect(modal.classList.contains('show')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -38,6 +38,8 @@ vi.mock('../../../static/js/utils/constants.js', () => ({
|
|||||||
DEFAULT_PATH_TEMPLATES: {},
|
DEFAULT_PATH_TEMPLATES: {},
|
||||||
MAPPABLE_BASE_MODELS: [],
|
MAPPABLE_BASE_MODELS: [],
|
||||||
PATH_TEMPLATE_PLACEHOLDERS: {},
|
PATH_TEMPLATE_PLACEHOLDERS: {},
|
||||||
|
FILENAME_TEMPLATE_PLACEHOLDERS: [],
|
||||||
|
DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' },
|
||||||
DEFAULT_PRIORITY_TAG_CONFIG: {
|
DEFAULT_PRIORITY_TAG_CONFIG: {
|
||||||
lora: 'character, style',
|
lora: 'character, style',
|
||||||
checkpoint: 'base, guide',
|
checkpoint: 'base, guide',
|
||||||
@@ -665,6 +667,22 @@ describe('SettingsManager other-model root selects', () => {
|
|||||||
expect(container.classList.contains('is-disabled')).toBe(false);
|
expect(container.classList.contains('is-disabled')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('restores the master toggle checked state from settings', () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const masterToggle = document.createElement('input');
|
||||||
|
masterToggle.type = 'checkbox';
|
||||||
|
masterToggle.id = 'enableOtherModels';
|
||||||
|
document.body.appendChild(masterToggle);
|
||||||
|
|
||||||
|
state.global.settings = { enable_other_models: true };
|
||||||
|
manager.updateOtherModelsControls();
|
||||||
|
expect(masterToggle.checked).toBe(true);
|
||||||
|
|
||||||
|
state.global.settings = { enable_other_models: false };
|
||||||
|
manager.updateOtherModelsControls();
|
||||||
|
expect(masterToggle.checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('persists the checked sub_types as the whole allow-list', async () => {
|
it('persists the checked sub_types as the whole allow-list', async () => {
|
||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
appendToggles('vae', 'upscaler', 'controlnet');
|
appendToggles('vae', 'upscaler', 'controlnet');
|
||||||
|
|||||||
@@ -0,0 +1,491 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
|
||||||
|
modalManager: {
|
||||||
|
closeModal: vi.fn(),
|
||||||
|
showModal: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||||
|
showToast: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/state/index.js', () => {
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
global: {
|
||||||
|
settings: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createDefaultSettings: () => ({
|
||||||
|
language: 'en',
|
||||||
|
standalone_mode: false,
|
||||||
|
folder_paths: {},
|
||||||
|
folder_path_schema: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||||
|
resetAndReload: 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: {},
|
||||||
|
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 { bannerService } from '../../../static/js/managers/BannerService.js';
|
||||||
|
import { state } from '../../../static/js/state/index.js';
|
||||||
|
|
||||||
|
const CORE_SCHEMA = [
|
||||||
|
{ key: 'loras', category: 'core', sub_type: null },
|
||||||
|
{ key: 'checkpoints', category: 'core', sub_type: null },
|
||||||
|
{ key: 'unet', category: 'core', sub_type: null },
|
||||||
|
{ key: 'embeddings', category: 'core', sub_type: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
const OTHER_SCHEMA = [
|
||||||
|
{ key: 'vae', category: 'other', sub_type: 'vae' },
|
||||||
|
{ key: 'controlnet', category: 'other', sub_type: 'controlnet' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const createManager = () => {
|
||||||
|
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 buildModalDom = () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<nav class="settings-nav">
|
||||||
|
<ul class="settings-nav-list">
|
||||||
|
<li class="settings-nav-group">
|
||||||
|
<button type="button" class="settings-nav-item active" data-section="general">General</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div class="settings-form">
|
||||||
|
<div class="settings-section active" id="section-general" data-section="general"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setStandaloneSettings = (overrides = {}) => {
|
||||||
|
state.global.settings = {
|
||||||
|
standalone_mode: true,
|
||||||
|
folder_paths: {},
|
||||||
|
folder_path_schema: [...CORE_SCHEMA, ...OTHER_SCHEMA],
|
||||||
|
enable_other_models: false,
|
||||||
|
enabled_other_sub_types: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
vi.clearAllMocks();
|
||||||
|
bannerService.banners.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete global.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SettingsManager Model Paths section', () => {
|
||||||
|
it('does not create the section in plugin mode', () => {
|
||||||
|
buildModalDom();
|
||||||
|
state.global.settings = { standalone_mode: false };
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
manager.setupModelPathsSection();
|
||||||
|
|
||||||
|
expect(document.querySelector('.settings-nav-item[data-section="modelPaths"]')).toBeNull();
|
||||||
|
expect(document.getElementById('section-modelPaths')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates nav item and section in standalone mode', () => {
|
||||||
|
buildModalDom();
|
||||||
|
setStandaloneSettings();
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
manager.setupModelPathsSection();
|
||||||
|
|
||||||
|
expect(document.querySelector('.settings-nav-item[data-section="modelPaths"]')).not.toBeNull();
|
||||||
|
expect(document.getElementById('section-modelPaths')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches sections when the nav item is clicked', () => {
|
||||||
|
buildModalDom();
|
||||||
|
setStandaloneSettings();
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
manager.setupModelPathsSection();
|
||||||
|
|
||||||
|
document.querySelector('.settings-nav-item[data-section="modelPaths"]').click();
|
||||||
|
|
||||||
|
expect(document.getElementById('section-modelPaths').classList.contains('active')).toBe(true);
|
||||||
|
expect(document.getElementById('section-general').classList.contains('active')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('static nav clicks clear the Model Paths active state (regression)', () => {
|
||||||
|
buildModalDom();
|
||||||
|
setStandaloneSettings();
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
manager.setupModelPathsSection();
|
||||||
|
// Static nav items were bound before the Model Paths button existed;
|
||||||
|
// their handler must still clear its active state.
|
||||||
|
manager.initializeNavigation();
|
||||||
|
|
||||||
|
const modelPathsNav = document.querySelector('.settings-nav-item[data-section="modelPaths"]');
|
||||||
|
modelPathsNav.click();
|
||||||
|
expect(modelPathsNav.classList.contains('active')).toBe(true);
|
||||||
|
|
||||||
|
document.querySelector('.settings-nav-item[data-section="general"]').click();
|
||||||
|
|
||||||
|
expect(modelPathsNav.classList.contains('active')).toBe(false);
|
||||||
|
expect(document.getElementById('section-modelPaths').classList.contains('active')).toBe(false);
|
||||||
|
expect(document.getElementById('section-general').classList.contains('active')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders core editors and only enabled other-model editors', () => {
|
||||||
|
buildModalDom();
|
||||||
|
setStandaloneSettings({
|
||||||
|
enable_other_models: true,
|
||||||
|
enabled_other_sub_types: ['vae'],
|
||||||
|
folder_paths: { loras: ['/models/loras'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
manager.setupModelPathsSection();
|
||||||
|
manager.loadModelPaths();
|
||||||
|
|
||||||
|
// Core editors always rendered
|
||||||
|
CORE_SCHEMA.forEach(({ key }) => {
|
||||||
|
expect(document.getElementById(`modelFolderPaths-${key}`)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only the enabled other-model sub-type is rendered
|
||||||
|
expect(document.getElementById('modelFolderPaths-vae')).not.toBeNull();
|
||||||
|
expect(document.getElementById('modelFolderPaths-controlnet')).toBeNull();
|
||||||
|
expect(document.getElementById('modelPathsOtherEmpty').style.display).toBe('none');
|
||||||
|
|
||||||
|
// Existing values populate rows
|
||||||
|
const loraInput = document.querySelector('#modelFolderPaths-loras .extra-folder-path-input');
|
||||||
|
expect(loraInput.value).toBe('/models/loras');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the empty hint when no other-model types are enabled', () => {
|
||||||
|
buildModalDom();
|
||||||
|
setStandaloneSettings();
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
manager.setupModelPathsSection();
|
||||||
|
manager.loadModelPaths();
|
||||||
|
|
||||||
|
expect(document.getElementById('modelPathsOtherEmpty').style.display).toBe('block');
|
||||||
|
expect(document.getElementById('modelFolderPaths-vae')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders inline enable controls synced with current settings', () => {
|
||||||
|
buildModalDom();
|
||||||
|
setStandaloneSettings({
|
||||||
|
enable_other_models: true,
|
||||||
|
enabled_other_sub_types: ['vae'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
manager.setupModelPathsSection();
|
||||||
|
manager.loadModelPaths();
|
||||||
|
|
||||||
|
const master = document.getElementById('modelPathsEnableOtherModels');
|
||||||
|
expect(master).not.toBeNull();
|
||||||
|
expect(master.checked).toBe(true);
|
||||||
|
|
||||||
|
const vaeBox = document.querySelector('[data-model-paths-subtype="vae"]');
|
||||||
|
const controlnetBox = document.querySelector('[data-model-paths-subtype="controlnet"]');
|
||||||
|
expect(vaeBox.checked).toBe(true);
|
||||||
|
expect(vaeBox.disabled).toBe(false);
|
||||||
|
expect(controlnetBox.checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inline master toggle saves the setting and re-renders editors', async () => {
|
||||||
|
buildModalDom();
|
||||||
|
setStandaloneSettings({
|
||||||
|
enable_other_models: true,
|
||||||
|
enabled_other_sub_types: ['vae'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
manager.saveSetting = vi.fn().mockImplementation(async (key, value) => {
|
||||||
|
state.global.settings[key] = value;
|
||||||
|
});
|
||||||
|
manager.loadOtherRoots = vi.fn().mockResolvedValue();
|
||||||
|
manager.setupModelPathsSection();
|
||||||
|
manager.loadModelPaths();
|
||||||
|
|
||||||
|
expect(document.getElementById('modelFolderPaths-vae')).not.toBeNull();
|
||||||
|
|
||||||
|
const master = document.getElementById('modelPathsEnableOtherModels');
|
||||||
|
master.checked = false;
|
||||||
|
await manager.handleModelPathsEnableOtherModels();
|
||||||
|
|
||||||
|
expect(manager.saveSetting).toHaveBeenCalledWith('enable_other_models', false);
|
||||||
|
expect(state.global.settings.enable_other_models).toBe(false);
|
||||||
|
// Editors removed in place, empty hint back
|
||||||
|
expect(document.getElementById('modelFolderPaths-vae')).toBeNull();
|
||||||
|
expect(document.getElementById('modelPathsOtherEmpty').style.display).toBe('block');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inline sub-type checkboxes save the allow-list and re-render editors', async () => {
|
||||||
|
buildModalDom();
|
||||||
|
setStandaloneSettings({
|
||||||
|
enable_other_models: true,
|
||||||
|
enabled_other_sub_types: ['vae'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
manager.saveSetting = vi.fn().mockImplementation(async (key, value) => {
|
||||||
|
state.global.settings[key] = value;
|
||||||
|
});
|
||||||
|
manager.loadOtherRoots = vi.fn().mockResolvedValue();
|
||||||
|
manager.setupModelPathsSection();
|
||||||
|
manager.loadModelPaths();
|
||||||
|
|
||||||
|
document.querySelector('[data-model-paths-subtype="controlnet"]').checked = true;
|
||||||
|
await manager.handleModelPathsSubTypeToggles();
|
||||||
|
|
||||||
|
expect(manager.saveSetting).toHaveBeenCalledWith('enabled_other_sub_types', ['vae', 'controlnet']);
|
||||||
|
expect(document.getElementById('modelFolderPaths-controlnet')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves collected folder paths via saveSetting', async () => {
|
||||||
|
buildModalDom();
|
||||||
|
setStandaloneSettings();
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
manager.saveSetting = vi.fn().mockResolvedValue();
|
||||||
|
manager.setupModelPathsSection();
|
||||||
|
manager.loadModelPaths();
|
||||||
|
|
||||||
|
// No rows exist until the user clicks Add
|
||||||
|
expect(document.querySelector('#modelFolderPaths-loras .extra-folder-path-input')).toBeNull();
|
||||||
|
|
||||||
|
manager.addModelFolderPathRow('loras');
|
||||||
|
document.querySelector('#modelFolderPaths-loras .extra-folder-path-input').value = '/data/loras';
|
||||||
|
|
||||||
|
await manager.updateModelFolderPaths('loras');
|
||||||
|
|
||||||
|
expect(manager.saveSetting).toHaveBeenCalledWith('folder_paths', {
|
||||||
|
loras: ['/data/loras'],
|
||||||
|
checkpoints: [],
|
||||||
|
unet: [],
|
||||||
|
embeddings: [],
|
||||||
|
});
|
||||||
|
expect(state.global.settings.folder_paths.loras).toEqual(['/data/loras']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks saving when checkpoints and unet share a path', async () => {
|
||||||
|
buildModalDom();
|
||||||
|
setStandaloneSettings();
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
manager.saveSetting = vi.fn().mockResolvedValue();
|
||||||
|
manager.setupModelPathsSection();
|
||||||
|
manager.loadModelPaths();
|
||||||
|
|
||||||
|
manager.addModelFolderPathRow('checkpoints');
|
||||||
|
manager.addModelFolderPathRow('unet');
|
||||||
|
document.querySelector('#modelFolderPaths-checkpoints .extra-folder-path-input').value = '/same/dir';
|
||||||
|
document.querySelector('#modelFolderPaths-unet .extra-folder-path-input').value = '/same/dir';
|
||||||
|
|
||||||
|
await manager.updateModelFolderPaths('checkpoints');
|
||||||
|
|
||||||
|
expect(manager.saveSetting).not.toHaveBeenCalled();
|
||||||
|
const ckptInput = document.querySelector('#modelFolderPaths-checkpoints .extra-folder-path-input');
|
||||||
|
expect(ckptInput.classList.contains('has-error')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends a trailing empty row after filling one, but not after a removal', async () => {
|
||||||
|
buildModalDom();
|
||||||
|
setStandaloneSettings({ folder_paths: { loras: ['/data/a'] } });
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
manager.saveSetting = vi.fn().mockResolvedValue();
|
||||||
|
manager.setupModelPathsSection();
|
||||||
|
manager.loadModelPaths();
|
||||||
|
|
||||||
|
// Fill the trailing empty row -> save appends a fresh empty row
|
||||||
|
manager.addModelFolderPathRow('loras');
|
||||||
|
const rows = () => document.querySelectorAll('#modelFolderPaths-loras .extra-folder-path-row');
|
||||||
|
expect(rows()).toHaveLength(2);
|
||||||
|
rows()[1].querySelector('.extra-folder-path-input').value = '/data/b';
|
||||||
|
await manager.updateModelFolderPaths('loras');
|
||||||
|
expect(rows()).toHaveLength(3);
|
||||||
|
|
||||||
|
// Removing a row never resurrects an empty row
|
||||||
|
const removeBtn = rows()[0].querySelector('.remove-path-btn');
|
||||||
|
manager.removeModelFolderPathRow(removeBtn, 'loras');
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(manager.saveSetting).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
// Two rows left: the saved '/data/b' plus the pre-existing trailing
|
||||||
|
// empty row — removal must not append yet another empty row.
|
||||||
|
expect(rows()).toHaveLength(2);
|
||||||
|
const emptyRows = Array.from(rows()).filter(
|
||||||
|
(row) => row.querySelector('.extra-folder-path-input').value === '',
|
||||||
|
);
|
||||||
|
expect(emptyRows).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores previous state when saving fails', async () => {
|
||||||
|
buildModalDom();
|
||||||
|
setStandaloneSettings({ folder_paths: { loras: ['/original'] } });
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
manager.saveSetting = vi.fn().mockRejectedValue(new Error('nope'));
|
||||||
|
manager.setupModelPathsSection();
|
||||||
|
manager.loadModelPaths();
|
||||||
|
|
||||||
|
const input = document.querySelector('#modelFolderPaths-loras .extra-folder-path-input');
|
||||||
|
input.value = '/changed';
|
||||||
|
|
||||||
|
await manager.updateModelFolderPaths('loras');
|
||||||
|
|
||||||
|
expect(state.global.settings.folder_paths).toEqual({ loras: ['/original'] });
|
||||||
|
// Rows reloaded from restored state
|
||||||
|
const reloaded = document.querySelector('#modelFolderPaths-loras .extra-folder-path-input');
|
||||||
|
expect(reloaded.value).toBe('/original');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks pending-restart cues after a successful save', async () => {
|
||||||
|
buildModalDom();
|
||||||
|
setStandaloneSettings();
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
manager.saveSetting = vi.fn().mockResolvedValue();
|
||||||
|
manager.setupModelPathsSection();
|
||||||
|
manager.loadModelPaths();
|
||||||
|
|
||||||
|
const navItem = document.querySelector('.settings-nav-item[data-section="modelPaths"]');
|
||||||
|
expect(navItem.classList.contains('has-pending-restart')).toBe(false);
|
||||||
|
|
||||||
|
manager.addModelFolderPathRow('loras');
|
||||||
|
document.querySelector('#modelFolderPaths-loras .extra-folder-path-input').value = '/data/loras';
|
||||||
|
await manager.updateModelFolderPaths('loras');
|
||||||
|
|
||||||
|
expect(navItem.classList.contains('has-pending-restart')).toBe(true);
|
||||||
|
expect(document.getElementById('modelPathsRestartNotice').classList.contains('visible')).toBe(true);
|
||||||
|
|
||||||
|
// A unique-per-change banner id: dismissing it once must not mute
|
||||||
|
// future reminders (dismissed ids persist across restarts).
|
||||||
|
const restartBanners = Array.from(bannerService.banners.keys())
|
||||||
|
.filter((id) => id.startsWith('model-paths-restart-'));
|
||||||
|
expect(restartBanners).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the restart banner a higher priority than startup warnings', async () => {
|
||||||
|
buildModalDom();
|
||||||
|
setStandaloneSettings();
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
manager.saveSetting = vi.fn().mockResolvedValue();
|
||||||
|
manager.setupModelPathsSection();
|
||||||
|
manager.loadModelPaths();
|
||||||
|
|
||||||
|
manager.addModelFolderPathRow('loras');
|
||||||
|
document.querySelector('#modelFolderPaths-loras .extra-folder-path-input').value = '/data/loras';
|
||||||
|
await manager.updateModelFolderPaths('loras');
|
||||||
|
|
||||||
|
const restartBanner = Array.from(bannerService.banners.values())
|
||||||
|
.find((banner) => banner.id.startsWith('model-paths-restart-'));
|
||||||
|
// Startup warnings map to 60; the restart cue must outrank them so it
|
||||||
|
// preempts the "model folders need setup" prompt in the banner pager.
|
||||||
|
expect(restartBanner.priority).toBeGreaterThan(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the "model folders need setup" startup banner once a path is saved', async () => {
|
||||||
|
buildModalDom();
|
||||||
|
setStandaloneSettings();
|
||||||
|
|
||||||
|
bannerService.registerBanner('startup-missing-model-paths', {
|
||||||
|
id: 'startup-missing-model-paths',
|
||||||
|
title: 'Model folders need setup',
|
||||||
|
content: 'stub',
|
||||||
|
dismissible: false,
|
||||||
|
priority: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
manager.saveSetting = vi.fn().mockResolvedValue();
|
||||||
|
manager.setupModelPathsSection();
|
||||||
|
manager.loadModelPaths();
|
||||||
|
|
||||||
|
manager.addModelFolderPathRow('loras');
|
||||||
|
document.querySelector('#modelFolderPaths-loras .extra-folder-path-input').value = '/data/loras';
|
||||||
|
await manager.updateModelFolderPaths('loras');
|
||||||
|
|
||||||
|
expect(bannerService.banners.has('startup-missing-model-paths')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the setup banner when the saved paths are all empty', async () => {
|
||||||
|
buildModalDom();
|
||||||
|
setStandaloneSettings({ folder_paths: { loras: ['/data/loras'] } });
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
manager.saveSetting = vi.fn().mockResolvedValue();
|
||||||
|
manager.setupModelPathsSection();
|
||||||
|
manager.loadModelPaths();
|
||||||
|
|
||||||
|
bannerService.registerBanner('startup-missing-model-paths', {
|
||||||
|
id: 'startup-missing-model-paths',
|
||||||
|
title: 'Model folders need setup',
|
||||||
|
content: 'stub',
|
||||||
|
dismissible: false,
|
||||||
|
priority: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear every row and save: an all-empty path set must not retire the
|
||||||
|
// setup prompt.
|
||||||
|
document.querySelectorAll('#modelFolderPaths-loras .extra-folder-path-input')
|
||||||
|
.forEach((input) => { input.value = ''; });
|
||||||
|
await manager.updateModelFolderPaths('loras');
|
||||||
|
|
||||||
|
expect(manager.saveSetting).toHaveBeenCalled();
|
||||||
|
expect(bannerService.banners.has('startup-missing-model-paths')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
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', () => ({
|
||||||
|
state: {
|
||||||
|
global: {
|
||||||
|
settings: {},
|
||||||
|
},
|
||||||
|
loadingManager: {
|
||||||
|
showSimpleLoading: vi.fn(),
|
||||||
|
hide: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createDefaultSettings: () => ({
|
||||||
|
language: 'en',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||||
|
resetAndReload: 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: {},
|
||||||
|
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(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/components/DirectoryPickerModal.js', () => ({
|
||||||
|
directoryPickerModal: {
|
||||||
|
open: vi.fn(),
|
||||||
|
close: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { SettingsManager } from '../../../static/js/managers/SettingsManager.js';
|
||||||
|
import { directoryPickerModal } from '../../../static/js/components/DirectoryPickerModal.js';
|
||||||
|
|
||||||
|
const createManager = () => {
|
||||||
|
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 appendPathInput = (id = 'recipesPath') => {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'text-input-wrapper';
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.id = id;
|
||||||
|
wrapper.appendChild(input);
|
||||||
|
document.body.appendChild(wrapper);
|
||||||
|
return input;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validResponse = (path) => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
path,
|
||||||
|
exists: true,
|
||||||
|
is_directory: true,
|
||||||
|
readable: true,
|
||||||
|
writable: true,
|
||||||
|
error_code: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidResponse = (errorCode) => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
path: '/missing',
|
||||||
|
exists: false,
|
||||||
|
is_directory: false,
|
||||||
|
readable: false,
|
||||||
|
writable: false,
|
||||||
|
error_code: errorCode,
|
||||||
|
error: `server: ${errorCode}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
delete global.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SettingsManager.attachPathField', () => {
|
||||||
|
it('keeps the input in its wrapper and injects an inset browse button and a status element', () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const input = appendPathInput();
|
||||||
|
|
||||||
|
manager.attachPathField('recipesPath');
|
||||||
|
|
||||||
|
const wrapper = input.parentElement;
|
||||||
|
expect(wrapper.classList.contains('text-input-wrapper')).toBe(true);
|
||||||
|
const browseBtn = wrapper.querySelector('.browse-path-btn.inset');
|
||||||
|
expect(browseBtn).not.toBeNull();
|
||||||
|
expect(browseBtn.querySelector('i.fas.fa-folder-open')).not.toBeNull();
|
||||||
|
expect(input.classList.contains('has-inset-browse')).toBe(true);
|
||||||
|
expect(wrapper.querySelector('.path-validation')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent — a second call does not duplicate the button', () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const input = appendPathInput();
|
||||||
|
|
||||||
|
manager.attachPathField('recipesPath');
|
||||||
|
manager.attachPathField('recipesPath');
|
||||||
|
|
||||||
|
expect(document.querySelectorAll('.browse-path-btn')).toHaveLength(1);
|
||||||
|
expect(document.querySelectorAll('.path-validation')).toHaveLength(1);
|
||||||
|
expect(input.dataset.pathFieldAttached).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps only the input for insetting when inside .path-control, leaving siblings in place', () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'setting-control path-control';
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.id = 'exampleImagesPath';
|
||||||
|
const downloadBtn = document.createElement('button');
|
||||||
|
downloadBtn.id = 'exampleImagesDownloadBtn';
|
||||||
|
container.appendChild(input);
|
||||||
|
container.appendChild(downloadBtn);
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
manager.attachPathField('exampleImagesPath');
|
||||||
|
|
||||||
|
const wrapper = input.parentElement;
|
||||||
|
expect(wrapper.classList.contains('text-input-wrapper')).toBe(true);
|
||||||
|
expect(wrapper.parentElement).toBe(container);
|
||||||
|
const browseBtn = wrapper.querySelector('.browse-path-btn.inset');
|
||||||
|
expect(browseBtn).not.toBeNull();
|
||||||
|
expect(wrapper.nextElementSibling).toBe(downloadBtn);
|
||||||
|
expect(container.querySelector('.path-validation')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns and no-ops when the input is missing', () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
|
manager.attachPathField('doesNotExist');
|
||||||
|
|
||||||
|
expect(warnSpy).toHaveBeenCalled();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SettingsManager.validatePath', () => {
|
||||||
|
it('posts to /api/lm/validate-path on blur with expect directory', async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const input = appendPathInput();
|
||||||
|
input.value = '/data/recipes';
|
||||||
|
global.fetch = vi.fn().mockResolvedValue(validResponse('/data/recipes'));
|
||||||
|
|
||||||
|
manager.attachPathField('recipesPath');
|
||||||
|
input.dispatchEvent(new Event('blur'));
|
||||||
|
await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
const [url, options] = global.fetch.mock.calls[0];
|
||||||
|
expect(url).toBe('/api/lm/validate-path');
|
||||||
|
expect(options.method).toBe('POST');
|
||||||
|
expect(JSON.parse(options.body)).toEqual({ path: '/data/recipes', expect: 'directory' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debounces rapid input events into a single validation call', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const manager = createManager();
|
||||||
|
const input = appendPathInput();
|
||||||
|
global.fetch = vi.fn().mockResolvedValue(validResponse('/data'));
|
||||||
|
|
||||||
|
manager.attachPathField('recipesPath');
|
||||||
|
input.value = '/d';
|
||||||
|
input.dispatchEvent(new Event('input'));
|
||||||
|
input.value = '/da';
|
||||||
|
input.dispatchEvent(new Event('input'));
|
||||||
|
input.value = '/data';
|
||||||
|
input.dispatchEvent(new Event('input'));
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(499);
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a valid status for a valid path', async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const input = appendPathInput();
|
||||||
|
input.value = '/data/recipes';
|
||||||
|
global.fetch = vi.fn().mockResolvedValue(validResponse('/data/recipes'));
|
||||||
|
|
||||||
|
manager.attachPathField('recipesPath');
|
||||||
|
input.dispatchEvent(new Event('blur'));
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelector('.path-validation').classList.contains('visible')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusEl = document.querySelector('.path-validation');
|
||||||
|
expect(statusEl.classList.contains('valid')).toBe(true);
|
||||||
|
expect(statusEl.textContent).toContain('Path is valid');
|
||||||
|
expect(statusEl.querySelector('i.fas.fa-check-circle')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an error status mapped from error_code', async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const input = appendPathInput();
|
||||||
|
input.value = '/missing';
|
||||||
|
global.fetch = vi.fn().mockResolvedValue(invalidResponse('path_not_found'));
|
||||||
|
|
||||||
|
manager.attachPathField('recipesPath');
|
||||||
|
input.dispatchEvent(new Event('blur'));
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelector('.path-validation').classList.contains('visible')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusEl = document.querySelector('.path-validation');
|
||||||
|
expect(statusEl.classList.contains('valid')).toBe(false);
|
||||||
|
expect(statusEl.textContent).toContain('Path does not exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores stale responses overtaken by a newer value', async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const input = appendPathInput();
|
||||||
|
|
||||||
|
const deferreds = [];
|
||||||
|
global.fetch = vi.fn().mockImplementation(() => new Promise((resolve) => {
|
||||||
|
deferreds.push(resolve);
|
||||||
|
}));
|
||||||
|
|
||||||
|
manager.attachPathField('recipesPath');
|
||||||
|
|
||||||
|
input.value = '/old-path';
|
||||||
|
input.dispatchEvent(new Event('blur'));
|
||||||
|
input.value = '/new-path';
|
||||||
|
input.dispatchEvent(new Event('blur'));
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// Newer request resolves first and renders valid status.
|
||||||
|
deferreds[1](validResponse('/new-path'));
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelector('.path-validation').classList.contains('valid')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Older request resolves late and must not overwrite the status.
|
||||||
|
deferreds[0](invalidResponse('path_not_found'));
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const statusEl = document.querySelector('.path-validation');
|
||||||
|
expect(statusEl.classList.contains('valid')).toBe(true);
|
||||||
|
expect(statusEl.textContent).toContain('Path is valid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the status and skips fetch when the value is empty', async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const input = appendPathInput();
|
||||||
|
input.value = '/data';
|
||||||
|
global.fetch = vi.fn().mockResolvedValue(validResponse('/data'));
|
||||||
|
|
||||||
|
manager.attachPathField('recipesPath');
|
||||||
|
input.dispatchEvent(new Event('blur'));
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelector('.path-validation').classList.contains('visible')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
global.fetch.mockClear();
|
||||||
|
input.value = '';
|
||||||
|
input.dispatchEvent(new Event('blur'));
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const statusEl = document.querySelector('.path-validation');
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
expect(statusEl.classList.contains('visible')).toBe(false);
|
||||||
|
expect(statusEl.textContent).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the status silently on network failure', async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const input = appendPathInput();
|
||||||
|
input.value = '/data';
|
||||||
|
global.fetch = vi.fn().mockRejectedValue(new Error('network down'));
|
||||||
|
|
||||||
|
manager.attachPathField('recipesPath');
|
||||||
|
input.dispatchEvent(new Event('blur'));
|
||||||
|
await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1));
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const statusEl = document.querySelector('.path-validation');
|
||||||
|
expect(statusEl.classList.contains('visible')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SettingsManager.browseForPath', () => {
|
||||||
|
it('opens the picker with the current value and applies the selection', async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const input = appendPathInput();
|
||||||
|
input.value = '/initial';
|
||||||
|
const onAfterSelect = vi.fn();
|
||||||
|
global.fetch = vi.fn().mockResolvedValue(validResponse('/picked'));
|
||||||
|
|
||||||
|
manager.attachPathField('recipesPath', { onAfterSelect });
|
||||||
|
manager.browseForPath('recipesPath');
|
||||||
|
|
||||||
|
expect(directoryPickerModal.open).toHaveBeenCalledTimes(1);
|
||||||
|
const openArgs = directoryPickerModal.open.mock.calls[0][0];
|
||||||
|
expect(openArgs.initialPath).toBe('/initial');
|
||||||
|
|
||||||
|
openArgs.onSelect('/picked');
|
||||||
|
|
||||||
|
expect(input.value).toBe('/picked');
|
||||||
|
expect(onAfterSelect).toHaveBeenCalledWith('/picked');
|
||||||
|
await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1));
|
||||||
|
expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toEqual({
|
||||||
|
path: '/picked',
|
||||||
|
expect: 'directory',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SettingsManager dynamic path rows', () => {
|
||||||
|
const appendExtraFolderContainer = (modelType = 'loras') => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.id = `extraFolderPaths-${modelType}`;
|
||||||
|
document.body.appendChild(container);
|
||||||
|
return container;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('renders a browse button in extra folder path rows', () => {
|
||||||
|
const manager = createManager();
|
||||||
|
appendExtraFolderContainer('loras');
|
||||||
|
|
||||||
|
manager.addExtraFolderPathRow('loras', '/models/loras', false);
|
||||||
|
|
||||||
|
const row = document.querySelector('.extra-folder-path-row');
|
||||||
|
const browseBtn = row.querySelector('.browse-path-btn');
|
||||||
|
expect(browseBtn).not.toBeNull();
|
||||||
|
expect(browseBtn.querySelector('i.fas.fa-folder-open')).not.toBeNull();
|
||||||
|
// Browse button sits before the remove button.
|
||||||
|
expect(browseBtn.nextElementSibling.classList.contains('remove-path-btn')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picker selection routes through updateExtraFolderPaths', () => {
|
||||||
|
const manager = createManager();
|
||||||
|
appendExtraFolderContainer('loras');
|
||||||
|
const updateSpy = vi
|
||||||
|
.spyOn(manager, 'updateExtraFolderPaths')
|
||||||
|
.mockResolvedValue();
|
||||||
|
|
||||||
|
manager.addExtraFolderPathRow('loras', '/models/loras', false);
|
||||||
|
const row = document.querySelector('.extra-folder-path-row');
|
||||||
|
const input = row.querySelector('.extra-folder-path-input');
|
||||||
|
const browseBtn = row.querySelector('.browse-path-btn');
|
||||||
|
|
||||||
|
manager.browseForPathRow(browseBtn, 'loras');
|
||||||
|
|
||||||
|
expect(directoryPickerModal.open).toHaveBeenCalledTimes(1);
|
||||||
|
const openArgs = directoryPickerModal.open.mock.calls[0][0];
|
||||||
|
expect(openArgs.initialPath).toBe('/models/loras');
|
||||||
|
|
||||||
|
openArgs.onSelect('/picked/loras');
|
||||||
|
|
||||||
|
expect(input.value).toBe('/picked/loras');
|
||||||
|
expect(updateSpy).toHaveBeenCalledWith('loras');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('model path rows route through updateModelFolderPaths', () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.id = 'modelFolderPaths-loras';
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const updateSpy = vi
|
||||||
|
.spyOn(manager, 'updateModelFolderPaths')
|
||||||
|
.mockResolvedValue();
|
||||||
|
|
||||||
|
manager.addModelFolderPathRow('loras', '/models/loras', false);
|
||||||
|
const row = container.querySelector('.extra-folder-path-row');
|
||||||
|
const input = row.querySelector('.extra-folder-path-input');
|
||||||
|
const browseBtn = row.querySelector('.browse-path-btn');
|
||||||
|
expect(browseBtn).not.toBeNull();
|
||||||
|
|
||||||
|
manager.browseForPathRow(browseBtn, 'loras', true);
|
||||||
|
const openArgs = directoryPickerModal.open.mock.calls[0][0];
|
||||||
|
openArgs.onSelect('/picked/loras');
|
||||||
|
|
||||||
|
expect(input.value).toBe('/picked/loras');
|
||||||
|
expect(updateSpy).toHaveBeenCalledWith('loras');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,6 +25,8 @@ describe('Other Models disabled page', () => {
|
|||||||
document.body.innerHTML = [
|
document.body.innerHTML = [
|
||||||
'<button id="enableOtherModelsBtn"></button>',
|
'<button id="enableOtherModelsBtn"></button>',
|
||||||
'<button id="openOtherModelsSettingsBtn"></button>',
|
'<button id="openOtherModelsSettingsBtn"></button>',
|
||||||
|
'<button id="openModelPathsSettingsBtn"></button>',
|
||||||
|
'<button id="openSettingsFolderBtn"></button>',
|
||||||
].join('');
|
].join('');
|
||||||
|
|
||||||
Object.defineProperty(window, 'location', {
|
Object.defineProperty(window, 'location', {
|
||||||
@@ -64,6 +66,73 @@ describe('Other Models disabled page', () => {
|
|||||||
expect(showModal).toHaveBeenCalledWith('settingsModal');
|
expect(showModal).toHaveBeenCalledWith('settingsModal');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('opens the Model Paths settings from the standalone no-folders state', async () => {
|
||||||
|
const showModal = vi.fn();
|
||||||
|
window.modalManager = { showModal };
|
||||||
|
|
||||||
|
const navItem = document.createElement('button');
|
||||||
|
navItem.className = 'settings-nav-item';
|
||||||
|
navItem.dataset.section = 'modelPaths';
|
||||||
|
const navClick = vi.fn();
|
||||||
|
navItem.addEventListener('click', navClick);
|
||||||
|
document.body.appendChild(navItem);
|
||||||
|
|
||||||
|
document.getElementById('openModelPathsSettingsBtn').dispatchEvent(
|
||||||
|
new MouseEvent('click', { bubbles: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(showModal).toHaveBeenCalledWith('settingsModal');
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
expect(navClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reveals the settings.json location from the standalone no-folders state', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, message: 'Opened settings folder' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = document.getElementById('openSettingsFolderBtn');
|
||||||
|
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
await vi.waitFor(() => expect(showToastMock).toHaveBeenCalled());
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/lm/settings/open-location',
|
||||||
|
expect.objectContaining({ method: 'POST' }),
|
||||||
|
);
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
|
'settings.openSettingsFileLocation.success',
|
||||||
|
{},
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
expect(button.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies the settings path to the clipboard in Docker mode', async () => {
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
value: { writeText },
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, mode: 'clipboard', path: '/data/settings.json' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('openSettingsFolderBtn').dispatchEvent(
|
||||||
|
new MouseEvent('click', { bubbles: true }),
|
||||||
|
);
|
||||||
|
await vi.waitFor(() => expect(showToastMock).toHaveBeenCalled());
|
||||||
|
|
||||||
|
expect(writeText).toHaveBeenCalledWith('/data/settings.json');
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
|
'settings.openSettingsFileLocation.copied',
|
||||||
|
{ path: '/data/settings.json' },
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('enables Other Models through the settings API and reloads', async () => {
|
it('enables Other Models through the settings API and reloads', async () => {
|
||||||
global.fetch = vi.fn().mockResolvedValue({
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
'language': 'en',
|
'language': 'en',
|
||||||
'llm_api_key_set': False,
|
'llm_api_key_set': False,
|
||||||
'other_models_paths_available': False,
|
'other_models_paths_available': False,
|
||||||
|
'standalone_mode': False,
|
||||||
'theme': 'dark',
|
'theme': 'dark',
|
||||||
}),
|
}),
|
||||||
'success': True,
|
'success': True,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from py.services.metadata_sync_service import MetadataSyncService
|
|||||||
from py.services.model_file_service import AutoOrganizeResult
|
from py.services.model_file_service import AutoOrganizeResult
|
||||||
from py.services.model_update_service import ModelVersionRecord
|
from py.services.model_update_service import ModelVersionRecord
|
||||||
from py.services.service_registry import ServiceRegistry
|
from py.services.service_registry import ServiceRegistry
|
||||||
|
from py.services.use_cases import FilenameTemplateUseCase
|
||||||
from py.services.websocket_manager import ws_manager
|
from py.services.websocket_manager import ws_manager
|
||||||
from py.utils.exif_utils import ExifUtils
|
from py.utils.exif_utils import ExifUtils
|
||||||
from py.utils.metadata_manager import MetadataManager
|
from py.utils.metadata_manager import MetadataManager
|
||||||
@@ -126,9 +127,11 @@ async def create_test_client(service) -> TestClient[Any, Any]:
|
|||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def reset_ws_manager_state():
|
def reset_ws_manager_state():
|
||||||
ws_manager.cleanup_auto_organize_progress()
|
ws_manager.cleanup_auto_organize_progress()
|
||||||
|
ws_manager.cleanup_filename_template_progress()
|
||||||
ws_manager._download_progress.clear()
|
ws_manager._download_progress.clear()
|
||||||
yield
|
yield
|
||||||
ws_manager.cleanup_auto_organize_progress()
|
ws_manager.cleanup_auto_organize_progress()
|
||||||
|
ws_manager.cleanup_filename_template_progress()
|
||||||
ws_manager._download_progress.clear()
|
ws_manager._download_progress.clear()
|
||||||
|
|
||||||
|
|
||||||
@@ -762,6 +765,105 @@ def test_auto_organize_conflict_when_running(mock_service):
|
|||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_filename_template_route_emits_progress(
|
||||||
|
mock_service, monkeypatch: pytest.MonkeyPatch
|
||||||
|
):
|
||||||
|
async def fake_execute(self, file_paths=None, progress_callback=None):
|
||||||
|
result = AutoOrganizeResult()
|
||||||
|
result.total = 1
|
||||||
|
result.processed = 1
|
||||||
|
result.success_count = 1
|
||||||
|
result.operation_type = "filename_template"
|
||||||
|
if progress_callback is not None:
|
||||||
|
await progress_callback.on_progress(
|
||||||
|
{"type": "filename_template_progress", "status": "started"}
|
||||||
|
)
|
||||||
|
await progress_callback.on_progress(
|
||||||
|
{"type": "filename_template_progress", "status": "completed"}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
monkeypatch.setattr(FilenameTemplateUseCase, "execute", fake_execute)
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
client = await create_test_client(mock_service)
|
||||||
|
try:
|
||||||
|
response = await client.post(
|
||||||
|
"/api/lm/test-models/apply-filename-template",
|
||||||
|
json={"file_paths": ["/tmp/a.safetensors"]},
|
||||||
|
)
|
||||||
|
payload = await response.json()
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["summary"]["operation_type"] == "filename_template"
|
||||||
|
|
||||||
|
progress = ws_manager.get_filename_template_progress()
|
||||||
|
assert progress is not None
|
||||||
|
assert progress["status"] == "completed"
|
||||||
|
# Auto-organize progress state must stay untouched.
|
||||||
|
assert ws_manager.get_auto_organize_progress() is None
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_filename_template_get_parses_query_file_paths(
|
||||||
|
mock_service, monkeypatch: pytest.MonkeyPatch
|
||||||
|
):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_execute(self, file_paths=None, progress_callback=None):
|
||||||
|
captured["file_paths"] = file_paths
|
||||||
|
result = AutoOrganizeResult()
|
||||||
|
result.operation_type = "filename_template"
|
||||||
|
return result
|
||||||
|
|
||||||
|
monkeypatch.setattr(FilenameTemplateUseCase, "execute", fake_execute)
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
client = await create_test_client(mock_service)
|
||||||
|
try:
|
||||||
|
response = await client.get(
|
||||||
|
"/api/lm/test-models/apply-filename-template",
|
||||||
|
params={"file_paths": "/tmp/a.safetensors, /tmp/b.safetensors"},
|
||||||
|
)
|
||||||
|
payload = await response.json()
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert captured["file_paths"] == [
|
||||||
|
"/tmp/a.safetensors",
|
||||||
|
"/tmp/b.safetensors",
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_filename_template_conflict_when_running(mock_service):
|
||||||
|
async def scenario():
|
||||||
|
client = await create_test_client(mock_service)
|
||||||
|
try:
|
||||||
|
await ws_manager.broadcast_filename_template_progress(
|
||||||
|
{"type": "filename_template_progress", "status": "started"}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.post("/api/lm/test-models/apply-filename-template")
|
||||||
|
payload = await response.json()
|
||||||
|
|
||||||
|
assert response.status == 409
|
||||||
|
assert payload == {
|
||||||
|
"success": False,
|
||||||
|
"error": "Another library operation is already running. Please wait for it to complete.",
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
def test_download_model_returns_skipped_success(mock_service, download_manager_stub):
|
def test_download_model_returns_skipped_success(mock_service, download_manager_stub):
|
||||||
async def scenario():
|
async def scenario():
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py.routes.handlers.misc_handlers import FileSystemHandler
|
||||||
|
|
||||||
|
|
||||||
|
def _make_handler() -> FileSystemHandler:
|
||||||
|
# browse_directory/validate_path never touch the settings service
|
||||||
|
return FileSystemHandler(settings_service=SimpleNamespace())
|
||||||
|
|
||||||
|
|
||||||
|
class _Request:
|
||||||
|
def __init__(self, body: dict) -> None:
|
||||||
|
self._body = body
|
||||||
|
|
||||||
|
async def json(self):
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
|
||||||
|
async def _browse(handler: FileSystemHandler, path: str):
|
||||||
|
response = await handler.browse_directory(_Request({"path": path}))
|
||||||
|
return response, json.loads(response.text)
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate(handler: FileSystemHandler, path: str, expect: str = "directory"):
|
||||||
|
response = await handler.validate_path(
|
||||||
|
_Request({"path": path, "expect": expect})
|
||||||
|
)
|
||||||
|
return response, json.loads(response.text)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browse_directory_empty_path_defaults_to_home(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||||
|
|
||||||
|
response, payload = await _browse(_make_handler(), "")
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["current_path"] == str(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browse_directory_lists_subdirs_sorted_and_filters(tmp_path):
|
||||||
|
(tmp_path / "zeta").mkdir()
|
||||||
|
(tmp_path / "alpha").mkdir()
|
||||||
|
(tmp_path / ".hidden").mkdir()
|
||||||
|
(tmp_path / "node_modules").mkdir()
|
||||||
|
(tmp_path / "__pycache__").mkdir()
|
||||||
|
|
||||||
|
response, payload = await _browse(_make_handler(), str(tmp_path))
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert [d["name"] for d in payload["directories"]] == ["alpha", "zeta"]
|
||||||
|
assert payload["directory_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browse_directory_missing_returns_404(tmp_path):
|
||||||
|
response, payload = await _browse(_make_handler(), str(tmp_path / "nope"))
|
||||||
|
|
||||||
|
assert response.status == 404
|
||||||
|
assert payload["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browse_directory_file_path_returns_400(tmp_path):
|
||||||
|
file_path = tmp_path / "file.txt"
|
||||||
|
file_path.write_text("x")
|
||||||
|
|
||||||
|
response, payload = await _browse(_make_handler(), str(file_path))
|
||||||
|
|
||||||
|
assert response.status == 400
|
||||||
|
assert payload["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browse_directory_relative_path_returns_403(monkeypatch):
|
||||||
|
# resolve() normally absolutizes relative paths against the cwd; bypass it
|
||||||
|
# to exercise the access-denied branch directly.
|
||||||
|
monkeypatch.setattr(Path, "resolve", lambda self: self)
|
||||||
|
|
||||||
|
response, payload = await _browse(_make_handler(), "relative/path")
|
||||||
|
|
||||||
|
assert response.status == 403
|
||||||
|
assert payload["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_path_existing_directory(tmp_path):
|
||||||
|
response, payload = await _validate(_make_handler(), str(tmp_path))
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload == {
|
||||||
|
"success": True,
|
||||||
|
"path": os.path.abspath(str(tmp_path)),
|
||||||
|
"exists": True,
|
||||||
|
"is_directory": True,
|
||||||
|
"readable": True,
|
||||||
|
"writable": True,
|
||||||
|
"error_code": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_path_not_found(tmp_path):
|
||||||
|
response, payload = await _validate(_make_handler(), str(tmp_path / "missing"))
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["exists"] is False
|
||||||
|
assert payload["error_code"] == "path_not_found"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_path_file_when_directory_expected(tmp_path):
|
||||||
|
file_path = tmp_path / "file.txt"
|
||||||
|
file_path.write_text("x")
|
||||||
|
|
||||||
|
response, payload = await _validate(_make_handler(), str(file_path))
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["error_code"] == "not_a_directory"
|
||||||
|
assert payload["exists"] is True
|
||||||
|
assert payload["is_directory"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_path_expect_file_on_file(tmp_path):
|
||||||
|
file_path = tmp_path / "file.txt"
|
||||||
|
file_path.write_text("x")
|
||||||
|
|
||||||
|
response, payload = await _validate(_make_handler(), str(file_path), expect="file")
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["error_code"] is None
|
||||||
|
assert payload["exists"] is True
|
||||||
|
assert payload["is_directory"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
not hasattr(os, "geteuid") or os.geteuid() == 0,
|
||||||
|
reason="root bypasses permission checks",
|
||||||
|
)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_path_unreadable_directory(tmp_path):
|
||||||
|
locked = tmp_path / "locked"
|
||||||
|
locked.mkdir()
|
||||||
|
locked.chmod(0o000)
|
||||||
|
try:
|
||||||
|
response, payload = await _validate(_make_handler(), str(locked))
|
||||||
|
finally:
|
||||||
|
locked.chmod(0o755)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["error_code"] == "not_readable"
|
||||||
|
assert payload["readable"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_path_empty_path_returns_400():
|
||||||
|
response, payload = await _validate(_make_handler(), "")
|
||||||
|
|
||||||
|
assert response.status == 400
|
||||||
|
assert payload["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_path_expands_user(tmp_path, monkeypatch):
|
||||||
|
subdir = tmp_path / "subdir"
|
||||||
|
subdir.mkdir()
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
|
||||||
|
response, payload = await _validate(_make_handler(), "~/subdir")
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["error_code"] is None
|
||||||
|
assert payload["path"] == os.path.abspath(str(subdir))
|
||||||
@@ -532,6 +532,62 @@ async def test_open_backup_location_uses_settings_directory(tmp_path, monkeypatc
|
|||||||
assert calls == [["xdg-open", str(backup_dir)]]
|
assert calls == [["xdg-open", str(backup_dir)]]
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
path to the browser instead of reporting a success that never happened."""
|
||||||
|
settings_file = tmp_path / "settings" / "settings.json"
|
||||||
|
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
settings_file.write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
handler = FileSystemHandler(settings_service=SimpleNamespace(settings_file=str(settings_file)))
|
||||||
|
|
||||||
|
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_settings_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(settings_file)
|
||||||
|
assert popen_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_open_settings_location_with_display_opens_folder(tmp_path, monkeypatch):
|
||||||
|
settings_file = tmp_path / "settings" / "settings.json"
|
||||||
|
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
settings_file.write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
handler = FileSystemHandler(settings_service=SimpleNamespace(settings_file=str(settings_file)))
|
||||||
|
|
||||||
|
monkeypatch.setenv("DISPLAY", ":0")
|
||||||
|
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_docker", lambda: False)
|
||||||
|
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_wsl", lambda: False)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_popen(args):
|
||||||
|
calls.append(args)
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||||||
|
|
||||||
|
response = await handler.open_settings_location(FakeRequest()) # pyright: ignore[reportArgumentType]
|
||||||
|
payload = _json_payload(response)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert calls == [["xdg-open", str(settings_file.parent)]]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_open_wildcards_location_creates_and_opens_directory(tmp_path, monkeypatch):
|
async def test_open_wildcards_location_creates_and_opens_directory(tmp_path, monkeypatch):
|
||||||
wildcards_dir = tmp_path / "settings" / "wildcards"
|
wildcards_dir = tmp_path / "settings" / "wildcards"
|
||||||
@@ -2369,3 +2425,135 @@ async def test_get_init_status_reports_pending_scanners():
|
|||||||
assert "embedding" in payload["details"]
|
assert "embedding" in payload["details"]
|
||||||
assert "recipe" in payload["details"]
|
assert "recipe" in payload["details"]
|
||||||
assert "lora" not in payload["details"]
|
assert "lora" not in payload["details"]
|
||||||
|
|
||||||
|
|
||||||
|
class StaticMetadataProvider:
|
||||||
|
"""Metadata provider returning one fixed CivitAI model payload."""
|
||||||
|
|
||||||
|
def __init__(self, payload):
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
async def get_model_versions(self, _model_id):
|
||||||
|
return self.payload
|
||||||
|
|
||||||
|
async def get_user_models(self, _username, cursor=None):
|
||||||
|
return {"items": [], "nextCursor": None}
|
||||||
|
|
||||||
|
async def get_creator_model_count(self, _username):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _versions_status_handler(payload, *, other_scanner=None):
|
||||||
|
async def metadata_factory():
|
||||||
|
return StaticMetadataProvider(payload)
|
||||||
|
|
||||||
|
async def other_factory():
|
||||||
|
return other_scanner
|
||||||
|
|
||||||
|
return ModelLibraryHandler(
|
||||||
|
ServiceRegistryAdapter(
|
||||||
|
get_lora_scanner=fake_scanner_factory,
|
||||||
|
get_checkpoint_scanner=fake_scanner_factory,
|
||||||
|
get_embedding_scanner=fake_scanner_factory,
|
||||||
|
get_other_scanner=other_factory,
|
||||||
|
get_downloaded_version_history_service=fake_download_history_service_factory,
|
||||||
|
),
|
||||||
|
metadata_provider_factory=metadata_factory,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_model_versions_status_unsupported_type_is_read_only():
|
||||||
|
"""A type with no scanner answers 200 with a read-only list + reason."""
|
||||||
|
handler = _versions_status_handler(
|
||||||
|
{
|
||||||
|
"name": "Wildcards pack",
|
||||||
|
"type": "Wildcards",
|
||||||
|
"modelVersions": [
|
||||||
|
{"id": 11, "name": "v1", "images": [{"url": "https://img/1.png"}]},
|
||||||
|
{"id": 12, "name": "v2", "images": []},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.get_model_versions_status(
|
||||||
|
FakeRequest(query={"modelId": "45448"}) # pyright: ignore[reportArgumentType]
|
||||||
|
)
|
||||||
|
payload = _json_payload(response)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["supported"] is False
|
||||||
|
assert payload["reason"] == "model_type_unsupported"
|
||||||
|
assert payload["modelType"] == "wildcards"
|
||||||
|
assert payload["versions"] == [
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"name": "v1",
|
||||||
|
"thumbnailUrl": "https://img/1.png",
|
||||||
|
"inLibrary": False,
|
||||||
|
"hasBeenDownloaded": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"name": "v2",
|
||||||
|
"thumbnailUrl": None,
|
||||||
|
"inLibrary": False,
|
||||||
|
"hasBeenDownloaded": False,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_model_versions_status_other_disabled_is_read_only():
|
||||||
|
"""The opt-in gate keeps its own reason instead of the permanent one."""
|
||||||
|
_set_other_models_enabled(False)
|
||||||
|
handler = _versions_status_handler(
|
||||||
|
{
|
||||||
|
"name": "SDXL VAE",
|
||||||
|
"type": "VAE",
|
||||||
|
"modelVersions": [{"id": 333245, "name": "SDXL-VAE", "images": []}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.get_model_versions_status(
|
||||||
|
FakeRequest(query={"modelId": "296576"}) # pyright: ignore[reportArgumentType]
|
||||||
|
)
|
||||||
|
payload = _json_payload(response)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["supported"] is False
|
||||||
|
assert payload["reason"] == "other_models_disabled"
|
||||||
|
assert payload["modelType"] == "vae"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_model_versions_status_supported_type_stays_interactive():
|
||||||
|
"""A managed type keeps the existing enriched, fully interactive payload."""
|
||||||
|
handler = _versions_status_handler(
|
||||||
|
{
|
||||||
|
"name": "Some LoRA",
|
||||||
|
"type": "LORA",
|
||||||
|
"modelVersions": [{"id": 1, "name": "v1", "images": []}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.get_model_versions_status(
|
||||||
|
FakeRequest(query={"modelId": "5"}) # pyright: ignore[reportArgumentType]
|
||||||
|
)
|
||||||
|
payload = _json_payload(response)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["supported"] is True
|
||||||
|
assert "reason" not in payload
|
||||||
|
assert payload["versions"] == [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "v1",
|
||||||
|
"thumbnailUrl": None,
|
||||||
|
"inLibrary": False,
|
||||||
|
"hasBeenDownloaded": False,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|||||||
@@ -121,6 +121,25 @@ def test_page_context_reports_feature_state(monkeypatch):
|
|||||||
assert provider(None) == {"other_disabled": True, "other_no_paths": False}
|
assert provider(None) == {"other_disabled": True, "other_no_paths": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_context_exposes_settings_file_in_standalone(monkeypatch):
|
||||||
|
"""Standalone users must edit settings.json by hand; the empty state
|
||||||
|
needs the real file path to point them at."""
|
||||||
|
from py.config import config
|
||||||
|
from py.services.settings_manager import get_settings_manager
|
||||||
|
|
||||||
|
manager = get_settings_manager()
|
||||||
|
handler = OtherRoutes()
|
||||||
|
provider = handler._get_page_context_provider()
|
||||||
|
|
||||||
|
monkeypatch.setattr(config, "other_roots", [], raising=False)
|
||||||
|
monkeypatch.setenv("LORA_MANAGER_STANDALONE", "1")
|
||||||
|
|
||||||
|
context = provider(None)
|
||||||
|
assert context["other_no_paths"] is True
|
||||||
|
assert context["standalone_mode"] is True
|
||||||
|
assert context["settings_file"] == manager.settings_file
|
||||||
|
|
||||||
|
|
||||||
def test_get_expected_model_types_mentions_supported_types():
|
def test_get_expected_model_types_mentions_supported_types():
|
||||||
expected = OtherRoutes()._get_expected_model_types()
|
expected = OtherRoutes()._get_expected_model_types()
|
||||||
for name in ("VAE", "Upscaler", "TextEncoder", "CLIPVision", "Controlnet"):
|
for name in ("VAE", "Upscaler", "TextEncoder", "CLIPVision", "Controlnet"):
|
||||||
|
|||||||
@@ -160,3 +160,134 @@ async def test_activate_library_unexpected_error_returns_500(monkeypatch):
|
|||||||
assert response.status == 500
|
assert response.status == 500
|
||||||
assert payload["success"] is False
|
assert payload["success"] is False
|
||||||
assert payload["error"] == "bad things"
|
assert payload["error"] == "bad things"
|
||||||
|
|
||||||
|
|
||||||
|
class DummySettingsForGet:
|
||||||
|
def __init__(self, values=None):
|
||||||
|
self._values = dict(values or {})
|
||||||
|
self.settings_file = "/tmp/settings.json"
|
||||||
|
self.set_calls = []
|
||||||
|
|
||||||
|
def keys(self):
|
||||||
|
return self._values.keys()
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return self._values.get(key, default)
|
||||||
|
|
||||||
|
def set(self, key, value):
|
||||||
|
self.set_calls.append((key, value))
|
||||||
|
self._values[key] = value
|
||||||
|
|
||||||
|
def get_startup_messages(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def make_get_handler(values=None) -> SettingsHandler:
|
||||||
|
return SettingsHandler(
|
||||||
|
settings_service=DummySettingsForGet(values),
|
||||||
|
metadata_provider_updater=noop_async,
|
||||||
|
downloader_factory=dummy_downloader_factory,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def patch_other_models_availability(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
config,
|
||||||
|
"get_other_models_availability",
|
||||||
|
lambda: {"available": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_settings_plugin_mode_hides_folder_paths(
|
||||||
|
monkeypatch, patch_other_models_availability
|
||||||
|
):
|
||||||
|
monkeypatch.delenv("LORA_MANAGER_STANDALONE", raising=False)
|
||||||
|
handler = make_get_handler(
|
||||||
|
{
|
||||||
|
"language": "en",
|
||||||
|
"folder_paths": {"loras": ["/models/loras"]},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.get_settings(FakeRequest())
|
||||||
|
payload = json_payload(response)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
settings = payload["settings"]
|
||||||
|
assert settings["standalone_mode"] is False
|
||||||
|
assert "folder_paths" not in settings
|
||||||
|
assert "folder_path_schema" not in settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_settings_standalone_exposes_folder_paths_and_schema(
|
||||||
|
monkeypatch, patch_other_models_availability
|
||||||
|
):
|
||||||
|
monkeypatch.setenv("LORA_MANAGER_STANDALONE", "1")
|
||||||
|
folder_paths = {"loras": ["/models/loras"], "vae": ["/models/vae"]}
|
||||||
|
handler = make_get_handler({"language": "en", "folder_paths": folder_paths})
|
||||||
|
|
||||||
|
response = await handler.get_settings(FakeRequest())
|
||||||
|
payload = json_payload(response)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
settings = payload["settings"]
|
||||||
|
assert settings["standalone_mode"] is True
|
||||||
|
assert settings["folder_paths"] == folder_paths
|
||||||
|
|
||||||
|
schema = settings["folder_path_schema"]
|
||||||
|
core_keys = [entry["key"] for entry in schema if entry["category"] == "core"]
|
||||||
|
assert core_keys == ["loras", "checkpoints", "unet", "embeddings"]
|
||||||
|
other_entries = {entry["key"]: entry for entry in schema if entry["category"] == "other"}
|
||||||
|
assert other_entries["vae"]["sub_type"] == "vae"
|
||||||
|
assert other_entries["text_encoders"]["sub_type"] == "text_encoder"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_settings_passes_folder_paths_through(
|
||||||
|
monkeypatch, patch_other_models_availability
|
||||||
|
):
|
||||||
|
monkeypatch.setenv("LORA_MANAGER_STANDALONE", "1")
|
||||||
|
handler = make_get_handler({"folder_paths": {}})
|
||||||
|
new_paths = {"loras": ["/models/loras"]}
|
||||||
|
|
||||||
|
response = await handler.update_settings(
|
||||||
|
FakeRequest(json_data={"folder_paths": new_paths})
|
||||||
|
)
|
||||||
|
payload = json_payload(response)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert handler._settings.set_calls == [("folder_paths", new_paths)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_settings_standalone_filters_template_placeholders(
|
||||||
|
monkeypatch, patch_other_models_availability
|
||||||
|
):
|
||||||
|
"""Fresh installs are seeded from settings.json.example; its placeholder
|
||||||
|
paths must not show up as real values in the Model Paths UI."""
|
||||||
|
monkeypatch.setenv("LORA_MANAGER_STANDALONE", "1")
|
||||||
|
handler = make_get_handler(
|
||||||
|
{
|
||||||
|
"folder_paths": {
|
||||||
|
"loras": ["C:/path/to/your/loras_folder", "/real/loras"],
|
||||||
|
"vae": ["C:/path/to/another/vae_folder"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
handler._settings.get_template_folder_path_placeholders = lambda: {
|
||||||
|
"C:/path/to/your/loras_folder",
|
||||||
|
"C:/path/to/another/vae_folder",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await handler.get_settings(FakeRequest())
|
||||||
|
payload = json_payload(response)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["settings"]["folder_paths"] == {
|
||||||
|
"loras": ["/real/loras"],
|
||||||
|
"vae": [],
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""Tests for the post-download filename template rename phase."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py.services.download_manager import DownloadManager
|
||||||
|
from py.services.service_registry import ServiceRegistry
|
||||||
|
from py.services.settings_manager import get_settings_manager
|
||||||
|
|
||||||
|
|
||||||
|
class DummyScanner:
|
||||||
|
def __init__(self, root: Path):
|
||||||
|
self._root = root
|
||||||
|
self.model_type = "lora"
|
||||||
|
self.updates = []
|
||||||
|
|
||||||
|
def get_model_roots(self):
|
||||||
|
return [str(self._root)]
|
||||||
|
|
||||||
|
async def update_single_model_cache(self, original_path, new_path, metadata):
|
||||||
|
self.updates.append((original_path, new_path, metadata))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def download_manager() -> DownloadManager:
|
||||||
|
return DownloadManager()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def no_recipe_scanner(monkeypatch: pytest.MonkeyPatch):
|
||||||
|
async def _no_scanner():
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(ServiceRegistry, "get_recipe_scanner", _no_scanner)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_template(template: str, model_type: str = "lora") -> None:
|
||||||
|
manager = get_settings_manager()
|
||||||
|
templates = dict(manager.settings.get("download_filename_templates") or {})
|
||||||
|
templates[model_type] = template
|
||||||
|
manager.settings["download_filename_templates"] = templates
|
||||||
|
|
||||||
|
|
||||||
|
def _write_model(root: Path, stem: str, model_name: str, sha256: str) -> Path:
|
||||||
|
model_path = root / f"{stem}.safetensors"
|
||||||
|
model_path.write_bytes(b"model")
|
||||||
|
metadata_path = root / f"{stem}.metadata.json"
|
||||||
|
metadata_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"file_name": stem,
|
||||||
|
"file_path": model_path.as_posix(),
|
||||||
|
"model_name": model_name,
|
||||||
|
"sha256": sha256,
|
||||||
|
"civitai": {"id": 1},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return model_path
|
||||||
|
|
||||||
|
|
||||||
|
async def test_download_rename_applies_filename_template(
|
||||||
|
tmp_path: Path, download_manager: DownloadManager
|
||||||
|
):
|
||||||
|
_set_template("{model_name}-{hash_short}")
|
||||||
|
model_path = _write_model(tmp_path, "V1", "My Model", "abcdef0123456789")
|
||||||
|
download_manager._active_downloads["dl1"] = {"file_path": model_path.as_posix()}
|
||||||
|
|
||||||
|
downloaded_metadata = [
|
||||||
|
{
|
||||||
|
"file_path": model_path.as_posix(),
|
||||||
|
"file_name": "V1",
|
||||||
|
"model_name": "My Model",
|
||||||
|
"sha256": "abcdef0123456789",
|
||||||
|
"civitai": {"id": 1},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
await download_manager._apply_download_filename_template(
|
||||||
|
scanner=DummyScanner(tmp_path),
|
||||||
|
model_type="lora",
|
||||||
|
downloaded_metadata=downloaded_metadata,
|
||||||
|
download_id="dl1",
|
||||||
|
)
|
||||||
|
|
||||||
|
new_path = tmp_path / "My Model-abcdef0123.safetensors"
|
||||||
|
assert new_path.exists()
|
||||||
|
assert not model_path.exists()
|
||||||
|
|
||||||
|
new_metadata = json.loads(
|
||||||
|
(tmp_path / "My Model-abcdef0123.metadata.json").read_text()
|
||||||
|
)
|
||||||
|
assert new_metadata["original_file_name"] == "V1"
|
||||||
|
|
||||||
|
assert (
|
||||||
|
download_manager._active_downloads["dl1"]["file_path"]
|
||||||
|
== new_path.as_posix()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_download_rename_keeps_original_on_conflict(
|
||||||
|
tmp_path: Path, download_manager: DownloadManager
|
||||||
|
):
|
||||||
|
_set_template("{model_name}-{hash_short}")
|
||||||
|
model_path = _write_model(tmp_path, "V1", "My Model", "abcdef0123456789")
|
||||||
|
# Conflicting target already exists.
|
||||||
|
(tmp_path / "My Model-abcdef0123.safetensors").write_bytes(b"other")
|
||||||
|
|
||||||
|
downloaded_metadata = [
|
||||||
|
{
|
||||||
|
"file_path": model_path.as_posix(),
|
||||||
|
"file_name": "V1",
|
||||||
|
"model_name": "My Model",
|
||||||
|
"sha256": "abcdef0123456789",
|
||||||
|
"civitai": {"id": 1},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Must not raise: a rename conflict never fails the download.
|
||||||
|
await download_manager._apply_download_filename_template(
|
||||||
|
scanner=DummyScanner(tmp_path),
|
||||||
|
model_type="lora",
|
||||||
|
downloaded_metadata=downloaded_metadata,
|
||||||
|
download_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert model_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_download_rename_noop_without_template(
|
||||||
|
tmp_path: Path, download_manager: DownloadManager
|
||||||
|
):
|
||||||
|
_set_template("")
|
||||||
|
model_path = _write_model(tmp_path, "V1", "My Model", "abcdef0123456789")
|
||||||
|
|
||||||
|
await download_manager._apply_download_filename_template(
|
||||||
|
scanner=DummyScanner(tmp_path),
|
||||||
|
model_type="lora",
|
||||||
|
downloaded_metadata=[{"file_path": model_path.as_posix()}],
|
||||||
|
download_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert model_path.exists()
|
||||||
|
assert (tmp_path / "V1.metadata.json").exists()
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Tests for LoraService.get_cycler_list usage_tips exposure."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock, AsyncMock
|
||||||
|
|
||||||
|
from py.services.lora_service import LoraService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def lora_service():
|
||||||
|
"""Create a LoraService instance with a mocked scanner cache."""
|
||||||
|
scanner = Mock()
|
||||||
|
cache_mock = Mock()
|
||||||
|
cache_mock.raw_data = [
|
||||||
|
{
|
||||||
|
"file_name": "with_tips.safetensors",
|
||||||
|
"folder": "sub",
|
||||||
|
"model_name": "With Tips",
|
||||||
|
"usage_tips": '{"strength": 0.6, "strength_min": 0.4, "strength_max": 0.8}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_name": "empty_tips.safetensors",
|
||||||
|
"folder": "",
|
||||||
|
"model_name": "Empty Tips",
|
||||||
|
"usage_tips": "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_name": "no_tips.safetensors",
|
||||||
|
"folder": "",
|
||||||
|
"model_name": "No Tips",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
scanner.get_cached_data = AsyncMock(return_value=cache_mock)
|
||||||
|
return LoraService(scanner)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cycler_list_includes_usage_tips_when_present(lora_service):
|
||||||
|
loras = await lora_service.get_cycler_list()
|
||||||
|
|
||||||
|
with_tips = next(l for l in loras if l["file_name"] == "sub/with_tips.safetensors")
|
||||||
|
assert with_tips["usage_tips"] == (
|
||||||
|
'{"strength": 0.6, "strength_min": 0.4, "strength_max": 0.8}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cycler_list_omits_usage_tips_when_empty_or_missing(lora_service):
|
||||||
|
loras = await lora_service.get_cycler_list()
|
||||||
|
|
||||||
|
empty_tips = next(l for l in loras if l["file_name"] == "empty_tips.safetensors")
|
||||||
|
no_tips = next(l for l in loras if l["file_name"] == "no_tips.safetensors")
|
||||||
|
assert "usage_tips" not in empty_tips
|
||||||
|
assert "usage_tips" not in no_tips
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cycler_list_keeps_existing_fields(lora_service):
|
||||||
|
loras = await lora_service.get_cycler_list()
|
||||||
|
|
||||||
|
with_tips = next(l for l in loras if l["model_name"] == "With Tips")
|
||||||
|
assert with_tips["file_name"] == "sub/with_tips.safetensors"
|
||||||
|
assert with_tips["folder"] == "sub"
|
||||||
|
assert with_tips["model_name"] == "With Tips"
|
||||||
@@ -424,6 +424,52 @@ async def test_rename_model_preserves_extension(tmp_path: Path):
|
|||||||
assert payload["file_name"] == new_name
|
assert payload["file_name"] == new_name
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_model_records_original_file_name(tmp_path: Path):
|
||||||
|
old_name = "V1"
|
||||||
|
new_name = "flux-my-model-v3"
|
||||||
|
|
||||||
|
model_path = tmp_path / f"{old_name}.safetensors"
|
||||||
|
model_path.write_bytes(b"model")
|
||||||
|
|
||||||
|
metadata_path = tmp_path / f"{old_name}.metadata.json"
|
||||||
|
metadata_payload = {
|
||||||
|
"file_name": old_name,
|
||||||
|
"file_path": model_path.as_posix(),
|
||||||
|
}
|
||||||
|
metadata_path.write_text(json.dumps(metadata_payload))
|
||||||
|
|
||||||
|
async def metadata_loader(path: str):
|
||||||
|
with open(path, "r", encoding="utf-8") as handle:
|
||||||
|
return json.load(handle)
|
||||||
|
|
||||||
|
service = ModelLifecycleService(
|
||||||
|
scanner=DummyScanner(),
|
||||||
|
metadata_manager=PassthroughMetadataManager(),
|
||||||
|
metadata_loader=metadata_loader,
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.rename_model(
|
||||||
|
file_path=model_path.as_posix(),
|
||||||
|
new_file_name=new_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
saved_metadata = json.loads((tmp_path / f"{new_name}.metadata.json").read_text())
|
||||||
|
assert saved_metadata["original_file_name"] == old_name
|
||||||
|
|
||||||
|
# A second rename keeps the very first recorded name.
|
||||||
|
second_name = "flux-my-model-v4"
|
||||||
|
await service.rename_model(
|
||||||
|
file_path=(tmp_path / f"{new_name}.safetensors").as_posix(),
|
||||||
|
new_file_name=second_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
saved_metadata = json.loads(
|
||||||
|
(tmp_path / f"{second_name}.metadata.json").read_text()
|
||||||
|
)
|
||||||
|
assert saved_metadata["original_file_name"] == old_name
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_rename_model_with_dotted_basename(tmp_path: Path):
|
async def test_rename_model_with_dotted_basename(tmp_path: Path):
|
||||||
old_name = "model.v1"
|
old_name = "model.v1"
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Tests for the portable-mode flag lifecycle (issue #1114 follow-up).
|
||||||
|
|
||||||
|
``LORA_MANAGER_PORTABLE=1`` persists ``use_portable_settings: true`` into the
|
||||||
|
plugin''s own settings.json. That is convenient for repeat runs, but it used to
|
||||||
|
be a one-way trip: the flag made every instance sharing that plugin folder read
|
||||||
|
(and write) the portable settings directory, and the only way back was editing
|
||||||
|
settings.json by hand. ``LORA_MANAGER_PORTABLE=0`` is now the explicit exit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py.services import settings_manager as settings_manager_module
|
||||||
|
from py.services.settings_manager import SettingsManager
|
||||||
|
|
||||||
|
|
||||||
|
def _write_settings(path, **extra):
|
||||||
|
payload = {
|
||||||
|
"folder_paths": {"loras": ["/loras"]},
|
||||||
|
}
|
||||||
|
payload.update(extra)
|
||||||
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def isolated_settings_path(tmp_path, monkeypatch):
|
||||||
|
"""Point SettingsManager at a settings.json we control."""
|
||||||
|
settings_path = tmp_path / "settings.json"
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.settings_manager.ensure_settings_file",
|
||||||
|
lambda logger=None: str(settings_path),
|
||||||
|
)
|
||||||
|
settings_manager_module.reset_settings_manager()
|
||||||
|
yield settings_path
|
||||||
|
settings_manager_module.reset_settings_manager()
|
||||||
|
|
||||||
|
|
||||||
|
def test_portable_env_enables_and_persists_the_flag(
|
||||||
|
isolated_settings_path, monkeypatch
|
||||||
|
):
|
||||||
|
_write_settings(isolated_settings_path)
|
||||||
|
monkeypatch.setenv("LORA_MANAGER_PORTABLE", "1")
|
||||||
|
|
||||||
|
manager = SettingsManager()
|
||||||
|
|
||||||
|
assert manager.get("use_portable_settings") is True
|
||||||
|
persisted = json.loads(isolated_settings_path.read_text(encoding="utf-8"))
|
||||||
|
assert persisted["use_portable_settings"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_zero_clears_the_persisted_flag(
|
||||||
|
isolated_settings_path, monkeypatch
|
||||||
|
):
|
||||||
|
"""`=0` must undo a previous `=1`, without hand-editing settings.json."""
|
||||||
|
_write_settings(isolated_settings_path, use_portable_settings=True)
|
||||||
|
monkeypatch.setenv("LORA_MANAGER_PORTABLE", "0")
|
||||||
|
|
||||||
|
manager = SettingsManager()
|
||||||
|
|
||||||
|
assert manager.get("use_portable_settings") is False
|
||||||
|
persisted = json.loads(isolated_settings_path.read_text(encoding="utf-8"))
|
||||||
|
# A default value is omitted from disk, so the key is gone entirely.
|
||||||
|
assert persisted.get("use_portable_settings") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_unset_env_keeps_the_persisted_flag(
|
||||||
|
isolated_settings_path, monkeypatch
|
||||||
|
):
|
||||||
|
"""Portable mode must persist across runs when the variable is unset."""
|
||||||
|
_write_settings(isolated_settings_path, use_portable_settings=True)
|
||||||
|
monkeypatch.delenv("LORA_MANAGER_PORTABLE", raising=False)
|
||||||
|
|
||||||
|
manager = SettingsManager()
|
||||||
|
|
||||||
|
assert manager.get("use_portable_settings") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_is_a_noop_when_portable_was_never_enabled(
|
||||||
|
isolated_settings_path, monkeypatch
|
||||||
|
):
|
||||||
|
_write_settings(isolated_settings_path)
|
||||||
|
monkeypatch.setenv("LORA_MANAGER_PORTABLE", "0")
|
||||||
|
|
||||||
|
manager = SettingsManager()
|
||||||
|
|
||||||
|
assert manager.get("use_portable_settings") in (False, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pinned_settings_dir_wins_over_portable_env(
|
||||||
|
isolated_settings_path, monkeypatch
|
||||||
|
):
|
||||||
|
"""LORA_MANAGER_SETTINGS_DIR still takes precedence, as documented."""
|
||||||
|
_write_settings(isolated_settings_path)
|
||||||
|
monkeypatch.setenv("LORA_MANAGER_PORTABLE", "1")
|
||||||
|
monkeypatch.setenv("LORA_MANAGER_SETTINGS_DIR", str(isolated_settings_path.parent))
|
||||||
|
|
||||||
|
manager = SettingsManager()
|
||||||
|
|
||||||
|
# The pinned directory already decides the location, so the portable flag
|
||||||
|
# is deliberately left alone.
|
||||||
|
assert not manager.get("use_portable_settings")
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""Regression tests for the recipe empty-prune guard (issue #1116).
|
||||||
|
|
||||||
|
A scan that finds no recipe files at all is not a trustworthy deletion signal:
|
||||||
|
an unmounted drive, a ``recipes_path`` that silently fell back to another LoRA
|
||||||
|
root, or a cache shared with a second instance all look identical to a real
|
||||||
|
wipe. Before this guard, such a scan overwrote the persistent cache with an
|
||||||
|
empty one, destroying the user's only record of their recipes.
|
||||||
|
|
||||||
|
Covered contracts:
|
||||||
|
|
||||||
|
1. ``_reconcile_recipe_cache`` reports the "every persisted file vanished"
|
||||||
|
condition and does not treat an empty directory as a trustworthy prune.
|
||||||
|
2. ``_initialize_recipe_cache_sync`` keeps the stored cache in that case
|
||||||
|
instead of persisting the empty result.
|
||||||
|
3. A partial orphan (some files still present) still prunes normally, so
|
||||||
|
ordinary manual deletions keep working.
|
||||||
|
4. ``PersistentRecipeCache.save_cache(skip_if_empty=True)`` is the
|
||||||
|
storage-level backstop and a manual rebuild can still clear the cache.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py.config import config
|
||||||
|
from py.services import recipe_scanner as recipe_scanner_module
|
||||||
|
from py.services import settings_manager as settings_manager_module
|
||||||
|
from py.services.persistent_recipe_cache import (
|
||||||
|
PersistedRecipeData,
|
||||||
|
PersistentRecipeCache,
|
||||||
|
)
|
||||||
|
from py.services.recipe_cache import RecipeCache
|
||||||
|
from py.services.recipe_scanner import RecipeScanner
|
||||||
|
|
||||||
|
|
||||||
|
def _write_recipe_json(path: Path, recipe_id: str) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": str(path.with_suffix(".png")),
|
||||||
|
"title": f"Recipe {recipe_id}",
|
||||||
|
"modified": 0.0,
|
||||||
|
"created_date": 0.0,
|
||||||
|
"loras": [],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _persisted_for(paths: list[Path]) -> PersistedRecipeData:
|
||||||
|
"""Build persisted cache state describing *paths* as known recipe files."""
|
||||||
|
raw_data = []
|
||||||
|
file_stats = {}
|
||||||
|
for path in paths:
|
||||||
|
recipe_id = path.name[: -len(".recipe.json")]
|
||||||
|
raw_data.append({"id": recipe_id, "title": f"Recipe {recipe_id}"})
|
||||||
|
stat = path.stat()
|
||||||
|
file_stats[str(path)] = (stat.st_mtime, stat.st_size)
|
||||||
|
return PersistedRecipeData(
|
||||||
|
raw_data=raw_data, file_stats=file_stats, image_id_map={}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def guard_scanner(tmp_path: Path, monkeypatch):
|
||||||
|
"""RecipeScanner wired to a real persistent cache, without a ComfyUI app."""
|
||||||
|
RecipeScanner._instance = None
|
||||||
|
settings_manager_module.reset_settings_manager()
|
||||||
|
monkeypatch.setattr(config, "loras_roots", [str(tmp_path / "loras-root")])
|
||||||
|
|
||||||
|
scanner = RecipeScanner.__new__(RecipeScanner)
|
||||||
|
scanner._persistent_cache = PersistentRecipeCache(
|
||||||
|
db_path=str(tmp_path / "recipe_cache.sqlite")
|
||||||
|
)
|
||||||
|
scanner._cache = None
|
||||||
|
scanner._json_path_map = {}
|
||||||
|
scanner._lora_scanner = SimpleNamespace()
|
||||||
|
|
||||||
|
yield scanner, scanner._persistent_cache
|
||||||
|
|
||||||
|
RecipeScanner._instance = None
|
||||||
|
settings_manager_module.reset_settings_manager()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_flags_prune_when_every_persisted_file_is_gone(
|
||||||
|
guard_scanner, tmp_path: Path
|
||||||
|
):
|
||||||
|
"""An empty recipes dir must not be reported as a trustworthy prune."""
|
||||||
|
scanner, _cache = guard_scanner
|
||||||
|
recipes_dir = tmp_path / "recipes"
|
||||||
|
recipes_dir.mkdir()
|
||||||
|
|
||||||
|
# The files used to live at another root (a changed recipes_path) and are
|
||||||
|
# all gone from the directory the scanner resolved this time.
|
||||||
|
old_files = [tmp_path / "elsewhere" / f"r{idx}.recipe.json" for idx in range(3)]
|
||||||
|
for path in old_files:
|
||||||
|
_write_recipe_json(path, path.name[: -len(".recipe.json")])
|
||||||
|
persisted = _persisted_for(old_files)
|
||||||
|
for path in old_files:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
recipes, changed, json_paths, skipped_prune_reason = (
|
||||||
|
scanner._reconcile_recipe_cache(persisted, str(recipes_dir))
|
||||||
|
)
|
||||||
|
|
||||||
|
assert recipes == []
|
||||||
|
assert json_paths == {}
|
||||||
|
assert changed is True
|
||||||
|
assert skipped_prune_reason is not None
|
||||||
|
assert str(recipes_dir) in skipped_prune_reason
|
||||||
|
assert "3" in skipped_prune_reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_prunes_normally_when_only_some_files_disappear(
|
||||||
|
guard_scanner, tmp_path: Path
|
||||||
|
):
|
||||||
|
"""A partial orphan is an ordinary deletion and keeps its old behaviour."""
|
||||||
|
scanner, _cache = guard_scanner
|
||||||
|
recipes_dir = tmp_path / "recipes"
|
||||||
|
|
||||||
|
survivor = recipes_dir / "survivor.recipe.json"
|
||||||
|
_write_recipe_json(survivor, "survivor")
|
||||||
|
vanished = recipes_dir / "vanished.recipe.json"
|
||||||
|
_write_recipe_json(vanished, "vanished")
|
||||||
|
persisted = _persisted_for([survivor, vanished])
|
||||||
|
vanished.unlink()
|
||||||
|
|
||||||
|
recipes, changed, _json_paths, skipped_prune_reason = (
|
||||||
|
scanner._reconcile_recipe_cache(persisted, str(recipes_dir))
|
||||||
|
)
|
||||||
|
|
||||||
|
assert skipped_prune_reason is None
|
||||||
|
assert changed is True
|
||||||
|
assert [recipe["id"] for recipe in recipes] == ["survivor"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_ignores_empty_persisted_cache(guard_scanner, tmp_path: Path):
|
||||||
|
"""A genuinely empty cache has nothing to lose and must not be guarded."""
|
||||||
|
scanner, _cache = guard_scanner
|
||||||
|
recipes_dir = tmp_path / "recipes"
|
||||||
|
recipes_dir.mkdir()
|
||||||
|
|
||||||
|
persisted = PersistedRecipeData(raw_data=[], file_stats={}, image_id_map={})
|
||||||
|
|
||||||
|
_recipes, changed, _json_paths, skipped_prune_reason = (
|
||||||
|
scanner._reconcile_recipe_cache(persisted, str(recipes_dir))
|
||||||
|
)
|
||||||
|
|
||||||
|
assert changed is False
|
||||||
|
assert skipped_prune_reason is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_prunes_when_stored_metadata_is_inconsistent(
|
||||||
|
guard_scanner, tmp_path: Path
|
||||||
|
):
|
||||||
|
"""A stale row set must not masquerade as a fresh mass disappearance.
|
||||||
|
|
||||||
|
Leftover rows (rows without a recorded file stat) mean the stored cache is
|
||||||
|
already out of date; guarding them would preserve orphans forever.
|
||||||
|
"""
|
||||||
|
scanner, _cache = guard_scanner
|
||||||
|
recipes_dir = tmp_path / "recipes"
|
||||||
|
recipes_dir.mkdir()
|
||||||
|
|
||||||
|
gone = tmp_path / "old-location" / "kept.recipe.json"
|
||||||
|
_write_recipe_json(gone, "kept")
|
||||||
|
persisted = _persisted_for([gone])
|
||||||
|
gone.unlink()
|
||||||
|
# A row with no matching file record: the cache diverged at some point.
|
||||||
|
persisted.raw_data.append({"id": "orphan-row", "title": "Orphan"})
|
||||||
|
|
||||||
|
recipes, changed, _json_paths, skipped_prune_reason = (
|
||||||
|
scanner._reconcile_recipe_cache(persisted, str(recipes_dir))
|
||||||
|
)
|
||||||
|
|
||||||
|
assert recipes == []
|
||||||
|
assert changed is True
|
||||||
|
assert skipped_prune_reason is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_init_keeps_stored_cache_when_scan_finds_nothing(
|
||||||
|
guard_scanner, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||||
|
):
|
||||||
|
"""The startup path must not overwrite the stored cache with an empty one."""
|
||||||
|
scanner, cache = guard_scanner
|
||||||
|
recipes_dir = Path(config.loras_roots[0]) / "recipes"
|
||||||
|
gone = tmp_path / "old-location" / "kept.recipe.json"
|
||||||
|
_write_recipe_json(gone, "kept")
|
||||||
|
|
||||||
|
assert cache.save_cache(
|
||||||
|
[{"id": "kept", "title": "Recipe kept"}], {"kept": str(gone)}
|
||||||
|
)
|
||||||
|
gone.unlink()
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger=recipe_scanner_module.__name__):
|
||||||
|
scanner._initialize_recipe_cache_sync()
|
||||||
|
|
||||||
|
assert "Recipe cache prune skipped" in caplog.text
|
||||||
|
assert scanner._prune_skipped is True
|
||||||
|
# The stored cache survived, so the recipes remain recoverable.
|
||||||
|
persisted = cache.load_cache()
|
||||||
|
assert persisted is not None
|
||||||
|
assert [recipe["id"] for recipe in persisted.raw_data] == ["kept"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_skipped_prune_leaves_fts_index_untouched(guard_scanner, tmp_path: Path):
|
||||||
|
"""A skipped prune must not rebuild the FTS index from the empty view."""
|
||||||
|
scanner, cache = guard_scanner
|
||||||
|
gone = tmp_path / "old-location" / "kept.recipe.json"
|
||||||
|
_write_recipe_json(gone, "kept")
|
||||||
|
assert cache.save_cache(
|
||||||
|
[{"id": "kept", "title": "Recipe kept"}], {"kept": str(gone)}
|
||||||
|
)
|
||||||
|
gone.unlink()
|
||||||
|
|
||||||
|
schedule_calls = []
|
||||||
|
scanner._schedule_fts_index_build = lambda: schedule_calls.append(True)
|
||||||
|
|
||||||
|
scanner._initialize_recipe_cache_sync()
|
||||||
|
|
||||||
|
assert scanner._prune_skipped is True
|
||||||
|
assert schedule_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_init_persists_when_recipes_are_found(guard_scanner, tmp_path: Path):
|
||||||
|
"""The guard must not block a normal successful scan."""
|
||||||
|
scanner, cache = guard_scanner
|
||||||
|
recipes_dir = Path(config.loras_roots[0]) / "recipes"
|
||||||
|
_write_recipe_json(recipes_dir / "fresh.recipe.json", "fresh")
|
||||||
|
|
||||||
|
scanner._initialize_recipe_cache_sync()
|
||||||
|
|
||||||
|
persisted = cache.load_cache()
|
||||||
|
assert persisted is not None
|
||||||
|
assert [recipe["id"] for recipe in persisted.raw_data] == ["fresh"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_force_refresh_scan_persists_an_empty_result(guard_scanner, tmp_path: Path):
|
||||||
|
"""A manual rebuild stays the escape hatch from a skipped prune.
|
||||||
|
|
||||||
|
The startup guard deliberately keeps a stale cache, which leaves the in-memory
|
||||||
|
view empty until the files come back. An explicit rebuild must be able to land
|
||||||
|
on the real (empty) filesystem state instead, otherwise there is no way out.
|
||||||
|
The route to it is `refresh_cache(force=True)`, which clears the stored cache
|
||||||
|
first and then does a full directory scan.
|
||||||
|
"""
|
||||||
|
scanner, cache = guard_scanner
|
||||||
|
gone = tmp_path / "old-location" / "kept.recipe.json"
|
||||||
|
_write_recipe_json(gone, "kept")
|
||||||
|
assert cache.save_cache(
|
||||||
|
[{"id": "kept", "title": "Recipe kept"}], {"kept": str(gone)}
|
||||||
|
)
|
||||||
|
gone.unlink()
|
||||||
|
|
||||||
|
# Simulate the explicit rebuild: clear the stored cache, then full scan.
|
||||||
|
assert cache.save_cache([], {}) is True
|
||||||
|
scanner._initialize_recipe_cache_sync()
|
||||||
|
|
||||||
|
assert scanner._prune_skipped is False
|
||||||
|
persisted = cache.load_cache()
|
||||||
|
assert persisted is None or persisted.raw_data == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_cache_skip_if_empty_preserves_existing_rows(tmp_path: Path):
|
||||||
|
"""The storage-level backstop refuses to empty a populated cache."""
|
||||||
|
cache = PersistentRecipeCache(db_path=str(tmp_path / "recipe_cache.sqlite"))
|
||||||
|
assert cache.save_cache([{"id": "r1", "title": "One"}], {"r1": "/tmp/r1.json"})
|
||||||
|
|
||||||
|
written = cache.save_cache([], {}, skip_if_empty=True)
|
||||||
|
|
||||||
|
assert written is False
|
||||||
|
persisted = cache.load_cache()
|
||||||
|
assert persisted is not None
|
||||||
|
assert [recipe["id"] for recipe in persisted.raw_data] == ["r1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_cache_skip_if_empty_allows_clearing_an_empty_cache(tmp_path: Path):
|
||||||
|
"""Nothing to protect: an already-empty cache still returns success."""
|
||||||
|
cache = PersistentRecipeCache(db_path=str(tmp_path / "recipe_cache.sqlite"))
|
||||||
|
|
||||||
|
assert cache.save_cache([], {}, skip_if_empty=True) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_cache_default_still_allows_intentional_full_clear(tmp_path: Path):
|
||||||
|
"""A manual rebuild passes skip_if_empty=False and must clear the cache."""
|
||||||
|
cache = PersistentRecipeCache(db_path=str(tmp_path / "recipe_cache.sqlite"))
|
||||||
|
assert cache.save_cache([{"id": "r1", "title": "One"}], {"r1": "/tmp/r1.json"})
|
||||||
|
|
||||||
|
assert cache.save_cache([], {}) is True
|
||||||
|
|
||||||
|
persisted = cache.load_cache()
|
||||||
|
assert persisted is None or persisted.raw_data == []
|
||||||
@@ -19,6 +19,7 @@ from py.services.use_cases import (
|
|||||||
DownloadModelEarlyAccessError,
|
DownloadModelEarlyAccessError,
|
||||||
DownloadModelUseCase,
|
DownloadModelUseCase,
|
||||||
DownloadModelValidationError,
|
DownloadModelValidationError,
|
||||||
|
FilenameTemplateUseCase,
|
||||||
ImportExampleImagesUseCase,
|
ImportExampleImagesUseCase,
|
||||||
ImportExampleImagesValidationError,
|
ImportExampleImagesValidationError,
|
||||||
)
|
)
|
||||||
@@ -33,7 +34,7 @@ from py.utils.example_images_processor import (
|
|||||||
ExampleImagesValidationError,
|
ExampleImagesValidationError,
|
||||||
)
|
)
|
||||||
from py.utils.metadata_manager import MetadataManager
|
from py.utils.metadata_manager import MetadataManager
|
||||||
from tests.conftest import MockModelService, MockScanner
|
from tests.conftest import MockCache, MockModelService, MockScanner
|
||||||
|
|
||||||
|
|
||||||
class StubLockProvider:
|
class StubLockProvider:
|
||||||
@@ -503,3 +504,233 @@ async def test_import_example_images_use_case_propagates_generic_error() -> None
|
|||||||
|
|
||||||
with pytest.raises(ExampleImagesImportError):
|
with pytest.raises(ExampleImagesImportError):
|
||||||
await use_case.execute(request) # pyright: ignore[reportArgumentType]
|
await use_case.execute(request) # pyright: ignore[reportArgumentType]
|
||||||
|
|
||||||
|
|
||||||
|
class StubLifecycleService:
|
||||||
|
def __init__(self, scanner: Optional[MockScanner] = None) -> None:
|
||||||
|
self.renames: List[Dict[str, str]] = []
|
||||||
|
self.error: Optional[Exception] = None
|
||||||
|
self.cancel_on_rename = False
|
||||||
|
self._scanner = scanner
|
||||||
|
|
||||||
|
async def rename_model(self, *, file_path: str, new_file_name: str) -> Dict[str, Any]:
|
||||||
|
if self.error is not None:
|
||||||
|
raise self.error
|
||||||
|
self.renames.append({"file_path": file_path, "new_file_name": new_file_name})
|
||||||
|
if self.cancel_on_rename and self._scanner is not None:
|
||||||
|
self._scanner.cancel_task()
|
||||||
|
return {"success": True, "new_file_path": file_path}
|
||||||
|
|
||||||
|
|
||||||
|
def _filename_template_model(
|
||||||
|
file_path: str,
|
||||||
|
model_name: str,
|
||||||
|
sha256: str = "abcdef0123456789",
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"file_path": file_path,
|
||||||
|
"file_name": file_path.rsplit("/", 1)[-1].rsplit(".", 1)[0],
|
||||||
|
"model_name": model_name,
|
||||||
|
"sha256": sha256,
|
||||||
|
"civitai": {"id": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _set_filename_template(template: str, model_type: str = "lora") -> None:
|
||||||
|
from py.services.settings_manager import get_settings_manager
|
||||||
|
|
||||||
|
manager = get_settings_manager()
|
||||||
|
templates = dict(manager.settings.get("download_filename_templates") or {})
|
||||||
|
templates[model_type] = template
|
||||||
|
manager.settings["download_filename_templates"] = templates
|
||||||
|
|
||||||
|
|
||||||
|
def _make_filename_template_use_case(
|
||||||
|
scanner: MockScanner,
|
||||||
|
lifecycle: StubLifecycleService,
|
||||||
|
lock_provider: Optional[StubLockProvider] = None,
|
||||||
|
metadata_loader: Optional[Any] = None,
|
||||||
|
) -> FilenameTemplateUseCase:
|
||||||
|
kwargs: Dict[str, Any] = {}
|
||||||
|
if metadata_loader is not None:
|
||||||
|
kwargs["metadata_loader"] = metadata_loader
|
||||||
|
return FilenameTemplateUseCase(
|
||||||
|
scanner=scanner,
|
||||||
|
lifecycle_service=lifecycle, # pyright: ignore[reportArgumentType]
|
||||||
|
lock_provider=lock_provider or StubLockProvider(),
|
||||||
|
model_type="lora",
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_filename_template_use_case_renames_models() -> None:
|
||||||
|
_set_filename_template("{model_name}-{hash_short}")
|
||||||
|
scanner = MockScanner(cache=MockCache([
|
||||||
|
_filename_template_model("/library/alpha.safetensors", "Alpha"),
|
||||||
|
_filename_template_model("/library/beta.safetensors", "Beta"),
|
||||||
|
]))
|
||||||
|
lifecycle = StubLifecycleService()
|
||||||
|
progress = ProgressCollector()
|
||||||
|
use_case = _make_filename_template_use_case(scanner, lifecycle)
|
||||||
|
|
||||||
|
result = await use_case.execute(progress_callback=progress)
|
||||||
|
|
||||||
|
assert result.status == "success"
|
||||||
|
assert result.operation_type == "filename_template"
|
||||||
|
assert result.total == 2
|
||||||
|
assert result.success_count == 2
|
||||||
|
assert result.failure_count == 0
|
||||||
|
assert lifecycle.renames == [
|
||||||
|
{"file_path": "/library/alpha.safetensors", "new_file_name": "Alpha-abcdef0123"},
|
||||||
|
{"file_path": "/library/beta.safetensors", "new_file_name": "Beta-abcdef0123"},
|
||||||
|
]
|
||||||
|
statuses = [event["status"] for event in progress.events]
|
||||||
|
assert statuses[0] == "started"
|
||||||
|
assert statuses[-1] == "completed"
|
||||||
|
assert all(event["type"] == "filename_template_progress" for event in progress.events)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_filename_template_use_case_skips_unchanged_names() -> None:
|
||||||
|
_set_filename_template("{model_name}-{hash_short}")
|
||||||
|
scanner = MockScanner(cache=MockCache([
|
||||||
|
_filename_template_model("/library/Alpha-abcdef0123.safetensors", "Alpha"),
|
||||||
|
]))
|
||||||
|
lifecycle = StubLifecycleService()
|
||||||
|
use_case = _make_filename_template_use_case(scanner, lifecycle)
|
||||||
|
|
||||||
|
result = await use_case.execute(progress_callback=None)
|
||||||
|
|
||||||
|
assert result.success_count == 0
|
||||||
|
assert result.skipped_count == 1
|
||||||
|
assert lifecycle.renames == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_filename_template_use_case_reverts_to_recorded_original_when_template_empty() -> None:
|
||||||
|
_set_filename_template("")
|
||||||
|
scanner = MockScanner(cache=MockCache([
|
||||||
|
_filename_template_model("/library/alpha-renamed.safetensors", "Alpha"),
|
||||||
|
_filename_template_model("/library/beta.safetensors", "Beta"),
|
||||||
|
]))
|
||||||
|
lifecycle = StubLifecycleService()
|
||||||
|
|
||||||
|
async def metadata_loader(metadata_path: str) -> Dict[str, Any]:
|
||||||
|
if metadata_path == "/library/alpha-renamed.metadata.json":
|
||||||
|
return {"original_file_name": "alpha-original"}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
use_case = _make_filename_template_use_case(
|
||||||
|
scanner, lifecycle, metadata_loader=metadata_loader
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await use_case.execute(progress_callback=None)
|
||||||
|
|
||||||
|
assert result.success_count == 1
|
||||||
|
assert result.skipped_count == 1
|
||||||
|
assert lifecycle.renames == [
|
||||||
|
{
|
||||||
|
"file_path": "/library/alpha-renamed.safetensors",
|
||||||
|
"new_file_name": "alpha-original",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_filename_template_use_case_skips_revert_without_recorded_original() -> None:
|
||||||
|
_set_filename_template("")
|
||||||
|
scanner = MockScanner(cache=MockCache([
|
||||||
|
_filename_template_model("/library/alpha.safetensors", "Alpha"),
|
||||||
|
]))
|
||||||
|
lifecycle = StubLifecycleService()
|
||||||
|
use_case = _make_filename_template_use_case(scanner, lifecycle)
|
||||||
|
|
||||||
|
result = await use_case.execute(progress_callback=None)
|
||||||
|
|
||||||
|
assert result.skipped_count == 1
|
||||||
|
assert lifecycle.renames == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_filename_template_use_case_skips_revert_matching_current_name() -> None:
|
||||||
|
_set_filename_template("")
|
||||||
|
scanner = MockScanner(cache=MockCache([
|
||||||
|
_filename_template_model("/library/alpha.safetensors", "Alpha"),
|
||||||
|
]))
|
||||||
|
lifecycle = StubLifecycleService()
|
||||||
|
|
||||||
|
async def metadata_loader(metadata_path: str) -> Dict[str, Any]:
|
||||||
|
return {"original_file_name": "alpha"}
|
||||||
|
|
||||||
|
use_case = _make_filename_template_use_case(
|
||||||
|
scanner, lifecycle, metadata_loader=metadata_loader
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await use_case.execute(progress_callback=None)
|
||||||
|
|
||||||
|
assert result.success_count == 0
|
||||||
|
assert result.skipped_count == 1
|
||||||
|
assert lifecycle.renames == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_filename_template_use_case_counts_conflicts_as_failures() -> None:
|
||||||
|
_set_filename_template("{model_name}")
|
||||||
|
scanner = MockScanner(cache=MockCache([
|
||||||
|
_filename_template_model("/library/alpha.safetensors", "Alpha"),
|
||||||
|
_filename_template_model("/library/beta.safetensors", "Beta"),
|
||||||
|
]))
|
||||||
|
lifecycle = StubLifecycleService()
|
||||||
|
lifecycle.error = ValueError("A file with this name already exists")
|
||||||
|
use_case = _make_filename_template_use_case(scanner, lifecycle)
|
||||||
|
|
||||||
|
result = await use_case.execute(progress_callback=None)
|
||||||
|
|
||||||
|
assert result.status == "success"
|
||||||
|
assert result.failure_count == 2
|
||||||
|
assert result.success_count == 0
|
||||||
|
assert len(result.results) == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_filename_template_use_case_honours_cancellation() -> None:
|
||||||
|
_set_filename_template("{model_name}-{hash_short}")
|
||||||
|
scanner = MockScanner(cache=MockCache([
|
||||||
|
_filename_template_model("/library/alpha.safetensors", "Alpha"),
|
||||||
|
_filename_template_model("/library/beta.safetensors", "Beta"),
|
||||||
|
]))
|
||||||
|
lifecycle = StubLifecycleService(scanner=scanner)
|
||||||
|
lifecycle.cancel_on_rename = True
|
||||||
|
progress = ProgressCollector()
|
||||||
|
use_case = _make_filename_template_use_case(scanner, lifecycle)
|
||||||
|
|
||||||
|
result = await use_case.execute(progress_callback=progress)
|
||||||
|
|
||||||
|
assert result.status == "cancelled"
|
||||||
|
assert len(lifecycle.renames) == 1
|
||||||
|
assert progress.events[-1]["status"] == "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_filename_template_use_case_filters_file_paths() -> None:
|
||||||
|
_set_filename_template("{model_name}-{hash_short}")
|
||||||
|
scanner = MockScanner(cache=MockCache([
|
||||||
|
_filename_template_model("/library/alpha.safetensors", "Alpha"),
|
||||||
|
_filename_template_model("/library/beta.safetensors", "Beta"),
|
||||||
|
]))
|
||||||
|
lifecycle = StubLifecycleService()
|
||||||
|
use_case = _make_filename_template_use_case(scanner, lifecycle)
|
||||||
|
|
||||||
|
result = await use_case.execute(
|
||||||
|
file_paths=["/library/beta.safetensors"], progress_callback=None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.total == 1
|
||||||
|
assert lifecycle.renames == [
|
||||||
|
{"file_path": "/library/beta.safetensors", "new_file_name": "Beta-abcdef0123"}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_filename_template_use_case_rejects_when_lock_held() -> None:
|
||||||
|
_set_filename_template("{model_name}")
|
||||||
|
scanner = MockScanner(cache=MockCache())
|
||||||
|
lifecycle = StubLifecycleService()
|
||||||
|
lock_provider = StubLockProvider()
|
||||||
|
lock_provider.running = True
|
||||||
|
use_case = _make_filename_template_use_case(scanner, lifecycle, lock_provider)
|
||||||
|
|
||||||
|
with pytest.raises(AutoOrganizeInProgressError):
|
||||||
|
await use_case.execute(progress_callback=None)
|
||||||
@@ -52,12 +52,18 @@ def test_missing_settings_creates_defaults_and_emits_warnings(tmp_path):
|
|||||||
|
|
||||||
actions = warning.get("actions") or []
|
actions = warning.get("actions") or []
|
||||||
assert actions == [
|
assert actions == [
|
||||||
|
{
|
||||||
|
"action": "open-model-paths-settings",
|
||||||
|
"label": "Configure model folders",
|
||||||
|
"type": "primary",
|
||||||
|
"icon": "fas fa-cog",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"action": "open-settings-location",
|
"action": "open-settings-location",
|
||||||
"label": "Open settings folder",
|
"label": "Open settings folder",
|
||||||
"type": "primary",
|
"type": "primary",
|
||||||
"icon": "fas fa-folder-open",
|
"icon": "fas fa-folder-open",
|
||||||
}
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -155,3 +161,13 @@ def test_apply_settings_dir_from_argv():
|
|||||||
os.environ.pop("LORA_MANAGER_SETTINGS_DIR", None)
|
os.environ.pop("LORA_MANAGER_SETTINGS_DIR", None)
|
||||||
else:
|
else:
|
||||||
os.environ["LORA_MANAGER_SETTINGS_DIR"] = previous
|
os.environ["LORA_MANAGER_SETTINGS_DIR"] = previous
|
||||||
|
|
||||||
|
|
||||||
|
def test_template_folder_path_placeholders_are_exposed():
|
||||||
|
manager = get_settings_manager()
|
||||||
|
|
||||||
|
placeholders = manager.get_template_folder_path_placeholders()
|
||||||
|
|
||||||
|
assert "C:/path/to/your/loras_folder" in placeholders
|
||||||
|
assert "C:/path/to/another/embeddings_folder" in placeholders
|
||||||
|
assert len(placeholders) == 8
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Tests for the shared cache SQLite connection settings (:mod:`py.utils.cache_db`).
|
||||||
|
|
||||||
|
Two LoRA Manager processes can share one settings directory, so cache
|
||||||
|
connections must tolerate a competing writer instead of failing immediately
|
||||||
|
with "database is locked".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from py.utils.cache_db import CONCURRENT_TIMEOUT_SECONDS, connect_cache_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_busy_timeout_pragma_is_applied(tmp_path):
|
||||||
|
"""The connection must retry inside SQLite, not just at connect() time."""
|
||||||
|
conn = connect_cache_db(str(tmp_path / "cache.sqlite"))
|
||||||
|
try:
|
||||||
|
value = conn.execute("PRAGMA busy_timeout").fetchone()[0]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
assert value == int(CONCURRENT_TIMEOUT_SECONDS * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def test_waiting_writer_succeeds_after_competing_writer_commits(tmp_path):
|
||||||
|
"""A blocked writer waits for the lock instead of raising."""
|
||||||
|
db_path = str(tmp_path / "cache.sqlite")
|
||||||
|
|
||||||
|
holder = connect_cache_db(db_path)
|
||||||
|
holder.execute("CREATE TABLE t (v INTEGER)")
|
||||||
|
holder.commit()
|
||||||
|
holder.execute("BEGIN IMMEDIATE")
|
||||||
|
|
||||||
|
def release_after_delay() -> None:
|
||||||
|
time.sleep(0.5)
|
||||||
|
holder.commit()
|
||||||
|
|
||||||
|
releaser = threading.Thread(target=release_after_delay)
|
||||||
|
releaser.start()
|
||||||
|
try:
|
||||||
|
waiter = connect_cache_db(db_path)
|
||||||
|
try:
|
||||||
|
# Under the old 5s default this still worked, but an immediate
|
||||||
|
# failure is what low-timeout connections produced; assert the
|
||||||
|
# write lands rather than propagating "database is locked".
|
||||||
|
waiter.execute("INSERT INTO t VALUES (1)")
|
||||||
|
waiter.commit()
|
||||||
|
finally:
|
||||||
|
waiter.close()
|
||||||
|
finally:
|
||||||
|
releaser.join()
|
||||||
|
holder.close()
|
||||||
|
|
||||||
|
check = connect_cache_db(db_path)
|
||||||
|
try:
|
||||||
|
assert check.execute("SELECT COUNT(*) FROM t").fetchone()[0] == 1
|
||||||
|
finally:
|
||||||
|
check.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_readwrite_connection_uses_row_factory(tmp_path):
|
||||||
|
conn = connect_cache_db(str(tmp_path / "cache.sqlite"), row_factory=sqlite3.Row)
|
||||||
|
try:
|
||||||
|
conn.execute("CREATE TABLE t (v INTEGER)")
|
||||||
|
conn.execute("INSERT INTO t VALUES (7)")
|
||||||
|
conn.commit()
|
||||||
|
row = conn.execute("SELECT v FROM t").fetchone()
|
||||||
|
assert row["v"] == 7
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_readonly_connection_reads_without_writing(tmp_path):
|
||||||
|
db_path = str(tmp_path / "cache.sqlite")
|
||||||
|
writer = connect_cache_db(db_path)
|
||||||
|
writer.execute("CREATE TABLE t (v INTEGER)")
|
||||||
|
writer.execute("INSERT INTO t VALUES (1)")
|
||||||
|
writer.commit()
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
conn = connect_cache_db(db_path, readonly=True)
|
||||||
|
try:
|
||||||
|
assert conn.execute("SELECT v FROM t").fetchone()[0] == 1
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_readonly_connection_rejects_writes(tmp_path):
|
||||||
|
db_path = str(tmp_path / "cache.sqlite")
|
||||||
|
writer = connect_cache_db(db_path)
|
||||||
|
writer.execute("CREATE TABLE t (v INTEGER)")
|
||||||
|
writer.commit()
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
conn = connect_cache_db(db_path, readonly=True)
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
conn.execute("INSERT INTO t VALUES (1)")
|
||||||
|
conn.commit()
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
else: # pragma: no cover - would mean mode=ro was not applied
|
||||||
|
raise AssertionError("read-only connection accepted a write")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
@@ -43,3 +43,35 @@ class TestIsEmptyPlaceholderHash:
|
|||||||
def test_rejects_non_strings(self):
|
def test_rejects_non_strings(self):
|
||||||
assert not is_empty_placeholder_hash(None)
|
assert not is_empty_placeholder_hash(None)
|
||||||
assert not is_empty_placeholder_hash(123)
|
assert not is_empty_placeholder_hash(123)
|
||||||
|
|
||||||
|
class TestFolderPathSchema:
|
||||||
|
def test_core_keys_first_in_canonical_order(self):
|
||||||
|
from py.utils.constants import CORE_FOLDER_PATH_KEYS, folder_path_schema
|
||||||
|
|
||||||
|
schema = folder_path_schema()
|
||||||
|
core = [entry for entry in schema if entry["category"] == "core"]
|
||||||
|
|
||||||
|
assert [entry["key"] for entry in core] == CORE_FOLDER_PATH_KEYS
|
||||||
|
assert all(entry["sub_type"] is None for entry in core)
|
||||||
|
assert schema[: len(core)] == core
|
||||||
|
|
||||||
|
def test_other_entries_derive_from_subtypes_table(self):
|
||||||
|
from py.utils.constants import OTHER_MODEL_FOLDER_SUBTYPES, folder_path_schema
|
||||||
|
|
||||||
|
schema = folder_path_schema()
|
||||||
|
other = {entry["key"]: entry for entry in schema if entry["category"] == "other"}
|
||||||
|
|
||||||
|
assert set(other) == set(OTHER_MODEL_FOLDER_SUBTYPES)
|
||||||
|
for folder_key, sub_type in OTHER_MODEL_FOLDER_SUBTYPES.items():
|
||||||
|
assert other[folder_key]["sub_type"] == sub_type
|
||||||
|
|
||||||
|
def test_text_encoder_exposes_both_folder_keys(self):
|
||||||
|
from py.utils.constants import folder_path_schema
|
||||||
|
|
||||||
|
text_encoder_keys = [
|
||||||
|
entry["key"]
|
||||||
|
for entry in folder_path_schema()
|
||||||
|
if entry["sub_type"] == "text_encoder"
|
||||||
|
]
|
||||||
|
|
||||||
|
assert text_encoder_keys == ["text_encoders", "clip"]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import Any, Dict, List, Tuple
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from py.utils import example_images_metadata as metadata_module
|
from py.utils import example_images_metadata as metadata_module
|
||||||
|
from tests.utils.test_video_dimension_probe import build_mp4, build_webm
|
||||||
|
|
||||||
|
|
||||||
class StubScanner:
|
class StubScanner:
|
||||||
@@ -217,3 +218,127 @@ async def test_update_metadata_from_local_examples_generates_entries(monkeypatch
|
|||||||
)
|
)
|
||||||
assert success is True
|
assert success is True
|
||||||
assert model_data["civitai"]["images"]
|
assert model_data["civitai"]["images"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_metadata_after_import_uses_real_video_dimensions(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path, patch_metadata_manager
|
||||||
|
):
|
||||||
|
"""Regression: imported videos must not fall back to the 720x1280 default.
|
||||||
|
|
||||||
|
See issue #1115 — landscape videos were stored as portrait, so the showcase
|
||||||
|
viewer letterboxed them into a 9:16 container.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_hash = "d" * 64
|
||||||
|
model_file = tmp_path / "video-model.safetensors"
|
||||||
|
model_file.write_text("content", encoding="utf-8")
|
||||||
|
model_data = {
|
||||||
|
"model_name": "VideoExample",
|
||||||
|
"file_path": str(model_file),
|
||||||
|
"civitai": {},
|
||||||
|
}
|
||||||
|
scanner = StubScanner([model_data])
|
||||||
|
|
||||||
|
video_path = tmp_path / "custom_abc.mp4"
|
||||||
|
video_path.write_bytes(build_mp4(1280, 720))
|
||||||
|
|
||||||
|
monkeypatch.setattr(metadata_module.ExifUtils, "extract_image_metadata", staticmethod(lambda _path: None))
|
||||||
|
|
||||||
|
_regular, custom = await metadata_module.MetadataUpdater.update_metadata_after_import(
|
||||||
|
model_hash,
|
||||||
|
model_data,
|
||||||
|
scanner,
|
||||||
|
[(str(video_path), "abc")],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert custom[0]["type"] == "video"
|
||||||
|
assert (custom[0]["width"], custom[0]["height"]) == (1280, 720)
|
||||||
|
assert patch_metadata_manager[-1][1]["civitai"]["customImages"][0]["width"] == 1280
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_metadata_after_import_uses_real_webm_dimensions(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path, patch_metadata_manager
|
||||||
|
):
|
||||||
|
model_hash = "e" * 64
|
||||||
|
model_file = tmp_path / "webm-model.safetensors"
|
||||||
|
model_file.write_text("content", encoding="utf-8")
|
||||||
|
model_data = {
|
||||||
|
"model_name": "WebmExample",
|
||||||
|
"file_path": str(model_file),
|
||||||
|
"civitai": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
video_path = tmp_path / "custom_def.webm"
|
||||||
|
video_path.write_bytes(build_webm(480, 832))
|
||||||
|
|
||||||
|
monkeypatch.setattr(metadata_module.ExifUtils, "extract_image_metadata", staticmethod(lambda _path: None))
|
||||||
|
|
||||||
|
_regular, custom = await metadata_module.MetadataUpdater.update_metadata_after_import(
|
||||||
|
model_hash,
|
||||||
|
model_data,
|
||||||
|
StubScanner([model_data]),
|
||||||
|
[(str(video_path), "def")],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (custom[0]["width"], custom[0]["height"]) == (480, 832)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_metadata_after_import_falls_back_for_unreadable_video(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path, patch_metadata_manager
|
||||||
|
):
|
||||||
|
"""An unparsable video keeps the legacy placeholder rather than failing."""
|
||||||
|
|
||||||
|
model_hash = "f" * 64
|
||||||
|
model_file = tmp_path / "broken-model.safetensors"
|
||||||
|
model_file.write_text("content", encoding="utf-8")
|
||||||
|
model_data = {
|
||||||
|
"model_name": "BrokenExample",
|
||||||
|
"file_path": str(model_file),
|
||||||
|
"civitai": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
video_path = tmp_path / "custom_ghi.mp4"
|
||||||
|
video_path.write_bytes(b"\x00\x00\x00\x20ftypisom" + b"\xff" * 32)
|
||||||
|
|
||||||
|
monkeypatch.setattr(metadata_module.ExifUtils, "extract_image_metadata", staticmethod(lambda _path: None))
|
||||||
|
|
||||||
|
_regular, custom = await metadata_module.MetadataUpdater.update_metadata_after_import(
|
||||||
|
model_hash,
|
||||||
|
model_data,
|
||||||
|
StubScanner([model_data]),
|
||||||
|
[(str(video_path), "ghi")],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (custom[0]["width"], custom[0]["height"]) == (720, 1280)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_metadata_from_local_examples_uses_real_video_dimensions(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||||
|
):
|
||||||
|
model_hash = "1" * 64
|
||||||
|
model_dir = tmp_path / model_hash
|
||||||
|
model_dir.mkdir()
|
||||||
|
(model_dir / "clip.mp4").write_bytes(build_mp4(1920, 1080))
|
||||||
|
model_data: Dict[str, Any] = {
|
||||||
|
"model_name": "LocalVideo",
|
||||||
|
"civitai": {},
|
||||||
|
"file_path": str(tmp_path / "model.safetensors"),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_save(path, metadata):
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(metadata_module.MetadataManager, "save_metadata", staticmethod(fake_save))
|
||||||
|
|
||||||
|
success = await metadata_module.MetadataUpdater.update_metadata_from_local_examples(
|
||||||
|
model_hash,
|
||||||
|
model_data,
|
||||||
|
"lora",
|
||||||
|
StubScanner([model_data]),
|
||||||
|
str(model_dir),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
entry = model_data["civitai"]["images"][0]
|
||||||
|
assert entry["type"] == "video"
|
||||||
|
assert (entry["width"], entry["height"]) == (1920, 1080)
|
||||||
@@ -177,3 +177,156 @@ async def test_migrations_run_and_update_progress(tmp_path, monkeypatch):
|
|||||||
update_args = lora_scanner.update_calls[0]
|
update_args = lora_scanner.update_calls[0]
|
||||||
assert update_args[0] == str(metadata_path)
|
assert update_args[0] == str(metadata_path)
|
||||||
assert update_args[2]["civitai"]["customImages"][0]["id"] == "short1234"
|
assert update_args[2]["civitai"]["customImages"][0]["id"] == "short1234"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_v2_to_v3_migration_repairs_video_dimensions(tmp_path, monkeypatch):
|
||||||
|
"""Upgrading a library already at v2 backfills local video dimensions once.
|
||||||
|
|
||||||
|
This mirrors the real upgrade path for issue #1115: the naming migration is
|
||||||
|
already done, but imported videos still carry the 720x1280 placeholder.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from tests.utils.test_video_dimension_probe import build_mp4
|
||||||
|
|
||||||
|
example_root = tmp_path / "example_images"
|
||||||
|
library_root = example_root / "main"
|
||||||
|
library_root.mkdir(parents=True)
|
||||||
|
|
||||||
|
progress_path = library_root / ".download_progress.json"
|
||||||
|
progress_path.write_text(json.dumps({"naming_version": 2}))
|
||||||
|
|
||||||
|
model_hash = "d" * 64
|
||||||
|
model_folder = library_root / model_hash
|
||||||
|
model_folder.mkdir()
|
||||||
|
# Landscape clip stored during the buggy import path.
|
||||||
|
(model_folder / "custom_land1.mp4").write_bytes(build_mp4(1280, 720))
|
||||||
|
|
||||||
|
model_file = tmp_path / "models" / "video.safetensors"
|
||||||
|
model_file.parent.mkdir()
|
||||||
|
model_file.write_text("weights", encoding="utf-8")
|
||||||
|
|
||||||
|
scanner = FakeScanner(
|
||||||
|
{
|
||||||
|
model_hash: {
|
||||||
|
"sha256": model_hash,
|
||||||
|
"file_path": str(model_file),
|
||||||
|
"civitai": {
|
||||||
|
"images": [
|
||||||
|
{"url": "https://example.com/remote.jpg", "type": "image", "width": 512, "height": 512}
|
||||||
|
],
|
||||||
|
"customImages": [
|
||||||
|
{"url": "", "id": "land1", "type": "video", "width": 720, "height": 1280}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_get_lora_scanner(cls):
|
||||||
|
return scanner
|
||||||
|
|
||||||
|
async def fake_get_checkpoint_scanner(cls):
|
||||||
|
return FakeScanner({})
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
migration_module.ServiceRegistry, "get_lora_scanner", classmethod(fake_get_lora_scanner)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
migration_module.ServiceRegistry,
|
||||||
|
"get_checkpoint_scanner",
|
||||||
|
classmethod(fake_get_checkpoint_scanner),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
migration_module.settings,
|
||||||
|
"get",
|
||||||
|
lambda key, default=None: str(example_root) if key == "example_images_path" else default,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
migration_module,
|
||||||
|
"iter_library_roots",
|
||||||
|
lambda: [("main", str(library_root))],
|
||||||
|
)
|
||||||
|
|
||||||
|
saved_metadata = []
|
||||||
|
|
||||||
|
async def fake_save_metadata(path, metadata):
|
||||||
|
saved_metadata.append((path, metadata))
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def fake_load_payload(path):
|
||||||
|
return {
|
||||||
|
"model_name": "Video",
|
||||||
|
"civitai": {
|
||||||
|
"images": [
|
||||||
|
{"url": "https://example.com/remote.jpg", "type": "image", "width": 512, "height": 512}
|
||||||
|
],
|
||||||
|
"customImages": [
|
||||||
|
{"url": "", "id": "land1", "type": "video", "width": 720, "height": 1280}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
migration_module.MetadataManager, "save_metadata", staticmethod(fake_save_metadata)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
migration_module.MetadataManager, "load_metadata_payload", staticmethod(fake_load_payload)
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduled = []
|
||||||
|
original_create_task = asyncio.create_task
|
||||||
|
|
||||||
|
def capture_create_task(coro, *args, **kwargs):
|
||||||
|
task = original_create_task(coro, *args, **kwargs)
|
||||||
|
scheduled.append(task)
|
||||||
|
return task
|
||||||
|
|
||||||
|
monkeypatch.setattr(migration_module.asyncio, "create_task", capture_create_task)
|
||||||
|
|
||||||
|
await migration_module.ExampleImagesMigration.check_and_run_migrations()
|
||||||
|
await asyncio.gather(*scheduled)
|
||||||
|
|
||||||
|
assert len(saved_metadata) == 1
|
||||||
|
_path, payload = saved_metadata[0]
|
||||||
|
entry = payload["civitai"]["customImages"][0]
|
||||||
|
assert (entry["width"], entry["height"]) == (1280, 720)
|
||||||
|
# Remote-backed entry is untouched.
|
||||||
|
assert payload["civitai"]["images"][0]["width"] == 512
|
||||||
|
|
||||||
|
assert json.loads(progress_path.read_text())["naming_version"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_v3_migration_does_not_run_twice(tmp_path, monkeypatch):
|
||||||
|
"""The version gate keeps the repair off the startup path after one run."""
|
||||||
|
|
||||||
|
example_root = tmp_path / "example_images"
|
||||||
|
library_root = example_root / "main"
|
||||||
|
library_root.mkdir(parents=True)
|
||||||
|
(library_root / ".download_progress.json").write_text(json.dumps({"naming_version": 3}))
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
migration_module.settings,
|
||||||
|
"get",
|
||||||
|
lambda key, default=None: str(example_root) if key == "example_images_path" else default,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
migration_module,
|
||||||
|
"iter_library_roots",
|
||||||
|
lambda: [("main", str(library_root))],
|
||||||
|
)
|
||||||
|
|
||||||
|
called = []
|
||||||
|
|
||||||
|
async def spy_run_migrations(*args, **kwargs):
|
||||||
|
called.append(args)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
migration_module.ExampleImagesMigration, "run_migrations", staticmethod(spy_run_migrations)
|
||||||
|
)
|
||||||
|
|
||||||
|
await migration_module.ExampleImagesMigration.check_and_run_migrations()
|
||||||
|
|
||||||
|
assert called == []
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
"""Tests for the one-shot repair of locally imported video dimensions (issue #1115)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py.utils import example_images_migration as migration_module
|
||||||
|
from py.utils import example_images_metadata as metadata_module
|
||||||
|
from tests.utils.test_video_dimension_probe import build_mp4
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_payload(**civitai: Any) -> Dict[str, Any]:
|
||||||
|
return {"model_name": "Example", "civitai": civitai}
|
||||||
|
|
||||||
|
|
||||||
|
def test_repair_backfills_landscape_video_dimensions(tmp_path: Path):
|
||||||
|
video = tmp_path / "custom_abc123.mp4"
|
||||||
|
video.write_bytes(build_mp4(1280, 720))
|
||||||
|
|
||||||
|
payload = _metadata_payload(
|
||||||
|
customImages=[
|
||||||
|
{
|
||||||
|
"url": "",
|
||||||
|
"id": "abc123",
|
||||||
|
"type": "video",
|
||||||
|
"width": 720,
|
||||||
|
"height": 1280,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
repaired = metadata_module.repair_local_video_dimensions(
|
||||||
|
payload, {"abc123": str(video)}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert repaired == 1
|
||||||
|
entry = payload["civitai"]["customImages"][0]
|
||||||
|
assert (entry["width"], entry["height"]) == (1280, 720)
|
||||||
|
|
||||||
|
|
||||||
|
def test_repair_handles_index_marked_images_array(tmp_path: Path):
|
||||||
|
video = tmp_path / "image_3.mp4"
|
||||||
|
video.write_bytes(build_mp4(1920, 1080))
|
||||||
|
|
||||||
|
payload = _metadata_payload(
|
||||||
|
images=[
|
||||||
|
{"url": "https://example.com/remote.png", "type": "image"},
|
||||||
|
{"url": "", "type": "video", "width": 720, "height": 1280},
|
||||||
|
{"url": "", "type": "video", "width": 720, "height": 1280},
|
||||||
|
{"url": "", "type": "video", "width": 720, "height": 1280},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
repaired = metadata_module.repair_local_video_dimensions(
|
||||||
|
payload, {"3": str(video)}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert repaired == 1
|
||||||
|
# Position 3 (index 3) is the one carrying the local file.
|
||||||
|
assert payload["civitai"]["images"][3]["width"] == 1920
|
||||||
|
assert payload["civitai"]["images"][3]["height"] == 1080
|
||||||
|
# The remote entry keeps its API-provided shape.
|
||||||
|
assert payload["civitai"]["images"][0].get("width") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_repair_never_touches_remote_entries(tmp_path: Path):
|
||||||
|
"""Remote entries keep API-provided dimensions even if a file exists."""
|
||||||
|
|
||||||
|
video = tmp_path / "custom_remote.mp4"
|
||||||
|
video.write_bytes(build_mp4(1280, 720))
|
||||||
|
|
||||||
|
payload = _metadata_payload(
|
||||||
|
customImages=[
|
||||||
|
{
|
||||||
|
"url": "https://civitai.com/1234.mp4",
|
||||||
|
"id": "remote",
|
||||||
|
"type": "video",
|
||||||
|
"width": 720,
|
||||||
|
"height": 1280,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
before = json.dumps(payload, sort_keys=True)
|
||||||
|
|
||||||
|
repaired = metadata_module.repair_local_video_dimensions(
|
||||||
|
payload, {"remote": str(video)}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert repaired == 0
|
||||||
|
assert json.dumps(payload, sort_keys=True) == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_repair_is_idempotent(tmp_path: Path):
|
||||||
|
video = tmp_path / "custom_abc.mp4"
|
||||||
|
video.write_bytes(build_mp4(1280, 720))
|
||||||
|
|
||||||
|
payload = _metadata_payload(
|
||||||
|
customImages=[{"url": "", "id": "abc", "type": "video", "width": 720, "height": 1280}]
|
||||||
|
)
|
||||||
|
files = {"abc": str(video)}
|
||||||
|
|
||||||
|
assert metadata_module.repair_local_video_dimensions(payload, files) == 1
|
||||||
|
# Second run finds nothing to do and leaves the entry byte-identical.
|
||||||
|
snapshot = json.dumps(payload, sort_keys=True)
|
||||||
|
assert metadata_module.repair_local_video_dimensions(payload, files) == 0
|
||||||
|
assert json.dumps(payload, sort_keys=True) == snapshot
|
||||||
|
|
||||||
|
|
||||||
|
def test_repair_dry_run_does_not_mutate(tmp_path: Path):
|
||||||
|
video = tmp_path / "custom_abc.mp4"
|
||||||
|
video.write_bytes(build_mp4(1280, 720))
|
||||||
|
|
||||||
|
payload = _metadata_payload(
|
||||||
|
customImages=[{"url": "", "id": "abc", "type": "video", "width": 720, "height": 1280}]
|
||||||
|
)
|
||||||
|
before = json.dumps(payload, sort_keys=True)
|
||||||
|
|
||||||
|
repaired = metadata_module.repair_local_video_dimensions(
|
||||||
|
payload, {"abc": str(video)}, dry_run=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert repaired == 1
|
||||||
|
assert json.dumps(payload, sort_keys=True) == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_repair_skips_missing_file(tmp_path: Path):
|
||||||
|
payload = _metadata_payload(
|
||||||
|
customImages=[{"url": "", "id": "gone", "type": "video", "width": 720, "height": 1280}]
|
||||||
|
)
|
||||||
|
|
||||||
|
repaired = metadata_module.repair_local_video_dimensions(
|
||||||
|
payload, {"gone": str(tmp_path / "does-not-exist.mp4")}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert repaired == 0
|
||||||
|
assert payload["civitai"]["customImages"][0]["width"] == 720
|
||||||
|
|
||||||
|
|
||||||
|
def test_repair_leaves_correct_entries_untouched(tmp_path: Path):
|
||||||
|
video = tmp_path / "custom_ok.mp4"
|
||||||
|
video.write_bytes(build_mp4(1280, 720))
|
||||||
|
|
||||||
|
payload = _metadata_payload(
|
||||||
|
customImages=[{"url": "", "id": "ok", "type": "video", "width": 1280, "height": 720}]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert metadata_module.repair_local_video_dimensions(payload, {"ok": str(video)}) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_file_map_keys_strip_naming_prefix(tmp_path: Path):
|
||||||
|
(tmp_path / "custom_abc.mp4").write_bytes(build_mp4(1280, 720))
|
||||||
|
(tmp_path / "image_2.png").write_bytes(b"not-a-real-image")
|
||||||
|
(tmp_path / "notes.txt").write_text("ignore me", encoding="utf-8")
|
||||||
|
|
||||||
|
mapping = migration_module.ExampleImagesMigration._build_local_file_map(str(tmp_path))
|
||||||
|
|
||||||
|
assert set(mapping) == {"abc", "2"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_migrate_to_v3_repairs_and_syncs_cache(tmp_path: Path, monkeypatch):
|
||||||
|
model_hash = "a" * 64
|
||||||
|
folder = tmp_path / model_hash
|
||||||
|
folder.mkdir()
|
||||||
|
(folder / "custom_xyz.mp4").write_bytes(build_mp4(1080, 1920))
|
||||||
|
|
||||||
|
model_file = tmp_path / "model.safetensors"
|
||||||
|
model_file.write_text("weights", encoding="utf-8")
|
||||||
|
|
||||||
|
payload = _metadata_payload(
|
||||||
|
customImages=[{"url": "", "id": "xyz", "type": "video", "width": 720, "height": 1280}]
|
||||||
|
)
|
||||||
|
saved: list[tuple[str, Dict[str, Any]]] = []
|
||||||
|
|
||||||
|
async def fake_load(file_path):
|
||||||
|
return dict(payload, civitai=dict(payload["civitai"]))
|
||||||
|
|
||||||
|
async def fake_save(file_path, data):
|
||||||
|
saved.append((file_path, data))
|
||||||
|
return True
|
||||||
|
|
||||||
|
synced: list[tuple[str, Dict[str, Any]]] = []
|
||||||
|
|
||||||
|
async def fake_sync(scanner, file_path, data):
|
||||||
|
synced.append((file_path, data))
|
||||||
|
return True
|
||||||
|
|
||||||
|
class StubScanner:
|
||||||
|
def has_hash(self, _hash):
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_cached_data(self):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
return SimpleNamespace(raw_data=[{"sha256": model_hash, "file_path": str(model_file)}])
|
||||||
|
|
||||||
|
monkeypatch.setattr(migration_module.MetadataManager, "load_metadata_payload", fake_load)
|
||||||
|
monkeypatch.setattr(migration_module.MetadataManager, "save_metadata", fake_save)
|
||||||
|
monkeypatch.setattr(migration_module, "update_cache_from_metadata", fake_sync)
|
||||||
|
|
||||||
|
async def fake_lora():
|
||||||
|
return StubScanner()
|
||||||
|
|
||||||
|
async def fake_none():
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(migration_module.ServiceRegistry, "get_lora_scanner", fake_lora)
|
||||||
|
monkeypatch.setattr(migration_module.ServiceRegistry, "get_checkpoint_scanner", fake_none)
|
||||||
|
monkeypatch.setattr(migration_module.ServiceRegistry, "get_embedding_scanner", fake_none)
|
||||||
|
|
||||||
|
await migration_module.ExampleImagesMigration._migrate_to_v3(
|
||||||
|
str(tmp_path), [str(folder)]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(saved) == 1
|
||||||
|
saved_entry = saved[0][1]["civitai"]["customImages"][0]
|
||||||
|
assert (saved_entry["width"], saved_entry["height"]) == (1080, 1920)
|
||||||
|
assert len(synced) == 1
|
||||||
|
assert synced[0][1]["civitai"]["customImages"][0]["width"] == 1080
|
||||||
|
|
||||||
|
|
||||||
|
async def test_migrate_to_v3_skips_when_nothing_to_repair(tmp_path: Path, monkeypatch):
|
||||||
|
model_hash = "b" * 64
|
||||||
|
folder = tmp_path / model_hash
|
||||||
|
folder.mkdir()
|
||||||
|
(folder / "custom_ok.mp4").write_bytes(build_mp4(1080, 1920))
|
||||||
|
|
||||||
|
model_file = tmp_path / "model.safetensors"
|
||||||
|
model_file.write_text("weights", encoding="utf-8")
|
||||||
|
|
||||||
|
payload = _metadata_payload(
|
||||||
|
customImages=[{"url": "", "id": "ok", "type": "video", "width": 1080, "height": 1920}]
|
||||||
|
)
|
||||||
|
saved: list[Any] = []
|
||||||
|
|
||||||
|
async def fake_load(file_path):
|
||||||
|
return dict(payload, civitai=dict(payload["civitai"]))
|
||||||
|
|
||||||
|
async def fake_save(file_path, data):
|
||||||
|
saved.append(data)
|
||||||
|
return True
|
||||||
|
|
||||||
|
class StubScanner:
|
||||||
|
def has_hash(self, _hash):
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_cached_data(self):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
return SimpleNamespace(raw_data=[{"sha256": model_hash, "file_path": str(model_file)}])
|
||||||
|
|
||||||
|
monkeypatch.setattr(migration_module.MetadataManager, "load_metadata_payload", fake_load)
|
||||||
|
monkeypatch.setattr(migration_module.MetadataManager, "save_metadata", fake_save)
|
||||||
|
|
||||||
|
async def fake_lora():
|
||||||
|
return StubScanner()
|
||||||
|
|
||||||
|
async def fake_none():
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(migration_module.ServiceRegistry, "get_lora_scanner", fake_lora)
|
||||||
|
monkeypatch.setattr(migration_module.ServiceRegistry, "get_checkpoint_scanner", fake_none)
|
||||||
|
monkeypatch.setattr(migration_module.ServiceRegistry, "get_embedding_scanner", fake_none)
|
||||||
|
|
||||||
|
await migration_module.ExampleImagesMigration._migrate_to_v3(str(tmp_path), [str(folder)])
|
||||||
|
|
||||||
|
# Correctly-sized entries are never rewritten.
|
||||||
|
assert saved == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_migrate_to_v3_skips_unindexed_model(tmp_path: Path, monkeypatch):
|
||||||
|
"""A folder whose model is absent from every scanner cache is skipped, not fatal."""
|
||||||
|
|
||||||
|
model_hash = "c" * 64
|
||||||
|
folder = tmp_path / model_hash
|
||||||
|
folder.mkdir()
|
||||||
|
(folder / "custom_zzz.mp4").write_bytes(build_mp4(1080, 1920))
|
||||||
|
|
||||||
|
class EmptyScanner:
|
||||||
|
def has_hash(self, _hash):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_cached_data(self):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
return SimpleNamespace(raw_data=[])
|
||||||
|
|
||||||
|
async def fake_scanner():
|
||||||
|
return EmptyScanner()
|
||||||
|
|
||||||
|
monkeypatch.setattr(migration_module.ServiceRegistry, "get_lora_scanner", fake_scanner)
|
||||||
|
monkeypatch.setattr(migration_module.ServiceRegistry, "get_checkpoint_scanner", fake_scanner)
|
||||||
|
monkeypatch.setattr(migration_module.ServiceRegistry, "get_embedding_scanner", fake_scanner)
|
||||||
|
|
||||||
|
# Must not raise.
|
||||||
|
await migration_module.ExampleImagesMigration._migrate_to_v3(str(tmp_path), [str(folder)])
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Tests for the cross-process advisory lock (:mod:`py.utils.file_lock`)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py.utils.file_lock import (
|
||||||
|
CrossProcessLock,
|
||||||
|
FileLockUnavailable,
|
||||||
|
exclusive_lock,
|
||||||
|
lock_path_for,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lock_path_is_a_sibling_of_the_resource(tmp_path):
|
||||||
|
db_path = str(tmp_path / "recipe" / "default.sqlite")
|
||||||
|
lock_path = lock_path_for(db_path)
|
||||||
|
|
||||||
|
assert os.path.dirname(lock_path) == os.path.dirname(db_path)
|
||||||
|
assert os.path.basename(lock_path) == ".default.sqlite.lock"
|
||||||
|
|
||||||
|
|
||||||
|
def test_acquire_and_release_round_trip(tmp_path):
|
||||||
|
lock = exclusive_lock(str(tmp_path / "cache.sqlite"))
|
||||||
|
|
||||||
|
assert lock.acquire() is True
|
||||||
|
lock.release()
|
||||||
|
# Releasing twice must be safe.
|
||||||
|
lock.release()
|
||||||
|
# ...and the lock is reusable afterwards.
|
||||||
|
assert lock.acquire() is True
|
||||||
|
lock.release()
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_lock_holder_waits_until_release(tmp_path):
|
||||||
|
"""A held lock blocks a competing holder for the same resource."""
|
||||||
|
db_path = str(tmp_path / "cache.sqlite")
|
||||||
|
first = exclusive_lock(db_path)
|
||||||
|
second = CrossProcessLock(lock_path_for(db_path), timeout=0.2)
|
||||||
|
|
||||||
|
assert first.acquire() is True
|
||||||
|
try:
|
||||||
|
started = time.monotonic()
|
||||||
|
assert second.acquire() is False
|
||||||
|
# It must have waited for the timeout rather than failing instantly.
|
||||||
|
assert time.monotonic() - started >= 0.15
|
||||||
|
finally:
|
||||||
|
first.release()
|
||||||
|
|
||||||
|
# Once released, the contender gets the lock.
|
||||||
|
assert second.acquire() is True
|
||||||
|
second.release()
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_manager_releases_on_exception(tmp_path):
|
||||||
|
lock = exclusive_lock(str(tmp_path / "cache.sqlite"))
|
||||||
|
contender = CrossProcessLock(lock.path, timeout=0.2)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
with lock:
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
assert contender.acquire() is True
|
||||||
|
contender.release()
|
||||||
|
|
||||||
|
|
||||||
|
def test_lock_file_is_not_deleted(tmp_path):
|
||||||
|
"""Deleting the lock file would let a second process lock a fresh inode."""
|
||||||
|
lock = exclusive_lock(str(tmp_path / "cache.sqlite"))
|
||||||
|
assert lock.acquire() is True
|
||||||
|
lock.release()
|
||||||
|
|
||||||
|
assert os.path.exists(lock.path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsupported_platform_degrades_gracefully(tmp_path, monkeypatch):
|
||||||
|
"""Without a platform primitive the lock reports failure instead of raising."""
|
||||||
|
import py.utils.file_lock as file_lock_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(file_lock_module, "fcntl", None)
|
||||||
|
monkeypatch.setattr(file_lock_module, "msvcrt", None)
|
||||||
|
|
||||||
|
lock = exclusive_lock(str(tmp_path / "cache.sqlite"))
|
||||||
|
assert lock.acquire() is False
|
||||||
|
# Callers use it as a context manager and continue without the lock.
|
||||||
|
with exclusive_lock(str(tmp_path / "cache.sqlite")):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_lock_unavailable_is_exported():
|
||||||
|
assert issubclass(FileLockUnavailable, RuntimeError)
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_cache_creates_lock_next_to_database(tmp_path):
|
||||||
|
"""The recipe cache write path actually takes the cross-process lock."""
|
||||||
|
from py.services.persistent_recipe_cache import PersistentRecipeCache
|
||||||
|
|
||||||
|
db_path = tmp_path / "recipe_cache.sqlite"
|
||||||
|
cache = PersistentRecipeCache(db_path=str(db_path))
|
||||||
|
assert cache.save_cache([{"id": "r1", "title": "One"}], {"r1": "/tmp/r1.json"})
|
||||||
|
|
||||||
|
assert os.path.exists(lock_path_for(str(db_path)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_cache_releases_lock_after_write(tmp_path):
|
||||||
|
"""A second writer must not be blocked once the first has finished."""
|
||||||
|
from py.services.persistent_recipe_cache import PersistentRecipeCache
|
||||||
|
|
||||||
|
db_path = tmp_path / "recipe_cache.sqlite"
|
||||||
|
cache = PersistentRecipeCache(db_path=str(db_path))
|
||||||
|
cache.save_cache([{"id": "r1", "title": "One"}], {"r1": "/tmp/r1.json"})
|
||||||
|
|
||||||
|
contender = CrossProcessLock(lock_path_for(str(db_path)), timeout=0.2)
|
||||||
|
assert contender.acquire() is True
|
||||||
|
contender.release()
|
||||||
@@ -34,10 +34,12 @@ class TestShouldUsePortableSettings:
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"env_value, settings_flag, expected",
|
"env_value, settings_flag, expected",
|
||||||
[
|
[
|
||||||
("1", False, True), # env = 1 overrides settings.json false
|
("1", False, True), # env = 1 forces portable on
|
||||||
("1", True, True), # env = 1 matches settings.json true
|
("1", True, True), # env = 1 matches settings.json true
|
||||||
("0", False, False), # env = 0 → rely on settings.json
|
("0", False, False), # env = 0 forces portable off
|
||||||
("0", True, True), # env = 0 → rely on settings.json
|
("0", True, False), # env = 0 overrides a persisted true
|
||||||
|
("yes", False, False), # unrecognised value → rely on settings.json
|
||||||
|
("yes", True, True), # unrecognised value → rely on settings.json
|
||||||
("", False, False), # unset → rely on settings.json
|
("", False, False), # unset → rely on settings.json
|
||||||
("", True, True), # unset → rely on settings.json
|
("", True, True), # unset → rely on settings.json
|
||||||
],
|
],
|
||||||
@@ -58,6 +60,21 @@ class TestShouldUsePortableSettings:
|
|||||||
result = _should_use_portable_settings(str(settings_file), logging.getLogger())
|
result = _should_use_portable_settings(str(settings_file), logging.getLogger())
|
||||||
assert result == expected
|
assert result == expected
|
||||||
|
|
||||||
|
def test_explicit_zero_is_the_documented_opt_out(self, tmp_path, caplog):
|
||||||
|
"""`=0` must be honoured even against a persisted true flag."""
|
||||||
|
settings_file = tmp_path / "settings.json"
|
||||||
|
settings_file.write_text(json.dumps({"use_portable_settings": True}))
|
||||||
|
|
||||||
|
with pytest.MonkeyPatch.context() as mp:
|
||||||
|
mp.setenv("LORA_MANAGER_PORTABLE", "0")
|
||||||
|
with caplog.at_level(logging.INFO):
|
||||||
|
result = _should_use_portable_settings(
|
||||||
|
str(settings_file), logging.getLogger()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert "Portable mode disabled" in caplog.text
|
||||||
|
|
||||||
def test_missing_file_without_env(self, tmp_path):
|
def test_missing_file_without_env(self, tmp_path):
|
||||||
"""Without env var, missing settings file returns False."""
|
"""Without env var, missing settings file returns False."""
|
||||||
missing = tmp_path / "nonexistent.json"
|
missing = tmp_path / "nonexistent.json"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import pytest
|
|||||||
from py.services.settings_manager import SettingsManager, get_settings_manager
|
from py.services.settings_manager import SettingsManager, get_settings_manager
|
||||||
from py.services.service_registry import ServiceRegistry
|
from py.services.service_registry import ServiceRegistry
|
||||||
from py.utils.utils import (
|
from py.utils.utils import (
|
||||||
|
calculate_filename_for_model,
|
||||||
calculate_recipe_fingerprint,
|
calculate_recipe_fingerprint,
|
||||||
calculate_relative_path_for_model,
|
calculate_relative_path_for_model,
|
||||||
get_lora_info,
|
get_lora_info,
|
||||||
@@ -164,6 +165,145 @@ def test_calculate_recipe_fingerprint_empty_input():
|
|||||||
assert calculate_recipe_fingerprint([]) == ""
|
assert calculate_recipe_fingerprint([]) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def _set_filename_templates(isolated_settings, template, model_types=("lora", "checkpoint", "embedding")):
|
||||||
|
isolated_settings["download_filename_templates"] = {
|
||||||
|
model_type: template for model_type in model_types
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_filename_returns_empty_without_template(isolated_settings):
|
||||||
|
model_data = {"model_name": "Model", "file_path": "/models/V1.safetensors"}
|
||||||
|
|
||||||
|
assert calculate_filename_for_model(model_data, "lora") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_filename_substitutes_all_placeholders(isolated_settings):
|
||||||
|
_set_filename_templates(
|
||||||
|
isolated_settings,
|
||||||
|
"{base_model}-{model_name}-{version_name}-{author}-{first_tag}-{hash_short}-{original_name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
model_data = {
|
||||||
|
"model_name": "My Model",
|
||||||
|
"base_model": "SDXL",
|
||||||
|
"tags": ["Style"],
|
||||||
|
"sha256": "ABCDEF0123456789",
|
||||||
|
"file_path": "/models/V1.safetensors",
|
||||||
|
"civitai": {"id": 1, "name": "v3", "creator": {"username": "Author"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = calculate_filename_for_model(model_data, "lora")
|
||||||
|
|
||||||
|
assert result == "SDXL-My Model-v3-Author-style-abcdef0123-V1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_filename_hash_short_empty_when_unknown(isolated_settings):
|
||||||
|
_set_filename_templates(isolated_settings, "{model_name}-{hash_short}")
|
||||||
|
|
||||||
|
model_data = {
|
||||||
|
"model_name": "My Model",
|
||||||
|
"file_path": "/models/V1.safetensors",
|
||||||
|
"civitai": {"id": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Missing hash leaves an empty segment; the dangling separator collapses.
|
||||||
|
assert calculate_filename_for_model(model_data, "lora") == "My Model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_filename_missing_metadata_produces_empty_segments(isolated_settings):
|
||||||
|
_set_filename_templates(isolated_settings, "{base_model}-{model_name}")
|
||||||
|
|
||||||
|
model_data = {
|
||||||
|
"model_name": "My Model",
|
||||||
|
"base_model": "",
|
||||||
|
"tags": [],
|
||||||
|
"file_path": "/models/V1.safetensors",
|
||||||
|
"civitai": {"id": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert calculate_filename_for_model(model_data, "lora") == "My Model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_filename_rejects_path_separators(isolated_settings):
|
||||||
|
_set_filename_templates(isolated_settings, "{base_model}/{model_name}")
|
||||||
|
|
||||||
|
model_data = {
|
||||||
|
"model_name": "My Model",
|
||||||
|
"base_model": "SDXL",
|
||||||
|
"file_path": "/models/V1.safetensors",
|
||||||
|
"civitai": {"id": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert calculate_filename_for_model(model_data, "lora") == ""
|
||||||
|
|
||||||
|
_set_filename_templates(isolated_settings, "{base_model}\\{model_name}")
|
||||||
|
assert calculate_filename_for_model(model_data, "lora") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_filename_strips_illegal_characters(isolated_settings):
|
||||||
|
_set_filename_templates(isolated_settings, '{model_name}:"custom"')
|
||||||
|
|
||||||
|
model_data = {
|
||||||
|
"model_name": "My:Model*",
|
||||||
|
"file_path": "/models/V1.safetensors",
|
||||||
|
"civitai": {"id": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = calculate_filename_for_model(model_data, "lora")
|
||||||
|
|
||||||
|
assert result == "My_Modelcustom"
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_filename_empty_result_returns_empty(isolated_settings):
|
||||||
|
_set_filename_templates(isolated_settings, "{base_model}-{first_tag}")
|
||||||
|
|
||||||
|
model_data = {
|
||||||
|
"base_model": "",
|
||||||
|
"tags": [],
|
||||||
|
"file_path": "/models/V1.safetensors",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert calculate_filename_for_model(model_data, "lora") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_filename_uses_base_model_mapping(isolated_settings):
|
||||||
|
_set_filename_templates(isolated_settings, "{base_model}-{model_name}")
|
||||||
|
isolated_settings["base_model_path_mappings"] = {"SDXL": "sdxl-mapped"}
|
||||||
|
|
||||||
|
model_data = {
|
||||||
|
"model_name": "Model",
|
||||||
|
"base_model": "SDXL",
|
||||||
|
"file_path": "/models/V1.safetensors",
|
||||||
|
"civitai": {"id": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert calculate_filename_for_model(model_data, "lora") == "sdxl-mapped-Model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_filename_embedding_replaces_spaces(isolated_settings):
|
||||||
|
_set_filename_templates(isolated_settings, "{base_model} {model_name}")
|
||||||
|
|
||||||
|
model_data = {
|
||||||
|
"model_name": "My Model",
|
||||||
|
"base_model": "Base Model",
|
||||||
|
"file_path": "/models/V1.safetensors",
|
||||||
|
"civitai": {"id": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert calculate_filename_for_model(model_data, "embedding") == "Base_Model_My_Model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_filename_original_name_falls_back_to_file_name(isolated_settings):
|
||||||
|
_set_filename_templates(isolated_settings, "{original_name}-{hash_short}")
|
||||||
|
|
||||||
|
model_data = {
|
||||||
|
"file_name": "legacy-name",
|
||||||
|
"sha256": "0123456789abcdef",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert calculate_filename_for_model(model_data, "lora") == "legacy-name-0123456789"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"original, expected",
|
"original, expected",
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Tests for the container-level video dimension probe."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from py.utils.video_metadata import get_video_dimensions
|
||||||
|
|
||||||
|
|
||||||
|
def _box(box_type: bytes, payload: bytes) -> bytes:
|
||||||
|
return struct.pack(">I", len(payload) + 8) + box_type + payload
|
||||||
|
|
||||||
|
|
||||||
|
def _full_box(box_type: bytes, payload: bytes) -> bytes:
|
||||||
|
"""Build a box with a 4-byte version/flags header."""
|
||||||
|
|
||||||
|
return _box(box_type, b"\x00\x00\x00\x00" + payload)
|
||||||
|
|
||||||
|
|
||||||
|
def build_mp4(width: int, height: int, *, with_stsd: bool = False) -> bytes:
|
||||||
|
"""Build a minimal but structurally valid MP4 holding one video track."""
|
||||||
|
|
||||||
|
mvhd = _full_box(b"mvhd", b"\x00" * 96)
|
||||||
|
|
||||||
|
hdlr = _full_box(b"hdlr", b"\x00" * 4 + b"vide" + b"\x00" * 12)
|
||||||
|
|
||||||
|
tkhd_payload = struct.pack(">IIII", 0, 0, 0, 0) + b"\x00" * 52
|
||||||
|
tkhd_payload += struct.pack(">II", width << 16, height << 16)
|
||||||
|
tkhd = _full_box(b"tkhd", tkhd_payload)
|
||||||
|
|
||||||
|
stbl_children = b""
|
||||||
|
if with_stsd:
|
||||||
|
sample_entry = (
|
||||||
|
b"\x00" * 6 + struct.pack(">H", 1) + struct.pack(">HH", width, height)
|
||||||
|
)
|
||||||
|
stsd = _full_box(b"stsd", struct.pack(">I", 1) + _box(b"avc1", sample_entry))
|
||||||
|
stbl_children = stsd
|
||||||
|
|
||||||
|
minf = _box(b"minf", _box(b"stbl", stbl_children))
|
||||||
|
mdia = _box(b"mdia", hdlr + minf)
|
||||||
|
trak = _box(b"trak", tkhd + mdia)
|
||||||
|
moov = _box(b"moov", mvhd + trak)
|
||||||
|
ftyp = _box(b"ftyp", b"isom" + b"\x00\x00\x02\x00" + b"isomiso2avc1mp41")
|
||||||
|
|
||||||
|
return ftyp + moov
|
||||||
|
|
||||||
|
|
||||||
|
def _ebml_vint(value: int) -> bytes:
|
||||||
|
"""Encode a value as a minimal-length EBML variable length integer."""
|
||||||
|
|
||||||
|
for length in range(1, 9):
|
||||||
|
if value < (1 << (7 * length)):
|
||||||
|
encoded = value | (1 << (7 * length))
|
||||||
|
return encoded.to_bytes(length, "big")
|
||||||
|
raise ValueError("value too large for an EBML vint")
|
||||||
|
|
||||||
|
|
||||||
|
def _ebml_element(element_id: bytes, payload: bytes) -> bytes:
|
||||||
|
return element_id + _ebml_vint(len(payload)) + payload
|
||||||
|
|
||||||
|
|
||||||
|
def _uint_element(element_id: int, value: int) -> bytes:
|
||||||
|
length = max(1, (value.bit_length() + 7) // 8)
|
||||||
|
return _ebml_element(
|
||||||
|
element_id.to_bytes(2, "big") if element_id > 0xFF else element_id.to_bytes(1, "big"),
|
||||||
|
value.to_bytes(length, "big"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_webm(width: int, height: int, *, track_type: int = 1) -> bytes:
|
||||||
|
"""Build a minimal WebM file holding one TrackEntry."""
|
||||||
|
|
||||||
|
video = _ebml_element(b"\xe0", _uint_element(0xB0, width) + _uint_element(0xBA, height))
|
||||||
|
track_entry = _ebml_element(
|
||||||
|
b"\xae", _uint_element(0x83, track_type) + video
|
||||||
|
)
|
||||||
|
tracks = _ebml_element(b"\x16\x54\xae\x6b", track_entry)
|
||||||
|
|
||||||
|
segment = _ebml_element(b"\x18\x53\x80\x67", tracks)
|
||||||
|
ebml_header = _ebml_element(
|
||||||
|
b"\x1a\x45\xdf\xa3",
|
||||||
|
_uint_element(0x4286, 1) + _ebml_element(b"\x42\x82", b"webm"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return ebml_header + segment
|
||||||
|
|
||||||
|
|
||||||
|
def test_mp4_dimensions_come_from_tkhd(tmp_path):
|
||||||
|
video = tmp_path / "landscape.mp4"
|
||||||
|
video.write_bytes(build_mp4(1280, 720))
|
||||||
|
|
||||||
|
assert get_video_dimensions(str(video)) == (1280, 720)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mp4_uses_stsd_when_tkhd_is_empty(tmp_path):
|
||||||
|
video = tmp_path / "stsd-only.mp4"
|
||||||
|
video.write_bytes(build_mp4(640, 480, with_stsd=True))
|
||||||
|
|
||||||
|
assert get_video_dimensions(str(video)) == (640, 480)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mp4_without_video_track_returns_none(tmp_path):
|
||||||
|
# A moov whose only trak has no mdia box at all.
|
||||||
|
tkhd = _full_box(b"tkhd", b"\x00" * 60)
|
||||||
|
moov = _box(b"moov", _box(b"trak", tkhd))
|
||||||
|
video = tmp_path / "audio-only.mp4"
|
||||||
|
video.write_bytes(moov)
|
||||||
|
|
||||||
|
assert get_video_dimensions(str(video)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_webm_dimensions(tmp_path):
|
||||||
|
video = tmp_path / "portrait.webm"
|
||||||
|
video.write_bytes(build_webm(720, 1280))
|
||||||
|
|
||||||
|
assert get_video_dimensions(str(video)) == (720, 1280)
|
||||||
|
|
||||||
|
|
||||||
|
def test_webm_non_video_track_is_ignored(tmp_path):
|
||||||
|
video = tmp_path / "audio.webm"
|
||||||
|
video.write_bytes(build_webm(720, 1280, track_type=2))
|
||||||
|
|
||||||
|
assert get_video_dimensions(str(video)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_container_signature_wins_over_extension(tmp_path):
|
||||||
|
"""A WebM file named ``.mp4`` is still parsed as WebM."""
|
||||||
|
|
||||||
|
video = tmp_path / "actually-webm.mp4"
|
||||||
|
video.write_bytes(build_webm(480, 832))
|
||||||
|
|
||||||
|
assert get_video_dimensions(str(video)) == (480, 832)
|
||||||
|
|
||||||
|
|
||||||
|
def test_webp_renamed_to_mp4_is_read(tmp_path):
|
||||||
|
"""Animated WebP examples are frequently saved with a video extension."""
|
||||||
|
|
||||||
|
vp8_payload = b"\x30\x36\x02" + b"\x9d\x01\x2a" + struct.pack("<HH", 450, 800)
|
||||||
|
chunk = b"VP8 " + struct.pack("<I", len(vp8_payload)) + vp8_payload
|
||||||
|
body = b"WEBP" + chunk
|
||||||
|
riff = b"RIFF" + struct.pack("<I", len(body)) + body
|
||||||
|
|
||||||
|
video = tmp_path / "animated.mp4"
|
||||||
|
video.write_bytes(riff)
|
||||||
|
|
||||||
|
assert get_video_dimensions(str(video)) == (450, 800)
|
||||||
|
|
||||||
|
|
||||||
|
def test_webp_vp8x_canvas_dimensions(tmp_path):
|
||||||
|
vp8x_payload = b"\x00" * 4 + (449).to_bytes(3, "little") + (799).to_bytes(3, "little")
|
||||||
|
chunk = b"VP8X" + struct.pack("<I", len(vp8x_payload)) + vp8x_payload
|
||||||
|
body = b"WEBP" + chunk
|
||||||
|
riff = b"RIFF" + struct.pack("<I", len(body)) + body
|
||||||
|
|
||||||
|
video = tmp_path / "canvas.mp4"
|
||||||
|
video.write_bytes(riff)
|
||||||
|
|
||||||
|
assert get_video_dimensions(str(video)) == (450, 800)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_file_returns_none(tmp_path):
|
||||||
|
assert get_video_dimensions(str(tmp_path / "nope.mp4")) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupt_file_returns_none(tmp_path):
|
||||||
|
video = tmp_path / "corrupt.mp4"
|
||||||
|
video.write_bytes(b"\x00\x00\x00\x20ftypisom" + b"\xff" * 64)
|
||||||
|
|
||||||
|
assert get_video_dimensions(str(video)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsupported_extension_without_video_signature_returns_none(tmp_path):
|
||||||
|
"""A non-video file is not probed just because of a video-like name."""
|
||||||
|
|
||||||
|
video = tmp_path / "clip.avi"
|
||||||
|
video.write_bytes(b"RIFF\x00\x00\x00\x00AVI LIST\x00\x00\x00\x00")
|
||||||
|
|
||||||
|
assert get_video_dimensions(str(video)) is None
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user