mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
Compare commits
16 Commits
v1.2.3
...
3b9e8efb3d
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,15 @@ 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`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Hard rules (do not violate)
|
## 1. Hard rules (do not violate)
|
||||||
|
|||||||
+52
-4
@@ -382,7 +382,8 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"general": "Allgemein",
|
"general": "Allgemein",
|
||||||
"interface": "Oberfläche",
|
"interface": "Oberfläche",
|
||||||
"library": "Bibliothek"
|
"library": "Bibliothek",
|
||||||
|
"modelPaths": "Modellpfade"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Einstellungen durchsuchen...",
|
"placeholder": "Einstellungen durchsuchen...",
|
||||||
@@ -583,6 +584,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))",
|
||||||
@@ -1241,11 +1282,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": {
|
||||||
@@ -2698,6 +2741,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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-4
@@ -382,7 +382,8 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"interface": "Interface",
|
"interface": "Interface",
|
||||||
"library": "Library"
|
"library": "Library",
|
||||||
|
"modelPaths": "Model Paths"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Search settings...",
|
"placeholder": "Search settings...",
|
||||||
@@ -583,6 +584,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))",
|
||||||
@@ -1241,11 +1282,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": {
|
||||||
@@ -2698,6 +2741,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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-4
@@ -382,7 +382,8 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"interface": "Interfaz",
|
"interface": "Interfaz",
|
||||||
"library": "Biblioteca"
|
"library": "Biblioteca",
|
||||||
|
"modelPaths": "Rutas de modelos"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Buscar ajustes...",
|
"placeholder": "Buscar ajustes...",
|
||||||
@@ -583,6 +584,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))",
|
||||||
@@ -1241,11 +1282,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": {
|
||||||
@@ -2698,6 +2741,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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-4
@@ -382,7 +382,8 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"general": "Général",
|
"general": "Général",
|
||||||
"interface": "Interface",
|
"interface": "Interface",
|
||||||
"library": "Bibliothèque"
|
"library": "Bibliothèque",
|
||||||
|
"modelPaths": "Chemins de modèles"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Rechercher dans les paramètres...",
|
"placeholder": "Rechercher dans les paramètres...",
|
||||||
@@ -583,6 +584,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))",
|
||||||
@@ -1241,11 +1282,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": {
|
||||||
@@ -2698,6 +2741,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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-4
@@ -382,7 +382,8 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"general": "כללי",
|
"general": "כללי",
|
||||||
"interface": "ממשק",
|
"interface": "ממשק",
|
||||||
"library": "ספרייה"
|
"library": "ספרייה",
|
||||||
|
"modelPaths": "נתיבי מודלים"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "חיפוש בהגדרות...",
|
"placeholder": "חיפוש בהגדרות...",
|
||||||
@@ -583,6 +584,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))",
|
||||||
@@ -1241,11 +1282,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": {
|
||||||
@@ -2698,6 +2741,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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-4
@@ -382,7 +382,8 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"general": "一般",
|
"general": "一般",
|
||||||
"interface": "インターフェース",
|
"interface": "インターフェース",
|
||||||
"library": "ライブラリ"
|
"library": "ライブラリ",
|
||||||
|
"modelPaths": "モデルパス"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "設定を検索...",
|
"placeholder": "設定を検索...",
|
||||||
@@ -583,6 +584,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))",
|
||||||
@@ -1241,11 +1282,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": {
|
||||||
@@ -2698,6 +2741,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} 件目の通知"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-4
@@ -382,7 +382,8 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"general": "일반",
|
"general": "일반",
|
||||||
"interface": "인터페이스",
|
"interface": "인터페이스",
|
||||||
"library": "라이브러리"
|
"library": "라이브러리",
|
||||||
|
"modelPaths": "모델 경로"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "설정 검색...",
|
"placeholder": "설정 검색...",
|
||||||
@@ -583,6 +584,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)).",
|
||||||
@@ -1241,11 +1282,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": {
|
||||||
@@ -2698,6 +2741,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}번째 알림"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-4
@@ -382,7 +382,8 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"general": "Общее",
|
"general": "Общее",
|
||||||
"interface": "Интерфейс",
|
"interface": "Интерфейс",
|
||||||
"library": "Библиотека"
|
"library": "Библиотека",
|
||||||
|
"modelPaths": "Пути к моделям"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Поиск в настройках...",
|
"placeholder": "Поиск в настройках...",
|
||||||
@@ -583,6 +584,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)).",
|
||||||
@@ -1241,11 +1282,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": {
|
||||||
@@ -2698,6 +2741,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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-4
@@ -382,7 +382,8 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"general": "通用",
|
"general": "通用",
|
||||||
"interface": "界面",
|
"interface": "界面",
|
||||||
"library": "库"
|
"library": "库",
|
||||||
|
"modelPaths": "模型路径"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "搜索设置...",
|
"placeholder": "搜索设置...",
|
||||||
@@ -583,6 +584,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))",
|
||||||
@@ -1241,11 +1282,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": {
|
||||||
@@ -2698,6 +2741,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} 条"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-4
@@ -382,7 +382,8 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"general": "通用",
|
"general": "通用",
|
||||||
"interface": "介面",
|
"interface": "介面",
|
||||||
"library": "模型庫"
|
"library": "模型庫",
|
||||||
|
"modelPaths": "模型路徑"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "搜尋設定...",
|
"placeholder": "搜尋設定...",
|
||||||
@@ -583,6 +584,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))",
|
||||||
@@ -1241,11 +1282,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": {
|
||||||
@@ -2698,6 +2741,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} 則"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -172,13 +174,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 +309,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 +1243,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
@@ -230,29 +376,21 @@ class MetadataUpdater:
|
|||||||
# Determine if video or image
|
# Determine if video or image
|
||||||
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
|
||||||
@@ -321,14 +459,16 @@ class MetadataUpdater:
|
|||||||
# Determine if video or image
|
# Determine if video or image
|
||||||
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:
|
||||||
@@ -437,4 +448,137 @@ class ExampleImagesMigration:
|
|||||||
logger.error(f"Error migrating folder {folder}: {e}")
|
logger.error(f"Error migrating folder {folder}: {e}")
|
||||||
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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -183,6 +221,10 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.banner-pager {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.banner-action {
|
.banner-action {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
@@ -121,12 +124,22 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -164,11 +177,10 @@ class BannerService {
|
|||||||
if (banner && typeof banner.onRemove === 'function') {
|
if (banner && typeof banner.onRemove === 'function') {
|
||||||
banner.onRemove(bannerElement);
|
banner.onRemove(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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
@@ -230,11 +301,34 @@ class BannerService {
|
|||||||
</a>`;
|
</a>`;
|
||||||
}).join('') : '';
|
}).join('') : '';
|
||||||
|
|
||||||
const dismissButtonHtml = banner.dismissible ?
|
const dismissButtonHtml = banner.dismissible ?
|
||||||
`<button class="banner-dismiss" onclick="bannerService.dismissBanner('${banner.id}').catch(console.error)" title="Dismiss">
|
`<button class="banner-dismiss" onclick="bannerService.dismissBanner('${banner.id}').catch(console.error)" title="Dismiss">
|
||||||
<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() {
|
||||||
|
|||||||
@@ -15,9 +15,27 @@ import { i18n } from '../i18n/index.js';
|
|||||||
import { configureModelCardVideo } from '../components/shared/ModelCard.js';
|
import { configureModelCardVideo } from '../components/shared/ModelCard.js';
|
||||||
import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePriorityTagSuggestionsCache } from '../utils/priorityTagHelpers.js';
|
import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePriorityTagSuggestionsCache } from '../utils/priorityTagHelpers.js';
|
||||||
import { bannerService } from './BannerService.js';
|
import { bannerService } from './BannerService.js';
|
||||||
|
import { directoryPickerModal } from '../components/DirectoryPickerModal.js';
|
||||||
|
|
||||||
const VALID_MATURE_BLUR_LEVELS = new Set(['PG13', 'R', 'X', 'XXX']);
|
const VALID_MATURE_BLUR_LEVELS = new Set(['PG13', 'R', 'X', 'XXX']);
|
||||||
|
|
||||||
|
const PATH_VALIDATION_ERROR_I18N = {
|
||||||
|
path_not_found: { key: 'settings.pathValidation.pathNotFound', fallback: 'Path does not exist' },
|
||||||
|
not_a_directory: { key: 'settings.pathValidation.notADirectory', fallback: 'Not a directory' },
|
||||||
|
not_readable: { key: 'settings.pathValidation.notReadable', fallback: 'Path is not readable' },
|
||||||
|
not_writable: { key: 'settings.pathValidation.notWritable', fallback: 'Path is not writable' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Other-model sub_type -> i18n label key, mirroring the checkbox list in
|
||||||
|
// templates/components/modals/settings/library.html.
|
||||||
|
const OTHER_SUB_TYPE_LABEL_KEYS = {
|
||||||
|
vae: 'settings.folderSettings.subTypeVae',
|
||||||
|
upscaler: 'settings.folderSettings.subTypeUpscaler',
|
||||||
|
text_encoder: 'settings.folderSettings.subTypeTextEncoder',
|
||||||
|
clip_vision: 'settings.folderSettings.subTypeClipVision',
|
||||||
|
controlnet: 'settings.folderSettings.subTypeControlnet',
|
||||||
|
};
|
||||||
|
|
||||||
export class SettingsManager {
|
export class SettingsManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.initialized = false;
|
this.initialized = false;
|
||||||
@@ -26,6 +44,8 @@ export class SettingsManager {
|
|||||||
this.availableLibraries = {};
|
this.availableLibraries = {};
|
||||||
this.activeLibrary = '';
|
this.activeLibrary = '';
|
||||||
this.registeredStartupBannerIds = new Set();
|
this.registeredStartupBannerIds = new Set();
|
||||||
|
this.modelPathsSectionInitialized = false;
|
||||||
|
this.modelPathsDirty = false;
|
||||||
|
|
||||||
// Add initialization to sync with modal state
|
// Add initialization to sync with modal state
|
||||||
this.currentPage = document.body.dataset.page || 'loras';
|
this.currentPage = document.body.dataset.page || 'loras';
|
||||||
@@ -78,6 +98,7 @@ export class SettingsManager {
|
|||||||
|
|
||||||
await this.applyLanguageSetting();
|
await this.applyLanguageSetting();
|
||||||
this.applyFrontendSettings();
|
this.applyFrontendSettings();
|
||||||
|
this.setupModelPathsSection();
|
||||||
}
|
}
|
||||||
|
|
||||||
async applyLanguageSetting() {
|
async applyLanguageSetting() {
|
||||||
@@ -276,6 +297,10 @@ export class SettingsManager {
|
|||||||
case 'open-settings-modal':
|
case 'open-settings-modal':
|
||||||
modalManager.showModal('settingsModal');
|
modalManager.showModal('settingsModal');
|
||||||
break;
|
break;
|
||||||
|
case 'open-model-paths-settings':
|
||||||
|
modalManager.showModal('settingsModal');
|
||||||
|
document.querySelector('.settings-nav-item[data-section="modelPaths"]')?.click();
|
||||||
|
break;
|
||||||
case 'open-settings-location':
|
case 'open-settings-location':
|
||||||
this.openSettingsFileLocation();
|
this.openSettingsFileLocation();
|
||||||
break;
|
break;
|
||||||
@@ -472,8 +497,11 @@ export class SettingsManager {
|
|||||||
const sectionId = item.dataset.section;
|
const sectionId = item.dataset.section;
|
||||||
if (!sectionId) return;
|
if (!sectionId) return;
|
||||||
|
|
||||||
// Hide all sections
|
// Query live instead of using the captured NodeLists: the
|
||||||
sections.forEach(section => {
|
// standalone Model Paths section is added after this
|
||||||
|
// initializer runs, and a stale snapshot would leave it
|
||||||
|
// active forever.
|
||||||
|
document.querySelectorAll('.settings-section').forEach(section => {
|
||||||
section.classList.remove('active');
|
section.classList.remove('active');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -484,7 +512,7 @@ export class SettingsManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update active nav state
|
// Update active nav state
|
||||||
navItems.forEach(nav => nav.classList.remove('active'));
|
document.querySelectorAll('.settings-nav-item').forEach(nav => nav.classList.remove('active'));
|
||||||
item.classList.add('active');
|
item.classList.add('active');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1160,6 +1188,9 @@ export class SettingsManager {
|
|||||||
// Load extra folder paths
|
// Load extra folder paths
|
||||||
this.loadExtraFolderPaths();
|
this.loadExtraFolderPaths();
|
||||||
|
|
||||||
|
// Load standalone model library paths (no-op in plugin mode)
|
||||||
|
this.loadModelPaths();
|
||||||
|
|
||||||
// Load language setting
|
// Load language setting
|
||||||
const languageSelect = document.getElementById('languageSelect');
|
const languageSelect = document.getElementById('languageSelect');
|
||||||
if (languageSelect) {
|
if (languageSelect) {
|
||||||
@@ -1175,6 +1206,24 @@ export class SettingsManager {
|
|||||||
if (useNewLicenseIconsCheckbox) {
|
if (useNewLicenseIconsCheckbox) {
|
||||||
useNewLicenseIconsCheckbox.checked = state.global.settings.use_new_license_icons !== false;
|
useNewLicenseIconsCheckbox.checked = state.global.settings.use_new_license_icons !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Directory browse buttons + advisory path validation (idempotent,
|
||||||
|
// safe to call on every modal open).
|
||||||
|
this.attachPathField('recipesPath', {
|
||||||
|
onAfterSelect: () => this.saveInputSetting('recipesPath', 'recipes_path'),
|
||||||
|
});
|
||||||
|
this.attachPathField('exampleImagesPath', {
|
||||||
|
onAfterSelect: (pickedPath) => {
|
||||||
|
// Mirror ExampleImagesManager's blur-save flow.
|
||||||
|
window.exampleImagesManager?.updateDownloadButtonState?.(pickedPath.trim() !== '');
|
||||||
|
this.saveSetting('example_images_path', pickedPath)
|
||||||
|
.then(() => showToast('toast.exampleImages.pathUpdated', {}, 'success'))
|
||||||
|
.catch((error) => showToast('toast.exampleImages.pathUpdateFailed', { message: error.message }, 'error'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.attachPathField('exampleImagesLocalRoot', {
|
||||||
|
onAfterSelect: () => this.saveInputSetting('exampleImagesLocalRoot', 'example_images_local_root'),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
loadDownloadBackendSettings() {
|
loadDownloadBackendSettings() {
|
||||||
@@ -1783,6 +1832,11 @@ export class SettingsManager {
|
|||||||
onblur="settingsManager.updateExtraFolderPaths('${modelType}')"
|
onblur="settingsManager.updateExtraFolderPaths('${modelType}')"
|
||||||
onfocus="settingsManager.clearExtraFolderPathError(this)"
|
onfocus="settingsManager.clearExtraFolderPathError(this)"
|
||||||
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
|
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
|
||||||
|
<button type="button" class="browse-path-btn"
|
||||||
|
onclick="settingsManager.browseForPathRow(this, '${modelType}')"
|
||||||
|
title="${translate('settings.directoryPicker.title', {}, 'Browse Folders')}">
|
||||||
|
<i class="fas fa-folder-open"></i>
|
||||||
|
</button>
|
||||||
<button type="button" class="remove-path-btn"
|
<button type="button" class="remove-path-btn"
|
||||||
onclick="settingsManager.removeExtraFolderPathRow(this, '${modelType}')"
|
onclick="settingsManager.removeExtraFolderPathRow(this, '${modelType}')"
|
||||||
title="${translate('common.actions.delete', {}, 'Delete')}">
|
title="${translate('common.actions.delete', {}, 'Delete')}">
|
||||||
@@ -1942,6 +1996,478 @@ export class SettingsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Standalone Model Paths section --------------------------------------
|
||||||
|
// The section only exists in standalone mode, where primary folder_paths
|
||||||
|
// are read from settings.json instead of the ComfyUI host. Editors are
|
||||||
|
// rendered from the backend-provided folder_path_schema so new model
|
||||||
|
// categories appear automatically.
|
||||||
|
|
||||||
|
setupModelPathsSection() {
|
||||||
|
if (this.modelPathsSectionInitialized) return;
|
||||||
|
if (!state.global.settings.standalone_mode) return;
|
||||||
|
|
||||||
|
const navGroup = document.querySelector('.settings-nav-list .settings-nav-group');
|
||||||
|
const settingsForm = document.querySelector('.settings-form');
|
||||||
|
if (!navGroup || !settingsForm) return;
|
||||||
|
|
||||||
|
const navButton = document.createElement('button');
|
||||||
|
navButton.type = 'button';
|
||||||
|
navButton.className = 'settings-nav-item';
|
||||||
|
navButton.dataset.section = 'modelPaths';
|
||||||
|
navButton.textContent = translate('settings.nav.modelPaths', {}, 'Model Paths');
|
||||||
|
|
||||||
|
const section = document.createElement('div');
|
||||||
|
section.className = 'settings-section';
|
||||||
|
section.id = 'section-modelPaths';
|
||||||
|
section.dataset.section = 'modelPaths';
|
||||||
|
section.innerHTML = `
|
||||||
|
<div class="settings-subsection">
|
||||||
|
<div class="settings-subsection-header">
|
||||||
|
<h4>
|
||||||
|
${translate('settings.modelPaths.title', {}, 'Model Library Paths')}
|
||||||
|
<i class="fas fa-sync-alt restart-required-icon" title="${translate('settings.modelPaths.restartRequired', {}, 'Restart required for changes to take effect')}"></i>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="input-help">
|
||||||
|
${translate('settings.modelPaths.description', {}, 'Root folders LoRA Manager scans for your models. Changes take effect after restarting the server.')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="model-paths-restart-notice" id="modelPathsRestartNotice">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
<span>${translate('settings.modelPaths.pendingRestartNotice', {}, 'Path changes saved. Restart LoRA Manager for them to take effect.')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-subsection-header">
|
||||||
|
<h4>${translate('settings.modelPaths.coreTypes', {}, 'Core Model Types')}</h4>
|
||||||
|
</div>
|
||||||
|
<div id="modelPathsCoreTypes"></div>
|
||||||
|
<div class="settings-subsection-header">
|
||||||
|
<h4>${translate('settings.modelPaths.otherTypes', {}, 'Other Model Types')}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="modelPathsEnableOtherModels">
|
||||||
|
${translate('settings.folderSettings.enableOtherModels', {}, 'Enable Other Models Management')}
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="${translate('settings.folderSettings.enableOtherModelsHelp', {}, '')}"></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="modelPathsEnableOtherModels" onchange="settingsManager.handleModelPathsEnableOtherModels()">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item other-subtype-toggles" id="modelPathsSubTypeToggles">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label>
|
||||||
|
${translate('settings.folderSettings.otherSubTypes', {}, 'Enabled Model Types')}
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="${translate('settings.folderSettings.otherSubTypesHelp', {}, '')}"></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control other-subtype-checkboxes">
|
||||||
|
${this._buildModelPathSubTypeCheckboxes()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item" id="modelPathsOtherEmpty">
|
||||||
|
<div class="input-help">
|
||||||
|
${translate('settings.modelPaths.otherTypesDisabledHint', {}, 'No other model types are enabled. Turn on the types you need above to configure their folders.')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="modelPathsOtherTypes"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// The static nav items were bound by initializeNavigation() before the
|
||||||
|
// backend sync completed, so this dynamically added item carries its
|
||||||
|
// own handler with the same show-section behavior.
|
||||||
|
navButton.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.settings-section').forEach((s) => s.classList.remove('active'));
|
||||||
|
section.classList.add('active');
|
||||||
|
document.querySelectorAll('.settings-nav-item').forEach((n) => n.classList.remove('active'));
|
||||||
|
navButton.classList.add('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
navGroup.appendChild(navButton);
|
||||||
|
settingsForm.appendChild(section);
|
||||||
|
this.modelPathsSectionInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildModelPathSubTypeCheckboxes() {
|
||||||
|
const schema = state.global.settings.folder_path_schema || [];
|
||||||
|
const subTypes = [];
|
||||||
|
schema.forEach((entry) => {
|
||||||
|
if (entry.category === 'other' && entry.sub_type && !subTypes.includes(entry.sub_type)) {
|
||||||
|
subTypes.push(entry.sub_type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return subTypes.map((subType) => {
|
||||||
|
const labelKey = OTHER_SUB_TYPE_LABEL_KEYS[subType];
|
||||||
|
const label = labelKey ? translate(labelKey, {}, subType) : subType;
|
||||||
|
return `
|
||||||
|
<label class="other-subtype-checkbox">
|
||||||
|
<input type="checkbox" value="${subType}"
|
||||||
|
data-model-paths-subtype="${subType}"
|
||||||
|
onchange="settingsManager.handleModelPathsSubTypeToggles()">
|
||||||
|
<span>${label}</span>
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Master toggle inside the standalone Model Paths section. Edits the same
|
||||||
|
* enable_other_models key as the Library tab control and keeps both in
|
||||||
|
* sync, then re-renders the other-model path editors in place.
|
||||||
|
*/
|
||||||
|
async handleModelPathsEnableOtherModels() {
|
||||||
|
const toggle = document.getElementById('modelPathsEnableOtherModels');
|
||||||
|
if (!toggle) return;
|
||||||
|
|
||||||
|
const enabled = toggle.checked;
|
||||||
|
const previous = !!state.global.settings.enable_other_models;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.saveSetting('enable_other_models', enabled);
|
||||||
|
// Mirror the Library tab flow: refresh its controls and roots,
|
||||||
|
// then re-render this section's editors immediately.
|
||||||
|
this.updateOtherModelsControls();
|
||||||
|
await this.loadOtherRoots();
|
||||||
|
this.updateOtherModelsControls();
|
||||||
|
this.updateOtherModelsNavVisibility(enabled);
|
||||||
|
this.removeOtherModelsAnnouncement(enabled);
|
||||||
|
this.loadModelPaths();
|
||||||
|
showToast('toast.settings.settingsUpdated', { setting: 'enable other models' }, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
toggle.checked = previous;
|
||||||
|
state.global.settings.enable_other_models = previous;
|
||||||
|
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sub-type checkboxes inside the standalone Model Paths section. Saves the
|
||||||
|
* same enabled_other_sub_types allow-list as the Library tab checkboxes
|
||||||
|
* (which use data-other-subtype-toggle, so the two never mix) and
|
||||||
|
* re-renders the editors without requiring a modal reopen.
|
||||||
|
*/
|
||||||
|
async handleModelPathsSubTypeToggles() {
|
||||||
|
const values = Array.from(document.querySelectorAll('[data-model-paths-subtype]'))
|
||||||
|
.filter((input) => input.checked)
|
||||||
|
.map((input) => input.value);
|
||||||
|
|
||||||
|
const previous = state.global.settings.enabled_other_sub_types;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.saveSetting('enabled_other_sub_types', values);
|
||||||
|
this.updateOtherModelsControls();
|
||||||
|
await this.loadOtherRoots();
|
||||||
|
this.updateOtherModelsControls();
|
||||||
|
this.loadModelPaths();
|
||||||
|
showToast('toast.settings.settingsUpdated', { setting: 'other model types' }, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
state.global.settings.enabled_other_sub_types = previous;
|
||||||
|
this.loadModelPaths();
|
||||||
|
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadModelPaths() {
|
||||||
|
if (!state.global.settings.standalone_mode) return;
|
||||||
|
|
||||||
|
const coreHost = document.getElementById('modelPathsCoreTypes');
|
||||||
|
const otherHost = document.getElementById('modelPathsOtherTypes');
|
||||||
|
if (!coreHost || !otherHost) return;
|
||||||
|
|
||||||
|
coreHost.innerHTML = '';
|
||||||
|
otherHost.innerHTML = '';
|
||||||
|
|
||||||
|
const schema = state.global.settings.folder_path_schema || [];
|
||||||
|
const otherModelsEnabled = state.global.settings.enable_other_models === true;
|
||||||
|
const enabledSubTypes = new Set(state.global.settings.enabled_other_sub_types || []);
|
||||||
|
|
||||||
|
// Keep the inline enable controls in sync with the current settings.
|
||||||
|
const masterToggle = document.getElementById('modelPathsEnableOtherModels');
|
||||||
|
if (masterToggle) {
|
||||||
|
masterToggle.checked = otherModelsEnabled;
|
||||||
|
}
|
||||||
|
document.querySelectorAll('[data-model-paths-subtype]').forEach((input) => {
|
||||||
|
input.checked = enabledSubTypes.has(input.value);
|
||||||
|
input.disabled = !otherModelsEnabled;
|
||||||
|
});
|
||||||
|
const subTypeToggles = document.getElementById('modelPathsSubTypeToggles');
|
||||||
|
if (subTypeToggles) {
|
||||||
|
subTypeToggles.classList.toggle('is-disabled', !otherModelsEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
let otherCount = 0;
|
||||||
|
schema.forEach((entry) => {
|
||||||
|
if (entry.category === 'core') {
|
||||||
|
this._buildModelPathTypeGroup(coreHost, entry);
|
||||||
|
} else if (otherModelsEnabled && entry.sub_type && enabledSubTypes.has(entry.sub_type)) {
|
||||||
|
otherCount++;
|
||||||
|
this._buildModelPathTypeGroup(otherHost, entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyHint = document.getElementById('modelPathsOtherEmpty');
|
||||||
|
if (emptyHint) {
|
||||||
|
emptyHint.style.display = otherCount === 0 ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderPaths = state.global.settings.folder_paths || {};
|
||||||
|
schema.forEach((entry) => {
|
||||||
|
const container = document.getElementById(`modelFolderPaths-${entry.key}`);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
const paths = folderPaths[entry.key] || [];
|
||||||
|
paths.forEach((path) => {
|
||||||
|
this.addModelFolderPathRow(entry.key, path);
|
||||||
|
});
|
||||||
|
// No trailing empty row on load: an unconfigured type shows just
|
||||||
|
// its Add button, and removing a row never resurrects an empty one.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildModelPathTypeGroup(host, entry) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'setting-item';
|
||||||
|
|
||||||
|
const label = translate(`settings.modelPaths.folderKeys.${entry.key}`, {}, entry.key);
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label>${label}</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<button type="button" class="add-mapping-btn" onclick="settingsManager.addModelFolderPathRow('${entry.key}')">
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
<span>${translate('common.actions.add', {}, 'Add')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="extra-folder-paths-container" id="modelFolderPaths-${entry.key}">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
host.appendChild(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
addModelFolderPathRow(key, path = '', shouldFocus = true) {
|
||||||
|
const container = document.getElementById(`modelFolderPaths-${key}`);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'extra-folder-path-row mapping-row';
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="path-controls">
|
||||||
|
<input type="text" class="extra-folder-path-input"
|
||||||
|
placeholder="${translate('settings.extraFolderPaths.pathPlaceholder', {}, '/path/to/models')}" value="${path}"
|
||||||
|
onblur="settingsManager.updateModelFolderPaths('${key}')"
|
||||||
|
onfocus="settingsManager.clearModelFolderPathError(this)"
|
||||||
|
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
|
||||||
|
<button type="button" class="browse-path-btn"
|
||||||
|
onclick="settingsManager.browseForPathRow(this, '${key}', true)"
|
||||||
|
title="${translate('settings.directoryPicker.title', {}, 'Browse Folders')}">
|
||||||
|
<i class="fas fa-folder-open"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="remove-path-btn"
|
||||||
|
onclick="settingsManager.removeModelFolderPathRow(this, '${key}')"
|
||||||
|
title="${translate('common.actions.delete', {}, 'Delete')}">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="extra-folder-path-error"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(row);
|
||||||
|
|
||||||
|
if (!path && shouldFocus) {
|
||||||
|
const input = row.querySelector('.extra-folder-path-input');
|
||||||
|
if (input) {
|
||||||
|
setTimeout(() => input.focus(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearModelFolderPathError(input) {
|
||||||
|
input.classList.remove('has-error');
|
||||||
|
const row = input.closest('.extra-folder-path-row');
|
||||||
|
if (row) {
|
||||||
|
const errEl = row.querySelector('.extra-folder-path-error');
|
||||||
|
if (errEl) {
|
||||||
|
errEl.classList.remove('visible');
|
||||||
|
errEl.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearAllModelFolderPathErrors() {
|
||||||
|
const section = document.getElementById('section-modelPaths');
|
||||||
|
if (!section) return;
|
||||||
|
section.querySelectorAll('.extra-folder-path-input.has-error').forEach((input) => {
|
||||||
|
input.classList.remove('has-error');
|
||||||
|
});
|
||||||
|
section.querySelectorAll('.extra-folder-path-error.visible').forEach((el) => {
|
||||||
|
el.classList.remove('visible');
|
||||||
|
el.textContent = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_markModelFolderPathsError(key, overlappingPaths, showMessage = false) {
|
||||||
|
const container = document.getElementById(`modelFolderPaths-${key}`);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const inputs = container.querySelectorAll('.extra-folder-path-input');
|
||||||
|
inputs.forEach((input) => {
|
||||||
|
const val = input.value.trim();
|
||||||
|
if (val && overlappingPaths.includes(val)) {
|
||||||
|
input.classList.add('has-error');
|
||||||
|
if (showMessage) {
|
||||||
|
const row = input.closest('.extra-folder-path-row');
|
||||||
|
if (row) {
|
||||||
|
const errEl = row.querySelector('.extra-folder-path-error');
|
||||||
|
if (errEl) {
|
||||||
|
errEl.textContent = translate('settings.extraFolderPaths.validation.checkpointUnetOverlapInline', {}, 'This path is also used for a different model type. Use separate folders for checkpoints and diffusion models.');
|
||||||
|
errEl.classList.add('visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeModelFolderPathRow(btn, key) {
|
||||||
|
const row = btn.closest('.extra-folder-path-row');
|
||||||
|
if (row) {
|
||||||
|
row.remove();
|
||||||
|
this.updateModelFolderPaths(key, { fromRemoval: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateModelFolderPaths(changedKey, { fromRemoval = false } = {}) {
|
||||||
|
this._clearAllModelFolderPathErrors();
|
||||||
|
|
||||||
|
const folderPaths = {};
|
||||||
|
|
||||||
|
const section = document.getElementById('section-modelPaths');
|
||||||
|
if (!section) return;
|
||||||
|
|
||||||
|
section.querySelectorAll('.extra-folder-paths-container[id^="modelFolderPaths-"]').forEach((container) => {
|
||||||
|
const key = container.id.slice('modelFolderPaths-'.length);
|
||||||
|
const paths = [];
|
||||||
|
container.querySelectorAll('.extra-folder-path-input').forEach((input) => {
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (value) {
|
||||||
|
paths.push(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
folderPaths[key] = paths;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Client-side pre-check: checkpoints and unet must not share the same path.
|
||||||
|
const normalise = (p) => p.replace(/[/\\]+$/, '').toLowerCase();
|
||||||
|
const ckptSet = new Set((folderPaths.checkpoints || []).map(normalise));
|
||||||
|
const unetSet = new Set((folderPaths.unet || []).map(normalise));
|
||||||
|
const ckptOverlap = (folderPaths.checkpoints || []).filter(p => p && unetSet.has(normalise(p)));
|
||||||
|
const unetOverlap = (folderPaths.unet || []).filter(p => p && ckptSet.has(normalise(p)));
|
||||||
|
const hasOverlap = ckptOverlap.length > 0 || unetOverlap.length > 0;
|
||||||
|
|
||||||
|
if (hasOverlap) {
|
||||||
|
if (changedKey === 'checkpoints') {
|
||||||
|
this._markModelFolderPathsError('checkpoints', ckptOverlap, true);
|
||||||
|
this._markModelFolderPathsError('unet', unetOverlap, false);
|
||||||
|
} else if (changedKey === 'unet') {
|
||||||
|
this._markModelFolderPathsError('unet', unetOverlap, true);
|
||||||
|
this._markModelFolderPathsError('checkpoints', ckptOverlap, false);
|
||||||
|
} else {
|
||||||
|
this._markModelFolderPathsError('checkpoints', ckptOverlap, false);
|
||||||
|
this._markModelFolderPathsError('unet', unetOverlap, false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPaths = state.global.settings.folder_paths || {};
|
||||||
|
const pathsChanged = JSON.stringify(currentPaths) !== JSON.stringify(folderPaths);
|
||||||
|
|
||||||
|
if (!pathsChanged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.global.settings.folder_paths = folderPaths;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.saveSetting('folder_paths', folderPaths);
|
||||||
|
|
||||||
|
// The "model folders need setup" startup banner is obsolete once
|
||||||
|
// at least one folder path is configured.
|
||||||
|
const hasAnyPath = Object.values(folderPaths).some(value => {
|
||||||
|
const list = Array.isArray(value) ? value : [value];
|
||||||
|
return list.some(path => typeof path === 'string' && path.trim());
|
||||||
|
});
|
||||||
|
if (hasAnyPath) {
|
||||||
|
bannerService.removeBannerElement('startup-missing-model-paths');
|
||||||
|
}
|
||||||
|
|
||||||
|
this._markModelPathsDirty();
|
||||||
|
showToast('settings.modelPaths.saveSuccessRestart', {}, 'success');
|
||||||
|
|
||||||
|
// Keep the continuous-add flow: after the user fills the trailing
|
||||||
|
// empty row, append a fresh one — but never after a removal.
|
||||||
|
const container = document.getElementById(`modelFolderPaths-${changedKey}`);
|
||||||
|
if (container && !fromRemoval) {
|
||||||
|
const inputs = container.querySelectorAll('.extra-folder-path-input');
|
||||||
|
const hasEmptyRow = Array.from(inputs).some((input) => !input.value.trim());
|
||||||
|
|
||||||
|
if (!hasEmptyRow) {
|
||||||
|
this.addModelFolderPathRow(changedKey, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save folder paths:', error);
|
||||||
|
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
|
||||||
|
|
||||||
|
state.global.settings.folder_paths = currentPaths;
|
||||||
|
this.loadModelPaths();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surface a persistent "restart required" cue after folder_paths changes:
|
||||||
|
* a dot on the Model Paths nav item, an inline notice in the section, and
|
||||||
|
* a global banner. The banner id is unique per change because dismissed
|
||||||
|
* banner ids persist across restarts — reusing one would mute future
|
||||||
|
* reminders. The whole state clears on the next page load (i.e. after the
|
||||||
|
* restart the user was asked to do).
|
||||||
|
*/
|
||||||
|
_markModelPathsDirty() {
|
||||||
|
if (this.modelPathsDirty) return;
|
||||||
|
this.modelPathsDirty = true;
|
||||||
|
|
||||||
|
document.querySelector('.settings-nav-item[data-section="modelPaths"]')
|
||||||
|
?.classList.add('has-pending-restart');
|
||||||
|
|
||||||
|
document.getElementById('modelPathsRestartNotice')?.classList.add('visible');
|
||||||
|
|
||||||
|
const bannerId = `model-paths-restart-${Date.now()}`;
|
||||||
|
bannerService.registerBanner(bannerId, {
|
||||||
|
id: bannerId,
|
||||||
|
title: translate('settings.modelPaths.pendingRestartBannerTitle', {}, 'Restart required to apply path changes'),
|
||||||
|
content: translate('settings.modelPaths.pendingRestartBannerMessage', {}, 'Model library paths were updated. Restart the LoRA Manager server to scan the new folders.'),
|
||||||
|
dismissible: true,
|
||||||
|
// Above startup warnings (60), below startup errors (90): a pending
|
||||||
|
// restart is the most actionable state and should preempt prompts.
|
||||||
|
priority: 80,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
loadBaseModelMappings() {
|
loadBaseModelMappings() {
|
||||||
const mappingsContainer = document.getElementById('baseModelMappingsContainer');
|
const mappingsContainer = document.getElementById('baseModelMappingsContainer');
|
||||||
if (!mappingsContainer) return;
|
if (!mappingsContainer) return;
|
||||||
@@ -2432,6 +2958,11 @@ export class SettingsManager {
|
|||||||
|| ['vae', 'upscaler', 'text_encoder']
|
|| ['vae', 'upscaler', 'text_encoder']
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const masterToggle = document.getElementById('enableOtherModels');
|
||||||
|
if (masterToggle) {
|
||||||
|
masterToggle.checked = enableOtherModels;
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelectorAll('[data-other-subtype-toggle]').forEach((input) => {
|
document.querySelectorAll('[data-other-subtype-toggle]').forEach((input) => {
|
||||||
input.checked = enabledSubTypes.has(input.value);
|
input.checked = enabledSubTypes.has(input.value);
|
||||||
input.disabled = !enableOtherModels;
|
input.disabled = !enableOtherModels;
|
||||||
@@ -3281,6 +3812,161 @@ export class SettingsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Directory picker + live path validation ─────────────────────────
|
||||||
|
// Validation is advisory only: it never blocks or alters save flows.
|
||||||
|
|
||||||
|
attachPathField(inputId, { expect = 'directory', onAfterSelect } = {}) {
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
if (!input) {
|
||||||
|
console.warn(`SettingsManager.attachPathField: #${inputId} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (input.dataset.pathFieldAttached === '1') return;
|
||||||
|
input.dataset.pathFieldAttached = '1';
|
||||||
|
|
||||||
|
const browseBtn = document.createElement('button');
|
||||||
|
browseBtn.type = 'button';
|
||||||
|
browseBtn.className = 'browse-path-btn inset';
|
||||||
|
browseBtn.title = translate('settings.directoryPicker.title', {}, 'Browse Folders');
|
||||||
|
browseBtn.innerHTML = '<i class="fas fa-folder-open"></i>';
|
||||||
|
|
||||||
|
// Inset layout: the button is absolutely positioned inside the right
|
||||||
|
// edge of the input, so the row keeps its original single-control look.
|
||||||
|
const parent = input.parentElement;
|
||||||
|
let wrapper;
|
||||||
|
let statusHost;
|
||||||
|
if (parent && parent.classList.contains('path-control')) {
|
||||||
|
// e.g. #exampleImagesPath sits beside a Download button: wrap only
|
||||||
|
// the input so the button insets into it and Download stays beside it.
|
||||||
|
wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'text-input-wrapper';
|
||||||
|
parent.insertBefore(wrapper, input);
|
||||||
|
wrapper.appendChild(input);
|
||||||
|
statusHost = parent;
|
||||||
|
} else {
|
||||||
|
// .text-input-wrapper provided by the setting_input macro
|
||||||
|
wrapper = parent;
|
||||||
|
statusHost = parent;
|
||||||
|
}
|
||||||
|
wrapper.appendChild(browseBtn);
|
||||||
|
input.classList.add('has-inset-browse');
|
||||||
|
|
||||||
|
const statusEl = document.createElement('div');
|
||||||
|
statusEl.className = 'path-validation';
|
||||||
|
statusHost.appendChild(statusEl);
|
||||||
|
|
||||||
|
input._pathFieldConfig = { expect, onAfterSelect, statusEl };
|
||||||
|
|
||||||
|
browseBtn.addEventListener('click', () => this.browseForPath(inputId));
|
||||||
|
input.addEventListener('blur', () => {
|
||||||
|
clearTimeout(input._pathValidationTimer);
|
||||||
|
this.validatePath(input, statusEl, expect);
|
||||||
|
});
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
clearTimeout(input._pathValidationTimer);
|
||||||
|
input._pathValidationTimer = setTimeout(() => {
|
||||||
|
this.validatePath(input, statusEl, expect);
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validatePath(input, statusEl, expect = 'directory') {
|
||||||
|
const value = input.value.trim();
|
||||||
|
const seq = input._pathValidationSeq = (input._pathValidationSeq || 0) + 1;
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
this._clearPathStatus(statusEl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/lm/validate-path', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path: value, expect }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
data = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
if (seq === input._pathValidationSeq) {
|
||||||
|
this._clearPathStatus(statusEl);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore responses overtaken by newer input or validation runs.
|
||||||
|
if (seq !== input._pathValidationSeq || input.value.trim() !== value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.success && !data.error_code) {
|
||||||
|
statusEl.innerHTML = `<i class="fas fa-check-circle"></i><span>${translate('settings.pathValidation.valid', {}, 'Path is valid')}</span>`;
|
||||||
|
statusEl.classList.add('visible', 'valid');
|
||||||
|
} else {
|
||||||
|
const message = this._getPathValidationMessage(data);
|
||||||
|
statusEl.innerHTML = '<i class="fas fa-exclamation-circle"></i><span></span>';
|
||||||
|
statusEl.querySelector('span').textContent = message;
|
||||||
|
statusEl.classList.add('visible');
|
||||||
|
statusEl.classList.remove('valid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_getPathValidationMessage(data) {
|
||||||
|
const entry = PATH_VALIDATION_ERROR_I18N[data?.error_code];
|
||||||
|
if (entry) {
|
||||||
|
return translate(entry.key, {}, entry.fallback);
|
||||||
|
}
|
||||||
|
return data?.error || translate('settings.pathValidation.pathNotFound', {}, 'Path does not exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearPathStatus(statusEl) {
|
||||||
|
statusEl.classList.remove('visible', 'valid');
|
||||||
|
statusEl.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
browseForPath(inputId, { onAfterSelect } = {}) {
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
if (!input) return;
|
||||||
|
const config = input._pathFieldConfig || {};
|
||||||
|
const afterSelect = onAfterSelect || config.onAfterSelect;
|
||||||
|
|
||||||
|
directoryPickerModal.open({
|
||||||
|
initialPath: input.value.trim(),
|
||||||
|
onSelect: (pickedPath) => {
|
||||||
|
input.value = pickedPath;
|
||||||
|
if (config.statusEl) {
|
||||||
|
this.validatePath(input, config.statusEl, config.expect || 'directory');
|
||||||
|
}
|
||||||
|
if (typeof afterSelect === 'function') {
|
||||||
|
afterSelect(pickedPath);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browse variant for the dynamic extra/model folder path rows: picking a
|
||||||
|
// folder routes through the row's existing validation + save logic.
|
||||||
|
browseForPathRow(btn, key, isModelPath = false) {
|
||||||
|
const row = btn.closest('.extra-folder-path-row');
|
||||||
|
const input = row ? row.querySelector('.extra-folder-path-input') : null;
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
directoryPickerModal.open({
|
||||||
|
initialPath: input.value.trim(),
|
||||||
|
onSelect: (pickedPath) => {
|
||||||
|
input.value = pickedPath;
|
||||||
|
if (isModelPath) {
|
||||||
|
this.updateModelFolderPaths(key);
|
||||||
|
} else {
|
||||||
|
this.updateExtraFolderPaths(key);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async saveInputSetting(elementId, settingKey) {
|
async saveInputSetting(elementId, settingKey) {
|
||||||
const element = document.getElementById(elementId);
|
const element = document.getElementById(elementId);
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ export function createDefaultSettings() {
|
|||||||
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: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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' %}
|
||||||
|
|||||||
@@ -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>
|
||||||
+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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -665,6 +665,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,489 @@
|
|||||||
|
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: {},
|
||||||
|
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,433 @@
|
|||||||
|
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: {},
|
||||||
|
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,
|
||||||
|
|||||||
@@ -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,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 == []
|
||||||
@@ -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()
|
||||||
@@ -42,4 +42,36 @@ 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:
|
||||||
@@ -216,4 +217,128 @@ async def test_update_metadata_from_local_examples_generates_entries(monkeypatch
|
|||||||
str(model_dir),
|
str(model_dir),
|
||||||
)
|
)
|
||||||
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"
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user